Skip to content

Commit ff41873

Browse files
committed
feat: add mutable protocol-level multisig account support
- src/model/primitives/address.ts, index.ts, src/index.ts: add Address.multisigByte/isMultisigAddress/fromMultisigCreation and the MULTISIG_ADDRESS vanity constant. - src/model/nom/accountBlock.ts: add optional multisigAuth field to AccountBlockTemplate/AccountBlock with base64 JSON round-trip; omitted entirely from toJson when unset. - src/utilities/block.ts: document that multisigAuth is excluded from the consensus preimage in getTxHash; extract validateReceiveBlock out of checkAndSetFields; add exported freezeBlock/signBlock/assembleMultisigAuth primitives for send and receive multisig blocks. - src/embedded/multisig.ts, src/model/embedded/multisig.ts, src/api/embedded/multisig.ts: new embedded contract (CreateMultisig/ ChangePolicy ABI, MultisigPolicyInfo/MultisigRecordInfo models, MultisigApi with getPolicy/createMultisig/changePolicy), wired into src/embedded/index.ts, src/model/embedded/index.ts, src/api/embedded/embedded.ts and src/index.ts. - src/api/embedded/constants.ts: add MULTISIG_CREATION_FEE_IN_ZNN; createMultisig burns this fee instead of 0n, takes a required creator address, and throws MultisigCreatorMustBeSingleSigException if the creator is itself a multisig address. - src/client/nodeErrors.ts (new) + src/client/index.ts, src/index.ts: central typed node-error registry (mapNodeError) and a flat Znn...Exception hierarchy rooted at ZnnEmbeddedContractException extends ZnnClientException, covering multisig policy/threshold/authority errors and account-block auth errors. - src/client/http.ts, src/client/websocket.ts, src/api/ledger.ts: route RPC errors and the publishRawTransaction non-null-result channel through mapNodeError. - docs/multisig.md, docs/api-overview.md: document address derivation, account creation (including the balance precondition for the burn), cross-machine signing, signature-collection timing, and error handling. - test/api/embedded/multisig.spec.ts, test/client/errors.spec.ts, test/api/ledger.spec.ts: cover the new contract templates, error mappings, and the typed-error publish path.
1 parent f56d62e commit ff41873

32 files changed

Lines changed: 1247 additions & 29 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ await zenon.ledger.publishRawTransaction(prepared);
295295
- **[Examples](./docs/examples.md)** – Complete working examples
296296
- **[API Overview](./docs/api-overview.md)** – All API methods and embedded contract calls
297297
- **[Embedded Contracts](./docs/embedded-contracts/index.md)** – Detailed documentation for embedded contracts
298+
- **[Multisig Accounts](./docs/multisig.md)** – Creating and signing with mutable X-of-N multisig accounts
298299
- **[Utilities](./docs/utilities.md)** – Utilities and constants for common tasks
299300
- **[CLI Tool](./docs/cli.md)** – Command-line interface
300301
- **[Wallet Management](./docs/wallet.md)** – Creating and managing wallets

docs/api-overview.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ All APIs are available on the `zenon` object.
9797
- `zenon.embedded.htlc.denyProxyUnlock()` - Deny proxy unlock
9898
- `zenon.embedded.htlc.allowProxyUnlock()` - Allow proxy unlock
9999

