Skip to content

request canister install --argument silently corrupts Candid variant and integer types #623

@baolongt

Description

@baolongt

Summary

dfx-orbit request canister install --argument encodes Candid text without type information (parse_idl_args().to_bytes()), causing silent data corruption with no error. Data passes encoding, passes checksum verification, but decodes incorrectly on the canister.

Root Cause

In tools/dfx-orbit/src/canister/util.rs, parse_arguments() encodes Candid text without a type environment:

candid_parser::parse_idl_args(&arg_string)
    .with_context(|| "Invalid Candid values".to_string())?
    .to_bytes()  // ← encodes without type info

Without type information, the Candid encoder:

  1. Infers integer types incorrectly8 becomes int instead of nat8
  2. Creates incomplete variant type tablesvariant { ICRC1 } doesn't know ICRC2/ICRC3 exist

This is a fundamental Candid text format limitation — the text cannot express full type definitions.

Bug 1: opt fields silently become None

When untyped encoding encodes 8 as int but the canister expects nat8 (e.g. decimals field), the int→nat8 subtyping check fails at decode time. Because the field is wrapped in opt, Candid's upgradeability rule silently coerces the failure to Nonedropping all data without any error.

Reproduction

#[test]
fn test_untyped_encoding_causes_silent_data_loss() {
    #[derive(candid::CandidType, candid::Deserialize, Debug)]
    struct InitData {
        owner: Principal,
        tokens: Option<Vec<Token>>,
    }

    #[derive(candid::CandidType, candid::Deserialize, Debug)]
    struct Token {
        decimals: u8,  // expects nat8
        name: String,
    }

    // Untyped encoding: 8 encoded as int (not nat8)
    let args = r#"(record {
        owner = principal "aaaaa-aa";
        tokens = opt vec { record { decimals = 8; name = "ICP" } }
    })"#;

    let bytes = candid_parser::parse_idl_args(args).unwrap().to_bytes().unwrap();
    let result: (InitData,) = candid::decode_args(&bytes).unwrap();

    // No error thrown, but tokens silently became None!
    assert!(result.0.tokens.is_none()); // BUG: data silently dropped
}

Bug 2: Variant type collapse

Candid text variant { ICRC2 } doesn't tell the encoder that ICRC1 and ICRC3 also exist. The encoder creates a type table with only one variant. On decode, all variants map to index 0.

Reproduction (CLI)

#[test]
fn test_untyped_encoding_causes_variant_collapse() {
    #[derive(candid::CandidType, candid::Deserialize, Debug, PartialEq)]
    enum IcrcStandard {
        ICRC1,
        ICRC2,
        ICRC3,
    }

    // Encode three distinct variants WITHOUT type info
    let untyped = r#"(vec {
        variant { ICRC1 };
        variant { ICRC2 };
        variant { ICRC3 }
    })"#;

    let bytes = candid_parser::parse_idl_args(untyped)
        .unwrap()
        .to_bytes()
        .unwrap();

    let result: (Vec<IcrcStandard>,) = candid::decode_args(&bytes).unwrap();

    // BUG: all three decode as ICRC1!
    assert_eq!(result.0.len(), 3);
    assert_eq!(result.0[0], IcrcStandard::ICRC1);
    assert_eq!(result.0[1], IcrcStandard::ICRC1); // should be ICRC2
    assert_eq!(result.0[2], IcrcStandard::ICRC1); // should be ICRC3
}                   

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions