diff --git a/README.md b/README.md index 0900f12..4752dae 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/api-overview.md b/docs/api-overview.md index f75fc06..b634d1f 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -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 @@ -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 diff --git a/docs/cli.md b/docs/cli.md index ca17e68..dd1d087 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 diff --git a/docs/examples.md b/docs/examples.md index ed88f7c..cfb4a5e 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -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 diff --git a/docs/multisig.md b/docs/multisig.md new file mode 100644 index 0000000..e8b43dd --- /dev/null +++ b/docs/multisig.md @@ -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 diff --git a/docs/utilities.md b/docs/utilities.md index 70bd4f5..807ac99 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -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 diff --git a/docs/wallet.md b/docs/wallet.md index 703685b..4be62ab 100644 --- a/docs/wallet.md +++ b/docs/wallet.md @@ -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 diff --git a/src/api/embedded/constants.ts b/src/api/embedded/constants.ts index 262e2e6..dc9533b 100644 --- a/src/api/embedded/constants.ts +++ b/src/api/embedded/constants.ts @@ -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})$" diff --git a/src/api/embedded/embedded.ts b/src/api/embedded/embedded.ts index c16eb7f..19b30d8 100644 --- a/src/api/embedded/embedded.ts +++ b/src/api/embedded/embedded.ts @@ -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"; @@ -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(), @@ -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); diff --git a/src/api/embedded/multisig.ts b/src/api/embedded/multisig.ts new file mode 100644 index 0000000..84de99e --- /dev/null +++ b/src/api/embedded/multisig.ts @@ -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 { + 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]), + ); + } +} diff --git a/src/api/ledger.ts b/src/api/ledger.ts index d5bedbf..f1f0e30 100644 --- a/src/api/ledger.ts +++ b/src/api/ledger.ts @@ -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(); @@ -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()}`); diff --git a/src/client/http.ts b/src/client/http.ts index c43d132..fa7d3fb 100644 --- a/src/client/http.ts +++ b/src/client/http.ts @@ -1,6 +1,6 @@ import { HTTPTransport, Client as OpenRpcClient, RequestManager } from "@open-rpc/client-js"; import { Client as ClientInterface } from "./interfaces.js"; -import { ZnnClientException } from "./errors.js"; +import { mapNodeError } from "./nodeErrors.js"; export class HttpClient implements ClientInterface { private _client: OpenRpcClient; @@ -18,7 +18,7 @@ export class HttpClient implements ClientInterface { const message = error?.message || error?.toString() || "Unknown error occurred"; const data = error?.data; - throw new ZnnClientException(message, code, method, parameters, data); + throw mapNodeError(message, code, method, parameters, data); } } } diff --git a/src/client/index.ts b/src/client/index.ts index c914b3d..aedf89d 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -3,3 +3,20 @@ export { WSUpdateStream, WsClient, type WsClientOptions } from "./websocket.js" export { HttpClient } from "./http.js" export { newClient } from "./factory.js" export { ZnnClientException } from "./errors.js" +export { + ZnnEmbeddedContractException, + MultisigPolicyLockedException, + MultisigAccountExistsException, + MultisigNoPolicyException, + MultisigInvalidPolicyException, + MultisigSporkNotActivatedException, + MultisigThresholdMismatchException, + AccountBlockPublicKeyNotZeroException, + AccountBlockSignatureNotZeroException, + AccountBlockMultisigAuthMissingException, + AccountBlockMomentumTooOldException, + MultisigStaleAuthorityException, + AccountBlockMultisigAuthMustBeZeroException, + MultisigCreatorMustBeSingleSigException, + mapNodeError, +} from "./nodeErrors.js" diff --git a/src/client/nodeErrors.ts b/src/client/nodeErrors.ts new file mode 100644 index 0000000..0f683cf --- /dev/null +++ b/src/client/nodeErrors.ts @@ -0,0 +1,124 @@ +import { ZnnClientException } from "./errors.js"; + +export class ZnnEmbeddedContractException extends ZnnClientException { + public readonly contract: string; + + constructor(contract: string, message: string, code: number, method?: string, params?: any[], data?: any) { + super(message, code, method, params, data); + this.name = "ZnnEmbeddedContractException"; + this.contract = contract; + } +} + +export class MultisigPolicyLockedException extends ZnnEmbeddedContractException { + constructor(message: string, code: number, method?: string, params?: any[], data?: any) { + super("multisig", message, code, method, params, data); + this.name = "MultisigPolicyLockedException"; + } +} + +export class MultisigAccountExistsException extends ZnnEmbeddedContractException { + constructor(message: string, code: number, method?: string, params?: any[], data?: any) { + super("multisig", message, code, method, params, data); + this.name = "MultisigAccountExistsException"; + } +} + +export class MultisigNoPolicyException extends ZnnEmbeddedContractException { + constructor(message: string, code: number, method?: string, params?: any[], data?: any) { + super("multisig", message, code, method, params, data); + this.name = "MultisigNoPolicyException"; + } +} + +export class MultisigInvalidPolicyException extends ZnnEmbeddedContractException { + constructor(message: string, code: number, method?: string, params?: any[], data?: any) { + super("multisig", message, code, method, params, data); + this.name = "MultisigInvalidPolicyException"; + } +} + +export class MultisigSporkNotActivatedException extends ZnnEmbeddedContractException { + constructor(message: string, code: number, method?: string, params?: any[], data?: any) { + super("multisig", message, code, method, params, data); + this.name = "MultisigSporkNotActivatedException"; + } +} + +export class MultisigThresholdMismatchException extends ZnnEmbeddedContractException { + constructor(message: string, code: number, method?: string, params?: any[], data?: any) { + super("multisig", message, code, method, params, data); + this.name = "MultisigThresholdMismatchException"; + } +} + +export class AccountBlockPublicKeyNotZeroException extends ZnnEmbeddedContractException { + constructor(message: string, code: number, method?: string, params?: any[], data?: any) { + super("account-block", message, code, method, params, data); + this.name = "AccountBlockPublicKeyNotZeroException"; + } +} + +export class AccountBlockSignatureNotZeroException extends ZnnEmbeddedContractException { + constructor(message: string, code: number, method?: string, params?: any[], data?: any) { + super("account-block", message, code, method, params, data); + this.name = "AccountBlockSignatureNotZeroException"; + } +} + +export class AccountBlockMultisigAuthMissingException extends ZnnEmbeddedContractException { + constructor(message: string, code: number, method?: string, params?: any[], data?: any) { + super("account-block", message, code, method, params, data); + this.name = "AccountBlockMultisigAuthMissingException"; + } +} + +export class AccountBlockMomentumTooOldException extends ZnnEmbeddedContractException { + constructor(message: string, code: number, method?: string, params?: any[], data?: any) { + super("account-block", message, code, method, params, data); + this.name = "AccountBlockMomentumTooOldException"; + } +} + +export class MultisigStaleAuthorityException extends ZnnEmbeddedContractException { + constructor(message: string, code: number, method?: string, params?: any[], data?: any) { + super("multisig", message, code, method, params, data); + this.name = "MultisigStaleAuthorityException"; + } +} + +export class AccountBlockMultisigAuthMustBeZeroException extends ZnnEmbeddedContractException { + constructor(message: string, code: number, method?: string, params?: any[], data?: any) { + super("account-block", message, code, method, params, data); + this.name = "AccountBlockMultisigAuthMustBeZeroException"; + } +} + +export class MultisigCreatorMustBeSingleSigException extends ZnnEmbeddedContractException { + constructor(message: string, code: number, method?: string, params?: any[], data?: any) { + super("multisig", message, code, method, params, data); + this.name = "MultisigCreatorMustBeSingleSigException"; + } +} + +const NODE_ERROR_REGISTRY: Array<{ match: string; create: (m: string, c: number, meth?: string, p?: any[], d?: any) => ZnnClientException }> = [ + { match: "multisig: policy is locked", create: (...a) => new MultisigPolicyLockedException(...a) }, + { match: "multisig: account already exists", create: (...a) => new MultisigAccountExistsException(...a) }, + { match: "multisig: no policy for this account", create: (...a) => new MultisigNoPolicyException(...a) }, + { match: "multisig: invalid policy", create: (...a) => new MultisigInvalidPolicyException(...a) }, + { match: "multisig: spork not activated", create: (...a) => new MultisigSporkNotActivatedException(...a) }, + { match: "multisig: signature count does not match policy threshold", create: (...a) => new MultisigThresholdMismatchException(...a) }, + { match: "account-block publicKey must be zero", create: (...a) => new AccountBlockPublicKeyNotZeroException(...a) }, + { match: "account-block signature must be zero", create: (...a) => new AccountBlockSignatureNotZeroException(...a) }, + { match: "account-block multisig-auth is missing", create: (...a) => new AccountBlockMultisigAuthMissingException(...a) }, + { match: "account-block momentum-acknowledged is too old", create: (...a) => new AccountBlockMomentumTooOldException(...a) }, + { match: "multisig: authorization does not satisfy current active policy", create: (...a) => new MultisigStaleAuthorityException(...a) }, + { match: "multisig: creator must be a single-sig account", create: (...a) => new MultisigCreatorMustBeSingleSigException(...a) }, + { match: "account-block multisig-auth must be zero", create: (...a) => new AccountBlockMultisigAuthMustBeZeroException(...a) }, +]; + +export function mapNodeError(message: string, code: number, method?: string, params?: any[], data?: any): ZnnClientException { + const hit = NODE_ERROR_REGISTRY.find(e => message.includes(e.match)); + return hit ? hit.create(message, code, method, params, data) + : new ZnnClientException(message, code, method, params, data); +} diff --git a/src/client/websocket.ts b/src/client/websocket.ts index 785f2d1..5ad32fd 100644 --- a/src/client/websocket.ts +++ b/src/client/websocket.ts @@ -1,7 +1,7 @@ import { ErrorCode, Logger } from "../utilities/logger.js"; import { Zenon } from "../zenon.js"; import { Client as ClientInterface } from "./interfaces.js"; -import { ZnnClientException } from "./errors.js"; +import { mapNodeError } from "./nodeErrors.js"; import { Client as WebSocketClient } from "rpc-websockets"; const logger = Logger.globalLogger(); @@ -169,7 +169,7 @@ export class WsClient implements ClientInterface { const message = error?.message || error?.toString() || "Unknown error occurred"; const data = error?.data; - throw new ZnnClientException(message, code, method, parameters, data); + throw mapNodeError(message, code, method, parameters, data); } } } diff --git a/src/embedded/index.ts b/src/embedded/index.ts index b2fe6a1..d0a2fd7 100644 --- a/src/embedded/index.ts +++ b/src/embedded/index.ts @@ -4,6 +4,7 @@ export { Bridge } from "./bridge.js" export { Common } from "./common.js" export { Htlc } from "./htlc.js" export { Liquidity } from "./liquidity.js" +export { Multisig } from "./multisig.js" export { Pillar } from "./pillar.js" export { Plasma } from "./plasma.js" export { Sentinel } from "./sentinel.js" diff --git a/src/embedded/multisig.ts b/src/embedded/multisig.ts new file mode 100644 index 0000000..20c79ac --- /dev/null +++ b/src/embedded/multisig.ts @@ -0,0 +1,17 @@ +import { EmbeddedContract } from "./embeddedContract.js"; + +export class Multisig extends EmbeddedContract { + protected static readonly definition: string = ` + [ + {"type":"function","name":"CreateMultisig","inputs":[ + {"name":"nonce","type":"uint64"}, + {"name":"threshold","type":"uint8"}, + {"name":"signers","type":"bytes[]"} + ]}, + {"type":"function","name":"ChangePolicy","inputs":[ + {"name":"threshold","type":"uint8"}, + {"name":"signers","type":"bytes[]"}, + {"name":"lock","type":"bool"} + ]} + ]`; +} diff --git a/src/index.ts b/src/index.ts index abdbb10..cacaebb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,7 @@ export { Common as CommonContract, Htlc as HtlcContract, Liquidity as LiquidityContract, + Multisig as MultisigContract, Pillar as PillarContract, Plasma as PlasmaContract, Sentinel as SentinelContract, @@ -82,6 +83,7 @@ export { ACCELERATOR_ADDRESS, BRIDGE_ADDRESS, HTLC_ADDRESS, + MULTISIG_ADDRESS, Hash, EMPTY_HASH, @@ -120,3 +122,29 @@ export { extractNumberDecimals, addNumberDecimals, } from "./utilities/amounts.js" + +export { + freezeBlock, + signBlock, + assembleMultisigAuth, +} from "./utilities/block.js" + +// +// Client / error exports +export { + ZnnClientException, + ZnnEmbeddedContractException, + MultisigPolicyLockedException, + MultisigAccountExistsException, + MultisigNoPolicyException, + MultisigInvalidPolicyException, + MultisigSporkNotActivatedException, + MultisigThresholdMismatchException, + AccountBlockPublicKeyNotZeroException, + AccountBlockSignatureNotZeroException, + AccountBlockMultisigAuthMissingException, + AccountBlockMomentumTooOldException, + MultisigStaleAuthorityException, + AccountBlockMultisigAuthMustBeZeroException, + MultisigCreatorMustBeSingleSigException, +} from "./client/index.js"; diff --git a/src/model/embedded/index.ts b/src/model/embedded/index.ts index 96107f2..ca307b1 100644 --- a/src/model/embedded/index.ts +++ b/src/model/embedded/index.ts @@ -3,6 +3,7 @@ export * from "./bridge.js" export * from "./common.js" export * from "./htlc.js" export * from "./liquidity.js" +export * from "./multisig.js" export * from "./pillar.js" export * from "./plasma.js" export * from "./sentinel.js" diff --git a/src/model/embedded/multisig.ts b/src/model/embedded/multisig.ts new file mode 100644 index 0000000..ce11ad6 --- /dev/null +++ b/src/model/embedded/multisig.ts @@ -0,0 +1,35 @@ +import { Buffer } from "buffer"; +import { Model } from "../base.js"; + +export class MultisigPolicyInfo extends Model { + constructor( + public threshold: number, + public signers: Buffer[], + public locked: boolean, + ) { super(); } + + static fromJson(json: any): MultisigPolicyInfo | null { + if (json === null) return null; + return new MultisigPolicyInfo( + json.threshold, + json.signers.map((s: string) => Buffer.from(s, "base64")), + json.locked, + ); + } +} + +export class MultisigRecordInfo extends Model { + constructor( + public active: MultisigPolicyInfo | null, + public pending: MultisigPolicyInfo | null, + public pendingHeight: number, + ) { super(); } + + static fromJson(json: any): MultisigRecordInfo { + return new MultisigRecordInfo( + MultisigPolicyInfo.fromJson(json.active), + MultisigPolicyInfo.fromJson(json.pending), + json.pendingHeight, + ); + } +} diff --git a/src/model/nom/accountBlock.ts b/src/model/nom/accountBlock.ts index b4db0ac..d4261fd 100644 --- a/src/model/nom/accountBlock.ts +++ b/src/model/nom/accountBlock.ts @@ -50,6 +50,7 @@ export interface AccountBlockTemplateOptions { nonce?: string; publicKey?: Buffer; signature?: Buffer; + multisigAuth?: { signatures: Buffer[] }; } interface AccountBlockOptions extends AccountBlockTemplateOptions { @@ -81,6 +82,7 @@ export class AccountBlockTemplate extends Model { public nonce: string; public publicKey: Buffer; public signature: Buffer; + public multisigAuth?: { signatures: Buffer[] }; constructor(options: AccountBlockTemplateOptions) { super(); @@ -102,6 +104,7 @@ export class AccountBlockTemplate extends Model { this.nonce = options.nonce ?? ""; this.publicKey = options.publicKey ?? Buffer.from([]); this.signature = options.signature ?? Buffer.from([]); + this.multisigAuth = options.multisigAuth; } static fromJson(json: {[key: string]: any}): AccountBlockTemplate { @@ -125,12 +128,15 @@ export class AccountBlockTemplate extends Model { difficulty: json.difficulty, nonce: json.nonce, publicKey: json.publicKey ? Buffer.from(json.publicKey) : Buffer.from([]), - signature: json.signature ? Buffer.from(json.signature) : Buffer.from([]) + signature: json.signature ? Buffer.from(json.signature) : Buffer.from([]), + multisigAuth: json.multisigAuth + ? { signatures: json.multisigAuth.signatures.map((s: string) => Buffer.from(s, "base64")) } + : undefined, }); } toJson(): {[key: string]: any} { - return { + const json: {[key: string]: any} = { version: this.version, chainIdentifier: this.chainIdentifier, blockType: this.blockType, @@ -150,6 +156,12 @@ export class AccountBlockTemplate extends Model { publicKey: this.publicKey.toString("base64"), signature: this.signature.toString("base64"), }; + if (this.multisigAuth) { + json.multisigAuth = { + signatures: this.multisigAuth.signatures.map(s => s.toString("base64")), + }; + } + return json; } static receive(fromBlockHash: Hash): AccountBlockTemplate { @@ -251,6 +263,9 @@ export class AccountBlock extends AccountBlockTemplate { nonce: json.nonce, publicKey: json.publicKey ? Buffer.from(json.publicKey) : Buffer.from([]), signature: json.signature ? Buffer.from(json.signature) : Buffer.from([]), + multisigAuth: json.multisigAuth + ? { signatures: json.multisigAuth.signatures.map((s: string) => Buffer.from(s, "base64")) } + : undefined, token: json.token ? Token.fromJson(json.token) : undefined, descendantBlocks: json.descendantBlocks ? json.descendantBlocks.map((block: {[key: string]: any}) => AccountBlock.fromJson(block)) diff --git a/src/model/primitives/address.ts b/src/model/primitives/address.ts index e6d0271..6e06ed6 100644 --- a/src/model/primitives/address.ts +++ b/src/model/primitives/address.ts @@ -9,8 +9,11 @@ export class Address { static prefix: string = "z"; static userByte: number = 0; + static embeddedByte: number = 1; + static multisigByte: number = 2; static coreSize: number = 20; + constructor( public hrp: string, public core: Buffer @@ -46,6 +49,25 @@ export class Address { return new Address(this.prefix, Buffer.concat([Buffer.from([this.userByte]), Buffer.from(digest)])); } + public static isUserAddress(address: Address): boolean { + return address.core[0] === this.userByte; + } + + public static isEmbeddedAddress(address: Address): boolean { + return address.core[0] === this.embeddedByte; + } + + public static isMultisigAddress(address: Address): boolean { + return address.core[0] === this.multisigByte; + } + + public static fromMultisigCreation(creatorPubKey: Buffer, nonce: bigint): Address { + const nonceBytes = Buffer.alloc(8); + nonceBytes.writeBigUInt64BE(nonce); + const digest = Crypto.digest(Buffer.concat([creatorPubKey, nonceBytes])).subarray(0, 19); + return new Address(this.prefix, Buffer.concat([Buffer.from([this.multisigByte]), Buffer.from(digest)])); + } + public static fromCore(address: | Buffer | Uint8Array): Address { const coreBuf = Buffer.isBuffer(address) ? address : Buffer.from(address); if (coreBuf.length !== this.coreSize) { @@ -84,6 +106,7 @@ const SPORK_ADDRESS = Address.parse("z1qxemdeddedxsp0rkxxxxxxxxxxxxxxxx956u48"); const ACCELERATOR_ADDRESS = Address.parse("z1qxemdeddedxaccelerat0rxxxxxxxxxxp4tk22"); const BRIDGE_ADDRESS = Address.parse("z1qxemdeddedxdrydgexxxxxxxxxxxxxxxmqgr0d"); const HTLC_ADDRESS = Address.parse("z1qxemdeddedxhtlcxxxxxxxxxxxxxxxxxygecvw"); +const MULTISIG_ADDRESS = Address.parse("z1qxemdeddedxmultysygxxxxxxxxxxxxx42zwd4"); export { EMPTY_ADDRESS, @@ -98,5 +121,6 @@ export { ACCELERATOR_ADDRESS, BRIDGE_ADDRESS, HTLC_ADDRESS, + MULTISIG_ADDRESS, } diff --git a/src/model/primitives/index.ts b/src/model/primitives/index.ts index 8e1436f..64ef2b6 100644 --- a/src/model/primitives/index.ts +++ b/src/model/primitives/index.ts @@ -12,6 +12,7 @@ export { ACCELERATOR_ADDRESS, BRIDGE_ADDRESS, HTLC_ADDRESS, + MULTISIG_ADDRESS, } from "./address.js" export { diff --git a/src/utilities/block.ts b/src/utilities/block.ts index bac32d3..967bad1 100644 --- a/src/utilities/block.ts +++ b/src/utilities/block.ts @@ -1,7 +1,7 @@ import { Buffer } from "buffer"; import { GetRequiredPowParam } from "../model/embedded/plasma.js"; import { AccountBlockTemplate, BlockTypeEnum } from "../model/nom/accountBlock.js"; -import { EMPTY_HASH, Hash, HashHeight } from "../model/primitives/index.js"; +import { Address, EMPTY_HASH, Hash, HashHeight } from "../model/primitives/index.js"; import { generate as generatePoW } from "../pow/pow.js"; import { KeyPair } from "../wallet/keyPair.js"; import { Zenon } from "../zenon.js"; @@ -26,6 +26,7 @@ export function getTxHash(transaction: AccountBlockTemplate): Hash { const emptyHash = Hash.digest(Buffer.from([])); const dataHash = Hash.digest(transaction.data); + // Consensus preimage: do NOT add publicKey/signature/multisigAuth — excluded by protocol. const source = Buffer.concat([ numberToBytes(transaction.version, 8), numberToBytes(transaction.chainIdentifier, 8), @@ -80,37 +81,41 @@ async function autofillTxParameters( return accountBlockTemplate; } -async function checkAndSetFields( - zenonInstance: Zenon, - transaction: AccountBlockTemplate, - currentKeyPair: KeyPair -): Promise { - transaction.address = currentKeyPair.getAddress(); - transaction.publicKey = currentKeyPair.getPublicKey(); - - await autofillTxParameters(zenonInstance, transaction); - - if (isReceiveBlock(transaction.blockType)) { - if (transaction.fromBlockHash === EMPTY_HASH) { +async function validateReceiveBlock(zenonInstance: Zenon, tx: AccountBlockTemplate): Promise { + if (isReceiveBlock(tx.blockType)) { + if (tx.fromBlockHash === EMPTY_HASH) { throw new ZnnBlockUtilitiesException("fromBlockHash cannot be empty for receive blocks"); } - const sendBlock = await zenonInstance.ledger.getAccountBlockByHash(transaction.fromBlockHash); + const sendBlock = await zenonInstance.ledger.getAccountBlockByHash(tx.fromBlockHash); if (sendBlock === null) { - throw new ZnnBlockUtilitiesException(`Send block not found: ${transaction.fromBlockHash}`); + throw new ZnnBlockUtilitiesException(`Send block not found: ${tx.fromBlockHash}`); } - if (sendBlock.toAddress.toString() !== transaction.address.toString()) { + if (sendBlock.toAddress.toString() !== tx.address.toString()) { throw new ZnnBlockUtilitiesException( - `Send block toAddress (${sendBlock.toAddress}) does not match transaction address (${transaction.address})` + `Send block toAddress (${sendBlock.toAddress}) does not match transaction address (${tx.address})` ); } - if (transaction.data.length > 0) { + if (tx.data.length > 0) { throw new ZnnBlockUtilitiesException("Receive blocks cannot have data"); } } +} + +async function checkAndSetFields( + zenonInstance: Zenon, + transaction: AccountBlockTemplate, + currentKeyPair: KeyPair +): Promise { + transaction.address = currentKeyPair.getAddress(); + transaction.publicKey = currentKeyPair.getPublicKey(); + + await autofillTxParameters(zenonInstance, transaction); + + await validateReceiveBlock(zenonInstance, transaction); if (transaction.difficulty > 0 && transaction.nonce === "") { throw new ZnnBlockUtilitiesException("Nonce is required when difficulty is set"); @@ -194,3 +199,47 @@ export async function send( return zenonInstance.ledger.publishRawTransaction(transaction); } +/** + * Autofill + validate + PoW + hash a multisig account block, without signing + * it. Leaves `publicKey`/`signature` empty. Serves both send blocks and + * multisig receive blocks (`AccountBlockTemplate.receive(...)`). + * + * The signed-with address is explicit (the multisig account), not derived + * from a keypair. + */ +export async function freezeBlock( + zenonInstance: Zenon, + transaction: AccountBlockTemplate, + address: Address, +): Promise { + transaction.address = address; + await autofillTxParameters(zenonInstance, transaction); + await validateReceiveBlock(zenonInstance, transaction); + await setDifficulty(zenonInstance, transaction); + transaction.hash = getTxHash(transaction); + return transaction; +} + +/** + * Sign a frozen block's hash with a single signer's keypair. The block must + * be frozen first (`freezeBlock`) so every signer signs the identical hash. + */ +export function signBlock(transaction: AccountBlockTemplate, keyPair: KeyPair): Buffer { + if (transaction.hash.getBytes().equals(EMPTY_HASH.getBytes())) { + throw new ZnnBlockUtilitiesException("Block must be frozen before signing"); + } + return keyPair.sign(transaction.hash.getBytes()); +} + +/** + * Attach collected multisig signatures to a frozen block. Order-independent; + * no threshold enforcement (the node validates the policy on publish). + */ +export function assembleMultisigAuth( + transaction: AccountBlockTemplate, + signatures: Buffer[], +): AccountBlockTemplate { + transaction.multisigAuth = { signatures: [...signatures] }; + return transaction; +} + diff --git a/test/abi/coders/bytes.spec.ts b/test/abi/coders/bytes.spec.ts index 2d90b18..8a46cb5 100644 --- a/test/abi/coders/bytes.spec.ts +++ b/test/abi/coders/bytes.spec.ts @@ -1,5 +1,6 @@ import { expect } from "chai"; import * as abi from "../../../src/abi/index.js"; +import { Multisig } from "../../../src/embedded/multisig.js"; describe("Bytes", () => { @@ -32,6 +33,24 @@ describe("Bytes", () => { expect(decoded[0]).to.deep.equal(values.map(v => v.toLowerCase())); }); + // Cross-checked against go-zenon's own ABIMultisig.PackMethod for the same + // (nonce, threshold, signers) triple - byte-for-byte identical, confirming + // this SDK's bytes[] encoding matches the real node's ABI packer. + it("bytes[] (CreateMultisig calldata) matches go-zenon packer output", function () { + const nonce = 1782933252398n; + const threshold = 2; + const signers = [ + Buffer.from("79a01ae9efde740e4916a911b89cf738cb211a5402df61de18e987801bab4fd3", "hex"), + Buffer.from("471fa0f6897cc2b7e873888a27d1fc70f5baaca94ece3d4bf29ff7b7cac532f7", "hex"), + Buffer.from("a918b3e5f35461a72fe4ce67d18cccda485ef50c7de497f3a34e96f342690324", "hex"), + ]; + + const calldata = "0x2adf7a330000000000000000000000000000000000000000000000000000019f1f1a692e000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000002079a01ae9efde740e4916a911b89cf738cb211a5402df61de18e987801bab4fd30000000000000000000000000000000000000000000000000000000000000020471fa0f6897cc2b7e873888a27d1fc70f5baaca94ece3d4bf29ff7b7cac532f70000000000000000000000000000000000000000000000000000000000000020a918b3e5f35461a72fe4ce67d18cccda485ef50c7de497f3a34e96f342690324"; + + const encoded = Multisig.encodeCall("CreateMultisig", [nonce, threshold, signers]); + expect(encoded).to.equal(calldata); + }); + it("bytes (large data)", function () { // 64 bytes of data const largeData = "0x" + "12".repeat(64); diff --git a/test/api/embedded/multisig.spec.ts b/test/api/embedded/multisig.spec.ts new file mode 100644 index 0000000..8eba6b9 --- /dev/null +++ b/test/api/embedded/multisig.spec.ts @@ -0,0 +1,51 @@ +import { expect } from "chai"; +import { MultisigApi } from "../../../src/api/embedded/multisig.js"; +import { Multisig as MultisigContract } from "../../../src/embedded/multisig.js"; +import { ONE_ZNN } from "../../../src/api/embedded/constants.js"; +import { AccountBlockTemplate } from "../../../src/model/nom/index.js"; +import { Address, MULTISIG_ADDRESS, ZNN_ZTS } from "../../../src/model/primitives/index.js"; +import { arrayify } from "../../../src/utilities/bytes.js"; +import { MultisigCreatorMustBeSingleSigException } from "../../../src/client/nodeErrors.js"; +import { MockClient } from "../mockClient.js"; + +describe("MultisigApi", () => { + let multisigApi: MultisigApi; + let mockClient: MockClient; + + beforeEach(() => { + mockClient = new MockClient(); + multisigApi = new MultisigApi(); + multisigApi.setClient(mockClient); + }); + + describe("createMultisig", () => { + it("should build a create multisig block burning 1 ZNN", () => { + const creator = Address.fromPublicKey(Buffer.alloc(32, 1)); + const nonce = 1n; + const threshold = 2; + const signers = [Buffer.alloc(32, 1), Buffer.alloc(32, 2)]; + + const template = multisigApi.createMultisig(creator, nonce, threshold, signers); + const expectedData = MultisigContract.abi.encodeFunctionData("CreateMultisig", [nonce, threshold, signers]); + + expect(template).to.be.instanceOf(AccountBlockTemplate); + expect(template.toAddress.toString()).to.equal(MULTISIG_ADDRESS.toString()); + expect(template.tokenStandard.toString()).to.equal(ZNN_ZTS.toString()); + expect(template.amount.toString()).to.equal(BigInt(ONE_ZNN).toString()); + expect(template.data.toString("hex")) + .to.equal(Buffer.from(arrayify(expectedData)).toString("hex")); + }); + + it("should throw when the creator is itself a multisig address", () => { + const multisigCreator = Address.fromMultisigCreation(Buffer.alloc(32, 1), 1n); + const nonce = 1n; + const threshold = 2; + const signers = [Buffer.alloc(32, 1), Buffer.alloc(32, 2)]; + + expect(() => multisigApi.createMultisig(multisigCreator, nonce, threshold, signers)) + .to.throw(MultisigCreatorMustBeSingleSigException); + + expect(mockClient.getLastCall()).to.not.exist; + }); + }); +}); diff --git a/test/api/ledger.spec.ts b/test/api/ledger.spec.ts index 4b39927..975eba6 100644 --- a/test/api/ledger.spec.ts +++ b/test/api/ledger.spec.ts @@ -12,6 +12,7 @@ import { MomentumList } from "../../src/model/nom/index.js"; import { Address, Hash } from "../../src/model/primitives/index.js"; +import { ZnnClientException } from "../../src/client/errors.js"; import { MockClient } from "./mockClient.js"; const ADDRESS = "z1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsggv2f"; @@ -155,7 +156,8 @@ describe("LedgerApi", () => { } expect(error).to.exist; - expect(error!.message).to.equal("Error publishing transaction: failure (code=NETWORK_ERROR)"); + expect(error).to.be.instanceOf(ZnnClientException); + expect(error!.message).to.equal("failure"); }); }); diff --git a/test/client/errors.spec.ts b/test/client/errors.spec.ts index 461204d..413e51c 100644 --- a/test/client/errors.spec.ts +++ b/test/client/errors.spec.ts @@ -1,5 +1,26 @@ import { expect } from "chai"; import { ZnnClientException } from "../../src/client/errors.js"; +import { ZnnSDKException } from "../../src/exception.js"; +import { LedgerApi } from "../../src/api/ledger.js"; +import { AccountBlockTemplate, BlockTypeEnum } from "../../src/model/nom/accountBlock.js"; +import { MockClient } from "../api/mockClient.js"; +import { + mapNodeError, + ZnnEmbeddedContractException, + MultisigPolicyLockedException, + MultisigAccountExistsException, + MultisigNoPolicyException, + MultisigInvalidPolicyException, + MultisigSporkNotActivatedException, + MultisigThresholdMismatchException, + AccountBlockPublicKeyNotZeroException, + AccountBlockSignatureNotZeroException, + AccountBlockMultisigAuthMissingException, + AccountBlockMomentumTooOldException, + MultisigStaleAuthorityException, + AccountBlockMultisigAuthMustBeZeroException, + MultisigCreatorMustBeSingleSigException, +} from "../../src/client/nodeErrors.js"; describe("ZnnClientException", () => { it("should format error details in toString", () => { @@ -26,3 +47,103 @@ describe("ZnnClientException", () => { expect(message).to.equal("ZnnClientException [-1]: Simple error"); }); }); + +describe("mapNodeError", () => { + const cases: Array<{ message: string; type: any }> = [ + { message: "multisig: policy is locked", type: MultisigPolicyLockedException }, + { message: "multisig: account already exists", type: MultisigAccountExistsException }, + { message: "multisig: no policy for this account", type: MultisigNoPolicyException }, + { message: "multisig: invalid policy", type: MultisigInvalidPolicyException }, + { message: "multisig: spork not activated", type: MultisigSporkNotActivatedException }, + { message: "multisig: signature count does not match policy threshold", type: MultisigThresholdMismatchException }, + { message: "account-block publicKey must be zero", type: AccountBlockPublicKeyNotZeroException }, + { message: "account-block signature must be zero", type: AccountBlockSignatureNotZeroException }, + { message: "account-block multisig-auth is missing", type: AccountBlockMultisigAuthMissingException }, + { message: "account-block momentum-acknowledged is too old", type: AccountBlockMomentumTooOldException }, + { message: "multisig: authorization does not satisfy current active policy", type: MultisigStaleAuthorityException }, + { message: "multisig: creator must be a single-sig account", type: MultisigCreatorMustBeSingleSigException }, + { message: "account-block multisig-auth must be zero", type: AccountBlockMultisigAuthMustBeZeroException }, + ]; + + for (const { message, type } of cases) { + it(`maps "${message}" to ${type.name}`, () => { + const error = mapNodeError(message, -1); + + expect(error).to.be.instanceOf(type); + expect(error).to.be.instanceOf(ZnnEmbeddedContractException); + expect(error).to.be.instanceOf(ZnnClientException); + expect(error).to.be.instanceOf(ZnnSDKException); + expect(error.message).to.equal(message); + }); + } + + it("falls through to a plain ZnnClientException for an unknown message", () => { + const error = mapNodeError("some unrelated node error", -1); + + expect(error).to.be.instanceOf(ZnnClientException); + expect(error).to.not.be.instanceOf(ZnnEmbeddedContractException); + expect(error.message).to.equal("some unrelated node error"); + }); + + it("does not map the generic pre-activation 'contract does not exist' string", () => { + const error = mapNodeError("contract does not exist", -1); + + expect(error).to.be.instanceOf(ZnnClientException); + expect(error).to.not.be.instanceOf(ZnnEmbeddedContractException); + }); + + it("distinguishes 'multisig-auth must be zero' from 'multisig-auth is missing'", () => { + const error = mapNodeError("account-block multisig-auth must be zero", -1); + + expect(error).to.be.instanceOf(AccountBlockMultisigAuthMustBeZeroException); + expect(error).to.not.be.instanceOf(AccountBlockMultisigAuthMissingException); + }); +}); + +describe("LedgerApi.publishRawTransaction — typed errors from a non-null result", () => { + let ledgerApi: LedgerApi; + let mockClient: MockClient; + + beforeEach(() => { + mockClient = new MockClient(); + ledgerApi = new LedgerApi(); + ledgerApi.setClient(mockClient); + }); + + it("throws the typed subclass for a non-null STRING publish result containing a verifier string", async () => { + const template = new AccountBlockTemplate({ blockType: BlockTypeEnum.UserSend }); + mockClient.setMockResponse("ledger.publishRawTransaction", "multisig: policy is locked"); + + let error: Error | null = null; + try { + await ledgerApi.publishRawTransaction(template); + } catch (err) { + error = err as Error; + } + + expect(error).to.be.instanceOf(MultisigPolicyLockedException); + }); + + it("degrades to a plain ZnnClientException for a non-null OBJECT publish result (does not throw on .includes)", async () => { + const template = new AccountBlockTemplate({ blockType: BlockTypeEnum.UserSend }); + mockClient.setMockResponse("ledger.publishRawTransaction", { code: 1, reason: "unknown" }); + + let error: Error | null = null; + try { + await ledgerApi.publishRawTransaction(template); + } catch (err) { + error = err as Error; + } + + expect(error).to.exist; + expect(error).to.be.instanceOf(ZnnClientException); + expect(error).to.not.be.instanceOf(ZnnEmbeddedContractException); + }); + + // Skipped: needs a real node's non-null publish-result shape for an + // object-bodied verifier error (e.g. multisig threshold/policy failures). + // Asserting a self-computed shape here would just check the matcher + // against a guess, not against what the node actually returns. + it.skip("typed-matches a verifier error carried in an OBJECT publish result", async () => { + }); +}); diff --git a/test/embedded/embeddedContract.spec.ts b/test/embedded/embeddedContract.spec.ts index b36607c..e6dab6e 100644 --- a/test/embedded/embeddedContract.spec.ts +++ b/test/embedded/embeddedContract.spec.ts @@ -1,6 +1,7 @@ import { expect } from "chai"; import { Abi } from "../../src/abi/abi.js"; import { EmbeddedContract } from "../../src/embedded/embeddedContract.js"; +import { Multisig } from "../../src/embedded/multisig.js"; // Test contract with sample ABI data from the old definitions tests class TestContract extends EmbeddedContract { @@ -193,6 +194,43 @@ describe("EmbeddedContract.decodeCall()", () => { }); }); +describe("Multisig ABI — bytes[] as function input", () => { + it("should round-trip CreateMultisig (nonce, threshold, bytes[] signers)", () => { + const abi = Multisig.abi; + const pk1 = Buffer.alloc(32, 1); + const pk2 = Buffer.alloc(32, 2); + const nonce = 42n; + const threshold = 2; + + const encoded = abi.encodeFunctionData("CreateMultisig", [nonce, threshold, [pk1, pk2]]); + const decoded = abi.decodeFunctionData("CreateMultisig", encoded, true) as Record; + + expect(BigInt(decoded.nonce)).to.equal(nonce); + expect(Number(decoded.threshold)).to.equal(threshold); + expect(decoded.signers).to.have.length(2); + expect(Buffer.from(decoded.signers[0].slice(2), "hex").equals(pk1)).to.be.true; + expect(Buffer.from(decoded.signers[1].slice(2), "hex").equals(pk2)).to.be.true; + }); + + it("should round-trip ChangePolicy (threshold, bytes[] signers, bool lock)", () => { + const abi = Multisig.abi; + const pk1 = Buffer.alloc(32, 3); + const pk2 = Buffer.alloc(32, 4); + const pk3 = Buffer.alloc(32, 5); + const threshold = 3; + + const encoded = abi.encodeFunctionData("ChangePolicy", [threshold, [pk1, pk2, pk3], true]); + const decoded = abi.decodeFunctionData("ChangePolicy", encoded, true) as Record; + + expect(Number(decoded.threshold)).to.equal(threshold); + expect(decoded.signers).to.have.length(3); + expect(Buffer.from(decoded.signers[0].slice(2), "hex").equals(pk1)).to.be.true; + expect(Buffer.from(decoded.signers[1].slice(2), "hex").equals(pk2)).to.be.true; + expect(Buffer.from(decoded.signers[2].slice(2), "hex").equals(pk3)).to.be.true; + expect(decoded.lock).to.equal(true); + }); +}); + describe("EmbeddedContract.decodeCallData()", () => { let encoded: string; diff --git a/test/model/embedded/multisig.spec.ts b/test/model/embedded/multisig.spec.ts new file mode 100644 index 0000000..8f9e150 --- /dev/null +++ b/test/model/embedded/multisig.spec.ts @@ -0,0 +1,64 @@ +import { expect } from "chai"; +import { MultisigPolicyInfo, MultisigRecordInfo } from "../../../src/model/embedded/multisig.js"; + +describe("MultisigPolicyInfo", () => { + it("should parse a policy JSON, base64-decoding signers", () => { + const pk1 = Buffer.alloc(32, 1); + const pk2 = Buffer.alloc(32, 2); + + const json = { + threshold: 2, + signers: [pk1.toString("base64"), pk2.toString("base64")], + locked: false, + }; + + const policy = MultisigPolicyInfo.fromJson(json)!; + + expect(policy.threshold).to.equal(2); + expect(policy.signers).to.have.length(2); + expect(policy.signers[0].equals(pk1)).to.be.true; + expect(policy.signers[1].equals(pk2)).to.be.true; + expect(policy.locked).to.equal(false); + }); + + it("should return null when json is null", () => { + expect(MultisigPolicyInfo.fromJson(null)).to.equal(null); + }); +}); + +describe("MultisigRecordInfo", () => { + it("should parse active/pending/pendingHeight", () => { + const pk1 = Buffer.alloc(32, 1); + + const json = { + active: { threshold: 1, signers: [pk1.toString("base64")], locked: false }, + pending: null, + pendingHeight: 0, + }; + + const record = MultisigRecordInfo.fromJson(json); + + expect(record.active).to.be.instanceOf(MultisigPolicyInfo); + expect(record.active!.threshold).to.equal(1); + expect(record.pending).to.equal(null); + expect(record.pendingHeight).to.equal(0); + }); + + it("should handle a pending policy being present", () => { + const pk1 = Buffer.alloc(32, 1); + const pk2 = Buffer.alloc(32, 2); + + const json = { + active: { threshold: 1, signers: [pk1.toString("base64")], locked: false }, + pending: { threshold: 2, signers: [pk1.toString("base64"), pk2.toString("base64")], locked: true }, + pendingHeight: 42, + }; + + const record = MultisigRecordInfo.fromJson(json); + + expect(record.pending).to.be.instanceOf(MultisigPolicyInfo); + expect(record.pending!.threshold).to.equal(2); + expect(record.pending!.locked).to.equal(true); + expect(record.pendingHeight).to.equal(42); + }); +}); diff --git a/test/model/primitives/address.spec.ts b/test/model/primitives/address.spec.ts index 861ee2d..b1b99d4 100644 --- a/test/model/primitives/address.spec.ts +++ b/test/model/primitives/address.spec.ts @@ -11,7 +11,8 @@ import { LIQUIDITY_ADDRESS, SPORK_ADDRESS, ACCELERATOR_ADDRESS, - BRIDGE_ADDRESS + BRIDGE_ADDRESS, + MULTISIG_ADDRESS } from "../../../src/model/primitives/address.js"; describe("Address", () => { @@ -78,6 +79,49 @@ describe("Address", () => { }); }); + describe("fromMultisigCreation", () => { + it("should create a multisig address with a 20-byte core and multisig class byte", () => { + const pubkey = Buffer.from("3338be694f50c5f338814986cdf0686453a888b84f424d792af4b9202398f392", "hex"); + const nonce = 1n; + + const address = Address.fromMultisigCreation(pubkey, nonce); + + expect(address.getBytes()).to.have.lengthOf(20); + expect(address.core[0]).to.equal(2); + expect(Address.isMultisigAddress(address)).to.be.true; + expect(address.toString()).to.match(/^z1/); + }); + + it("should create different addresses for different nonces", () => { + const pubkey = Buffer.from("3338be694f50c5f338814986cdf0686453a888b84f424d792af4b9202398f392", "hex"); + + const addr1 = Address.fromMultisigCreation(pubkey, 1n); + const addr2 = Address.fromMultisigCreation(pubkey, 2n); + + expect(addr1.toString()).to.not.equal(addr2.toString()); + }); + + it("should not be detected as multisig for a regular user address", () => { + const address = Address.parse("z1qq9n7fpaqd8lpcljandzmx4xtku9w4ftwyg0mq"); + expect(Address.isMultisigAddress(address)).to.be.false; + }); + + // Cross-checked against go-zenon's own MultisigCreationToAddress for the + // same (pubkey, nonce) pair - byte-for-byte identical, confirming this + // SDK derives the same address the node will actually create. + it("should produce the exact core bytes for a fixed (pubkey, nonce) pair", () => { + const creatorPubKey = Buffer.from("79a01ae9efde740e4916a911b89cf738cb211a5402df61de18e987801bab4fd3", "hex"); + const nonce = 1782933252398n; + const expectedCore = "021461cc9772a7544dc74f534a3ab1aecaaa3128"; + const expectedAddress = "z1qg2xrnyhw2n4gnw8faf55w434m925vfgl9qscp"; + + const address = Address.fromMultisigCreation(creatorPubKey, nonce); + + expect(address.getBytes().toString("hex")).to.equal(expectedCore); + expect(address.toString()).to.equal(expectedAddress); + }); + }); + describe("fromCore", () => { it("should create address from Buffer core", () => { const core = Buffer.alloc(20); @@ -184,5 +228,9 @@ describe("Address", () => { it("BRIDGE_ADDRESS has correct value", () => { expect(BRIDGE_ADDRESS.toString()).to.equal("z1qxemdeddedxdrydgexxxxxxxxxxxxxxxmqgr0d"); }); + + it("MULTISIG_ADDRESS has correct value", () => { + expect(MULTISIG_ADDRESS.toString()).to.equal("z1qxemdeddedxmultysygxxxxxxxxxxxxx42zwd4"); + }); }); }); diff --git a/test/utilities/block.spec.ts b/test/utilities/block.spec.ts index b91371b..c8acd1c 100644 --- a/test/utilities/block.spec.ts +++ b/test/utilities/block.spec.ts @@ -1,12 +1,17 @@ import { expect } from "chai"; -import { isSendBlock, isReceiveBlock, getTxHash, send, prepareBlock } from "../../src/utilities/block.js"; +import * as ed from "@noble/ed25519"; +import { + isSendBlock, isReceiveBlock, getTxHash, send, prepareBlock, + freezeBlock, signBlock, assembleMultisigAuth +} from "../../src/utilities/block.js"; import { Zenon } from "../../src/zenon.js"; import { BlockTypeEnum, AccountBlockTemplate } from "../../src/model/nom/accountBlock.js"; import { Address, Hash, EMPTY_HASH, HashHeight, - ZNN_ZTS + ZNN_ZTS, + MULTISIG_ADDRESS } from "../../src/model/primitives/index.js"; import { KeyPair } from "../../src/wallet/keyPair.js"; @@ -265,6 +270,59 @@ describe("Block Utilities", () => { }); }); + describe("getTxHash / multisigAuth invariance", () => { + const address = Address.parse("z1qqjnwjjpnue8xmmpanz6csze6tcmtzzdtfsww7"); + const toAddress = Address.parse("z1qzal6c5s9rjnnxd2z7dvdhjxpmmj4fmw56a0mz"); + + const createTransaction = () => new AccountBlockTemplate({ + version: 1, + chainIdentifier: 1, + blockType: BlockTypeEnum.UserSend, + previousHash: EMPTY_HASH, + height: 1, + momentumAcknowledged: new HashHeight(EMPTY_HASH, 0), + address: address, + toAddress: toAddress, + amount: BigInt(100000000), + tokenStandard: ZNN_ZTS, + fromBlockHash: EMPTY_HASH, + data: Buffer.from([]), + fusedPlasma: 0, + difficulty: 0, + nonce: "0000000000000000" + }); + + it("produces the same hash whether multisigAuth is unset, set, or mutated after hashing", () => { + const tx = createTransaction(); + const hashUnset = getTxHash(tx); + + tx.multisigAuth = { signatures: [Buffer.from("sig1"), Buffer.from("sig2")] }; + const hashSet = getTxHash(tx); + + tx.multisigAuth.signatures.push(Buffer.from("sig3")); + const hashMutated = getTxHash(tx); + + expect(hashSet.toString()).to.equal(hashUnset.toString()); + expect(hashMutated.toString()).to.equal(hashUnset.toString()); + }); + + it("produces the same hash after a toJson/fromJson round-trip with multisigAuth present", () => { + const tx = createTransaction(); + tx.multisigAuth = { signatures: [Buffer.from("sig1")] }; + + const roundTripped = AccountBlockTemplate.fromJson(tx.toJson()); + + expect(getTxHash(roundTripped).toString()).to.equal(getTxHash(tx).toString()); + }); + + it("omits multisigAuth entirely from toJson when unset", () => { + const tx = createTransaction(); + const json = tx.toJson(); + + expect(json).to.not.have.property("multisigAuth"); + }); + }); + describe("send", () => { it("should fill fields, set PoW defaults, and publish a send block", async () => { const keyPair = KeyPair.fromPrivateKey(Buffer.alloc(32, 1)); @@ -608,4 +666,216 @@ describe("Block Utilities", () => { expect(result.address.toString()).to.equal(keyPair.getAddress().toString()); }); }); + + describe("multisig signing pipeline (freeze / sign / assemble)", () => { + describe("freezeBlock (send block)", () => { + it("freezes a send-style block: sets address, leaves publicKey/signature empty, sets a non-empty hash", async () => { + const zenon = makeZenon({ + ledger: { + getFrontierAccountBlock: async () => ({ height: 5, hash: Hash.parse(HASH_A) }) + } + }); + + const transaction = new AccountBlockTemplate({ + blockType: BlockTypeEnum.UserSend, + toAddress: Address.parse(ADDRESS_B), + amount: BigInt(100), + tokenStandard: ZNN_ZTS, + data: Buffer.from([]) + }); + + const frozen = await freezeBlock(zenon as any, transaction, MULTISIG_ADDRESS); + + expect(frozen.address.toString()).to.equal(MULTISIG_ADDRESS.toString()); + expect(frozen.publicKey.length).to.equal(0); + expect(frozen.signature.length).to.equal(0); + expect(frozen.hash.getBytes().equals(EMPTY_HASH.getBytes())).to.be.false; + }); + + it("signBlock returns a 64-byte signature verifying against the signer pubkey over hash bytes", async () => { + const zenon = makeZenon({ + ledger: { + getFrontierAccountBlock: async () => ({ height: 5, hash: Hash.parse(HASH_A) }) + } + }); + + const transaction = new AccountBlockTemplate({ + blockType: BlockTypeEnum.UserSend, + toAddress: Address.parse(ADDRESS_B), + amount: BigInt(100), + tokenStandard: ZNN_ZTS, + data: Buffer.from([]) + }); + + const frozen = await freezeBlock(zenon as any, transaction, MULTISIG_ADDRESS); + const keyPair = KeyPair.fromPrivateKey(Buffer.alloc(32, 11)); + const signature = signBlock(frozen, keyPair); + + expect(signature).to.have.length(64); + const valid = await ed.verify(signature, frozen.hash.getBytes(), keyPair.getPublicKey()); + expect(valid).to.be.true; + }); + + it("signBlock throws when the block has not been frozen", () => { + const transaction = new AccountBlockTemplate({ + blockType: BlockTypeEnum.UserSend, + toAddress: Address.parse(ADDRESS_B), + amount: BigInt(100), + tokenStandard: ZNN_ZTS, + data: Buffer.from([]) + }); + const keyPair = KeyPair.fromPrivateKey(Buffer.alloc(32, 12)); + + expect(() => signBlock(transaction, keyPair)).to.throw("Block must be frozen before signing"); + }); + + it("assembleMultisigAuth populates multisigAuth.signatures order-independently", async () => { + const zenon = makeZenon({ + ledger: { + getFrontierAccountBlock: async () => ({ height: 5, hash: Hash.parse(HASH_A) }) + } + }); + + const transaction = new AccountBlockTemplate({ + blockType: BlockTypeEnum.UserSend, + toAddress: Address.parse(ADDRESS_B), + amount: BigInt(100), + tokenStandard: ZNN_ZTS, + data: Buffer.from([]) + }); + + const frozen = await freezeBlock(zenon as any, transaction, MULTISIG_ADDRESS); + const kp1 = KeyPair.fromPrivateKey(Buffer.alloc(32, 13)); + const kp2 = KeyPair.fromPrivateKey(Buffer.alloc(32, 14)); + const sig1 = signBlock(frozen, kp1); + const sig2 = signBlock(frozen, kp2); + + assembleMultisigAuth(frozen, [sig2, sig1]); + + expect(frozen.multisigAuth).to.exist; + expect(frozen.multisigAuth!.signatures).to.have.length(2); + expect(frozen.multisigAuth!.signatures[0].equals(sig2)).to.be.true; + expect(frozen.multisigAuth!.signatures[1].equals(sig1)).to.be.true; + }); + }); + + describe("freezeBlock (receive block)", () => { + it("succeeds for a receive block whose send toAddress matches the multisig account", async () => { + const sendHash = Hash.parse(HASH_A); + const zenon = makeZenon({ + ledger: { + getFrontierAccountBlock: async () => ({ height: 5, hash: Hash.parse(HASH_B) }), + getAccountBlockByHash: async () => ({ + toAddress: MULTISIG_ADDRESS + }) + } + }); + + const transaction = AccountBlockTemplate.receive(sendHash); + const frozen = await freezeBlock(zenon as any, transaction, MULTISIG_ADDRESS); + + expect(frozen.hash.getBytes().equals(EMPTY_HASH.getBytes())).to.be.false; + expect(frozen.publicKey.length).to.equal(0); + expect(frozen.signature.length).to.equal(0); + }); + + it("throws ZnnBlockUtilitiesException before hashing when the send toAddress does not match", async () => { + const sendHash = Hash.parse(HASH_A); + const zenon = makeZenon({ + ledger: { + getFrontierAccountBlock: async () => ({ height: 5, hash: Hash.parse(HASH_B) }), + getAccountBlockByHash: async () => ({ + toAddress: Address.parse(ADDRESS_A) + }) + } + }); + + const transaction = AccountBlockTemplate.receive(sendHash); + + let error: Error | null = null; + try { + await freezeBlock(zenon as any, transaction, MULTISIG_ADDRESS); + } catch (err) { + error = err as Error; + } + + expect(error).to.exist; + expect(error!.message).to.include("does not match transaction address"); + expect(transaction.hash.getBytes().equals(EMPTY_HASH.getBytes())).to.be.true; + }); + }); + + describe("send cross-machine hand-off", () => { + it("hash and signature are stable across a toJson/fromJson round-trip", async () => { + const zenon = makeZenon({ + ledger: { + getFrontierAccountBlock: async () => ({ height: 5, hash: Hash.parse(HASH_A) }) + } + }); + + const transaction = new AccountBlockTemplate({ + blockType: BlockTypeEnum.UserSend, + toAddress: Address.parse(ADDRESS_B), + amount: BigInt(100), + tokenStandard: ZNN_ZTS, + data: Buffer.from([]) + }); + + const frozen = await freezeBlock(zenon as any, transaction, MULTISIG_ADDRESS); + const reparsed = AccountBlockTemplate.fromJson(frozen.toJson()); + + expect(reparsed.hash.toString()).to.equal(frozen.hash.toString()); + + const keyPair = KeyPair.fromPrivateKey(Buffer.alloc(32, 15)); + expect(signBlock(reparsed, keyPair).equals(signBlock(frozen, keyPair))).to.be.true; + + const sig = signBlock(frozen, keyPair); + assembleMultisigAuth(frozen, [sig]); + + const withAuth = AccountBlockTemplate.fromJson(frozen.toJson()); + expect(withAuth.multisigAuth).to.exist; + expect(withAuth.multisigAuth!.signatures).to.have.length(1); + expect(withAuth.multisigAuth!.signatures[0].equals(sig)).to.be.true; + + // A block without multisigAuth omits the key entirely. + const withoutAuth = new AccountBlockTemplate({ + blockType: BlockTypeEnum.UserSend, + toAddress: Address.parse(ADDRESS_B), + amount: BigInt(100), + tokenStandard: ZNN_ZTS, + data: Buffer.from([]) + }); + expect(withoutAuth.toJson()).to.not.have.property("multisigAuth"); + }); + }); + + describe("receive cross-machine hand-off", () => { + it("hash and signature are stable across a toJson/fromJson round-trip for a receive block", async () => { + const sendHash = Hash.parse(HASH_A); + const zenon = makeZenon({ + ledger: { + getFrontierAccountBlock: async () => ({ height: 5, hash: Hash.parse(HASH_B) }), + getAccountBlockByHash: async () => ({ + toAddress: MULTISIG_ADDRESS + }) + } + }); + + const transaction = AccountBlockTemplate.receive(sendHash); + const frozen = await freezeBlock(zenon as any, transaction, MULTISIG_ADDRESS); + const reparsed = AccountBlockTemplate.fromJson(frozen.toJson()); + + expect(reparsed.hash.toString()).to.equal(frozen.hash.toString()); + + const keyPair = KeyPair.fromPrivateKey(Buffer.alloc(32, 16)); + expect(signBlock(reparsed, keyPair).equals(signBlock(frozen, keyPair))).to.be.true; + + const sig = signBlock(frozen, keyPair); + assembleMultisigAuth(frozen, [sig]); + + const withAuth = AccountBlockTemplate.fromJson(frozen.toJson()); + expect(withAuth.multisigAuth!.signatures[0].equals(sig)).to.be.true; + }); + }); + }); });