Chapter 27: High-Level SDK

PRE-ALPHA WARNING: This is a pre-alpha version of The Sigma Book. Content may be incomplete, inaccurate, or subject to change. Do not use as a source of truth. For authoritative information, consult the official repositories:

Prerequisites

Learning Objectives

By the end of this chapter, you will be able to:

  • Explain the SDK architecture layers from cryptography to transaction building
  • Use TxBuilder with the builder pattern for ergonomic transaction construction
  • Trace the reduce-then-sign pipeline for transaction signing
  • Work with TransactionContext and BoxSelection for complex transaction scenarios

SDK Architecture

The SDK provides a layered abstraction from low-level cryptography to high-level transaction building12:

SDK Layer Architecture
══════════════════════════════════════════════════════════════════

┌────────────────────────────────────────────────────────────────┐
│                     Application Layer                          │
│   TxBuilder    BoxSelector    ErgoBoxCandidateBuilder          │
├────────────────────────────────────────────────────────────────┤
│                     Wallet Layer                               │
│   Wallet    TransactionContext    TransactionHintsBag          │
├────────────────────────────────────────────────────────────────┤
│                     Reduction Layer                            │
│   reduce_tx()    ReducedTransaction    ReducedInput            │
├────────────────────────────────────────────────────────────────┤
│                     Signing Layer                              │
│   sign_transaction()    sign_reduced_transaction()             │
├────────────────────────────────────────────────────────────────┤
│                     Interpreter Layer                          │
│   Prover    Verifier    reduce_to_crypto()                     │
└────────────────────────────────────────────────────────────────┘

Transaction Builder

The builder pattern constructs unsigned transactions with validation34:

