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
}                   

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions