Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Example Walkthroughs

This chapter explains each KCC20 example flow by first showing the test that exercises it, then summarizing what that flow is meant to demonstrate at a high level.

All of the attached test code in this chapter comes from silverscript-lang/tests/kcc20_tests.rs [Link]. If you want to inspect the source directly in the repository, that is the file to open.

kcc20_can_split_then_merge_tokens_with_two_way_fanout

#![allow(unused)]
fn main() {
#[test]
fn kcc20_can_split_then_merge_tokens_with_two_way_fanout() {
    let source = load_example_source("kcc20.sil");

    let genesis_owner = random_keypair();
    let handoff_owner = random_keypair();
    let split_owner_a = random_keypair();
    let split_owner_b = random_keypair();
    let merged_owner = random_keypair();

    let genesis_owner_bytes = genesis_owner.x_only_public_key().0.serialize().to_vec();
    let handoff_owner_bytes = handoff_owner.x_only_public_key().0.serialize().to_vec();
    let split_owner_a_bytes = split_owner_a.x_only_public_key().0.serialize().to_vec();
    let split_owner_b_bytes = split_owner_b.x_only_public_key().0.serialize().to_vec();
    let merged_owner_bytes = merged_owner.x_only_public_key().0.serialize().to_vec();

    let genesis = compile_kcc20_state(&source, genesis_owner_bytes.clone(), 1_000, 2, 2);
    let handoff = compile_kcc20_state(&source, handoff_owner_bytes.clone(), 1_000, 2, 2);
    let split_a = compile_kcc20_state(&source, split_owner_a_bytes.clone(), 400, 2, 2);
    let split_b = compile_kcc20_state(&source, split_owner_b_bytes.clone(), 600, 2, 2);

    let handoff_outputs = vec![TransactionOutput {
        value: 1_000,
        script_public_key: pay_to_script_hash_script(&handoff.script),
        covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
    }];
    let handoff_entries = vec![covenant_utxo(&genesis, COV_A)];
    let handoff_unsigned_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: TransactionId::from_bytes([1; 32]), index: 0 }, vec![])],
        handoff_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );
    let handoff_sig = sign_tx_input(handoff_unsigned_tx, handoff_entries.clone(), 0, &genesis_owner);
    let handoff_sigscript = covenant_decl_sigscript(
        &genesis,
        "transfer",
        vec![
            kcc20_state_array_arg(vec![(handoff_owner_bytes.clone(), 1_000)]),
            sig_array_arg(vec![handoff_sig]),
            witness_array_arg(vec![0]),
        ],
        true,
    );
    let handoff_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(
            TransactionOutpoint { transaction_id: TransactionId::from_bytes([1; 32]), index: 0 },
            handoff_sigscript,
        )],
        handoff_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );

    execute_input_with_covenants(handoff_tx.clone(), handoff_entries, 0).expect("KCC20 handoff should succeed");

    let split_outputs = vec![
        TransactionOutput {
            value: 700,
            script_public_key: pay_to_script_hash_script(&split_a.script),
            covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
        },
        TransactionOutput {
            value: 700,
            script_public_key: pay_to_script_hash_script(&split_b.script),
            covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
        },
    ];
    let split_entries = vec![UtxoEntry::new(
        handoff_outputs[0].value,
        handoff_outputs[0].script_public_key.clone(),
        0,
        handoff_tx.is_coinbase(),
        Some(COV_A),
    )];
    let split_unsigned_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: handoff_tx.id(), index: 0 }, vec![])],
        split_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );
    let split_sig = sign_tx_input(split_unsigned_tx, split_entries.clone(), 0, &handoff_owner);
    let split_sigscript = covenant_decl_sigscript(
        &handoff,
        "transfer",
        vec![
            kcc20_state_array_arg(vec![(split_owner_a_bytes.clone(), 400), (split_owner_b_bytes.clone(), 600)]),
            sig_array_arg(vec![split_sig]),
            witness_array_arg(vec![0]),
        ],
        true,
    );
    let split_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: handoff_tx.id(), index: 0 }, split_sigscript)],
        split_outputs,
        0,
        Default::default(),
        0,
        vec![],
    );

    execute_input_with_covenants(split_tx.clone(), split_entries, 0).expect("KCC20 split should succeed");

    let merged = compile_kcc20_state(&source, merged_owner_bytes.clone(), 1_000, 2, 2);
    let merge_outputs = vec![TransactionOutput {
        value: 2_000,
        script_public_key: pay_to_script_hash_script(&merged.script),
        covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
    }];
    let merge_entries = vec![
        UtxoEntry::new(700, pay_to_script_hash_script(&split_a.script), 0, split_tx.is_coinbase(), Some(COV_A)),
        UtxoEntry::new(700, pay_to_script_hash_script(&split_b.script), 0, split_tx.is_coinbase(), Some(COV_A)),
    ];
    let merge_unsigned_tx = Transaction::new(
        1,
        vec![
            tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: split_tx.id(), index: 0 }, vec![]),
            tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: split_tx.id(), index: 1 }, vec![]),
        ],
        merge_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );
    let merge_sig_a = sign_tx_input(merge_unsigned_tx.clone(), merge_entries.clone(), 0, &split_owner_a);
    let merge_sig_b = sign_tx_input(merge_unsigned_tx, merge_entries.clone(), 0, &split_owner_b);
    let merge_leader_sigscript = covenant_decl_sigscript(
        &split_a,
        "transfer",
        vec![
            kcc20_state_array_arg(vec![(merged_owner_bytes, 1_000)]),
            sig_array_arg(vec![merge_sig_a, merge_sig_b]),
            witness_array_arg(vec![0, 1]),
        ],
        true,
    );
    let merge_delegate_sigscript = covenant_decl_sigscript(&split_b, "transfer", vec![], false);
    let merge_tx = Transaction::new(
        1,
        vec![
            tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: split_tx.id(), index: 0 }, merge_leader_sigscript),
            tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: split_tx.id(), index: 1 }, merge_delegate_sigscript),
        ],
        merge_outputs,
        0,
        Default::default(),
        0,
        vec![],
    );

    execute_input_with_covenants(merge_tx.clone(), merge_entries.clone(), 0).expect("KCC20 merge leader should succeed");
    execute_input_with_covenants(merge_tx, merge_entries, 1).expect("KCC20 merge delegate should succeed");
}
}

This flow has three stages:

  1. a full balance is handed off from one owner to another
  2. that balance is split into two branches
  3. those two branches are merged back into one

At a high level, this checks that ordinary KCC20 state behaves like a fungible asset with valid fan-out and fan-in transitions, while still preserving total supply.

start:   1000
           |
           v
handoff: 1000
           |
           v
split:  400 + 600
           |
           v
merge:   1000

kcc20_rejects_merge_when_one_signature_is_wrong

#![allow(unused)]
fn main() {
#[test]
fn kcc20_rejects_merge_when_one_signature_is_wrong() {
    let source = load_example_source("kcc20.sil");

    let genesis_owner = random_keypair();
    let handoff_owner = random_keypair();
    let split_owner_a = random_keypair();
    let split_owner_b = random_keypair();
    let wrong_signer = random_keypair();
    let merged_owner = random_keypair();

    let genesis_owner_bytes = genesis_owner.x_only_public_key().0.serialize().to_vec();
    let handoff_owner_bytes = handoff_owner.x_only_public_key().0.serialize().to_vec();
    let split_owner_a_bytes = split_owner_a.x_only_public_key().0.serialize().to_vec();
    let split_owner_b_bytes = split_owner_b.x_only_public_key().0.serialize().to_vec();
    let merged_owner_bytes = merged_owner.x_only_public_key().0.serialize().to_vec();

    let genesis = compile_kcc20_state(&source, genesis_owner_bytes.clone(), 1_000, 2, 2);
    let handoff = compile_kcc20_state(&source, handoff_owner_bytes.clone(), 1_000, 2, 2);
    let split_a = compile_kcc20_state(&source, split_owner_a_bytes.clone(), 400, 2, 2);
    let split_b = compile_kcc20_state(&source, split_owner_b_bytes.clone(), 600, 2, 2);
    let merged = compile_kcc20_state(&source, merged_owner_bytes.clone(), 1_000, 2, 2);

    let handoff_outputs = vec![TransactionOutput {
        value: 1_000,
        script_public_key: pay_to_script_hash_script(&handoff.script),
        covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
    }];
    let handoff_entries = vec![covenant_utxo(&genesis, COV_A)];
    let handoff_unsigned_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: TransactionId::from_bytes([1; 32]), index: 0 }, vec![])],
        handoff_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );
    let handoff_sig = sign_tx_input(handoff_unsigned_tx, handoff_entries.clone(), 0, &genesis_owner);
    let handoff_sigscript = covenant_decl_sigscript(
        &genesis,
        "transfer",
        vec![
            kcc20_state_array_arg(vec![(handoff_owner_bytes.clone(), 1_000)]),
            sig_array_arg(vec![handoff_sig]),
            witness_array_arg(vec![0]),
        ],
        true,
    );
    let handoff_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(
            TransactionOutpoint { transaction_id: TransactionId::from_bytes([1; 32]), index: 0 },
            handoff_sigscript,
        )],
        handoff_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );

    execute_input_with_covenants(handoff_tx.clone(), handoff_entries, 0).expect("KCC20 handoff should succeed");

    let split_outputs = vec![
        TransactionOutput {
            value: 700,
            script_public_key: pay_to_script_hash_script(&split_a.script),
            covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
        },
        TransactionOutput {
            value: 700,
            script_public_key: pay_to_script_hash_script(&split_b.script),
            covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
        },
    ];
    let split_entries = vec![UtxoEntry::new(
        handoff_outputs[0].value,
        handoff_outputs[0].script_public_key.clone(),
        0,
        handoff_tx.is_coinbase(),
        Some(COV_A),
    )];
    let split_unsigned_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: handoff_tx.id(), index: 0 }, vec![])],
        split_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );
    let split_sig = sign_tx_input(split_unsigned_tx, split_entries.clone(), 0, &handoff_owner);
    let split_sigscript = covenant_decl_sigscript(
        &handoff,
        "transfer",
        vec![
            kcc20_state_array_arg(vec![(split_owner_a_bytes.clone(), 400), (split_owner_b_bytes.clone(), 600)]),
            sig_array_arg(vec![split_sig]),
            witness_array_arg(vec![0]),
        ],
        true,
    );
    let split_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: handoff_tx.id(), index: 0 }, split_sigscript)],
        split_outputs,
        0,
        Default::default(),
        0,
        vec![],
    );

    execute_input_with_covenants(split_tx.clone(), split_entries, 0).expect("KCC20 split should succeed");

    let merge_outputs = vec![TransactionOutput {
        value: 2_000,
        script_public_key: pay_to_script_hash_script(&merged.script),
        covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
    }];
    let merge_entries = vec![
        UtxoEntry::new(700, pay_to_script_hash_script(&split_a.script), 0, split_tx.is_coinbase(), Some(COV_A)),
        UtxoEntry::new(700, pay_to_script_hash_script(&split_b.script), 0, split_tx.is_coinbase(), Some(COV_A)),
    ];
    let merge_unsigned_tx = Transaction::new(
        1,
        vec![
            tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: split_tx.id(), index: 0 }, vec![]),
            tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: split_tx.id(), index: 1 }, vec![]),
        ],
        merge_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );
    let merge_sig_a = sign_tx_input(merge_unsigned_tx.clone(), merge_entries.clone(), 0, &split_owner_a);
    let wrong_sig_b = sign_tx_input(merge_unsigned_tx, merge_entries.clone(), 0, &wrong_signer);
    let merge_leader_sigscript = covenant_decl_sigscript(
        &split_a,
        "transfer",
        vec![
            kcc20_state_array_arg(vec![(merged_owner_bytes, 1_000)]),
            sig_array_arg(vec![merge_sig_a, wrong_sig_b]),
            witness_array_arg(vec![0, 1]),
        ],
        true,
    );
    let merge_delegate_sigscript = covenant_decl_sigscript(&split_b, "transfer", vec![], false);
    let merge_tx = Transaction::new(
        1,
        vec![
            tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: split_tx.id(), index: 0 }, merge_leader_sigscript),
            tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: split_tx.id(), index: 1 }, merge_delegate_sigscript),
        ],
        merge_outputs,
        0,
        Default::default(),
        0,
        vec![],
    );

    let err = execute_input_with_covenants(merge_tx, merge_entries, 0)
        .expect_err("KCC20 merge should reject when one signature does not match the previous owner");
    assert_verify_like_error(err);
}
}

This flow is the same basic handoff, split, and merge pattern as the previous one, but one side of the merge is deliberately authorized with the wrong key.

At a high level, it checks that multi-input merges do not weaken ownership rules. Even when the structural shape of the transition is correct, KCC20 still rejects the merge if one of the previous owners was not properly authorized.

400 + 600
   |
   | one signature is wrong
   v
 reject

kcc20_rejects_split_when_amounts_do_not_match

