Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ await zenon.ledger.publishRawTransaction(prepared);
- **[Examples](./docs/examples.md)** – Complete working examples
- **[API Overview](./docs/api-overview.md)** – All API methods and embedded contract calls
- **[Embedded Contracts](./docs/embedded-contracts/index.md)** – Detailed documentation for embedded contracts
- **[Multisig Accounts](./docs/multisig.md)** – Creating and signing with mutable X-of-N multisig accounts
- **[Utilities](./docs/utilities.md)** – Utilities and constants for common tasks
- **[CLI Tool](./docs/cli.md)** – Command-line interface
- **[Wallet Management](./docs/wallet.md)** – Creating and managing wallets
Expand Down
6 changes: 6 additions & 0 deletions docs/api-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ All APIs are available on the `zenon` object.
- `zenon.embedded.htlc.denyProxyUnlock()` - Deny proxy unlock
- `zenon.embedded.htlc.allowProxyUnlock()` - Allow proxy unlock

### Multisig
- `zenon.embedded.multisig.getPolicy(address, height?)` - Get the active/pending policy for a multisig account
- `zenon.embedded.multisig.createMultisig(creator, nonce, threshold, signers)` - Create a new multisig account (send from the creator's own account)
- `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))

### Liquidity
- `zenon.embedded.liquidity.getLiquidityInfo()` - Get liquidity contract info
- `zenon.embedded.liquidity.getLiquidityStakeEntriesByAddress(address, pageIndex, pageSize)` - Get liquidity stake entries for address
Expand Down Expand Up @@ -475,6 +480,7 @@ console.log('Target height:', syncInfo.targetHeight);
## Next Steps

- **[Examples](./examples.md)** – Complete working examples
- **[Multisig Accounts](./multisig.md)** – Creating and signing with mutable X-of-N multisig accounts
- **[Utilities](./utilities.md)** – Utilities and constants for common tasks
- **[CLI Tool](./cli.md)** - Command-line interface
- **[Wallet Management](./wallet.md)** – Creating and managing wallets
Expand Down
1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ source ~/.bashrc

- **[Examples](./examples.md)** – Complete working examples
- **[API Overview](./api-overview.md)** – All API methods & Embedded Contract Calls
- **[Multisig Accounts](./multisig.md)** – Creating and signing with mutable X-of-N multisig accounts
- **[Utilities](./utilities.md)** – Utilities and constants for common tasks
- **[Wallet Management](./wallet.md)** – Creating and managing wallets
- **[Building WASM](./build-wasm.md)** – Rebuilding the PoW module from source
1 change: 1 addition & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ try {
## Next Steps

- **[API Overview](./api-overview.md)** – All API methods & Embedded Contract Calls
- **[Multisig Accounts](./multisig.md)** – Creating and signing with mutable X-of-N multisig accounts
- **[Utilities](./utilities.md)** – Utilities and constants for common tasks
- **[CLI Tool](./cli.md)** - Command-line interface
- **[Wallet Management](./wallet.md)** – Creating and managing wallets
Expand Down
231 changes: 231 additions & 0 deletions docs/multisig.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
# Multisig Accounts

Complete guide to creating and managing mutable, protocol-level X-of-N multisig accounts with the ZNN TypeScript SDK.

---

## Overview

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.

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:

1. **`freezeBlock`** – autofills, proof-of-works, and hashes the block, leaving it unsigned.
2. **`signBlock`** – has one signer produce a single raw signature over the frozen hash.
3. **`assembleMultisigAuth`** – attaches the collected signatures once enough have been gathered.

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.

> **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.

---

## Creating a Multisig Account

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.

```javascript
import { Address, KeyPair } from 'znn-typescript-sdk';

const creator = wallet.getKeyPair(0); // a normal KeyPair
const nonce = 1n;

// Deterministically derive the multisig account's address
const multisigAddress = Address.fromMultisigCreation(creator.publicKey, nonce);
console.log('Multisig address:', multisigAddress.toString());

// Detect whether any address is a multisig account
console.log(Address.isMultisigAddress(multisigAddress)); // true
```

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:

```javascript
import { Zenon } from 'znn-typescript-sdk';

const zenon = Zenon.getInstance();
await zenon.initialize('wss://node.zenonhub.io:35998');

// N (the total number of signers) is NOT a separate parameter — it's just
// signers.length. There is no "totalSigners" argument to set.
const signerPubKeys = [creator.publicKey, otherSigner.publicKey, thirdSigner.publicKey];

const block = zenon.embedded.multisig.createMultisig(
creator.getAddress(), // the creator's own address; must not itself be a multisig address
nonce,
2, // threshold (X): how many of the signers below must sign
signerPubKeys, // the N signers: raw 32-byte ed25519 public keys, creator's key must be included
);
// The line above creates a 2-of-3 policy purely because signerPubKeys has 3 entries.

await zenon.send(block, creator);

zenon.clearConnection();
```

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.

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.

---

## Reading the Active Policy

```javascript
const record = await zenon.embedded.multisig.getPolicy(multisigAddress);

if (record) {
console.log('Active threshold:', record.active.threshold);
console.log('Active signers:', record.active.signers.map(s => s.toString('hex')));
console.log('Locked:', record.active.locked);

if (record.pending) {
console.log('Pending policy change:', record.pending);
console.log('Matures at height:', record.pendingHeight);
}
}
```

`active` already reflects any matured `pending` change; `pending`/`pendingHeight` describe a still-staged change that hasn't taken effect yet.

---

## Signing a Block From the Multisig Account

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.

### Single Process (All Keys Available)

```javascript
import { Zenon, freezeBlock, signBlock, assembleMultisigAuth } from 'znn-typescript-sdk';

const zenon = Zenon.getInstance();
await zenon.initialize('wss://node.zenonhub.io:35998');

// 1. Build the contract call template (address is NOT derived from a keypair)
const template = zenon.embedded.multisig.changePolicy(newThreshold, newSigners, false);

// 2. Freeze it: autofill height/previousHash, run PoW, compute the hash.
// publicKey/signature are left empty — this is what every signer signs over.
const frozen = await freezeBlock(zenon, template, multisigAddress);

// 3. Collect signatures — order doesn't matter, the node trial-matches them
// against the active policy's signer set.
const sig1 = signBlock(frozen, signerKeyPair1);
const sig2 = signBlock(frozen, signerKeyPair2);

// 4. Assemble and publish once >= threshold signatures are collected.
assembleMultisigAuth(frozen, [sig1, sig2]);
await zenon.ledger.publishRawTransaction(frozen);

zenon.clearConnection();
```

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:

```javascript
import { AccountBlockTemplate } from 'znn-typescript-sdk';

const receiveTemplate = AccountBlockTemplate.receive(unreceivedSendHash);
const frozenReceive = await freezeBlock(zenon, receiveTemplate, multisigAddress);

const sig1 = signBlock(frozenReceive, signerKeyPair1);
const sig2 = signBlock(frozenReceive, signerKeyPair2);

assembleMultisigAuth(frozenReceive, [sig1, sig2]);
await zenon.ledger.publishRawTransaction(frozenReceive);
```

### Cross-Machine / Multi-Device Signing

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.

**Machine A — freeze and hand off:**

```javascript
const frozen = await freezeBlock(zenon, template, multisigAddress);

// Ship this JSON to the next signer (file, QR code, HTTP request, etc.)
const payload = JSON.stringify(frozen.toJson());
```

**Machine B — sign and hand back:**

```javascript
import { AccountBlockTemplate, signBlock } from 'znn-typescript-sdk';

const frozen = AccountBlockTemplate.fromJson(JSON.parse(payload));
const signature = signBlock(frozen, myKeyPair);

// Send just the signature back to whoever is assembling the final block
const signaturePayload = signature.toString('base64');
```

**Coordinator — assemble once enough signatures are back:**

```javascript
import { AccountBlockTemplate, assembleMultisigAuth } from 'znn-typescript-sdk';

const frozen = AccountBlockTemplate.fromJson(JSON.parse(payload));
const signatures = collectedBase64Signatures.map(s => Buffer.from(s, 'base64'));

assembleMultisigAuth(frozen, signatures);
await zenon.ledger.publishRawTransaction(frozen);
```

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.

**Important:**
- `freezeBlock` does not sign anything — call it once, then distribute the frozen block.
- `signBlock` will throw if called on a block that hasn't been frozen yet (its hash is still the empty default).
- `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.

### Signature Collection Timing

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.

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.

---

## Error Handling

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:

```javascript
import {
MultisigPolicyLockedException,
MultisigThresholdMismatchException,
MultisigSporkNotActivatedException,
ZnnEmbeddedContractException,
} from 'znn-typescript-sdk';

try {
await zenon.ledger.publishRawTransaction(frozen);
} catch (error) {
if (error instanceof MultisigPolicyLockedException) {
console.error('This account\'s policy is locked and cannot be changed.');
} else if (error instanceof MultisigThresholdMismatchException) {
console.error('Not enough valid signatures were collected.');
} else if (error instanceof MultisigSporkNotActivatedException) {
console.error('Multisig support is not yet active on this network.');
} else if (error instanceof ZnnEmbeddedContractException) {
// Catch-all for any other typed embedded-contract error
console.error(`${error.contract} error:`, error.message);
} else {
throw error;
}
}
```

All typed exceptions extend `ZnnClientException`, so existing code that catches `ZnnClientException` continues to work unchanged.

---

## Next Steps

- **[Examples](./examples.md)** – Complete working examples
- **[API Overview](./api-overview.md)** – All API methods & Embedded Contract Calls
- **[Utilities](./utilities.md)** – Utilities and constants for common tasks
- **[Wallet Management](./wallet.md)** – Creating and managing wallets
- **[CLI Tool](./cli.md)** – Command-line interface
1 change: 1 addition & 0 deletions docs/utilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ const dispplayValue = addNumberDecimals(100000000, 8);

- **[Examples](./examples.md)** – Complete working examples
- **[API Overview](./api-overview.md)** – All API methods & Embedded Contract Calls
- **[Multisig Accounts](./multisig.md)** – Creating and signing with mutable X-of-N multisig accounts
- **[CLI Tool](./cli.md)** - Command-line interface
- **[Wallet Management](./wallet.md)** – Creating and managing wallets
- **[Building WASM](./build-wasm.md)** – Rebuilding the PoW module from source
1 change: 1 addition & 0 deletions docs/wallet.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ console.log(balances);

- **[Examples](./examples.md)** – Complete working examples
- **[API Overview](./api-overview.md)** – All API methods & Embedded Contract Calls
- **[Multisig Accounts](./multisig.md)** – Creating and signing with mutable X-of-N multisig accounts
- **[Utilities](./utilities.md)** – Utilities and constants for common tasks
- **[CLI Tool](./cli.md)** - Command-line interface
- **[Building WASM](./build-wasm.md)** – Rebuilding the PoW module from source
3 changes: 3 additions & 0 deletions src/api/embedded/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export const TOKEN_DOMAIN_REG_EXP: RegExp = RegExp(
"^([A-Za-z0-9][A-Za-z0-9-]{0,61}[A-Za-z0-9].)+[A-Za-z]{2,}$"
);

// Multisig
export const MULTISIG_CREATION_FEE_IN_ZNN: bigint = BigInt(ONE_ZNN);

// Accelerator
export const PROPOSAL_URL_REG_EXP: RegExp = RegExp(
"^[a-zA-Z0-9]{2,60}.[a-zA-Z]{1,6}([a-zA-Z0-9()@:%_\\+.~#?&/=-]{0,100})$"
Expand Down
3 changes: 3 additions & 0 deletions src/api/embedded/embedded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AcceleratorApi } from "./accelerator.js";
import { BridgeApi } from "./bridge.js";
import { HtlcApi } from "./htlc.js";
import { LiquidityApi } from "./liquidity.js";
import { MultisigApi } from "./multisig.js";
import { PillarApi } from "./pillar.js";
import { PlasmaApi } from "./plasma.js";
import { SentinelApi } from "./sentinel.js";
Expand All @@ -19,6 +20,7 @@ export class EmbeddedApi extends Api {
public bridge = new BridgeApi(),
public htlc = new HtlcApi(),
public liquidity = new LiquidityApi(),
public multisig = new MultisigApi(),
public pillar = new PillarApi(),
public plasma = new PlasmaApi(),
public sentinel = new SentinelApi(),
Expand All @@ -36,6 +38,7 @@ export class EmbeddedApi extends Api {
this.bridge.setClient(client);
this.htlc.setClient(client);
this.liquidity.setClient(client);
this.multisig.setClient(client);
this.pillar.setClient(client);
this.plasma.setClient(client);
this.sentinel.setClient(client);
Expand Down
43 changes: 43 additions & 0 deletions src/api/embedded/multisig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Api } from "../base.js";
import { Address, MULTISIG_ADDRESS, ZNN_ZTS } from "../../model/primitives/index.js";
import { MultisigRecordInfo } from "../../model/embedded/multisig.js";
import { AccountBlockTemplate } from "../../model/nom/accountBlock.js";
import { Multisig as MultisigContract } from "../../embedded/index.js";
import { MULTISIG_CREATION_FEE_IN_ZNN } from "./constants.js";
import { MultisigCreatorMustBeSingleSigException } from "../../client/nodeErrors.js";

export class MultisigApi extends Api {

//
// RPC

async getPolicy(address: Address, height?: number): Promise<MultisigRecordInfo | null> {
const response = await this.client.sendRequest("embedded.multisig.getPolicy", [
address.toString(),
height !== undefined ? height : null,
]);
return response === null ? null : MultisigRecordInfo.fromJson(response);
}

//
// Contract-call templates (unsigned). createMultisig is sent by a normal user
// (feed to the existing send(zenon, tpl, keyPair)); changePolicy is sent BY the
// multisig account (feed to the freeze/sign/assemble path).

createMultisig(creator: Address, nonce: bigint, threshold: number, signers: Buffer[]): AccountBlockTemplate {
if (Address.isMultisigAddress(creator)) {
throw new MultisigCreatorMustBeSingleSigException("multisig: creator must be a single-sig account", 0);
}
return AccountBlockTemplate.callContract(
MULTISIG_ADDRESS, ZNN_ZTS, MULTISIG_CREATION_FEE_IN_ZNN,
MultisigContract.abi.encodeFunctionData("CreateMultisig", [nonce, threshold, signers]),
);
}

changePolicy(threshold: number, signers: Buffer[], lock: boolean): AccountBlockTemplate {
return AccountBlockTemplate.callContract(
MULTISIG_ADDRESS, ZNN_ZTS, 0n,
MultisigContract.abi.encodeFunctionData("ChangePolicy", [threshold, signers, lock]),
);
}
}
4 changes: 3 additions & 1 deletion src/api/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { Address, Hash } from "../model/primitives/index.js";
import { Api } from "./base.js";
import { Logger } from "../utilities/logger.js";
import { mapNodeError } from "../client/nodeErrors.js";

const logger = Logger.globalLogger();

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

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

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