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

Introduction

This book explains two SilverScript examples together:

  • silverscript-lang/tests/examples/kcc20.sil [Link]
  • silverscript-lang/tests/examples/kcc20-minter.sil [Link]

Together they form a worked example of a covenant-controlled fungible token system in SilverScript.

The example is interesting because it is not just “a token owned by a pubkey”. It demonstrates:

  • token state carried in covenant state
  • ownership by pubkey, by script hash, or by covenant ID
  • mint-capable and non-mint-capable token branches
  • a separate controller covenant that controls issuance
  • cross-contract linkage through covenant IDs
  • template-based validation of another contract’s state shape
  • covenant declaration flows for initialization, transfer, and minting

The contracts are examples, not a production token standard. Their value is that they show what the SilverScript covenant model can express.

The rest of this book is organized as follows:

  • KCC20 At A Glance describes the system as a whole.
  • The KCC20 Contract explains the token covenant itself.
  • The KCC20Minter Contract explains the companion issuance controller.
  • How The Examples Are Used explains the kinds of situations these examples are meant to model.
  • Example Walkthroughs explains the main flows and failure cases.
  • What The Examples Demonstrate summarizes the larger ideas these contracts are designed to show.

KCC20 At A Glance

The example is split into two contracts with different responsibilities.

KCC20

KCC20 is the token state machine.

Each KCC20 covenant output represents token state with four fields:

  • ownerIdentifier
  • identifierType
  • amount
  • isMinter

The meaning of ownerIdentifier depends on identifierType.

  • If identifierType == IDENTIFIER_PUBKEY, the owner identifier is a pubkey and a matching signature is required.
  • If identifierType == IDENTIFIER_SCRIPT_HASH, the owner identifier is a P2SH script hash and the transaction must include an input whose scriptPubKey matches that hash.
  • If identifierType == IDENTIFIER_COVENANT_ID, the owner identifier is a covenant ID and the transaction must include an input whose covenant ID matches it.

So the same token contract supports multiple ownership modes without changing the contract code.

KCC20 also uses isMinter to distinguish ordinary token branches from mint-authorized branches.

  • Ordinary branches must conserve supply.
  • Minter branches may increase or decrease supply.

KCC20Minter

KCC20Minter is the controller covenant used by this example. It controls issuance against a particular KCC20 covenant instance.

Its state is:

  • kcc20Covid
  • amount
  • initialized

The field name kcc20Covid is just the name used in the example source. Functionally, it stores the KCC20 covenant ID that this minter controls.

KCC20Minter also carries template metadata for the KCC20 contract:

  • templatePrefixLen
  • templateSuffixLen
  • expectedTemplateHash
  • templatePrefix
  • templateSuffix

That metadata lets the minter read and validate KCC20 state by template rather than blindly trusting that some output “looks like” a KCC20 output.

Controller Covenant Terminology

This book uses controller covenant for the external policy covenant whose covenant ID owns a privileged KCC20 minter branch.

In this book, issuance means the policy governing when new token supply may be created; minting means the concrete transaction-level act of creating that supply.

In this example:

  • A is the KCC20 asset covenant ID
  • C is the controller covenant ID
  • KCC20Minter is one concrete controller covenant implementation
  • owner is the admin pubkey that signs controller actions

The distinction matters. The KCC20 minter branch is not owned by the admin pubkey directly. It is owned by covenant ID C. The admin key authorizes the KCC20Minter script, and that script decides how its authority over asset A may be used.

How They Fit Together

The two contracts are meant to be read as one system.

KCC20 is the asset contract. It defines what a token state looks like, how ownership works, and when supply may or may not change.

KCC20Minter is the controller covenant. It does not redefine what a KCC20 token is. Instead, it binds itself to one KCC20 covenant instance and restricts how that particular KCC20 branch may be expanded over time.

So the relationship is:

  • KCC20 answers: “what counts as a valid token transition?”
  • KCC20Minter answers: “under what policy may new KCC20 tokens be issued?”

The contracts fit together through covenant-ID ownership, template validation, and a concrete transaction-level proof of control.

Inter-Covenant Communication

These examples also illustrate a practical form of inter-covenant communication, often abbreviated as ICC.