const TxBuilder = struct {
    box_selection: BoxSelection,
    data_inputs: std.ArrayList(DataInput),
    output_candidates: std.ArrayList(ErgoBoxCandidate),
    current_height: u32,
    fee_amount: BoxValue,
    change_address: Address,
    context_extensions: std.AutoHashMap(BoxId, ContextExtension),
    token_burn_permit: std.ArrayList(Token),
    allocator: Allocator,

    pub fn init(
        box_selection: BoxSelection,
        output_candidates: []const ErgoBoxCandidate,
        current_height: u32,
        fee_amount: BoxValue,
        change_address: Address,
        allocator: Allocator,
    ) !TxBuilder {
        var outputs = std.ArrayList(ErgoBoxCandidate).init(allocator);
        try outputs.appendSlice(output_candidates);

        return .{
            .box_selection = box_selection,
            .data_inputs = std.ArrayList(DataInput).init(allocator),
            .output_candidates = outputs,
            .current_height = current_height,
            .fee_amount = fee_amount,
            .change_address = change_address,
            .context_extensions = std.AutoHashMap(BoxId, ContextExtension).init(allocator),
            .token_burn_permit = std.ArrayList(Token).init(allocator),
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *TxBuilder) void {
        self.data_inputs.deinit();
        self.output_candidates.deinit();
        self.context_extensions.deinit();
        self.token_burn_permit.deinit();
    }

    pub fn setDataInputs(self: *TxBuilder, data_inputs: []const DataInput) !void {
        self.data_inputs.clearRetainingCapacity();
        try self.data_inputs.appendSlice(data_inputs);
    }

    pub fn setContextExtension(self: *TxBuilder, box_id: BoxId, ext: ContextExtension) !void {
        try self.context_extensions.put(box_id, ext);
    }

    pub fn setTokenBurnPermit(self: *TxBuilder, tokens: []const Token) !void {
        self.token_burn_permit.clearRetainingCapacity();
        try self.token_burn_permit.appendSlice(tokens);
    }
};

Build Validation

Building performs comprehensive validation before creating the transaction56:

pub fn build(self: *TxBuilder) !UnsignedTransaction {
    // Validate inputs
    if (self.box_selection.boxes.items.len == 0) {
        return error.EmptyInputs;
    }
    if (self.output_candidates.items.len == 0) {
        return error.EmptyOutputs;
    }
    if (self.box_selection.boxes.items.len > std.math.maxInt(u16)) {
        return error.TooManyInputs;
    }

    // Check for duplicate inputs
    var seen = std.AutoHashMap(BoxId, void).init(self.allocator);
    defer seen.deinit();
    for (self.box_selection.boxes.items) |box| {
        const result = try seen.getOrPut(box.box_id);
        if (result.found_existing) {
            return error.DuplicateInputs;
        }
    }

    // Build output candidates with change boxes
    var all_outputs = try self.buildOutputCandidates();
    defer all_outputs.deinit();

    // Validate coin preservation
    const total_in = sumValue(self.box_selection.boxes.items);
    const total_out = sumValue(all_outputs.items);

    if (total_out > total_in) {
        return error.NotEnoughCoinsInInputs;
    }
    if (total_out < total_in) {
        return error.NotEnoughCoinsInOutputs;
    }

    // Validate token balance
    try self.validateTokenBalance(all_outputs.items);

    // Create unsigned inputs with context extensions
    var unsigned_inputs = std.ArrayList(UnsignedInput).init(self.allocator);
    for (self.box_selection.boxes.items) |box| {
        const ext = self.context_extensions.get(box.box_id) orelse
            ContextExtension.empty();
        try unsigned_inputs.append(.{
            .box_id = box.box_id,
            .extension = ext,
        });
    }

    return UnsignedTransaction{
        .inputs = try unsigned_inputs.toOwnedSlice(),
        .data_inputs = try self.data_inputs.toOwnedSlice(),
        .output_candidates = try all_outputs.toOwnedSlice(),
    };
}

fn buildOutputCandidates(self: *TxBuilder) !std.ArrayList(ErgoBoxCandidate) {
    var outputs = std.ArrayList(ErgoBoxCandidate).init(self.allocator);

    // Add user-specified outputs
    try outputs.appendSlice(self.output_candidates.items);

    // Add change boxes from selection
    const change_tree = try Contract.payToAddress(self.change_address);
    for (self.box_selection.change_boxes.items) |change| {
        var candidate = try ErgoBoxCandidateBuilder.init(
            change.value,
            change_tree,
            self.current_height,
            self.allocator,
        );
        for (change.tokens) |token| {
            try candidate.addToken(token);
        }
        try outputs.append(try candidate.build());
    }

    // Add miner fee box
    const fee_box = try newMinerFeeBox(self.fee_amount, self.current_height);
    try outputs.append(fee_box);

    return outputs;
}

Token Balance Validation

Token flow must be explicitly validated78:

fn validateTokenBalance(self: *TxBuilder, outputs: []const ErgoBoxCandidate) !void {
    const input_tokens = try sumTokens(self.box_selection.boxes.items, self.allocator);
    defer input_tokens.deinit();

    const output_tokens = try sumTokens(outputs, self.allocator);
    defer output_tokens.deinit();

    // Token minting rule: new tokens can ONLY have token_id == first_input.box_id
    // You can mint any AMOUNT of this token type, but only ONE token type per tx.
    const first_input_id = TokenId.fromBoxId(self.box_selection.boxes.items[0].box_id);

    // Separate minted tokens (first_input_id) from transferred tokens
    var has_minted_token = false;
    var output_without_minted = std.AutoHashMap(TokenId, TokenAmount).init(self.allocator);
    defer output_without_minted.deinit();

    var iter = output_tokens.iterator();
    while (iter.next()) |entry| {
        if (entry.key_ptr.*.eql(first_input_id)) {
            has_minted_token = true;
            // Note: any amount is allowed for the minted token
        } else {
            try output_without_minted.put(entry.key_ptr.*, entry.value_ptr.*);
        }
    }
    _ = has_minted_token; // Used for documentation; actual validation is below

    // Check all output tokens exist in inputs
    var out_iter = output_without_minted.iterator();
    while (out_iter.next()) |entry| {
        const input_amt = input_tokens.get(entry.key_ptr.*) orelse {
            return error.NotEnoughTokens;
        };
        if (input_amt < entry.value_ptr.*) {
            return error.NotEnoughTokens;
        }
    }

    // Check token burn permits
    const burned = try subtractTokens(input_tokens, output_without_minted, self.allocator);
    defer burned.deinit();

    try self.checkBurnPermit(burned);
}

fn checkBurnPermit(self: *TxBuilder, burned: std.AutoHashMap(TokenId, TokenAmount)) !void {
    // Build permit map
    var permits = std.AutoHashMap(TokenId, TokenAmount).init(self.allocator);
    defer permits.deinit();
    for (self.token_burn_permit.items) |token| {
        try permits.put(token.id, token.amount);
    }

    // Every burned token must have permit
    var iter = burned.iterator();
    while (iter.next()) |entry| {
        const permit_amt = permits.get(entry.key_ptr.*) orelse {
            return error.TokenBurnPermitMissing;
        };
        if (entry.value_ptr.* > permit_amt) {
            return error.TokenBurnPermitExceeded;
        }
    }

    // Every permit must be used exactly
    var permit_iter = permits.iterator();
    while (permit_iter.next()) |entry| {
        const burned_amt = burned.get(entry.key_ptr.*) orelse {
            return error.TokenBurnPermitUnused;
        };
        if (burned_amt < entry.value_ptr.*) {
            return error.TokenBurnPermitUnused;
        }
    }
}

Box Candidate Builder

Constructs output boxes with fluent API:

const ErgoBoxCandidateBuilder = struct {
    value: BoxValue,
    ergo_tree: ErgoTree,
    creation_height: u32,
    tokens: std.ArrayList(Token),
    registers: [6]?Constant, // R4-R9
    allocator: Allocator,

    pub fn init(
        value: BoxValue,
        ergo_tree: ErgoTree,
        creation_height: u32,
        allocator: Allocator,
    ) !ErgoBoxCandidateBuilder {
        return .{
            .value = value,
            .ergo_tree = ergo_tree,
            .creation_height = creation_height,
            .tokens = std.ArrayList(Token).init(allocator),
            .registers = [_]?Constant{null} ** 6,
            .allocator = allocator,
        };
    }

    pub fn addToken(self: *ErgoBoxCandidateBuilder, token: Token) !void {
        if (self.tokens.items.len >= MAX_TOKENS) {
            return error.TooManyTokens;
        }
        try self.tokens.append(token);
    }

    pub fn mintToken(
        self: *ErgoBoxCandidateBuilder,
        token: Token,
        name: []const u8,
        description: []const u8,
        decimals: u8,
    ) !void {
        try self.addToken(token);
        // Store metadata in R4-R6
        self.registers[0] = Constant.fromBytes(name);
        self.registers[1] = Constant.fromBytes(description);
        self.registers[2] = Constant.fromByte(decimals);
    }

    pub fn setRegister(self: *ErgoBoxCandidateBuilder, reg: RegisterId, value: Constant) void {
        const idx = @intFromEnum(reg) - 4; // R4 = 0, R5 = 1, etc.
        self.registers[idx] = value;
    }

    pub fn build(self: *ErgoBoxCandidateBuilder) !ErgoBoxCandidate {
        return ErgoBoxCandidate{
            .value = self.value,
            .ergo_tree = self.ergo_tree,
            .creation_height = self.creation_height,
            .tokens = try self.tokens.toOwnedSlice(),
            .additional_registers = self.registers,
        };
    }
};

Transaction Context

Bundles transaction with input boxes for signing910:

const TransactionContext = struct {
    spending_tx: UnsignedTransaction,
    input_boxes: []const ErgoBox,
    data_boxes: ?[]const ErgoBox,

    pub fn init(
        spending_tx: UnsignedTransaction,
        input_boxes: []const ErgoBox,
        data_boxes: ?[]const ErgoBox,
    ) !TransactionContext {
        // Validate input boxes match transaction inputs
        if (input_boxes.len != spending_tx.inputs.len) {
            return error.InputBoxCountMismatch;
        }

        for (spending_tx.inputs, input_boxes) |input, box| {
            if (!input.box_id.eql(box.box_id())) {
                return error.InputBoxIdMismatch;
            }
        }

        // Validate data boxes if present
        if (spending_tx.data_inputs) |data_inputs| {
            const data = data_boxes orelse return error.DataInputBoxNotFound;
            if (data.len != data_inputs.len) {
                return error.DataInputBoxCountMismatch;
            }
        }

        return .{
            .spending_tx = spending_tx,
            .input_boxes = input_boxes,
            .data_boxes = data_boxes,
        };
    }

    pub fn getInputBox(self: *const TransactionContext, box_id: BoxId) ?*const ErgoBox {
        for (self.input_boxes) |*box| {
            if (box.box_id().eql(box_id)) {
                return box;
            }
        }
        return null;
    }
};

Box Selection

Selects input boxes to satisfy output requirements1112:

const BoxSelection = struct {
    boxes: std.ArrayList(ErgoBox),
    change_boxes: std.ArrayList(ErgoBoxAssets),

    const ErgoBoxAssets = struct {
        value: BoxValue,
        tokens: []const Token,
    };
};

const SimpleBoxSelector = struct {
    pub fn select(
        available: []const ErgoBox,
        target_value: BoxValue,
        target_tokens: []const Token,
        allocator: Allocator,
    ) !BoxSelection {
        var selected = std.ArrayList(ErgoBox).init(allocator);
        var total_value: u64 = 0;
        var token_sums = std.AutoHashMap(TokenId, TokenAmount).init(allocator);
        defer token_sums.deinit();

        // Greedy selection
        for (available) |box| {
            const needed = checkNeed(total_value, target_value, token_sums, target_tokens);
            if (!needed) break;

            try selected.append(box);
            total_value += box.value.as_u64();

            for (box.tokens) |token| {
                const entry = try token_sums.getOrPut(token.id);
                if (entry.found_existing) {
                    entry.value_ptr.* = try entry.value_ptr.*.checkedAdd(token.amount);
                } else {
                    entry.value_ptr.* = token.amount;
                }
            }
        }

        // Calculate change
        var change_boxes = std.ArrayList(BoxSelection.ErgoBoxAssets).init(allocator);
        const change_value = total_value - target_value.as_u64();
        if (change_value > 0) {
            const change_tokens = try calculateChangeTokens(token_sums, target_tokens, allocator);
            try change_boxes.append(.{
                .value = BoxValue.init(change_value) catch return error.ChangeValueTooSmall,
                .tokens = change_tokens,
            });
        }

        return .{
            .boxes = selected,
            .change_boxes = change_boxes,
        };
    }
};

Reduced Transaction

Script reduction separates evaluation from signing1314:

const ReducedInput = struct {
    sigma_prop: SigmaBoolean,
    cost: u64,
    extension: ContextExtension,
};

const ReducedTransaction = struct {
    unsigned_tx: UnsignedTransaction,
    reduced_inputs: []const ReducedInput,
    tx_cost: u32,

    pub fn reducedInputs(self: *const ReducedTransaction) []const ReducedInput {
        return self.reduced_inputs;
    }
};

/// Reduce transaction inputs to sigma propositions
pub fn reduceTx(
    tx_context: TransactionContext,
    state_context: *const ErgoStateContext,
    allocator: Allocator,
) !ReducedTransaction {
    var reduced_inputs = std.ArrayList(ReducedInput).init(allocator);

    for (tx_context.spending_tx.inputs, 0..) |input, idx| {
        // Build evaluation context
        var ctx = try makeContext(state_context, &tx_context, idx);

        // Get input box
        const input_box = tx_context.getInputBox(input.box_id) orelse
            return error.InputBoxNotFound;

        // Reduce ErgoTree to SigmaBoolean
        const result = try reduceToCrypto(&input_box.ergo_tree, &ctx);

        try reduced_inputs.append(.{
            .sigma_prop = result.sigma_prop,
            .cost = result.cost,
            .extension = input.extension,
        });
    }

    return .{
        .unsigned_tx = tx_context.spending_tx,
        .reduced_inputs = try reduced_inputs.toOwnedSlice(),
        .tx_cost = 0,
    };
}

Signing Pipeline

Signing Flow
══════════════════════════════════════════════════════════════════

┌─────────────────┐     ┌──────────────────┐     ┌───────────────┐
│ UnsignedTx      │     │ ReducedTx        │     │ SignedTx      │
│ + InputBoxes    │────▶│ (SigmaProps)     │────▶│ (Proofs)      │
│ + StateContext  │     │                  │     │               │
└─────────────────┘     └──────────────────┘     └───────────────┘
        │                       │                       │
        │  reduce_tx()          │  sign_reduced_tx()    │
        │  (needs context)      │  (context-free)       │
        ▼                       ▼                       ▼
   ┌─────────┐            ┌─────────┐              ┌─────────┐
   │ Online  │            │ Offline │              │ Verify  │
   │ Wallet  │            │ Wallet  │              │ Node    │
   └─────────┘            └─────────┘              └─────────┘

Transaction signing with optional hints1516:

pub fn signTransaction(
    prover: *const Prover,
    tx_context: TransactionContext,
    state_context: *const ErgoStateContext,
    tx_hints: ?*const TransactionHintsBag,
) !Transaction {
    const message = try tx_context.spending_tx.bytesToSign();

    var signed_inputs = std.ArrayList(Input).init(prover.allocator);
    for (tx_context.spending_tx.inputs, 0..) |input, idx| {
        const signed = try signTxInput(
            prover,
            &tx_context,
            state_context,
            tx_hints,
            idx,
            message,
        );
        try signed_inputs.append(signed);
    }

    return Transaction{
        .inputs = try signed_inputs.toOwnedSlice(),
        .data_inputs = tx_context.spending_tx.data_inputs,
        .outputs = tx_context.spending_tx.output_candidates,
    };
}

pub fn signReducedTransaction(
    prover: *const Prover,
    reduced_tx: ReducedTransaction,
    tx_hints: ?*const TransactionHintsBag,
) !Transaction {
    const message = try reduced_tx.unsigned_tx.bytesToSign();

    var signed_inputs = std.ArrayList(Input).init(prover.allocator);
    for (reduced_tx.unsigned_tx.inputs, 0..) |input, idx| {
        const reduced_input = reduced_tx.reduced_inputs[idx];

        // Get hints for this input
        const hints = if (tx_hints) |bag|
            bag.allHintsForInput(idx)
        else
            HintsBag.empty();

        // Generate proof from sigma proposition
        const proof = try prover.generateProof(
            reduced_input.sigma_prop,
            message,
            &hints,
        );

        try signed_inputs.append(.{
            .box_id = input.box_id,
            .spending_proof = .{
                .proof = proof,
                .extension = reduced_input.extension,
            },
        });
    }

    return Transaction{
        .inputs = try signed_inputs.toOwnedSlice(),
        .data_inputs = reduced_tx.unsigned_tx.data_inputs,
        .outputs = reduced_tx.unsigned_tx.output_candidates,
    };
}

Miner Fee Box

Standard miner fee output:

/// Miner fee ErgoTree (false proposition with height constraint)
const MINERS_FEE_ERGO_TREE = [_]u8{
    0x10, 0x05, 0x04, 0x00, 0x04, 0x00, 0x0e, 0x36,
    0x10, 0x02, 0x04, 0xa0, 0x0b, 0x08, 0xcd, 0x02,
    // ... (standard miner fee script)
};

pub fn newMinerFeeBox(fee: BoxValue, creation_height: u32) !ErgoBoxCandidate {
    const tree = try ErgoTree.sigmaParse(&MINERS_FEE_ERGO_TREE);

    return ErgoBoxCandidate{
        .value = fee,
        .ergo_tree = tree,
        .creation_height = creation_height,
        .tokens = &[_]Token{},
        .additional_registers = [_]?Constant{null} ** 6,
    };
}

/// Suggested transaction fee (1.1 mERG)
pub const SUGGESTED_TX_FEE = BoxValue.init(1_100_000) catch unreachable;

Reduced Transaction Serialization

EIP-19 format for cold wallet transfer1718:

const ReducedTransactionSerializer = struct {
    pub fn serialize(tx: *const ReducedTransaction, writer: anytype) !void {
        // Write message to sign (includes all tx data)
        const msg = try tx.unsigned_tx.bytesToSign();
        try writer.writeInt(u32, @intCast(msg.len), .little);
        try writer.writeAll(msg);

        // Write reduced inputs
        for (tx.reduced_inputs) |red_in| {
            try SigmaBoolean.serialize(&red_in.sigma_prop, writer);
            try writer.writeInt(u64, red_in.cost, .little);
        }

        try writer.writeInt(u32, tx.tx_cost, .little);
    }

    pub fn parse(reader: anytype, allocator: Allocator) !ReducedTransaction {
        // Read and parse message
        const msg_len = try reader.readInt(u32, .little);
        const msg = try allocator.alloc(u8, msg_len);
        try reader.readNoEof(msg);

        const tx = try Transaction.sigmaParse(msg);

        // Read reduced inputs
        var reduced_inputs = std.ArrayList(ReducedInput).init(allocator);
        for (tx.inputs) |input| {
            const sigma_prop = try SigmaBoolean.parse(reader);
            const cost = try reader.readInt(u64, .little);

            try reduced_inputs.append(.{
                .sigma_prop = sigma_prop,
                .cost = cost,
                .extension = input.spending_proof.extension,
            });
        }

        const tx_cost = try reader.readInt(u32, .little);

        return .{
            .unsigned_tx = tx.toUnsigned(),
            .reduced_inputs = try reduced_inputs.toOwnedSlice(),
            .tx_cost = tx_cost,
        };
    }
};

Cold Wallet Flow

Cold Wallet Signing
══════════════════════════════════════════════════════════════════

Online Wallet (Hot)              Cold Wallet (Air-gapped)
──────────────────────           ────────────────────────
       │                                    │
  Build Unsigned Tx                         │
       │                                    │
  reduce_tx()                               │
       │                                    │
  Serialize ReducedTx ─────────────────────▶│
  (QR code / USB)                           │
       │                               Parse ReducedTx
       │                                    │
       │                               sign_reduced_tx()
       │                               (uses secrets)
       │                                    │
       │◀──────────────────────── Serialize SignedTx
       │                          (QR code / USB)
  Broadcast Tx                              │
       │                                    │
       ▼                                    ▼

Complete Usage Example

pub fn buildAndSignTransaction(
    wallet: *const Wallet,
    available_boxes: []const ErgoBox,
    recipient: Address,
    amount: u64,
    state_context: *const ErgoStateContext,
    allocator: Allocator,
) !Transaction {
    const current_height = state_context.pre_header.height;

    // 1. Build output
    const recipient_tree = try Contract.payToAddress(recipient);
    var out_builder = try ErgoBoxCandidateBuilder.init(
        try BoxValue.init(amount),
        recipient_tree,
        current_height,
        allocator,
    );
    const output = try out_builder.build();

    // 2. Select inputs
    const total_needed = try BoxValue.init(amount + SUGGESTED_TX_FEE.as_u64());
    const selection = try SimpleBoxSelector.select(
        available_boxes,
        total_needed,
        &[_]Token{},
        allocator,
    );

    // 3. Build transaction
    const change_address = wallet.getP2PKAddress();
    var builder = try TxBuilder.init(
        selection,
        &[_]ErgoBoxCandidate{output},
        current_height,
        SUGGESTED_TX_FEE,
        change_address,
        allocator,
    );
    defer builder.deinit();

    const unsigned_tx = try builder.build();

    // 4. Create transaction context
    const tx_context = try TransactionContext.init(
        unsigned_tx,
        selection.boxes.items,
        null,
    );

    // 5. Sign transaction
    return wallet.signTransaction(tx_context, state_context, null);
}

Summary

  • TxBuilder constructs unsigned transactions with validation
  • BoxSelection satisfies value and token requirements
  • ErgoBoxCandidateBuilder creates output boxes with fluent API
  • TransactionContext bundles transaction with input data
  • reduce_tx() separates script evaluation from signing
  • ReducedTransaction enables air-gapped cold wallet signing
  • Token burn requires explicit permits to prevent accidents

Next: Chapter 28: Key Derivation

1

Scala: sdk/

7

Scala: AppkitProvingInterpreter.scala (token validation)

10

Rust: tx_context.rs

12

Rust: box_selector.rs