Skip to content

Commit 93b4def

Browse files
committed
feat: add mutable protocol-level multisig account support
- src/model/primitives/address.ts, index.ts, src/index.ts: add Address.multisigByte/isMultisig/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: comment-only note in getTxHash documenting that multisigAuth is excluded from the consensus preimage (INV-1); extract validateReceiveBlock out of checkAndSetFields (behavior-preserving, INV-2); add new exported freezeBlock/signBlock/assembleMultisigAuth primitives for both send and receive multisig blocks. - src/embedded/multisig.ts, src/model/embedded/multisig.ts, src/api/embedded/multisig.ts: new 12th 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 (MultisigContract only, matching the htlc precedent — MultisigApi is not surfaced top-level). - src/client/nodeErrors.ts (new) + src/client/index.ts: central typed node-error registry (mapNodeError) and a flat Znn...Exception hierarchy rooted at ZnnEmbeddedContractException extends ZnnClientException. - src/client/http.ts, src/client/websocket.ts, src/api/ledger.ts: route both RPC-error channels and the publishRawTransaction non-null-result channel through mapNodeError. - test/api/ledger.spec.ts: update the publish-error assertion to match the new typed-error shape (was asserting the old NETWORK_ERROR message).
1 parent f56d62e commit 93b4def

30 files changed

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

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/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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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+
7+
export class MultisigApi extends Api {
8+
9+
//
10+
// RPC
11+
12+
async getPolicy(address: Address, height?: number): Promise<MultisigRecordInfo | null> {
13+
const response = await this.client.sendRequest("embedded.multisig.getPolicy", [
14+
address.toString(),
15+
height !== undefined ? height : null,
16+
]);
17+
return response === null ? null : MultisigRecordInfo.fromJson(response);
18+
}
19+
20+
//
21+
// Contract-call templates (unsigned). createMultisig is sent by a normal user
22+
// (feed to the existing send(zenon, tpl, keyPair)); changePolicy is sent BY the
23+
// multisig account (feed to the freeze/sign/assemble path).
24+
25+
createMultisig(nonce: bigint, threshold: number, signers: Buffer[]): AccountBlockTemplate {
26+
return AccountBlockTemplate.callContract(
27+
MULTISIG_ADDRESS, ZNN_ZTS, 0n,
28+
MultisigContract.abi.encodeFunctionData("CreateMultisig", [nonce, threshold, signers]),
29+
);
30+
}
31+
32+
changePolicy(threshold: number, signers: Buffer[], lock: boolean): AccountBlockTemplate {
33+
return AccountBlockTemplate.callContract(
34+
MULTISIG_ADDRESS, ZNN_ZTS, 0n,
35+
MultisigContract.abi.encodeFunctionData("ChangePolicy", [threshold, signers, lock]),
36+
);
37+
}
38+
}

src/api/ledger.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { Address, Hash } from "../model/primitives/index.js";
99
import { Api } from "./base.js";
1010
import { Logger } from "../utilities/logger.js";
11+
import { mapNodeError } from "../client/nodeErrors.js";
1112

1213
const logger = Logger.globalLogger();
1314

@@ -20,7 +21,8 @@ export class LedgerApi extends Api {
2021
]);
2122

2223
if (response !== null) {
23-
logger.throwError(`Error publishing transaction: ${response}`, Logger.errors.NETWORK_ERROR);
24+
const message = typeof response === "string" ? response : JSON.stringify(response);
25+
throw mapNodeError(message, -1, "ledger.publishRawTransaction", [accountBlockTemplate.toJson()]);
2426
}
2527

2628
logger.info(`Published account-block: hash=${accountBlockTemplate.hash.toString()}`);

0 commit comments

Comments
 (0)