The key constraint is that there is no eval mechanism here. One covenant cannot directly execute another covenant’s code by reference inside the current script.

So when KCC20 wants to treat a token branch as “owned by another covenant”, it uses a different proof model:

  • the KCC20 state stores a covenant ID as the owner identifier
  • the spending transaction must include an input owned by that covenant
  • KCC20 checks that one of the chosen witness inputs has the matching covenant ID

In other words, the proof that “this token is owned by that contract” is not an abstract reference. The proof is that the KCC20 transaction actually spends a UTXO owned by that contract.

That is why covenant-ID ownership is so important in this example. It gives a concrete, transaction-level way for one covenant to demonstrate control over another covenant’s state.

Lifecycle

At a high level, the system is meant to work in three phases:

  • a minter genesis phase, where the controller covenant is created and receives covenant ID C
  • an asset genesis phase, where the KCC20 asset covenant is created with covenant ID A, while C binds itself to A
  • an issuance phase, where KCC20 and KCC20Minter are spent together and each checks its side of the rules

The intended lifecycle is:

  1. Spend a plain funding UTXO into an uninitialized KCC20Minter.
  2. The minter genesis transaction creates the controller covenant ID C using normal covenant genesis hashing.
  3. Spend C through init.
  4. In the same asset genesis transaction, create:
    • a KCC20 minter branch with amount 0
    • a new initialized minter output
  5. The KCC20 minter branch is owned by C.
  6. init stores the newly created KCC20 covenant ID A in the minter state.
  7. Later, spend both contracts together:
    • the KCC20 minter branch
    • the KCC20Minter output
  8. In each mint transaction, create:
    • a fresh zero-amount KCC20 minter branch
    • a separate KCC20 recipient output holding the newly minted amount
    • the next KCC20Minter output with reduced issuance allowance
  9. KCC20 authorizes the token transition.
  10. KCC20Minter verifies the issuance rule and decrements its remaining issuance allowance.

This means the token contract and the minter contract do not collapse into one script with one giant policy. They stay separate, and each one verifies the part of the transaction it is responsible for.

Separation Of Responsibility

This cleanly separates concerns:

  • KCC20 defines ownership and transfer semantics.
  • KCC20Minter defines issuance policy.

That split is the main architectural point of the example. The token contract is reusable as a token state machine, while the minter contract provides one particular issuance model on top of it.

System Diagram

KCC20Minter
  |
  | governs issuance for
  v
KCC20

Lifecycle Diagram

flowchart TD
    F[Plain funding UTXO]

    MG[minter_genesis_tx<br/>creates controller covenant C]
    C0[C: KCC20Minter<br/>initialized = false<br/>kcc20Covid = placeholder]

    AG[asset_genesis_tx<br/>spends C through init]
    A0[A: KCC20 minter branch<br/>ownerIdentifier = C<br/>amount = 0]
    C1[C: KCC20Minter<br/>initialized = true<br/>kcc20Covid = A]

    MT[later mint transactions<br/>spend A and C together]
    A1[A: recreated minter branch<br/>amount = 0]
    R[KCC20 recipient branch<br/>newly minted amount]
    C2[C: KCC20Minter<br/>reduced issuance allowance]

    F --> MG --> C0 --> AG
    AG --> A0
    AG --> C1
    A0 --> MT
    C1 --> MT
    MT --> A1
    MT --> R
    MT --> C2

The KCC20 Contract

Source: silverscript-lang/tests/examples/kcc20.sil [Link]

Full Source