#![allow(unused)]
fn main() {
#[test]
fn kcc20_rejects_split_when_amounts_do_not_match() {
    let source = load_example_source("kcc20.sil");

    let genesis_owner = random_keypair();
    let handoff_owner = random_keypair();
    let split_owner_a = random_keypair();
    let split_owner_b = random_keypair();

    let genesis_owner_bytes = genesis_owner.x_only_public_key().0.serialize().to_vec();
    let handoff_owner_bytes = handoff_owner.x_only_public_key().0.serialize().to_vec();
    let split_owner_a_bytes = split_owner_a.x_only_public_key().0.serialize().to_vec();
    let split_owner_b_bytes = split_owner_b.x_only_public_key().0.serialize().to_vec();

    let genesis = compile_kcc20_state(&source, genesis_owner_bytes.clone(), 1_000, 2, 2);
    let handoff = compile_kcc20_state(&source, handoff_owner_bytes.clone(), 1_000, 2, 2);
    let split_a = compile_kcc20_state(&source, split_owner_a_bytes.clone(), 400, 2, 2);
    let split_b = compile_kcc20_state(&source, split_owner_b_bytes.clone(), 500, 2, 2);

    let handoff_outputs = vec![TransactionOutput {
        value: 1_000,
        script_public_key: pay_to_script_hash_script(&handoff.script),
        covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
    }];
    let handoff_entries = vec![covenant_utxo(&genesis, COV_A)];
    let handoff_unsigned_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: TransactionId::from_bytes([1; 32]), index: 0 }, vec![])],
        handoff_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );
    let handoff_sig = sign_tx_input(handoff_unsigned_tx, handoff_entries.clone(), 0, &genesis_owner);
    let handoff_sigscript = covenant_decl_sigscript(
        &genesis,
        "transfer",
        vec![
            kcc20_state_array_arg(vec![(handoff_owner_bytes.clone(), 1_000)]),
            sig_array_arg(vec![handoff_sig]),
            witness_array_arg(vec![0]),
        ],
        true,
    );
    let handoff_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(
            TransactionOutpoint { transaction_id: TransactionId::from_bytes([1; 32]), index: 0 },
            handoff_sigscript,
        )],
        handoff_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );

    execute_input_with_covenants(handoff_tx.clone(), handoff_entries, 0).expect("KCC20 handoff should succeed");

    let split_outputs = vec![
        TransactionOutput {
            value: 700,
            script_public_key: pay_to_script_hash_script(&split_a.script),
            covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
        },
        TransactionOutput {
            value: 700,
            script_public_key: pay_to_script_hash_script(&split_b.script),
            covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
        },
    ];
    let split_entries = vec![UtxoEntry::new(
        handoff_outputs[0].value,
        handoff_outputs[0].script_public_key.clone(),
        0,
        handoff_tx.is_coinbase(),
        Some(COV_A),
    )];
    let split_unsigned_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: handoff_tx.id(), index: 0 }, vec![])],
        split_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );
    let split_sig = sign_tx_input(split_unsigned_tx, split_entries.clone(), 0, &handoff_owner);
    let split_sigscript = covenant_decl_sigscript(
        &handoff,
        "transfer",
        vec![
            kcc20_state_array_arg(vec![(split_owner_a_bytes, 400), (split_owner_b_bytes, 500)]),
            sig_array_arg(vec![split_sig]),
            witness_array_arg(vec![0]),
        ],
        true,
    );
    let split_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: handoff_tx.id(), index: 0 }, split_sigscript)],
        split_outputs,
        0,
        Default::default(),
        0,
        vec![],
    );

    let err = execute_input_with_covenants(split_tx, split_entries, 0)
        .expect_err("KCC20 split should reject when output amounts do not add up to the input amount");
    assert_verify_like_error(err);
}
}

This flow starts from a valid handoff, then tries to split 1000 tokens into outputs totaling only 900.

At a high level, it checks the simplest supply rule in the contract: an ordinary non-minter branch must preserve total amount across the transition.

input:   1000
outputs: 400 + 500

1000 != 900
   |
   v
 reject

kcc20_minter_can_split_then_mint_then_burn

#![allow(unused)]
fn main() {
#[test]
fn kcc20_minter_can_split_then_mint_then_burn() {
    let source = load_example_source("kcc20.sil");

    let genesis_owner = random_keypair();
    let other_owner = random_keypair();
    let minter_owner = random_keypair();

    let genesis_owner_bytes = genesis_owner.x_only_public_key().0.serialize().to_vec();
    let other_owner_bytes = other_owner.x_only_public_key().0.serialize().to_vec();
    let minter_owner_bytes = minter_owner.x_only_public_key().0.serialize().to_vec();

    let genesis = compile_kcc20_state_with_minter(&source, genesis_owner_bytes.clone(), 1_000, true, 2, 2);
    let split_minter = compile_kcc20_state_with_minter(&source, minter_owner_bytes.clone(), 400, true, 2, 2);
    let split_other = compile_kcc20_state(&source, other_owner_bytes.clone(), 600, 2, 2);
    let minted_minter = compile_kcc20_state_with_minter(&source, minter_owner_bytes.clone(), 900, true, 2, 2);
    let burned_minter = compile_kcc20_state_with_minter(&source, minter_owner_bytes.clone(), 500, true, 2, 2);

    let split_outputs = vec![
        TransactionOutput {
            value: 1_000,
            script_public_key: pay_to_script_hash_script(&split_minter.script),
            covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
        },
        TransactionOutput {
            value: 1_000,
            script_public_key: pay_to_script_hash_script(&split_other.script),
            covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
        },
    ];
    let split_entries = vec![covenant_utxo(&genesis, COV_A)];
    let split_unsigned_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: TransactionId::from_bytes([1; 32]), index: 0 }, vec![])],
        split_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );
    let split_sig = sign_tx_input(split_unsigned_tx, split_entries.clone(), 0, &genesis_owner);
    let split_sigscript = covenant_decl_sigscript(
        &genesis,
        "transfer",
        vec![
            kcc20_state_array_arg_with_minter(vec![(minter_owner_bytes.clone(), 400, true), (other_owner_bytes.clone(), 600, false)]),
            sig_array_arg(vec![split_sig]),
            witness_array_arg(vec![0]),
        ],
        true,
    );
    let split_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(
            TransactionOutpoint { transaction_id: TransactionId::from_bytes([1; 32]), index: 0 },
            split_sigscript,
        )],
        split_outputs,
        0,
        Default::default(),
        0,
        vec![],
    );

    execute_input_with_covenants(split_tx.clone(), split_entries, 0).expect("KCC20 minter split should succeed");

    let forged_other = compile_kcc20_state(&source, other_owner_bytes.clone(), 700, 2, 2);
    let forged_other_outputs = vec![TransactionOutput {
        value: 1_000,
        script_public_key: pay_to_script_hash_script(&forged_other.script),
        covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
    }];
    let forged_other_entries =
        vec![UtxoEntry::new(1_000, pay_to_script_hash_script(&split_other.script), 0, split_tx.is_coinbase(), Some(COV_A))];
    let forged_other_unsigned_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: split_tx.id(), index: 1 }, vec![])],
        forged_other_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );
    let forged_other_sig = sign_tx_input(forged_other_unsigned_tx, forged_other_entries.clone(), 0, &other_owner);
    let forged_other_sigscript = covenant_decl_sigscript(
        &split_other,
        "transfer",
        vec![
            kcc20_state_array_arg_with_minter(vec![(other_owner_bytes.clone(), 700, false)]),
            sig_array_arg(vec![forged_other_sig]),
            witness_array_arg(vec![0]),
        ],
        true,
    );
    let forged_other_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: split_tx.id(), index: 1 }, forged_other_sigscript)],
        forged_other_outputs,
        0,
        Default::default(),
        0,
        vec![],
    );

    let err = execute_input_with_covenants(forged_other_tx, forged_other_entries, 0)
        .expect_err("KCC20 non-minter branch should reject minting more tokens");
    assert_verify_like_error(err);

    let forged_other_minter = compile_kcc20_state_with_minter(&source, other_owner_bytes.clone(), 600, true, 2, 2);
    let forged_other_minter_outputs = vec![TransactionOutput {
        value: 1_000,
        script_public_key: pay_to_script_hash_script(&forged_other_minter.script),
        covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
    }];
    let forged_other_minter_entries =
        vec![UtxoEntry::new(1_000, pay_to_script_hash_script(&split_other.script), 0, split_tx.is_coinbase(), Some(COV_A))];
    let forged_other_minter_unsigned_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: split_tx.id(), index: 1 }, vec![])],
        forged_other_minter_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );
    let forged_other_minter_sig = sign_tx_input(forged_other_minter_unsigned_tx, forged_other_minter_entries.clone(), 0, &other_owner);
    let forged_other_minter_sigscript = covenant_decl_sigscript(
        &split_other,
        "transfer",
        vec![
            kcc20_state_array_arg_with_minter(vec![(other_owner_bytes.clone(), 600, true)]),
            sig_array_arg(vec![forged_other_minter_sig]),
            witness_array_arg(vec![0]),
        ],
        true,
    );
    let forged_other_minter_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(
            TransactionOutpoint { transaction_id: split_tx.id(), index: 1 },
            forged_other_minter_sigscript,
        )],
        forged_other_minter_outputs,
        0,
        Default::default(),
        0,
        vec![],
    );

    let err = execute_input_with_covenants(forged_other_minter_tx, forged_other_minter_entries, 0)
        .expect_err("KCC20 non-minter branch should reject setting isMinter=true");
    assert_verify_like_error(err);

    let mint_outputs = vec![TransactionOutput {
        value: 1_000,
        script_public_key: pay_to_script_hash_script(&minted_minter.script),
        covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
    }];
    let mint_entries =
        vec![UtxoEntry::new(1_000, pay_to_script_hash_script(&split_minter.script), 0, split_tx.is_coinbase(), Some(COV_A))];
    let mint_unsigned_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: split_tx.id(), index: 0 }, vec![])],
        mint_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );
    let mint_sig = sign_tx_input(mint_unsigned_tx, mint_entries.clone(), 0, &minter_owner);
    let mint_sigscript = covenant_decl_sigscript(
        &split_minter,
        "transfer",
        vec![
            kcc20_state_array_arg_with_minter(vec![(minter_owner_bytes.clone(), 900, true)]),
            sig_array_arg(vec![mint_sig]),
            witness_array_arg(vec![0]),
        ],
        true,
    );
    let mint_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: split_tx.id(), index: 0 }, mint_sigscript)],
        mint_outputs,
        0,
        Default::default(),
        0,
        vec![],
    );

    execute_input_with_covenants(mint_tx.clone(), mint_entries, 0).expect("KCC20 minter should be able to create tokens");

    let burn_outputs = vec![TransactionOutput {
        value: 1_000,
        script_public_key: pay_to_script_hash_script(&burned_minter.script),
        covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
    }];
    let burn_entries =
        vec![UtxoEntry::new(1_000, pay_to_script_hash_script(&minted_minter.script), 0, mint_tx.is_coinbase(), Some(COV_A))];
    let burn_unsigned_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: mint_tx.id(), index: 0 }, vec![])],
        burn_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );
    let burn_sig = sign_tx_input(burn_unsigned_tx, burn_entries.clone(), 0, &minter_owner);
    let burn_sigscript = covenant_decl_sigscript(
        &minted_minter,
        "transfer",
        vec![
            kcc20_state_array_arg_with_minter(vec![(minter_owner_bytes, 500, true)]),
            sig_array_arg(vec![burn_sig]),
            witness_array_arg(vec![0]),
        ],
        true,
    );
    let burn_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: mint_tx.id(), index: 0 }, burn_sigscript)],
        burn_outputs,
        0,
        Default::default(),
        0,
        vec![],
    );

    execute_input_with_covenants(burn_tx, burn_entries, 0).expect("KCC20 minter should be able to burn tokens");
}
}

This flow first splits a minter-capable KCC20 branch into:

  • one minter branch
  • one ordinary branch

It then checks four high-level properties:

  1. the ordinary branch cannot mint extra amount
  2. the ordinary branch cannot promote itself into a minter
  3. the minter branch can increase supply
  4. the minter branch can also decrease supply

The point of the example is to show that mint privilege is attached to the branch’s state and is enforced consistently across successor states.

minter branch
   |
   +--> split into minter + ordinary
   |
   +--> ordinary tries to mint        -> reject
   |
   +--> ordinary tries to become minter -> reject
   |
   +--> minter mints                  -> accept
   |
   +--> minter burns                  -> accept

kcc20_minter_can_mint_in_single_transaction

#![allow(unused)]
fn main() {
#[test]
fn kcc20_minter_can_mint_in_single_transaction() {
    let source = load_example_source("kcc20.sil");

    let genesis_owner = random_keypair();
    let genesis_owner_bytes = genesis_owner.x_only_public_key().0.serialize().to_vec();

    let genesis = compile_kcc20_state_with_minter(&source, genesis_owner_bytes.clone(), 1_000, true, 2, 2);
    let minted = compile_kcc20_state_with_minter(&source, genesis_owner_bytes.clone(), 1_500, true, 2, 2);

    let mint_outputs = vec![TransactionOutput {
        value: 1_000,
        script_public_key: pay_to_script_hash_script(&minted.script),
        covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
    }];
    let mint_entries = vec![covenant_utxo(&genesis, COV_A)];
    let mint_unsigned_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: TransactionId::from_bytes([1; 32]), index: 0 }, vec![])],
        mint_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );
    let mint_sig = sign_tx_input(mint_unsigned_tx, mint_entries.clone(), 0, &genesis_owner);
    let mint_sigscript = covenant_decl_sigscript(
        &genesis,
        "transfer",
        vec![
            kcc20_state_array_arg_with_minter(vec![(genesis_owner_bytes, 1_500, true)]),
            sig_array_arg(vec![mint_sig]),
            witness_array_arg(vec![0]),
        ],
        true,
    );
    let mint_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(
            TransactionOutpoint { transaction_id: TransactionId::from_bytes([1; 32]), index: 0 },
            mint_sigscript,
        )],
        mint_outputs,
        0,
        Default::default(),
        0,
        vec![],
    );

    execute_input_with_covenants(mint_tx, mint_entries, 0).expect("KCC20 minter should be able to mint in a single transaction");
}
}

This is the smallest possible minting example. It starts from one minter-marked branch and moves directly to a larger successor amount in one transition.

At a high level, it isolates the core rule that a minter branch may expand supply, without the extra complexity of splitting, burning, or cross-contract coordination.

minter branch amount

1000 -> 1500

result: accept

kcc20_covenant_minter

#![allow(unused)]
fn main() {
#[test]
fn kcc20_covenant_minter() {
    struct TestTx {
        tx: Transaction,
        entries: Vec<UtxoEntry>,
    }

    impl TestTx {
        fn populated(&self) -> PopulatedTransaction<'_> {
            PopulatedTransaction::new(&self.tx, self.entries.clone())
        }
    }

    let kcc20_source = load_example_source("kcc20.sil");
    let kcc20_minter_source = load_example_source("kcc20-minter.sil");
    const IDENTIFIER_COVENANT_ID: u8 = 0x02;
    const MAX_COV_INS: i64 = 2;
    const MAX_COV_OUTS: i64 = 2;
    const MINTER_AMOUNT: i64 = 1_000;
    const FIRST_MINTED_AMOUNT: i64 = 200;
    const SECOND_MINTED_AMOUNT: i64 = 300;
    const OVER_MINTED_AMOUNT: i64 = 700;
    const FIRST_MINTER_REMAINING_AMOUNT: i64 = MINTER_AMOUNT - FIRST_MINTED_AMOUNT;
    const SECOND_MINTER_REMAINING_AMOUNT: i64 = FIRST_MINTER_REMAINING_AMOUNT - SECOND_MINTED_AMOUNT;
    const OVER_MINT_MINTER_REMAINING_AMOUNT: i64 = SECOND_MINTER_REMAINING_AMOUNT - OVER_MINTED_AMOUNT;

    let owner = random_keypair();
    let alternate_owner = random_keypair();
    let owner_bytes = owner.x_only_public_key().0.serialize().to_vec();
    let alternate_owner_bytes = alternate_owner.x_only_public_key().0.serialize().to_vec();
    let placeholder_kcc20_covid = Hash::from_bytes([0; 32]);
    let funding_spk = ScriptPublicKey::new(0, vec![OpTrue].into());

    // ============================================================
    // shared contract templates
    // ============================================================
    let kcc20_template_probe =
        compile_kcc20_state_full(&kcc20_source, vec![0; 32], 0, IDENTIFIER_COVENANT_ID, true, MAX_COV_INS, MAX_COV_OUTS);
    let (template_prefix, template_suffix, expected_template_hash) = compiled_template_parts_and_hash(&kcc20_template_probe);
    let compile_minter = |kcc20_covid: Hash, amount: i64, initialized: bool| {
        compile_contract(
            &kcc20_minter_source,
            &[
                Expr::bytes(owner_bytes.clone()),
                Expr::bytes(kcc20_covid.as_bytes().to_vec()),
                Expr::int(amount),
                Expr::bool(initialized),
                Expr::int(template_prefix.len() as i64),
                Expr::int(template_suffix.len() as i64),
                Expr::bytes(expected_template_hash.clone()),
                Expr::bytes(template_prefix.clone()),
                Expr::bytes(template_suffix.clone()),
            ],
            CompileOptions::default(),
        )
        .expect("should compile")
    };
    let output_utxo = |output: &TransactionOutput, tx: &Transaction, covenant_id: Hash| {
        UtxoEntry::new(output.value, output.script_public_key.clone(), 0, tx.is_coinbase(), Some(covenant_id))
    };
    let build_tx = |inputs: Vec<TransactionInput>, outputs: Vec<TransactionOutput>, entries: Vec<UtxoEntry>| TestTx {
        tx: Transaction::new(1, inputs, outputs, 0, Default::default(), 0, vec![]),
        entries,
    };
    // ============================================================
    // bootstrap shape
    // ============================================================
    //
    // plain funding utxo
    //     |
    //     v
    // [minter genesis tx] -> C covenant id
    //     |
    //     v
    // [asset genesis/init tx] -> A covenant id + C binds to A

    // ============================================================
    // minter genesis tx: create C
    // ============================================================
    let pre_init = compile_minter(placeholder_kcc20_covid, MINTER_AMOUNT, false);
    let minter_genesis_outpoint = TransactionOutpoint { transaction_id: TransactionId::from_bytes([0x4d; 32]), index: 0 };
    let minter_genesis_input = tx_input_from_outpoint_v1(minter_genesis_outpoint, vec![]);
    let minter_genesis_utxo = UtxoEntry::new(1_500, funding_spk.clone(), 0, false, None);
    let minter_genesis_output_without_covenant =
        TransactionOutput { value: 1_000, script_public_key: pay_to_script_hash_script(&pre_init.script), covenant: None };
    let minter_cov_id = hashing::covenant_id::covenant_id(
        minter_genesis_outpoint,
        std::iter::once((0, &minter_genesis_output_without_covenant)),
    );
    let minter_genesis_outputs = vec![TransactionOutput {
        covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: minter_cov_id }),
        ..minter_genesis_output_without_covenant
    }];
    let minter_genesis_tx = build_tx(vec![minter_genesis_input], minter_genesis_outputs, vec![minter_genesis_utxo]);
    let execute_all_inputs = |label: &str, populated: PopulatedTransaction<'_>| {
        for input_idx in 0..populated.tx.inputs.len() {
            execute_input_with_covenants(populated.tx.clone(), populated.entries.clone(), input_idx)
                .unwrap_or_else(|err| panic!("{label} input {input_idx} should succeed: {err:?}"));
        }
    };

    // ============================================================
    // asset genesis preimage: compute A
    // ============================================================
    let pre_init_utxo = output_utxo(&minter_genesis_tx.tx.outputs[0], &minter_genesis_tx.tx, minter_cov_id);
    let genesis = compile_kcc20_state_full(
        &kcc20_source,
        minter_cov_id.as_bytes().to_vec(),
        0,
        IDENTIFIER_COVENANT_ID,
        true,
        MAX_COV_INS,
        MAX_COV_OUTS,
    );
    assert_eq!(
        compiled_template_parts_and_hash(&genesis),
        (template_prefix.clone(), template_suffix.clone(), expected_template_hash.clone())
    );
    let asset_genesis_outpoint = TransactionOutpoint { transaction_id: minter_genesis_tx.tx.id(), index: 0 };
    let kcc20_genesis_output = covenant_output(&genesis, 0, Hash::from_bytes([0; 32]));
    let kcc20_covenant_id = hashing::covenant_id::covenant_id(asset_genesis_outpoint, std::iter::once((0, &kcc20_genesis_output)));

    // ============================================================
    // mint tx builder: spend A and C together
    // ============================================================
    let build_mint_tx = |prev_tx: &TestTx,
                         prev_kcc20: &CompiledContract<'_>,
                         prev_minter: &CompiledContract<'_>,
                         next_minter_kcc20: &CompiledContract<'_>,
                         next_recipient_kcc20: &CompiledContract<'_>,
                         next_minter: &CompiledContract<'_>,
                         minted_amount: i64,
                         next_minter_amount: i64| {
        let outputs = vec![
            covenant_output(next_minter_kcc20, 0, kcc20_covenant_id),
            covenant_output(next_recipient_kcc20, 0, kcc20_covenant_id),
            covenant_output(next_minter, 1, minter_cov_id),
        ];
        let entries = vec![
            output_utxo(&prev_tx.tx.outputs[0], &prev_tx.tx, kcc20_covenant_id),
            output_utxo(prev_tx.tx.outputs.last().expect("previous tx has minter output"), &prev_tx.tx, minter_cov_id),
        ];
        let unsigned = build_tx(
            vec![
                tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: prev_tx.tx.id(), index: 0 }, vec![]),
                tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: prev_tx.tx.id(), index: 1 }, vec![]),
            ],
            outputs.clone(),
            entries.clone(),
        );
        let minter_sig = sign_tx_input(unsigned.tx.clone(), unsigned.entries.clone(), 1, &owner);
        let kcc20_sigscript = covenant_decl_sigscript(
            prev_kcc20,
            "transfer",
            vec![
                kcc20_state_array_arg_full(vec![
                    (minter_cov_id.as_bytes().to_vec(), IDENTIFIER_COVENANT_ID, 0, true),
                    (owner_bytes.clone(), 0, minted_amount, false),
                ]),
                sig_array_arg(vec![]),
                witness_array_arg(vec![1]),
            ],
            true,
        );
        let minter_sigscript = covenant_decl_sigscript(
            prev_minter,
            "mint",
            vec![
                kcc20_minter_state_arg(kcc20_covenant_id.as_bytes().to_vec(), next_minter_amount, true),
                Expr::bytes(minter_sig),
                kcc20_state_arg(minter_cov_id.as_bytes().to_vec(), IDENTIFIER_COVENANT_ID, 0, true),
                kcc20_state_arg(owner_bytes.clone(), 0, minted_amount, false),
            ],
            true,
        );
        build_tx(
            vec![
                tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: prev_tx.tx.id(), index: 0 }, kcc20_sigscript),
                tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: prev_tx.tx.id(), index: 1 }, minter_sigscript),
            ],
            outputs,
            entries,
        )
    };

    let minter_post_init = compile_minter(kcc20_covenant_id, MINTER_AMOUNT, true);

    // ============================================================
    // asset genesis tx: create A and bind C to A
    // ============================================================
    let asset_genesis_outputs =
        vec![covenant_output(&genesis, 0, kcc20_covenant_id), covenant_output(&minter_post_init, 0, minter_cov_id)];
    let asset_genesis_unsigned = build_tx(
        vec![tx_input_from_outpoint_v1(asset_genesis_outpoint, vec![])],
        asset_genesis_outputs.clone(),
        vec![pre_init_utxo.clone()],
    );
    let asset_genesis_sig = sign_tx_input(asset_genesis_unsigned.tx.clone(), asset_genesis_unsigned.entries.clone(), 0, &owner);
    let asset_genesis_sigscript = covenant_decl_sigscript(
        &pre_init,
        "init",
        vec![
            kcc20_minter_state_arg(kcc20_covenant_id.as_bytes().to_vec(), MINTER_AMOUNT, true),
            Expr::bytes(asset_genesis_sig),
        ],
        true,
    );
    let asset_genesis_tx = build_tx(
        vec![tx_input_from_outpoint_v1(asset_genesis_outpoint, asset_genesis_sigscript)],
        asset_genesis_outputs.clone(),
        vec![pre_init_utxo.clone()],
    );

    // ============================================================
    // first mint tx: issue spendable supply
    // ============================================================
    let kcc20_minter_after_first_mint = compile_kcc20_state_full(
        &kcc20_source,
        minter_cov_id.as_bytes().to_vec(),
        0,
        IDENTIFIER_COVENANT_ID,
        true,
        MAX_COV_INS,
        MAX_COV_OUTS,
    );
    let kcc20_recipient_after_first_mint =
        compile_kcc20_state(&kcc20_source, owner_bytes.clone(), FIRST_MINTED_AMOUNT, MAX_COV_INS, MAX_COV_OUTS);
    let minter_after_first_mint = compile_minter(kcc20_covenant_id, FIRST_MINTER_REMAINING_AMOUNT, true);
    let first_mint_tx = build_mint_tx(
        &asset_genesis_tx,
        &genesis,
        &minter_post_init,
        &kcc20_minter_after_first_mint,
        &kcc20_recipient_after_first_mint,
        &minter_after_first_mint,
        FIRST_MINTED_AMOUNT,
        FIRST_MINTER_REMAINING_AMOUNT,
    );

    // ============================================================
    // recipient transfer tx: prove minted tokens remain transferable
    // ============================================================
    let kcc20_recipient_after_transfer =
        compile_kcc20_state(&kcc20_source, alternate_owner_bytes.clone(), FIRST_MINTED_AMOUNT, MAX_COV_INS, MAX_COV_OUTS);
    let recipient_transfer_outputs = vec![covenant_output(&kcc20_recipient_after_transfer, 0, kcc20_covenant_id)];
    let recipient_transfer_entries = vec![output_utxo(&first_mint_tx.tx.outputs[1], &first_mint_tx.tx, kcc20_covenant_id)];
    let recipient_transfer_unsigned = build_tx(
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: first_mint_tx.tx.id(), index: 1 }, vec![])],
        recipient_transfer_outputs.clone(),
        recipient_transfer_entries.clone(),
    );
    let recipient_transfer_sig =
        sign_tx_input(recipient_transfer_unsigned.tx.clone(), recipient_transfer_unsigned.entries.clone(), 0, &owner);
    let recipient_transfer_sigscript = covenant_decl_sigscript(
        &kcc20_recipient_after_first_mint,
        "transfer",
        vec![
            kcc20_state_array_arg(vec![(alternate_owner_bytes.clone(), FIRST_MINTED_AMOUNT)]),
            sig_array_arg(vec![recipient_transfer_sig]),
            witness_array_arg(vec![0]),
        ],
        true,
    );
    let recipient_transfer_tx = build_tx(
        vec![tx_input_from_outpoint_v1(
            TransactionOutpoint { transaction_id: first_mint_tx.tx.id(), index: 1 },
            recipient_transfer_sigscript,
        )],
        recipient_transfer_outputs,
        recipient_transfer_entries,
    );

    // ============================================================
    // second mint tx: continue from the minter branch
    // ============================================================
    let kcc20_minter_after_second_mint = compile_kcc20_state_full(
        &kcc20_source,
        minter_cov_id.as_bytes().to_vec(),
        0,
        IDENTIFIER_COVENANT_ID,
        true,
        MAX_COV_INS,
        MAX_COV_OUTS,
    );
    let kcc20_recipient_after_second_mint =
        compile_kcc20_state(&kcc20_source, owner_bytes.clone(), SECOND_MINTED_AMOUNT, MAX_COV_INS, MAX_COV_OUTS);
    let minter_after_second_mint = compile_minter(kcc20_covenant_id, SECOND_MINTER_REMAINING_AMOUNT, true);
    let second_mint_tx = build_mint_tx(
        &first_mint_tx,
        &kcc20_minter_after_first_mint,
        &minter_after_first_mint,
        &kcc20_minter_after_second_mint,
        &kcc20_recipient_after_second_mint,
        &minter_after_second_mint,
        SECOND_MINTED_AMOUNT,
        SECOND_MINTER_REMAINING_AMOUNT,
    );

    // ============================================================
    // over-mint tx: construct an invalid mint past remaining supply
    // ============================================================
    let kcc20_minter_after_over_mint = compile_kcc20_state_full(
        &kcc20_source,
        minter_cov_id.as_bytes().to_vec(),
        0,
        IDENTIFIER_COVENANT_ID,
        true,
        MAX_COV_INS,
        MAX_COV_OUTS,
    );
    let kcc20_recipient_after_over_mint =
        compile_kcc20_state(&kcc20_source, vec![0; 32], OVER_MINTED_AMOUNT, MAX_COV_INS, MAX_COV_OUTS);
    let minter_after_over_mint = compile_minter(kcc20_covenant_id, OVER_MINT_MINTER_REMAINING_AMOUNT, true);
    let over_mint_tx = build_mint_tx(
        &second_mint_tx,
        &kcc20_minter_after_second_mint,
        &minter_after_second_mint,
        &kcc20_minter_after_over_mint,
        &kcc20_recipient_after_over_mint,
        &minter_after_over_mint,
        OVER_MINTED_AMOUNT,
        OVER_MINT_MINTER_REMAINING_AMOUNT,
    );

    // ============================================================
    // accept valid chain
    // ============================================================
    execute_all_inputs(stringify!(minter_genesis_tx), minter_genesis_tx.populated());
    execute_all_inputs(stringify!(asset_genesis_tx), asset_genesis_tx.populated());
    execute_all_inputs(stringify!(first_mint_tx), first_mint_tx.populated());
    execute_all_inputs(stringify!(recipient_transfer_tx), recipient_transfer_tx.populated());
    execute_all_inputs(stringify!(second_mint_tx), second_mint_tx.populated());

    // ============================================================
    // reject invalid continuation
    // ============================================================
    let err =
        execute_input_with_covenants(over_mint_tx.tx.clone(), over_mint_tx.entries.clone(), 1).expect_err("over-mint should fail");
    assert_verify_like_error(err);
}
}

This is the full two-contract story:

  1. a plain funding UTXO creates an uninitialized KCC20Minter covenant C
  2. the asset genesis transaction creates the KCC20 covenant A and calls init so C binds to A
  3. the KCC20 minter branch is owned by covenant ID C
  4. each mint spends the KCC20 minter branch and the KCC20Minter together
  5. every successful mint recreates a zero-amount KCC20 minter branch and also creates a separate recipient KCC20 output with the newly minted amount
  6. the first recipient output is then spent like an ordinary KCC20 branch to a different pubkey owner
  7. once the requested mint exceeds the remaining issuance allowance, the mint is rejected

At a high level, this is the example that shows covenant composition: one covenant carries the token state, while another covenant governs issuance policy for that token.

minter_genesis_tx
  plain funding utxo
      ->
  KCC20Minter(uninitialized) with covenant id C

asset_genesis_tx
  KCC20Minter(C, uninitialized)
      ->
  KCC20(A, minter branch, owner C, 0) + KCC20Minter(C, bound to A, issuance allowance 1000)

first_mint_tx
  KCC20(minter 0) + Minter(1000)
      ->
  KCC20(minter 0) + KCC20(recipient 200) + Minter(800)

second_mint_tx
  KCC20(minter 0) + Minter(800)
      ->
  KCC20(minter 0) + KCC20(recipient 300) + Minter(500)

