|
| 1 | +# Multisig Accounts |
| 2 | + |
| 3 | +Complete guide to creating and managing mutable, protocol-level X-of-N multisig accounts with the ZNN TypeScript SDK. |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## Overview |
| 8 | + |
| 9 | +A multisig account has its own address, but unlike a normal user address its `{signers, threshold}` policy is **mutable consensus state** — it can be rotated later (subject to a maturity delay) without changing the address. |
| 10 | + |
| 11 | +Because a multisig block must be signed by multiple independent parties — often on different machines — this SDK does not sign multisig blocks the same way it signs a normal single-keypair transaction. Instead it exposes three composable primitives: |
| 12 | + |
| 13 | +1. **`freezeBlock`** – autofills, proof-of-works, and hashes the block, leaving it unsigned. |
| 14 | +2. **`signBlock`** – has one signer produce a single raw signature over the frozen hash. |
| 15 | +3. **`assembleMultisigAuth`** – attaches the collected signatures once enough have been gathered. |
| 16 | + |
| 17 | +The frozen block is a plain `AccountBlockTemplate`, so it round-trips through the SDK's existing `toJson()`/`fromJson()` — the same mechanism used everywhere else in the SDK — which is what makes it possible to hand a pre-signed-but-not-yet-complete block to another signer on a different machine. |
| 18 | + |
| 19 | +> **Note:** This feature depends on a protocol-level spork that ships **dormant**. Sending to the multisig contract before the spork is activated on-chain fails synchronously with a node error — see [Error Handling](#error-handling) below. |
| 20 | +
|
| 21 | +--- |
| 22 | + |
| 23 | +## Creating a Multisig Account |
| 24 | + |
| 25 | +Multisig addresses are **derived, not chosen** — anyone who knows the creator's public key and a nonce can compute the address offline, before the account exists on-chain. |
| 26 | + |
| 27 | +```javascript |
| 28 | +import { Address, KeyPair } from 'znn-typescript-sdk'; |
| 29 | + |
| 30 | +const creator = wallet.getKeyPair(0); // a normal KeyPair |
| 31 | +const nonce = 1n; |
| 32 | + |
| 33 | +// Deterministically derive the multisig account's address |
| 34 | +const multisigAddress = Address.fromMultisigCreation(creator.publicKey, nonce); |
| 35 | +console.log('Multisig address:', multisigAddress.toString()); |
| 36 | + |
| 37 | +// Detect whether any address is a multisig account |
| 38 | +console.log(Address.isMultisigAddress(multisigAddress)); // true |
| 39 | +``` |
| 40 | + |
| 41 | +Send `CreateMultisig` from the creator's own (normal, single-keypair) account — this is a regular send, so the existing `zenon.send(...)` path is used directly: |
| 42 | + |
| 43 | +```javascript |
| 44 | +import { Zenon } from 'znn-typescript-sdk'; |
| 45 | + |
| 46 | +const zenon = Zenon.getInstance(); |
| 47 | +await zenon.initialize('wss://node.zenonhub.io:35998'); |
| 48 | + |
| 49 | +// N (the total number of signers) is NOT a separate parameter — it's just |
| 50 | +// signers.length. There is no "totalSigners" argument to set. |
| 51 | +const signerPubKeys = [creator.publicKey, otherSigner.publicKey, thirdSigner.publicKey]; |
| 52 | + |
| 53 | +const block = zenon.embedded.multisig.createMultisig( |
| 54 | + nonce, |
| 55 | + 2, // threshold (X): how many of the signers below must sign |
| 56 | + signerPubKeys, // the N signers: raw 32-byte ed25519 public keys, creator's key must be included |
| 57 | +); |
| 58 | +// The line above creates a 2-of-3 policy purely because signerPubKeys has 3 entries. |
| 59 | + |
| 60 | +await zenon.send(block, creator); |
| 61 | + |
| 62 | +zenon.clearConnection(); |
| 63 | +``` |
| 64 | + |
| 65 | +The node enforces `2 <= signers.length <= 16` and `threshold <= signers.length` — the SDK does not validate this client-side, so an out-of-range value surfaces as a `MultisigInvalidPolicyException` (see [Error Handling](#error-handling)) once you send the block. |
| 66 | + |
| 67 | +--- |
| 68 | + |
| 69 | +## Reading the Active Policy |
| 70 | + |
| 71 | +```javascript |
| 72 | +const record = await zenon.embedded.multisig.getPolicy(multisigAddress); |
| 73 | + |
| 74 | +if (record) { |
| 75 | + console.log('Active threshold:', record.active.threshold); |
| 76 | + console.log('Active signers:', record.active.signers.map(s => s.toString('hex'))); |
| 77 | + console.log('Locked:', record.active.locked); |
| 78 | + |
| 79 | + if (record.pending) { |
| 80 | + console.log('Pending policy change:', record.pending); |
| 81 | + console.log('Matures at height:', record.pendingHeight); |
| 82 | + } |
| 83 | +} |
| 84 | +``` |
| 85 | + |
| 86 | +`active` already reflects any matured `pending` change; `pending`/`pendingHeight` describe a still-staged change that hasn't taken effect yet. |
| 87 | + |
| 88 | +--- |
| 89 | + |
| 90 | +## Signing a Block From the Multisig Account |
| 91 | + |
| 92 | +Every block sent **by** the multisig account itself (e.g. `ChangePolicy`, or sending funds out of the account) needs `threshold`-many signatures instead of one. This is where `freezeBlock` / `signBlock` / `assembleMultisigAuth` come in. |
| 93 | + |
| 94 | +### Single Process (All Keys Available) |
| 95 | + |
| 96 | +```javascript |
| 97 | +import { Zenon, freezeBlock, signBlock, assembleMultisigAuth } from 'znn-typescript-sdk'; |
| 98 | + |
| 99 | +const zenon = Zenon.getInstance(); |
| 100 | +await zenon.initialize('wss://node.zenonhub.io:35998'); |
| 101 | + |
| 102 | +// 1. Build the contract call template (address is NOT derived from a keypair) |
| 103 | +const template = zenon.embedded.multisig.changePolicy(newThreshold, newSigners, false); |
| 104 | + |
| 105 | +// 2. Freeze it: autofill height/previousHash, run PoW, compute the hash. |
| 106 | +// publicKey/signature are left empty — this is what every signer signs over. |
| 107 | +const frozen = await freezeBlock(zenon, template, multisigAddress); |
| 108 | + |
| 109 | +// 3. Collect signatures — order doesn't matter, the node trial-matches them |
| 110 | +// against the active policy's signer set. |
| 111 | +const sig1 = signBlock(frozen, signerKeyPair1); |
| 112 | +const sig2 = signBlock(frozen, signerKeyPair2); |
| 113 | + |
| 114 | +// 4. Assemble and publish once >= threshold signatures are collected. |
| 115 | +assembleMultisigAuth(frozen, [sig1, sig2]); |
| 116 | +await zenon.ledger.publishRawTransaction(frozen); |
| 117 | + |
| 118 | +zenon.clearConnection(); |
| 119 | +``` |
| 120 | + |
| 121 | +The same three-step flow works for **receiving** funds into a multisig account — just start from `AccountBlockTemplate.receive(sendBlockHash)` instead of a contract-call template: |
| 122 | + |
| 123 | +```javascript |
| 124 | +import { AccountBlockTemplate } from 'znn-typescript-sdk'; |
| 125 | + |
| 126 | +const receiveTemplate = AccountBlockTemplate.receive(unreceivedSendHash); |
| 127 | +const frozenReceive = await freezeBlock(zenon, receiveTemplate, multisigAddress); |
| 128 | + |
| 129 | +const sig1 = signBlock(frozenReceive, signerKeyPair1); |
| 130 | +const sig2 = signBlock(frozenReceive, signerKeyPair2); |
| 131 | + |
| 132 | +assembleMultisigAuth(frozenReceive, [sig1, sig2]); |
| 133 | +await zenon.ledger.publishRawTransaction(frozenReceive); |
| 134 | +``` |
| 135 | + |
| 136 | +### Cross-Machine / Multi-Device Signing |
| 137 | + |
| 138 | +The most realistic deployment has each signer on a separate machine. Because a frozen block is a normal `AccountBlockTemplate`, it serializes through the SDK's existing JSON round-trip — no separate wire format is needed. |
| 139 | + |
| 140 | +**Machine A — freeze and hand off:** |
| 141 | + |
| 142 | +```javascript |
| 143 | +const frozen = await freezeBlock(zenon, template, multisigAddress); |
| 144 | + |
| 145 | +// Ship this JSON to the next signer (file, QR code, HTTP request, etc.) |
| 146 | +const payload = JSON.stringify(frozen.toJson()); |
| 147 | +``` |
| 148 | + |
| 149 | +**Machine B — sign and hand back:** |
| 150 | + |
| 151 | +```javascript |
| 152 | +import { AccountBlockTemplate, signBlock } from 'znn-typescript-sdk'; |
| 153 | + |
| 154 | +const frozen = AccountBlockTemplate.fromJson(JSON.parse(payload)); |
| 155 | +const signature = signBlock(frozen, myKeyPair); |
| 156 | + |
| 157 | +// Send just the signature back to whoever is assembling the final block |
| 158 | +const signaturePayload = signature.toString('base64'); |
| 159 | +``` |
| 160 | + |
| 161 | +**Coordinator — assemble once enough signatures are back:** |
| 162 | + |
| 163 | +```javascript |
| 164 | +import { AccountBlockTemplate, assembleMultisigAuth } from 'znn-typescript-sdk'; |
| 165 | + |
| 166 | +const frozen = AccountBlockTemplate.fromJson(JSON.parse(payload)); |
| 167 | +const signatures = collectedBase64Signatures.map(s => Buffer.from(s, 'base64')); |
| 168 | + |
| 169 | +assembleMultisigAuth(frozen, signatures); |
| 170 | +await zenon.ledger.publishRawTransaction(frozen); |
| 171 | +``` |
| 172 | + |
| 173 | +Because the hash is computed once during `freezeBlock` and carried through the JSON round-trip unchanged, every signer signs the exact same bytes regardless of which machine they're on. |
| 174 | + |
| 175 | +**Important:** |
| 176 | +- `freezeBlock` does not sign anything — call it once, then distribute the frozen block. |
| 177 | +- `signBlock` will throw if called on a block that hasn't been frozen yet (its hash is still the empty default). |
| 178 | +- `assembleMultisigAuth` doesn't enforce the threshold itself — the node validates signature count and validity against the account's active policy when the block is published. |
| 179 | + |
| 180 | +--- |
| 181 | + |
| 182 | +## Error Handling |
| 183 | + |
| 184 | +Embedded-contract and account-block validation errors raised by the node are mapped to typed exceptions so downstream apps can distinguish failure modes instead of parsing raw error strings: |
| 185 | + |
| 186 | +```javascript |
| 187 | +import { |
| 188 | + MultisigPolicyLockedException, |
| 189 | + MultisigThresholdMismatchException, |
| 190 | + MultisigSporkNotActivatedException, |
| 191 | + ZnnEmbeddedContractException, |
| 192 | +} from 'znn-typescript-sdk'; |
| 193 | + |
| 194 | +try { |
| 195 | + await zenon.ledger.publishRawTransaction(frozen); |
| 196 | +} catch (error) { |
| 197 | + if (error instanceof MultisigPolicyLockedException) { |
| 198 | + console.error('This account\'s policy is locked and cannot be changed.'); |
| 199 | + } else if (error instanceof MultisigThresholdMismatchException) { |
| 200 | + console.error('Not enough valid signatures were collected.'); |
| 201 | + } else if (error instanceof MultisigSporkNotActivatedException) { |
| 202 | + console.error('Multisig support is not yet active on this network.'); |
| 203 | + } else if (error instanceof ZnnEmbeddedContractException) { |
| 204 | + // Catch-all for any other typed embedded-contract error |
| 205 | + console.error(`${error.contract} error:`, error.message); |
| 206 | + } else { |
| 207 | + throw error; |
| 208 | + } |
| 209 | +} |
| 210 | +``` |
| 211 | + |
| 212 | +All typed exceptions extend `ZnnClientException`, so existing code that catches `ZnnClientException` continues to work unchanged. |
| 213 | + |
| 214 | +--- |
| 215 | + |
| 216 | +## Next Steps |
| 217 | + |
| 218 | +- **[Examples](./examples.md)** – Complete working examples |
| 219 | +- **[API Overview](./api-overview.md)** – All API methods & Embedded Contract Calls |
| 220 | +- **[Utilities](./utilities.md)** – Utilities and constants for common tasks |
| 221 | +- **[Wallet Management](./wallet.md)** – Creating and managing wallets |
| 222 | +- **[CLI Tool](./cli.md)** – Command-line interface |
0 commit comments