100+
### Multisig
101+
- `zenon.embedded.multisig.getPolicy(address, height?)` - Get the active/pending policy for a multisig account
102+
- `zenon.embedded.multisig.createMultisig(creator, nonce, threshold, signers)` - Create a new multisig account (send from the creator's own account)
103+
- `zenon.embedded.multisig.changePolicy(threshold, signers, lock)` - Stage a new policy for a multisig account (sent BY the multisig account itself - see [Multisig Accounts](./multisig.md))
104+
100105
### Liquidity
101106
- `zenon.embedded.liquidity.getLiquidityInfo()` - Get liquidity contract info
102107
- `zenon.embedded.liquidity.getLiquidityStakeEntriesByAddress(address, pageIndex, pageSize)` - Get liquidity stake entries for address
@@ -475,6 +480,7 @@ console.log('Target height:', syncInfo.targetHeight);
475480
## Next Steps
476481

477482
- **[Examples](./examples.md)** – Complete working examples
483+
- **[Multisig Accounts](./multisig.md)** – Creating and signing with mutable X-of-N multisig accounts
478484
- **[Utilities](./utilities.md)** – Utilities and constants for common tasks
479485
- **[CLI Tool](./cli.md)** - Command-line interface
480486
- **[Wallet Management](./wallet.md)** – Creating and managing wallets

docs/cli.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ source ~/.bashrc
346346

347347
- **[Examples](./examples.md)** – Complete working examples
348348
- **[API Overview](./api-overview.md)** – All API methods & Embedded Contract Calls
349+
- **[Multisig Accounts](./multisig.md)** – Creating and signing with mutable X-of-N multisig accounts
349350
- **[Utilities](./utilities.md)** – Utilities and constants for common tasks
350351
- **[Wallet Management](./wallet.md)** – Creating and managing wallets
351352
- **[Building WASM](./build-wasm.md)** – Rebuilding the PoW module from source

docs/examples.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ try {
334334
## Next Steps
335335
336336
- **[API Overview](./api-overview.md)** – All API methods & Embedded Contract Calls
337+
- **[Multisig Accounts](./multisig.md)** – Creating and signing with mutable X-of-N multisig accounts
337338
- **[Utilities](./utilities.md)** – Utilities and constants for common tasks
338339
- **[CLI Tool](./cli.md)** - Command-line interface
339340
- **[Wallet Management](./wallet.md)** – Creating and managing wallets

docs/multisig.md

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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

docs/utilities.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ const dispplayValue = addNumberDecimals(100000000, 8);
149149

150150
- **[Examples](./examples.md)** – Complete working examples
151151
- **[API Overview](./api-overview.md)** – All API methods & Embedded Contract Calls
152+
- **[Multisig Accounts](./multisig.md)** – Creating and signing with mutable X-of-N multisig accounts
152153
- **[CLI Tool](./cli.md)** - Command-line interface
153154
- **[Wallet Management](./wallet.md)** – Creating and managing wallets
154155
- **[Building WASM](./build-wasm.md)** – Rebuilding the PoW module from source

docs/wallet.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ console.log(balances);
400400

401401
- **[Examples](./examples.md)** – Complete working examples
402402
- **[API Overview](./api-overview.md)** – All API methods & Embedded Contract Calls
403+
- **[Multisig Accounts](./multisig.md)** – Creating and signing with mutable X-of-N multisig accounts
403404
- **[Utilities](./utilities.md)** – Utilities and constants for common tasks
404405
- **[CLI Tool](./cli.md)** - Command-line interface
405406
- **[Building WASM](./build-wasm.md)** – Rebuilding the PoW module from source

src/api/embedded/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ export const TOKEN_DOMAIN_REG_EXP: RegExp = RegExp(
4343
"^([A-Za-z0-9][A-Za-z0-9-]{0,61}[A-Za-z0-9].)+[A-Za-z]{2,}$"
4444
);
4545

46+
// Multisig
47+
export const MULTISIG_CREATION_FEE_IN_ZNN: bigint = BigInt(ONE_ZNN);
48+
4649
// Accelerator
4750
export const PROPOSAL_URL_REG_EXP: RegExp = RegExp(
4851
"^[a-zA-Z0-9]{2,60}.[a-zA-Z]{1,6}([a-zA-Z0-9()@:%_\\+.~#?&/=-]{0,100})$"

src/api/embedded/embedded.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { AcceleratorApi } from "./accelerator.js";
44
import { BridgeApi } from "./bridge.js";
55
import { HtlcApi } from "./htlc.js";
66
import { LiquidityApi } from "./liquidity.js";
7+
import { MultisigApi } from "./multisig.js";
78
import { PillarApi } from "./pillar.js";
89
import { PlasmaApi } from "./plasma.js";
910
import { SentinelApi } from "./sentinel.js";
@@ -19,6 +20,7 @@ export class EmbeddedApi extends Api {
1920
public bridge = new BridgeApi(),
2021
public htlc = new HtlcApi(),
2122
public liquidity = new LiquidityApi(),
23+
public multisig = new MultisigApi(),
2224
public pillar = new PillarApi(),
2325
public plasma = new PlasmaApi(),
2426
public sentinel = new SentinelApi(),
@@ -36,6 +38,7 @@ export class EmbeddedApi extends Api {
3638
this.bridge.setClient(client);
3739
this.htlc.setClient(client);
3840
this.liquidity.setClient(client);
41+
this.multisig.setClient(client);
3942
this.pillar.setClient(client);
4043
this.plasma.setClient(client);
4144
this.sentinel.setClient(client);

src/api/embedded/multisig.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Api } from "../base.js";
2+
import { Address, MULTISIG_ADDRESS, ZNN_ZTS } from "../../model/primitives/index.js";
3+
import { MultisigRecordInfo } from "../../model/embedded/multisig.js";
4+
import { AccountBlockTemplate } from "../../model/nom/accountBlock.js";
5+
import { Multisig as MultisigContract } from "../../embedded/index.js";
6+
import { MULTISIG_CREATION_FEE_IN_ZNN } from "./constants.js";
7+
import { MultisigCreatorMustBeSingleSigException } from "../../client/nodeErrors.js";
8+
9+
export class MultisigApi extends Api {
10+
11+
//
12+
// RPC
13+
14+
async getPolicy(address: Address, height?: number): Promise<MultisigRecordInfo | null> {
15+
const response = await this.client.sendRequest("embedded.multisig.getPolicy", [
16+
address.toString(),
17+
height !== undefined ? height : null,
18+
]);
19+
return response === null ? null : MultisigRecordInfo.fromJson(response);
20+
}
21+
22+
//
23+
// Contract-call templates (unsigned). createMultisig is sent by a normal user
24+
// (feed to the existing send(zenon, tpl, keyPair)); changePolicy is sent BY the
25+
// multisig account (feed to the freeze/sign/assemble path).
26+
27+
createMultisig(creator: Address, nonce: bigint, threshold: number, signers: Buffer[]): AccountBlockTemplate {
28+
if (Address.isMultisigAddress(creator)) {
29+
throw new MultisigCreatorMustBeSingleSigException("multisig: creator must be a single-sig account", 0);
30+
}
31+
return AccountBlockTemplate.callContract(
32+
MULTISIG_ADDRESS, ZNN_ZTS, MULTISIG_CREATION_FEE_IN_ZNN,
33+
MultisigContract.abi.encodeFunctionData("CreateMultisig", [nonce, threshold, signers]),
34+
);
35+
}
36+
37+
changePolicy(threshold: number, signers: Buffer[], lock: boolean): AccountBlockTemplate {
38+
return AccountBlockTemplate.callContract(
39+
MULTISIG_ADDRESS, ZNN_ZTS, 0n,
40+
MultisigContract.abi.encodeFunctionData("ChangePolicy", [threshold, signers, lock]),
41+
);
42+
}
43+
}

0 commit comments

Comments
 (0)