Skip to content

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

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
}                   

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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