contract KCC20(byte[32] genesisPk, int genesisAmount, byte genesisIdentifierType, bool genesisIsMinter, int maxCovIns, int maxCovOuts) {
    byte constant IDENTIFIER_PUBKEY = 0x00;
    byte constant IDENTIFIER_SCRIPT_HASH = 0x01;
    byte constant IDENTIFIER_COVENANT_ID = 0x02;
    
    byte[32] ownerIdentifier = genesisPk;
    byte identifierType = genesisIdentifierType;
    int amount = genesisAmount;
    bool isMinter = genesisIsMinter;

    function checkSigs(State[] prevStates, sig[] sigs, byte[] witnesses) {
        for(i, 0, prevStates.length, maxCovIns) {
            if(prevStates[i].identifierType == IDENTIFIER_PUBKEY){
                require(checkSig(sigs[i], prevStates[i].ownerIdentifier));
            } else if(prevStates[i].identifierType == IDENTIFIER_SCRIPT_HASH){
                byte[] spk = new ScriptPubKeyP2SH(prevStates[i].ownerIdentifier);
                require(tx.inputs[witnesses[i]].scriptPubKey == spk);
            } else if(prevStates[i].identifierType == IDENTIFIER_COVENANT_ID){
                require(OpInputCovenantId(witnesses[i]) == prevStates[i].ownerIdentifier);
            } else {
                require(false);
            }
        }
    }

    function checkAmounts(State[] prevStates, State[] newStates) {
        if(!isMinter){
            int totalIn = 0;
            for(i, 0, prevStates.length, maxCovIns) {
                totalIn = totalIn + prevStates[i].amount;
            }

            int totalOut = 0;
            for(i, 0, newStates.length, maxCovOuts) {
                totalOut = totalOut + newStates[i].amount;
            }

            require(totalIn == totalOut);
        }
    }

    function checkMintingTransfer(State[] newStates){
        if(!isMinter){
            for(i, 0, newStates.length, maxCovOuts) {
                require(!newStates[i].isMinter);
            }
        }
    }

    #[covenant(binding = cov, from = maxCovIns, to = maxCovOuts)]
    function transfer(State[] prevStates, State[] newStates, sig[] sigs, byte[] witnesses) {
        checkSigs(prevStates, sigs, witnesses);
        checkAmounts(prevStates, newStates);
        checkMintingTransfer(newStates);
    }
}

Constructor Parameters

The contract constructor is:

contract KCC20(
    byte[32] genesisPk,
    int genesisAmount,
    byte genesisIdentifierType,
    bool genesisIsMinter,
    int maxCovIns,
    int maxCovOuts
)

These constructor values become the initial state and loop bounds.

  • genesisPk becomes the initial ownerIdentifier
  • genesisAmount becomes the initial amount
  • genesisIdentifierType becomes the initial ownership mode
  • genesisIsMinter marks whether the branch starts with mint privileges
  • maxCovIns and maxCovOuts cap covenant fan-in and fan-out loops

State Layout

The contract state is encoded as contract fields:

byte[32] ownerIdentifier = genesisPk;
byte identifierType = genesisIdentifierType;
int amount = genesisAmount;
bool isMinter = genesisIsMinter;

Every covenant transition reads and writes these fields as State.

Ownership Modes

KCC20 defines three constants:

byte constant IDENTIFIER_PUBKEY = 0x00;
byte constant IDENTIFIER_SCRIPT_HASH = 0x01;
byte constant IDENTIFIER_COVENANT_ID = 0x02;

These constants drive checkSigs.

Pubkey ownership

require(checkSig(sigs[i], prevStates[i].ownerIdentifier));

The spender must supply a signature matching the previous state’s pubkey.

Script-hash ownership

byte[] spk = new ScriptPubKeyP2SH(prevStates[i].ownerIdentifier);
require(tx.inputs[witnesses[i]].scriptPubKey == spk);

Here KCC20 does not validate signatures itself. Instead it requires that the transaction include an input whose scriptPubKey corresponds to the owner script hash. In other words, the script-hash-owned KCC20 branch is authorized by the presence of a matching P2SH-controlled input.

Covenant-ID ownership

require(OpInputCovenantId(witnesses[i]) == prevStates[i].ownerIdentifier);

This lets a KCC20 branch be owned by another covenant. Spending it requires a witness input whose covenant ID matches the owner identifier.

Ownership Diagram

identifierType = 0x00  -> pubkey ownership
identifierType = 0x01  -> script-hash ownership
identifierType = 0x02  -> covenant-ID ownership

checkSigs

The first major function is:

function checkSigs(State[] prevStates, sig[] sigs, byte[] witnesses)

It iterates over previous states and checks authorization according to each state’s ownership mode.

Important details:

  • prevStates is an array because the contract supports covenant fan-in.
  • sigs is parallel to prevStates for pubkey-owned branches.
  • witnesses gives input indexes that the contract should inspect for script-hash and covenant-ID ownership.
  • witnesses exists so the contract can jump directly to the relevant transaction inputs instead of scanning all inputs to discover which one should authorize each previous state.
  • the loop upper bound is controlled by maxCovIns

This function is the core of KCC20’s flexible ownership model.

For the non-pubkey ownership case, see the Inter-Covenant Communication explanation in the overview chapter.

checkAmounts

The supply rule lives in:

function checkAmounts(State[] prevStates, State[] newStates)

It only enforces conservation when the active branch is not a minter:

if(!isMinter) {
    ...
    require(totalIn == totalOut);
}

So KCC20 has two distinct modes:

  • isMinter == false: token supply must be preserved across the transition
  • isMinter == true: the branch may increase or decrease amount

This design makes mint and burn behavior a property of a particular branch of token state rather than a separate opcode or special-case function.

Supply Rule Diagram

ordinary branch:
  total input amount == total output amount

minter branch:
  total output amount may change

checkMintingTransfer

The third function is:

function checkMintingTransfer(State[] newStates)

It prevents non-minter branches from creating minter-marked outputs:

if(!isMinter) {
    for(i, 0, newStates.length, maxCovOuts) {
        require(!newStates[i].isMinter);
    }
}

This matters because otherwise an ordinary KCC20 branch could escape the supply rules simply by setting isMinter = true in a child state.

The Covenant Entrypoint

KCC20 exposes one covenant declaration:

#[covenant(binding = cov, from = maxCovIns, to = maxCovOuts)]
function transfer(State[] prevStates, State[] newStates, sig[] sigs, byte[] witnesses)

The important parts are:

  • binding = cov: this is a covenant-bound transition, not an auth-only wrapper
  • from = maxCovIns: the transition may consume up to that many covenant inputs
  • to = maxCovOuts: the transition may produce up to that many covenant outputs

The body is intentionally small:

checkSigs(prevStates, sigs, witnesses);
checkAmounts(prevStates, newStates);
checkMintingTransfer(newStates);

That compact entrypoint is possible because the real policy is factored into the three functions above.

The KCC20Minter Contract

Source: silverscript-lang/tests/examples/kcc20-minter.sil [Link]

Full Source

contract KCC20Minter(pubkey owner, byte[32] initKCC20Covid, int initAmount,
    bool initInitialized, int templatePrefixLen, int templateSuffixLen, byte[32] expectedTemplateHash,
    byte[] templatePrefix, byte[] templateSuffix) {

    byte[32] kcc20Covid = initKCC20Covid;
    int amount = initAmount;
    bool initialized = initInitialized;

    struct KCC20State {
        byte[32] ownerIdentifier;
        byte identifierType;
        int amount;
        bool isMinter;
    }

    byte constant IDENTIFIER_COVENANT_ID = 0x02;

    function calcInAmount() : (int) {
        KCC20State kcc20PrevState = readInputStateWithTemplate(
            OpCovInputIdx(kcc20Covid, 0),
            templatePrefixLen,
            templateSuffixLen,
            expectedTemplateHash
        );
        return (kcc20PrevState.amount);
    }

    function checkMinterKcc20NewState(KCC20State minterKcc20NewState){
        byte[32] controllerId = OpInputCovenantId(this.activeInputIndex);
        require(minterKcc20NewState.ownerIdentifier == controllerId); // We do not allow the minter to delegate minting authority to another party.
        require(minterKcc20NewState.identifierType == IDENTIFIER_COVENANT_ID);
        require(minterKcc20NewState.isMinter); // The minter cannot stop being a minter.

        validateOutputStateWithTemplate(
            OpCovOutputIdx(kcc20Covid, 0),
            minterKcc20NewState,
            templatePrefix,
            templateSuffix,
            expectedTemplateHash
        );
    }

    function checkRecipientKcc20NewState(KCC20State recipientKcc20NewState){
        require(!recipientKcc20NewState.isMinter); // We do not allow the minter to designate another minter.
        validateOutputStateWithTemplate(
            OpCovOutputIdx(kcc20Covid, 1),
            recipientKcc20NewState,
            templatePrefix,
            templateSuffix,
            expectedTemplateHash
        );
    }

    #[covenant.singleton]
    function init(State prevState, State newState, sig s) {
        require(!initialized);
        require(newState.kcc20Covid == OpOutputCovenantId(0));
        require(newState.amount == prevState.amount);
        require(newState.initialized);
        require(checkSig(s, owner));

    }

    #[covenant.singleton]
    function mint(State prevState, State newState, sig s, KCC20State minterKcc20NewState, KCC20State recipientKcc20NewState) {
        require(initialized);
        require(newState.amount >= 0);
        require(newState.initialized);
        require(newState.kcc20Covid == prevState.kcc20Covid);

        // We focus on the simple case 1-2 minting transfer.
        require(OpCovOutputCount(kcc20Covid) == 2);
        require(OpCovInputCount(kcc20Covid) == 1);

        checkMinterKcc20NewState(minterKcc20NewState);
        checkRecipientKcc20NewState(recipientKcc20NewState);

        int inAmount = calcInAmount();
        int mintedAmount = minterKcc20NewState.amount + recipientKcc20NewState.amount - inAmount;
        require(newState.amount == amount - mintedAmount);
        require(checkSig(s, owner));
    }
}

Purpose

KCC20Minter is the example controller covenant for one KCC20 covenant instance.

The key idea is that issuance policy is not embedded directly into KCC20’s constructor or entrypoint arguments. Instead a separate controller covenant holds:

  • which KCC20 covenant it governs
  • how much issuance allowance remains
  • whether the cross-contract binding has already been initialized

Constructor And State

The constructor takes:

  • owner
  • initKCC20Covid
  • initAmount
  • initInitialized
  • templatePrefixLen
  • templateSuffixLen
  • expectedTemplateHash
  • templatePrefix
  • templateSuffix

The state fields derived from those constructor args are:

byte[32] kcc20Covid = initKCC20Covid;
int amount = initAmount;
bool initialized = initInitialized;

The template-related constructor fields are not mutable state. They are contract parameters baked into the script instance.

Embedded KCC20State

The minter declares:

struct KCC20State {
    byte[32] ownerIdentifier;
    byte identifierType;
    int amount;
    bool isMinter;
}

This local struct gives the minter an explicit schema for reading and validating KCC20 state.

Why Template Metadata Exists

The minter needs to reason about a KCC20 output. It cannot safely trust “some output at index X has the right fields”. It must ensure that the output really belongs to the intended KCC20 template.

That is why the contract stores:

  • prefix length
  • suffix length
  • expected template hash
  • the actual prefix bytes
  • the actual suffix bytes

These values come from the KCC20 script with its encoded state region removed. Conceptually, they identify the fixed template around the mutable KCC20 state payload.

calcInAmount

function calcInAmount() : (int)

This function reads the previous KCC20 state from the covenant input selected by:

OpCovInputIdx(kcc20Covid, 0)

That means:

  • find the first covenant input whose covenant ID equals kcc20Covid
  • parse it using the expected template metadata
  • return its amount

This is how the minter learns the old token supply before minting.

checkMinterKcc20NewState

function checkMinterKcc20NewState(KCC20State minterKcc20NewState)

This validates the continuing controller-owned KCC20 minter branch.

It enforces three things:

  • the branch must remain owned by the current KCC20Minter covenant ID
  • the branch must remain covenant-ID owned
  • the branch must remain marked as a minter

The first check deliberately uses the active input’s covenant ID:

byte[32] controllerId = OpInputCovenantId(this.activeInputIndex);
require(minterKcc20NewState.ownerIdentifier == controllerId);

This separates two identities:

  • owner is the admin key that signs minter actions
  • controllerId is the covenant ID that owns the KCC20 minter branch

So the admin key authorizes the controller, but the KCC20 branch remains owned by the controller covenant.

Then it validates the actual output with:

validateOutputStateWithTemplate(
    OpCovOutputIdx(kcc20Covid, 0),
    minterKcc20NewState,
    templatePrefix,
    templateSuffix,
    expectedTemplateHash
);

This does two jobs:

  • it selects the first KCC20 output for the governed covenant ID
  • it ensures that output matches the expected KCC20 template and state payload

This is much safer than trusting an arbitrary output index or script shape.

checkRecipientKcc20NewState

function checkRecipientKcc20NewState(KCC20State recipientKcc20NewState)

This validates the newly minted recipient output.

It enforces that the recipient output is not itself a minter branch, and then checks that the second KCC20 output in the transaction matches the supplied state.

That means each mint transaction has a fixed shape:

  • output 0 is the continuing minter KCC20 branch
  • output 1 is the freshly minted recipient KCC20 branch

init

The first entrypoint is:

#[covenant.singleton]
function init(State prevState, State newState, sig s)

This binds a previously uninitialized controller covenant to a freshly created KCC20 covenant.

The controller covenant already has its own covenant ID before this entrypoint runs. In the bootstrap flow, a plain funding UTXO first creates the uninitialized controller covenant C. Then the asset genesis transaction spends C through init, creates the KCC20 asset covenant A, and recreates C as initialized and bound to A.

Its key checks are:

require(!initialized);
require(newState.kcc20Covid == OpOutputCovenantId(0));
require(newState.amount == prevState.amount);
require(newState.initialized);
require(checkSig(s, owner));

Interpretation:

  • the minter must not already be initialized
  • the new minter state must point at the covenant ID of output 0
  • the issuance allowance is preserved during initialization
  • the new state flips initialized to true
  • the owner authorizes the operation

The critical piece is OpOutputCovenantId(0). That lets the minter learn the covenant ID of the KCC20 output created in the same transaction.

Without this check, this single transaction would not prove that the initialized minter bound itself to the exact KCC20 covenant output created beside it.

Initialization Diagram

plain funding utxo
    |
    v
[minter genesis tx] -> C covenant id
    |
    v
[asset genesis/init tx] -> A covenant id + C binds to A

before asset genesis/init:
  C.initialized = false
  C.kcc20Covid = placeholder

after asset genesis/init:
  C.initialized = true
  C.kcc20Covid = A
  A.ownerIdentifier = C

mint

The second entrypoint is:

#[covenant.singleton]
function mint(State prevState, State newState, sig s, KCC20State minterKcc20NewState, KCC20State recipientKcc20NewState)

This is the transaction-level minting step that enforces the issuance policy.

The checks break down into four groups.

Minter state invariants

require(initialized);
require(newState.amount >= 0);
require(newState.initialized);
require(newState.kcc20Covid == prevState.kcc20Covid);

The minter must stay initialized, cannot go negative, and cannot switch to a different KCC20 covenant.

KCC20 cardinality

require(OpCovOutputCount(kcc20Covid) == 2);
require(OpCovInputCount(kcc20Covid) == 1);

The example only allows minting when exactly one KCC20 covenant input and two KCC20 covenant outputs are involved. That keeps the accounting simple and makes the split between the persistent minter branch and the recipient branch explicit.

KCC20 template validation

checkMinterKcc20NewState(minterKcc20NewState);
checkRecipientKcc20NewState(recipientKcc20NewState);

This ensures both supplied KCC20 successor states match the actual outputs in the transaction.

Issuance accounting

int inAmount = calcInAmount();
int mintedAmount = minterKcc20NewState.amount + recipientKcc20NewState.amount - inAmount;
require(newState.amount == amount - mintedAmount);

This means:

  • compute previous KCC20 amount
  • compute the total amount in the two new KCC20 outputs
  • subtract the old amount to get the newly minted quantity
  • decrement the minter’s remaining issuance allowance by exactly that amount

If someone tries to mint more than the issuance allowance permits, the minter state cannot satisfy the final equality and the transaction fails.

Mint Accounting Diagram

mintedAmount
  = (new minter-branch amount + new recipient amount)
    - previous minter-branch amount

new issuance allowance
  = old issuance allowance - mintedAmount

Mint Shape Diagram

before mint:
  KCC20 minter branch amount = old amount
  KCC20Minter issuance allowance = remaining budget

after mint:
  KCC20 minter branch amount = 0
  KCC20 recipient branch amount = minted tokens for this transaction
  KCC20Minter issuance allowance = reduced by minted amount

Why A Separate Minter Covenant Matters

