Chapter 26: Wallet and Signing
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:
- sigmastate-interpreter — Reference Scala implementation
- sigma-rust — Rust implementation
- ergo — Ergo node
Prerequisites
- Chapter 15 for proof generation
- Chapter 23 for interpreter integration
- Chapter 11 for hint system and distributed signing
Learning Objectives
By the end of this chapter, you will be able to:
- Explain the wallet service architecture and its role in transaction signing
- Trace the complete transaction signing flow from unsigned to signed
- Use
TransactionHintsBagfor distributed multi-party signing - Implement box selection strategies for building transactions
Wallet Architecture
The wallet bridges high-level operations with the interpreter layer12:
Wallet Service Architecture
─────────────────────────────────────────────────────────
┌────────────────────────────────────────────────────────┐
│ Wallet │
├────────────────────────────────────────────────────────┤
│ prover: Box<dyn Prover> │
│ │
│ ├── from_mnemonic(phrase, pass) -> Wallet │
│ ├── from_secrets([]SecretKey) -> Wallet │
│ ├── add_secret(SecretKey) │
│ │ │
│ ├── sign_transaction(...) -> Transaction │
│ ├── sign_reduced_transaction(...) -> Transaction │
│ │ │
│ └── generate_commitments(...) -> TransactionHintsBag │
└────────────────────────────────────────────────────────┘
│
│ uses
▼
┌────────────────────────────────────────────────────────┐
│ Prover │
│ prove(tree, ctx, message, hints) -> ProverResult │
└────────────────────────────────────────────────────────┘
Wallet Structure
const Wallet = struct {
/// Underlying prover (boxed trait object)
prover: *Prover,
allocator: Allocator,
/// Create wallet from mnemonic phrase
pub fn fromMnemonic(
mnemonic_phrase: []const u8,
mnemonic_pass: []const u8,
allocator: Allocator,
) !Wallet {
const seed = Mnemonic.toSeed(mnemonic_phrase, mnemonic_pass);
const ext_sk = try ExtSecretKey.deriveMaster(seed);
return Wallet.fromSecrets(&.{ext_sk.secretKey()}, allocator);
}
/// Create wallet from secret keys
pub fn fromSecrets(secrets: []const SecretKey, allocator: Allocator) Wallet {
var private_inputs = allocator.alloc(PrivateInput, secrets.len) catch unreachable;
for (secrets, 0..) |sk, i| {
private_inputs[i] = PrivateInput.from(sk);
}
return .{
.prover = TestProver.init(private_inputs, allocator),
.allocator = allocator,
};
}
/// Add secret to wallet
pub fn addSecret(self: *Wallet, secret: SecretKey) void {
self.prover.appendSecret(PrivateInput.from(secret));
}
/// Sign a transaction
pub fn signTransaction(
self: *const Wallet,
tx_context: *const TransactionContext(UnsignedTransaction),
state_context: *const ErgoStateContext,
tx_hints: ?*const TransactionHintsBag,
) !Transaction {
return signTransactionImpl(
self.prover,
tx_context,
state_context,
tx_hints,
);
}
/// Sign a reduced transaction
pub fn signReducedTransaction(
self: *const Wallet,
reduced_tx: *const ReducedTransaction,
tx_hints: ?*const TransactionHintsBag,
) !Transaction {
return signReducedTransactionImpl(
self.prover,
reduced_tx,
tx_hints,
);
}
/// Generate commitments for distributed signing
pub fn generateCommitments(
self: *const Wallet,
tx_context: *const TransactionContext(UnsignedTransaction),
state_context: *const ErgoStateContext,
) !TransactionHintsBag {
var public_keys: []SigmaBoolean = &.{};
for (self.prover.secrets()) |secret| {
public_keys = append(public_keys, secret.publicImage());
}
return generateCommitmentsImpl(tx_context, state_context, public_keys);
}
};
Mnemonic Seed Generation
BIP-39 mnemonic to seed conversion34:
const Mnemonic = struct {
/// PBKDF2 iterations per BIP-39
pub const PBKDF2_ITERATIONS: u32 = 2048;
/// Seed output length (SHA-512)
pub const SEED_LENGTH: usize = 64;
/// Convert mnemonic phrase to seed bytes
pub fn toSeed(
mnemonic_phrase: []const u8,
mnemonic_pass: []const u8,
) [SEED_LENGTH]u8 {
var seed: [SEED_LENGTH]u8 = undefined;
// Normalize to NFKD form
const normalized_phrase = normalizeNfkd(mnemonic_phrase);
const normalized_pass = normalizeNfkd(mnemonic_pass);
// Salt is "mnemonic" + password
var salt_buf: [256]u8 = undefined;
const salt_prefix = "mnemonic";
@memcpy(salt_buf[0..salt_prefix.len], salt_prefix);
@memcpy(salt_buf[salt_prefix.len..][0..normalized_pass.len], normalized_pass);
const salt = salt_buf[0 .. salt_prefix.len + normalized_pass.len];
// PBKDF2-HMAC-SHA512
pbkdf2HmacSha512(
normalized_phrase,
salt,
PBKDF2_ITERATIONS,
&seed,
);
return seed;
}
};
Transaction Hints Bag
Manages hints for distributed signing (EIP-11)56:
const TransactionHintsBag = struct {
/// Secret hints by input index (own commitments)
secret_hints: std.AutoHashMap(usize, HintsBag),
/// Public hints by input index (other signers' commitments)
public_hints: std.AutoHashMap(usize, HintsBag),
allocator: Allocator,
pub fn empty(allocator: Allocator) TransactionHintsBag {
return .{
.secret_hints = std.AutoHashMap(usize, HintsBag).init(allocator),
.public_hints = std.AutoHashMap(usize, HintsBag).init(allocator),
.allocator = allocator,
};
}
/// Replace all hints for an input index
pub fn replaceHintsForInput(
self: *TransactionHintsBag,
index: usize,
hints_bag: HintsBag,
) void {
var secret_hints: []Hint = &.{};
var public_hints: []Hint = &.{};
for (hints_bag.hints) |hint| {
switch (hint) {
.own_commitment => secret_hints = append(secret_hints, hint),
else => public_hints = append(public_hints, hint),
}
}
self.secret_hints.put(index, HintsBag{ .hints = secret_hints }) catch {};
self.public_hints.put(index, HintsBag{ .hints = public_hints }) catch {};
}
/// Add hints for an input (accumulate with existing)
pub fn addHintsForInput(
self: *TransactionHintsBag,
index: usize,
hints_bag: HintsBag,
) void {
var existing_secret = self.secret_hints.get(index) orelse HintsBag.empty();
var existing_public = self.public_hints.get(index) orelse HintsBag.empty();
for (hints_bag.hints) |hint| {
switch (hint) {
.own_commitment => existing_secret.hints = append(existing_secret.hints, hint),
else => existing_public.hints = append(existing_public.hints, hint),
}
}
self.secret_hints.put(index, existing_secret) catch {};
self.public_hints.put(index, existing_public) catch {};
}
/// Get all hints (secret + public) for an input
pub fn allHintsForInput(self: *const TransactionHintsBag, index: usize) HintsBag {
var hints: []Hint = &.{};
if (self.secret_hints.get(index)) |bag| {
for (bag.hints) |h| hints = append(hints, h);
}
if (self.public_hints.get(index)) |bag| {
for (bag.hints) |h| hints = append(hints, h);
}
return HintsBag{ .hints = hints };
}
/// Get only public hints (safe to share)
pub fn publicHintsForInput(self: *const TransactionHintsBag, index: usize) HintsBag {
return self.public_hints.get(index) orelse HintsBag.empty();
}
};
Distributed Signing Protocol (EIP-11)
Distributed Signing Flow
─────────────────────────────────────────────────────
Party A Party B
───────── ─────────
1. Generate Commitments
commitmentsA = generateCommitments()
commitmentsB = generateCommitments()
2. Exchange Public Hints
publicA ──────────────────────────►
◄────────────────── publicB
3. Sign with Combined Hints
combinedA = commitmentsA + publicB
partialSigA = sign(tx, combinedA)
combinedB = commitmentsB + publicA
partialSigB = sign(tx, combinedB)
4. Extract & Complete
partialSigA ─────────────────────►
extractedHints = extractHints(partialSigA)
finalTx = sign(tx, commitmentsB + extracted)
Security: Secret hints (randomness r) NEVER leave their owner.
Only public hints (commitments g^r) are exchanged.
/// Generate commitments for all transaction inputs
pub fn generateCommitments(
wallet: *const Wallet,
tx_context: *const TransactionContext(UnsignedTransaction),
state_context: *const ErgoStateContext,
) !TransactionHintsBag {
var public_keys: []SigmaBoolean = &.{};
for (wallet.prover.secrets()) |secret| {
public_keys = append(public_keys, secret.publicImage());
}
var hints_bag = TransactionHintsBag.empty(wallet.allocator);
for (tx_context.spending_tx.inputs.items(), 0..) |_, idx| {
const ctx = try makeContext(state_context, tx_context, idx);
const input_box = tx_context.inputBoxes()[idx];
// Reduce to SigmaBoolean
const reduction = try reduceToCrypto(&input_box.ergo_tree, &ctx);
// Generate commitments for propositions we can prove
const input_hints = generateCommitmentsFor(
&reduction.sigma_prop,
public_keys,
);
hints_bag.addHintsForInput(idx, input_hints);
}
return hints_bag;
}
/// Extract hints from a partial signature
pub fn extractHints(
tx: *const Transaction,
real_propositions: []const SigmaBoolean,
simulated_propositions: []const SigmaBoolean,
boxes_to_spend: []const ErgoBox,
data_boxes: []const ErgoBox,
) TransactionHintsBag {
var hints_bag = TransactionHintsBag.empty(allocator);
for (tx.inputs.items(), 0..) |input, idx| {
const proof = input.spending_proof.proof;
if (proof.isEmpty()) continue;
const box = boxes_to_spend[idx];
const extracted = extractHintsFromProof(
&box.ergo_tree,
proof.bytes(),
real_propositions,
simulated_propositions,
);
hints_bag.addHintsForInput(idx, extracted);
}
return hints_bag;
}
Box Selection
Select inputs to satisfy target balance and tokens78:
const BoxSelector = struct {
/// Selects boxes to satisfy target balance and tokens
pub fn select(
self: *const BoxSelector,
inputs: []const ErgoBox,
target_balance: BoxValue,
target_tokens: []const Token,
) BoxSelectorError!BoxSelection {
var selected: []ErgoBox = &.{};
var total_value: u64 = 0;
var total_tokens = std.AutoHashMap(TokenId, u64).init(allocator);
defer total_tokens.deinit();
// First pass: select boxes until targets met
for (inputs) |box| {
const needed = needsMoreBoxes(
total_value,
&total_tokens,
target_balance.as_u64(),
target_tokens,
);
if (!needed) break;
selected = append(selected, box);
total_value += box.value.as_u64();
if (box.tokens) |tokens| {
for (tokens.items()) |token| {
const entry = total_tokens.getOrPut(token.token_id);
if (entry.found_existing) {
entry.value_ptr.* += token.amount.value;
} else {
entry.value_ptr.* = token.amount.value;
}
}
}
}
// Check if targets met
if (total_value < target_balance.as_u64()) {
return error.NotEnoughCoins;
}
for (target_tokens) |target| {
const have = total_tokens.get(target.token_id) orelse 0;
if (have < target.amount.value) {
return error.NotEnoughTokens;
}
}
// Calculate change
const change = calculateChange(
total_value,
&total_tokens,
target_balance.as_u64(),
target_tokens,
);
return BoxSelection{
.boxes = try BoundedVec(ErgoBox, 1, MAX_INPUTS).fromSlice(selected),
.change_boxes = change,
};
}
};
const BoxSelection = struct {
/// Selected boxes to spend
boxes: BoundedVec(ErgoBox, 1, MAX_INPUTS),
/// Change boxes to create
change_boxes: []ErgoBoxAssetsData,
};
const BoxSelectorError = error{
NotEnoughCoins,
NotEnoughTokens,
TokenAmountError,
NotEnoughCoinsForChangeBox,
SelectedInputsOutOfBounds,
};
Transaction Signing Flow
Transaction Signing Flow
─────────────────────────────────────────────────────
┌──────────────────────────────────────────────────┐
│ 1. User Request │
│ ├── Target balance │
│ └── Target tokens │
└──────────────────────────┬───────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ 2. Box Selection │
│ ├── BoxSelector.select(inputs, target) │
│ └── Returns: boxes + change │
└──────────────────────────┬───────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ 3. Build Unsigned Transaction │
│ ├── inputs: selected boxes │
│ ├── data_inputs: read-only references │
│ └── output_candidates: targets + change │
└──────────────────────────┬───────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ 4. Sign Transaction │
│ For each input: │
│ ├── Create Context │
│ ├── Get hints for input │
│ ├── prover.prove(tree, ctx, message, hints) │
│ └── Accumulate cost │
└──────────────────────────┬───────────────────────┘
▼
┌──────────────────────────────────────────────────┐
│ 5. Signed Transaction │
│ └── Submit to mempool │
└──────────────────────────────────────────────────┘
/// Sign transaction with prover
pub fn signTransaction(
prover: *const Prover,
tx_context: *const TransactionContext(UnsignedTransaction),
state_context: *const ErgoStateContext,
tx_hints: ?*const TransactionHintsBag,
) !Transaction {
const tx = tx_context.spending_tx;
const message = try tx.bytesToSign();
var signed_inputs: []Input = &.{};
for (tx.inputs.items(), 0..) |unsigned_input, idx| {
const ctx = try makeContext(state_context, tx_context, idx);
// Get hints for this input
const hints = if (tx_hints) |h| h.allHintsForInput(idx) else HintsBag.empty();
const input_box = tx_context.getInputBox(unsigned_input.box_id) orelse
return error.InputBoxNotFound;
// Generate proof
const prover_result = try prover.prove(
&input_box.ergo_tree,
&ctx,
message,
&hints,
);
signed_inputs = append(signed_inputs, Input{
.box_id = unsigned_input.box_id,
.spending_proof = prover_result,
});
}
return Transaction.new(
try TxIoVec(Input).fromSlice(signed_inputs),
tx.data_inputs,
tx.output_candidates,
);
}
Asset Extraction
Calculate token access costs9:
const ErgoBoxAssetExtractor = struct {
pub const MAX_ASSETS_PER_BOX: usize = 255;
/// Extract total token amounts from boxes
pub fn extractAssets(
boxes: []const ErgoBoxCandidate,
) !struct { assets: std.AutoHashMap(TokenId, u64), count: usize } {
var assets = std.AutoHashMap(TokenId, u64).init(allocator);
var total_count: usize = 0;
for (boxes) |box| {
if (box.tokens) |tokens| {
if (tokens.len() > MAX_ASSETS_PER_BOX) {
return error.TooManyAssetsInBox;
}
for (tokens.items()) |token| {
const entry = assets.getOrPut(token.token_id);
if (entry.found_existing) {
entry.value_ptr.* = std.math.add(
u64,
entry.value_ptr.*,
token.amount.value,
) catch return error.Overflow;
} else {
entry.value_ptr.* = token.amount.value;
}
}
total_count += tokens.len();
}
}
return .{ .assets = assets, .count = total_count };
}
/// Calculate total token access cost
pub fn totalAssetsAccessCost(
in_assets_num: usize,
in_assets_size: usize,
out_assets_num: usize,
out_assets_size: usize,
token_access_cost: u32,
) u64 {
// Cost to iterate through all tokens
const all_assets_cost = (out_assets_num + in_assets_num) * token_access_cost;
// Cost to check preservation of unique tokens
const unique_assets_cost = (in_assets_size + out_assets_size) * token_access_cost;
return all_assets_cost + unique_assets_cost;
}
};
Wallet Errors
const WalletError = error{
/// Transaction signing failed
TxSigningError,
/// Prover failed to generate proof
ProverError,
/// Key derivation failed
ExtSecretKeyError,
/// Secret key parsing failed
SecretKeyParsingError,
/// Wallet not initialized
WalletNotInitialized,
/// Wallet locked
WalletLocked,
/// Wallet already unlocked
WalletAlreadyUnlocked,
/// Box selection failed
BoxSelectionError,
};
Distributed Signing Example
// Party A: Generate commitments
const commitments_a = try wallet_a.generateCommitments(&tx_context, &state_context);
// Party B: Generate commitments
const commitments_b = try wallet_b.generateCommitments(&tx_context, &state_context);
// Exchange public hints (safe to share)
const public_a = commitments_a.publicHintsForInput(0);
const public_b = commitments_b.publicHintsForInput(0);
// Party A: Sign with combined hints
var combined_a = commitments_a;
combined_a.addHintsForInput(0, public_b);
const partial_sig_a = try wallet_a.signTransaction(&tx_context, &state_context, &combined_a);
// Party B: Extract hints from A's partial signature
const extracted = extractHints(
&partial_sig_a,
real_propositions,
simulated_propositions,
boxes_to_spend,
data_boxes,
);
// Party B: Complete signing
var final_hints = commitments_b;
final_hints.addHintsForInput(0, extracted.allHintsForInput(0));
const final_tx = try wallet_b.signTransaction(&tx_context, &state_context, &final_hints);
Summary
- Wallet wraps prover with high-level signing API
- Mnemonic converts BIP-39 phrase to seed via PBKDF2
- TransactionHintsBag separates secret/public hints for distributed signing
- BoxSelector finds optimal input set for target balance/tokens
- Distributed signing (EIP-11) exchanges commitments, never secrets
- Asset extraction calculates token access costs
Next: Chapter 27: High-Level SDK
1
2
Rust: wallet.rs:52-93
3
Scala: Mnemonic.scala
4
Rust: mnemonic.rs:20-37
5
6
Rust: wallet.rs:259-347
7
Scala: BoxSelector.scala
8
Rust: box_selector.rs:34-46
9