recipient_transfer_tx
  spend the first_mint_tx recipient output
      ->
  KCC20(alternate pubkey owner, 200)

over_mint_tx
  request exceeds remaining issuance allowance
      ->
  reject

kcc20_non_minter_can_spend_script_hash_and_covenant_id_owned_outputs

#![allow(unused)]
fn main() {
#[test]
fn kcc20_non_minter_can_spend_script_hash_and_covenant_id_owned_outputs() {
    let source = load_example_source("kcc20.sil");
    const IDENTIFIER_SCRIPT_HASH: u8 = 0x01;
    const IDENTIFIER_COVENANT_ID: u8 = 0x02;

    let genesis_owner = random_keypair();
    let multisig_spend_destination_owner = random_keypair();
    let covenant_spend_destination_owner = random_keypair();
    let multisig_key_0 = random_keypair();
    let multisig_key_1 = random_keypair();
    let multisig_key_2 = random_keypair();

    let genesis_owner_bytes = genesis_owner.x_only_public_key().0.serialize().to_vec();
    let multisig_spend_destination_owner_bytes = multisig_spend_destination_owner.x_only_public_key().0.serialize().to_vec();
    let covenant_spend_destination_owner_bytes = covenant_spend_destination_owner.x_only_public_key().0.serialize().to_vec();

    let multisig_redeem_script = multisig_redeem_script(
        vec![
            multisig_key_0.x_only_public_key().0.serialize(),
            multisig_key_1.x_only_public_key().0.serialize(),
            multisig_key_2.x_only_public_key().0.serialize(),
        ]
        .into_iter(),
        2,
    )
    .expect("multisig redeem script builds");
    let multisig_script_hash = blake2b32(&multisig_redeem_script);
    let covenant_owner = Hash::from_bytes(*b"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC");
    let covenant_owner_bytes = covenant_owner.as_bytes().to_vec();

    let genesis = compile_kcc20_state(&source, genesis_owner_bytes.clone(), 1_000, 2, 2);
    let split_states = [
        compile_kcc20_state_full(&source, multisig_script_hash.clone(), 400, IDENTIFIER_SCRIPT_HASH, false, 2, 2),
        compile_kcc20_state_full(&source, covenant_owner_bytes.clone(), 600, IDENTIFIER_COVENANT_ID, false, 2, 2),
    ];

    let split_outputs: Vec<_> = split_states
        .iter()
        .map(|state| TransactionOutput {
            value: 150,
            script_public_key: pay_to_script_hash_script(&state.script),
            covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
        })
        .collect();
    let split_entries = vec![covenant_utxo(&genesis, COV_A)];
    let split_unsigned_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: TransactionId::from_bytes([1; 32]), index: 0 }, vec![])],
        split_outputs.clone(),
        0,
        Default::default(),
        0,
        vec![],
    );
    let split_sig = sign_tx_input(split_unsigned_tx, split_entries.clone(), 0, &genesis_owner);
    let split_sigscript = covenant_decl_sigscript(
        &genesis,
        "transfer",
        vec![
            kcc20_state_array_arg_full(vec![
                (multisig_script_hash.clone(), IDENTIFIER_SCRIPT_HASH, 400, false),
                (covenant_owner_bytes.clone(), IDENTIFIER_COVENANT_ID, 600, false),
            ]),
            sig_array_arg(vec![split_sig]),
            witness_array_arg(vec![0]),
        ],
        true,
    );
    let split_tx = Transaction::new(
        1,
        vec![tx_input_from_outpoint_v1(
            TransactionOutpoint { transaction_id: TransactionId::from_bytes([1; 32]), index: 0 },
            split_sigscript,
        )],
        split_outputs,
        0,
        Default::default(),
        0,
        vec![],
    );

    execute_input_with_covenants(split_tx.clone(), split_entries, 0).expect("KCC20 non-minter split should succeed");

    let build_single_output = |state: &CompiledContract<'_>| {
        vec![TransactionOutput {
            value: 150,
            script_public_key: pay_to_script_hash_script(&state.script),
            covenant: Some(CovenantBinding { authorizing_input: 0, covenant_id: COV_A }),
        }]
    };
    let build_spend_tx =
        |kcc20_index: u32, auxiliary_outpoint: Option<TransactionOutpoint>, sigscript: Vec<u8>, outputs: Vec<TransactionOutput>| {
            let mut inputs =
                vec![tx_input_from_outpoint_v1(TransactionOutpoint { transaction_id: split_tx.id(), index: kcc20_index }, sigscript)];
            if let Some(outpoint) = auxiliary_outpoint {
                inputs.push(tx_input_from_outpoint_v1(outpoint, vec![]));
            }
            Transaction::new(1, inputs, outputs, 0, Default::default(), 0, vec![])
        };
    let build_kcc20_sigscript = |state: &CompiledContract<'_>, destination_owner: Vec<u8>, amount: i64, witness: u8| {
        covenant_decl_sigscript(
            state,
            "transfer",
            vec![kcc20_state_array_arg(vec![(destination_owner, amount)]), sig_array_arg(vec![]), witness_array_arg(vec![witness])],
            true,
        )
    };

    let script_hash_spent = compile_kcc20_state(&source, multisig_spend_destination_owner_bytes.clone(), 400, 2, 2);
    let script_hash_spend_outputs = build_single_output(&script_hash_spent);
    let script_hash_spend_entries = vec![
        UtxoEntry::new(150, pay_to_script_hash_script(&split_states[0].script), 0, split_tx.is_coinbase(), Some(COV_A)),
        UtxoEntry::new(500, pay_to_script_hash_script(&multisig_redeem_script), 0, false, None),
    ];
    let script_hash_auxiliary_outpoint = TransactionOutpoint { transaction_id: TransactionId::from_bytes([2; 32]), index: 0 };

    {
        let script_hash_wrong_witness_tx = build_spend_tx(
            0,
            Some(script_hash_auxiliary_outpoint),
            build_kcc20_sigscript(&split_states[0], multisig_spend_destination_owner_bytes.clone(), 400, 0),
            script_hash_spend_outputs.clone(),
        );
        let err = execute_input_with_covenants(script_hash_wrong_witness_tx, script_hash_spend_entries.clone(), 0)
            .expect_err("KCC20 script-hash-owned tokens should reject the wrong witness index");
        assert_verify_like_error(err);
    }

    {
        let script_hash_missing_extra_tx = build_spend_tx(
            0,
            None,
            build_kcc20_sigscript(&split_states[0], multisig_spend_destination_owner_bytes.clone(), 400, 0),
            script_hash_spend_outputs.clone(),
        );
        let err = execute_input_with_covenants(script_hash_missing_extra_tx, vec![script_hash_spend_entries[0].clone()], 0)
            .expect_err("KCC20 script-hash-owned tokens should reject a spend without the matching p2sh input");
        assert_verify_like_error(err);
    }

    {
        let script_hash_wrong_owner_entries = vec![
            script_hash_spend_entries[0].clone(),
            UtxoEntry::new(500, pay_to_script_hash_script(&[0x51]), 0, false, Some(covenant_owner)),
        ];
        let script_hash_wrong_owner_tx = build_spend_tx(
            0,
            Some(TransactionOutpoint { transaction_id: TransactionId::from_bytes([4; 32]), index: 0 }),
            build_kcc20_sigscript(&split_states[0], multisig_spend_destination_owner_bytes.clone(), 400, 1),
            script_hash_spend_outputs.clone(),
        );
        let err = execute_input_with_covenants(script_hash_wrong_owner_tx, script_hash_wrong_owner_entries, 0)
            .expect_err("KCC20 script-hash-owned tokens should reject a covenant-id witness input");
        assert_verify_like_error(err);
    }

    {
        let script_hash_spend_tx = build_spend_tx(
            0,
            Some(script_hash_auxiliary_outpoint),
            build_kcc20_sigscript(&split_states[0], multisig_spend_destination_owner_bytes.clone(), 400, 1),
            script_hash_spend_outputs,
        );
        execute_input_with_covenants(script_hash_spend_tx, script_hash_spend_entries, 0)
            .expect("KCC20 script-hash-owned tokens should spend when the matching p2sh input is present");
    }

    let covenant_id_spent = compile_kcc20_state(&source, covenant_spend_destination_owner_bytes.clone(), 600, 2, 2);
    let covenant_id_spend_outputs = build_single_output(&covenant_id_spent);
    let covenant_id_spend_entries = vec![
        UtxoEntry::new(150, pay_to_script_hash_script(&split_states[1].script), 0, split_tx.is_coinbase(), Some(COV_A)),
        UtxoEntry::new(500, pay_to_script_hash_script(&[0x51]), 0, false, Some(covenant_owner)),
    ];
    let covenant_id_auxiliary_outpoint = TransactionOutpoint { transaction_id: TransactionId::from_bytes([3; 32]), index: 0 };

    {
        let covenant_id_wrong_witness_tx = build_spend_tx(
            1,
            Some(covenant_id_auxiliary_outpoint),
            build_kcc20_sigscript(&split_states[1], covenant_spend_destination_owner_bytes.clone(), 600, 0),
            covenant_id_spend_outputs.clone(),
        );
        let err = execute_input_with_covenants(covenant_id_wrong_witness_tx, covenant_id_spend_entries.clone(), 0)
            .expect_err("KCC20 covenant-id-owned tokens should reject the wrong witness index");
        assert_verify_like_error(err);
    }

    {
        let covenant_id_missing_extra_tx = build_spend_tx(
            1,
            None,
            build_kcc20_sigscript(&split_states[1], covenant_spend_destination_owner_bytes.clone(), 600, 0),
            covenant_id_spend_outputs.clone(),
        );
        let err = execute_input_with_covenants(covenant_id_missing_extra_tx, vec![covenant_id_spend_entries[0].clone()], 0)
            .expect_err("KCC20 covenant-id-owned tokens should reject a spend without the matching covenant input");
        assert_verify_like_error(err);
    }

    {
        let covenant_id_wrong_owner_entries = vec![
            covenant_id_spend_entries[0].clone(),
            UtxoEntry::new(500, pay_to_script_hash_script(&multisig_redeem_script), 0, false, None),
        ];
        let covenant_id_wrong_owner_tx = build_spend_tx(
            1,
            Some(TransactionOutpoint { transaction_id: TransactionId::from_bytes([5; 32]), index: 0 }),
            build_kcc20_sigscript(&split_states[1], covenant_spend_destination_owner_bytes.clone(), 600, 1),
            covenant_id_spend_outputs.clone(),
        );
        let err = execute_input_with_covenants(covenant_id_wrong_owner_tx, covenant_id_wrong_owner_entries, 0)
            .expect_err("KCC20 covenant-id-owned tokens should reject a multisig witness input");
        assert_verify_like_error(err);
    }

    {
        let covenant_id_spend_tx = build_spend_tx(
            1,
            Some(covenant_id_auxiliary_outpoint),
            build_kcc20_sigscript(&split_states[1], covenant_spend_destination_owner_bytes, 600, 1),
            covenant_id_spend_outputs,
        );
        execute_input_with_covenants(covenant_id_spend_tx, covenant_id_spend_entries, 0)
            .expect("KCC20 covenant-id-owned tokens should spend when the matching covenant input is present");
    }
}
}

This example explores the two non-pubkey ownership modes in KCC20.

It first creates one script-hash-owned branch and one covenant-ID-owned branch. It then checks, for each mode:

  • the wrong witness input is rejected
  • a missing matching input is rejected
  • the wrong kind of owner input is rejected
  • the correct matching input is accepted

At a high level, this shows that KCC20 ownership is programmable: a branch can be controlled either by another script or by another covenant, not just by a key.

script-hash-owned branch:
  wrong witness      -> reject
  missing script     -> reject
  wrong owner kind   -> reject
  matching script    -> accept

covenant-ID-owned branch:
  wrong witness      -> reject
  missing covenant   -> reject
  wrong owner kind   -> reject
  matching covenant  -> accept