This design cleanly demonstrates covenant composition.

  • KCC20 knows how to authorize token state transitions.
  • KCC20Minter knows how to constrain issuance.

KCC20 can be reused with different issuance policies because issuance control is externalized into another covenant rather than welded into the token contract itself.

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

What The Examples Demonstrate

This chapter lists the main properties these examples are meant to exhibit.

At a high level, the two contracts play different roles:

  • KCC20 is the main example of a fungible covenant state machine with flexible ownership rules.
  • KCC20Minter is the main example of covenant composition, where one covenant governs the issuance policy of another.

These examples are easiest to understand as a small set of recurring stories:

  • a token can be handed off, split, and merged
  • non-minter branches must conserve supply
  • minter branches can expand or shrink supply
  • ownership can belong to a key, a script hash, or another covenant
  • a separate covenant can bind itself to a token covenant and control future issuance

1. KCC20 Behaves Like A Fungible Token State Machine

KCC20 can:

  • move a full balance from one owner to another
  • split one balance into several balances
  • merge several balances back into one

That means KCC20 supports the basic transformations people expect from a fungible asset model.

2. Ordinary KCC20 Branches Conserve Supply

Non-minter branches cannot:

  • create value out of nothing
  • destroy value arbitrarily
  • turn themselves into minter branches

This is the ordinary-token part of the contract.

3. Minter Branches Can Expand Or Shrink Supply

When a branch is marked with isMinter = true, it can:

  • mint more tokens
  • burn tokens

This means mint authority is represented directly in covenant state.

4. Ownership Is Flexible

The examples demonstrate three ownership models inside one token contract:

  • signature ownership
  • script-hash ownership
  • covenant-ID ownership

This is one of the strongest parts of the example. It shows that ownership can mean:

  • “the holder of this key may spend”
  • “a transaction containing this script-controlled input may spend”
  • “this other covenant may spend”

That is much more expressive than a token model that only understands pubkeys.

5. Another Covenant Can Own KCC20

A KCC20 branch can be owned by a covenant ID, and that ownership mode is what allows KCC20Minter to control issuance.

This is the bridge from “programmable ownership” to “cross-contract policy”.

6. KCC20Minter Controls Issuance, Not KCC20 Alone

The two-contract example shows a clean split:

  • KCC20 defines token semantics
  • KCC20Minter defines issuance policy

This matters because it keeps the token contract reusable. Different issuance policies could be modeled by different controller covenants.

7. Initialization Can Bind Contracts Together

One of the most important properties proven by the examples is that a controller covenant can be created first, then initialize itself against an asset covenant created in the next transaction.

That is what init in KCC20Minter does when it records:

  • the covenant ID of the newly created KCC20 output

The important shape is:

plain funding utxo
    |
    v
[minter genesis tx] -> C covenant id
    |
    v
[asset genesis/init tx] -> A covenant id + C binds to A

This is the mechanism that binds the minter to one specific KCC20 instance while preserving a concrete genesis preimage for both covenant IDs.

8. Template Validation Makes Cross-Contract Checks Safer

KCC20Minter does not merely inspect some KCC20-looking output. It validates:

  • the expected template shape
  • the expected template hash
  • the expected state payload

This is critical because it means the minter is validating a real KCC20 state transition, not trusting a lookalike output.

9. The Issuance Budget Is Enforced Across Transactions

The KCC20Minter flow walks through several mint transactions and shows that:

  • each successful mint reduces remaining issuance allowance
  • each successful mint keeps a zero-amount KCC20 minter branch alive for the next mint
  • each successful mint creates a separate ordinary KCC20 recipient output for the newly minted amount
  • those recipient outputs can later be spent like ordinary KCC20 branches
  • mint transactions continue to work while issuance allowance remains
  • mint transactions fail when the requested increase would overspend the budget

This is the clearest statement of what KCC20Minter is for.

10. The Whole Example Is About Covenant Composition

The biggest idea behind these files is not just “here is a token”.

It is:

  • one covenant can represent an asset
  • another covenant can own or govern that asset
  • both can participate in the same transaction
  • each contract can verify its own side of the policy

That is why these examples matter. They show how SilverScript can express systems of cooperating covenants, not just isolated spending scripts.