|
| 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 | + creator.getAddress(), // the creator's own address; must not itself be a multisig address |
| 55 | + nonce, |
| 56 | + 2, // threshold (X): how many of the signers below must sign |
| 57 | + signerPubKeys, // the N signers: raw 32-byte ed25519 public keys, creator's key must be included |
| 58 | +); |
| 59 | +// The line above creates a 2-of-3 policy purely because signerPubKeys has 3 entries. |
| 60 | + |
| 61 | +await zenon.send(block, creator); |
| 62 | + |
| 63 | +zenon.clearConnection(); |
| 64 | +``` |
| 65 | + |
| 66 | +The creator's account must hold at least 1 ZNN, which is burned irreversibly on creation, plus enough plasma or fused QSR to cover the send. |
| 67 | + |
| 68 | +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. `createMultisig` throws `MultisigCreatorMustBeSingleSigException` if `creator` is itself a multisig address — nested multisig creation is not supported. |
| 69 | + |
| 70 | +--- |
| 71 | + |
| 72 | +## Reading the Active Policy |
| 73 | + |
| 74 | +```javascript |
| 75 | +const record = await zenon.embedded.multisig.getPolicy(multisigAddress); |
| 76 | + |
| 77 | +if (record) { |
| 78 | + console.log('Active threshold:', record.active.threshold); |
| 79 | + console.log('Active signers:', record.active.signers.map(s => s.toString('hex'))); |
| 80 | + console.log('Locked:', record.active.locked); |
| 81 | + |
| 82 | + if (record.pending) { |
| 83 | + console.log('Pending policy change:', record.pending); |
| 84 | + console.log('Matures at height:', record.pendingHeight); |
| 85 | + } |
| 86 | +} |
| 87 | +``` |
| 88 | + |
| 89 | +`active` already reflects any matured `pending` change; `pending`/`pendingHeight` describe a still-staged change that hasn't taken effect yet. |
| 90 | + |
| 91 | +--- |
| 92 | + |
| 93 | +## Signing a Block From the Multisig Account |
| 94 | + |
| 95 | +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. |
| 96 | + |
| 97 | +### Single Process (All Keys Available) |
| 98 | + |
| 99 | +```javascript |
| 100 | +import { Zenon, freezeBlock, signBlock, assembleMultisigAuth } from 'znn-typescript-sdk'; |
| 101 | + |
| 102 | +const zenon = Zenon.getInstance(); |
| 103 | +await zenon.initialize('wss://node.zenonhub.io:35998'); |
| 104 | + |
| 105 | +// 1. Build the contract call template (address is NOT derived from a keypair) |
| 106 | +const template = zenon.embedded.multisig.changePolicy(newThreshold, newSigners, false); |
| 107 | + |
| 108 | +// 2. Freeze it: autofill height/previousHash, run PoW, compute the hash. |
| 109 | +// publicKey/signature are left empty — this is what every signer signs over. |
| 110 | +const frozen = await freezeBlock(zenon, template, multisigAddress); |
| 111 | + |
| 112 | +// 3. Collect signatures — order doesn't matter, the node trial-matches them |
| 113 | +// against the active policy's signer set. |
| 114 | +const sig1 = signBlock(frozen, signerKeyPair1); |
| 115 | +const sig2 = signBlock(frozen, signerKeyPair2); |
| 116 | + |
| 117 | +// 4. Assemble and publish once >= threshold signatures are collected. |
| 118 | +assembleMultisigAuth(frozen, [sig1, sig2]); |
| 119 | +await zenon.ledger.publishRawTransaction(frozen); |
| 120 | + |
| 121 | +zenon.clearConnection(); |
| 122 | +``` |
| 123 | + |
| 124 | +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: |
| 125 | + |
| 126 | +```javascript |
| 127 | +import { AccountBlockTemplate } from 'znn-typescript-sdk'; |
| 128 | + |
| 129 | +const receiveTemplate = AccountBlockTemplate.receive(unreceivedSendHash); |
| 130 | +const frozenReceive = await freezeBlock(zenon, receiveTemplate, multisigAddress); |
| 131 | + |
| 132 | +const sig1 = signBlock(frozenReceive, signerKeyPair1); |
| 133 | +const sig2 = signBlock(frozenReceive, signerKeyPair2); |
| 134 | + |
| 135 | +assembleMultisigAuth(frozenReceive, [sig1, sig2]); |
| 136 | +await zenon.ledger.publishRawTransaction(frozenReceive); |
| 137 | +``` |
| 138 | + |
| 139 | +### Cross-Machine / Multi-Device Signing |
| 140 | + |
| 141 | +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. |
| 142 | + |
| 143 | +**Machine A — freeze and hand off:** |
| 144 | + |
| 145 | +```javascript |
| 146 | +const frozen = await freezeBlock(zenon, template, multisigAddress); |
| 147 | + |
| 148 | +// Ship this JSON to the next signer (file, QR code, HTTP request, etc.) |
| 149 | +const payload = JSON.stringify(frozen.toJson()); |
| 150 | +``` |
| 151 | + |
| 152 | +**Machine B — sign and hand back:** |
| 153 | + |
| 154 | +```javascript |
| 155 | +import { AccountBlockTemplate, signBlock } from 'znn-typescript-sdk'; |
| 156 | + |
| 157 | +const frozen = AccountBlockTemplate.fromJson(JSON.parse(payload)); |
| 158 | +const signature = signBlock(frozen, myKeyPair); |
| 159 | + |
| 160 | +// Send just the signature back to whoever is assembling the final block |
| 161 | +const signaturePayload = signature.toString('base64'); |
| 162 | +``` |
| 163 | + |
| 164 | +**Coordinator — assemble once enough signatures are back:** |
| 165 | + |
| 166 | +```javascript |
| 167 | +import { AccountBlockTemplate, assembleMultisigAuth } from 'znn-typescript-sdk'; |
| 168 | + |
| 169 | +const frozen = AccountBlockTemplate.fromJson(JSON.parse(payload)); |
| 170 | +const signatures = collectedBase64Signatures.map(s => Buffer.from(s, 'base64')); |
| 171 | + |
| 172 | +assembleMultisigAuth(frozen, signatures); |
| 173 | +await zenon.ledger.publishRawTransaction(frozen); |
| 174 | +``` |
| 175 | + |
| 176 | +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. |
| 177 | + |
| 178 | +**Important:** |
| 179 | +- `freezeBlock` does not sign anything — call it once, then distribute the frozen block. |
| 180 | +- `signBlock` will throw if called on a block that hasn't been frozen yet (its hash is still the empty default). |
| 181 | +- `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. |
| 182 | + |
| 183 | +### Signature Collection Timing |
| 184 | + |
| 185 | +Freezing and signing are separable: a frozen block can be circulated for signing over an extended period — realistically anywhere from hours up to about a week. Authorization is checked live, at momentum-inclusion time, against whichever policy is active then — not against a snapshot pinned at freeze or submit time. |
| 186 | + |
| 187 | +If collection runs past the mempool's hygiene window, or a policy rotation invalidates the collected signatures before the block is included, the block is silently excluded from momentum production (and blocks any later blocks queued on the same account), with no node notification. A wallet must poll for confirmation (e.g. via `zenon.ledger.getAccountBlockByHash` or the account's height) and re-freeze and re-collect signatures if the block never lands. |
| 188 | + |
| 189 | +--- |
| 190 | + |
| 191 | +## Error Handling |
| 192 | + |
| 193 | +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: |
| 194 | + |
| 195 | +```javascript |
| 196 | +import { |
| 197 | + MultisigPolicyLockedException, |
| 198 | + MultisigThresholdMismatchException, |
| 199 | + MultisigSporkNotActivatedException, |
| 200 | + ZnnEmbeddedContractException, |
| 201 | +} from 'znn-typescript-sdk'; |
| 202 | + |
| 203 | +try { |
| 204 | + await zenon.ledger.publishRawTransaction(frozen); |
| 205 | +} catch (error) { |
| 206 | + if (error instanceof MultisigPolicyLockedException) { |
| 207 | + console.error('This account\'s policy is locked and cannot be changed.'); |
| 208 | + } else if (error instanceof MultisigThresholdMismatchException) { |
| 209 | + console.error('Not enough valid signatures were collected.'); |
| 210 | + } else if (error instanceof MultisigSporkNotActivatedException) { |
| 211 | + console.error('Multisig support is not yet active on this network.'); |
| 212 | + } else if (error instanceof ZnnEmbeddedContractException) { |
| 213 | + // Catch-all for any other typed embedded-contract error |
| 214 | + console.error(`${error.contract} error:`, error.message); |
| 215 | + } else { |
| 216 | + throw error; |
| 217 | + } |
| 218 | +} |
| 219 | +``` |
| 220 | + |
| 221 | +All typed exceptions extend `ZnnClientException`, so existing code that catches `ZnnClientException` continues to work unchanged. |
| 222 | + |
| 223 | +--- |
| 224 | + |
| 225 | +## Next Steps |
| 226 | + |
| 227 | +- **[Examples](./examples.md)** – Complete working examples |
| 228 | +- **[API Overview](./api-overview.md)** – All API methods & Embedded Contract Calls |
| 229 | +- **[Utilities](./utilities.md)** – Utilities and constants for common tasks |
| 230 | +- **[Wallet Management](./wallet.md)** – Creating and managing wallets |
| 231 | +- **[CLI Tool](./cli.md)** – Command-line interface |
0 commit comments