diff --git a/package.json b/package.json index b34eb35..7483a85 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,10 @@ "types": "./types/src/protocols/index.d.ts", "default": "./src/protocols/index.js" }, + "./multisig": { + "types": "./types/src/multisig/index.d.ts", + "default": "./src/multisig/index.js" + }, "./package": { "default": "./package.json" } diff --git a/src/multisig/i-multisig-owner-management.js b/src/multisig/i-multisig-owner-management.js new file mode 100644 index 0000000..04d5dc8 --- /dev/null +++ b/src/multisig/i-multisig-owner-management.js @@ -0,0 +1,82 @@ +// Copyright 2024 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +'use strict' + +import { NotImplementedError } from '../errors.js' + +/** @typedef {import('./wallet-account-read-only-multisig.js').MultisigProposal} MultisigProposal */ + +/** @typedef {import('../errors.js').SignerError} SignerError */ + +/** + * @typedef {Object} MultisigOptions + * @property {number} threshold - The new amount of approvals required to execute a transaction. + */ + +/** + * Optional owner-management surface for multisig accounts whose owner set is mutable + * (e.g. account-abstraction wallets). Chains whose owner set is fixed at creation — + * such as Bitcoin script multisig, where the participants are committed in the redeem + * script — do not implement this interface. + * + * @interface + */ +export class IMultisigOwnerManagement { + /** + * Proposes adding a new owner to the multisig wallet account. + * + * @param {string} owner - The owner's address. + * @param {MultisigOptions} [options] - The multisig options. + * @returns {Promise} The multisig proposal. + * @throws {SignerError} If the signer is not an owner of the multisig account. + */ + async addOwner (owner, options) { + throw new NotImplementedError('addOwner(owner, options)') + } + + /** + * Proposes removing an owner from the multisig wallet account. + * + * @param {string} owner - The owner's address. + * @param {MultisigOptions} [options] - The multisig options. + * @returns {Promise} The multisig proposal. + * @throws {SignerError} If the signer is not an owner of the multisig account. + */ + async removeOwner (owner, options) { + throw new NotImplementedError('removeOwner(owner, options)') + } + + /** + * Proposes replacing an owner with a different one. + * + * @param {string} oldOwner - The old owner. + * @param {string} newOwner - The new owner. + * @returns {Promise} The multisig proposal. + * @throws {SignerError} If the signer is not an owner of the multisig account. + */ + async swapOwner (oldOwner, newOwner) { + throw new NotImplementedError('swapOwner(oldOwner, newOwner)') + } + + /** + * Proposes changing the signature threshold. + * + * @param {number} newThreshold - The new threshold. + * @returns {Promise} The multisig proposal. + * @throws {SignerError} If the signer is not an owner of the multisig account. + */ + async changeThreshold (newThreshold) { + throw new NotImplementedError('changeThreshold(newThreshold)') + } +} diff --git a/src/multisig/i-multisig-transport.js b/src/multisig/i-multisig-transport.js new file mode 100644 index 0000000..3274a16 --- /dev/null +++ b/src/multisig/i-multisig-transport.js @@ -0,0 +1,128 @@ +// Copyright 2024 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +'use strict' + +import { NotImplementedError } from '../errors.js' + +/** + * A message proposal to share with the other owners for them to confirm. + * + * @typedef {Object} MultisigTransportMessageInput + * @property {string} message - The message to sign. + * @property {string} signature - The submitting owner's signature over the message. + */ + +/** + * A shared transaction proposal returned by the transport. The concrete payload is + * chain-specific and opaque to the transport: an implementation persists whatever the + * chain's execution layer produced and returns it intact, alongside the owner + * confirmations collected so far. + * + * @typedef {Object} MultisigTransportProposal + * @property {unknown[]} confirmations - The owner confirmations (signatures) collected so far. + */ + +/** + * A shared message proposal returned by the transport, alongside the owner confirmations + * collected so far. As with proposals, any further fields are chain-specific. + * + * @typedef {Object} MultisigTransportMessage + * @property {unknown[]} confirmations - The owner confirmations (signatures) collected so far. + */ + +/** + * Transport for sharing multisig calldata between the owners of a multisig account. + * + * A transport distributes transaction proposals and message proposals (and their + * confirmations) amongst the owners of a multisig account, so that signers running on + * separate machines can coordinate without a shared process. It is chain-agnostic: the + * proposal and message payloads are opaque to the transport and interpreted by each + * chain's multisig package, so the same transport can back account-abstraction, UTXO + * (PSBT) or other multisig wallets. Plug in a custom backend (a hosted service, a + * database, a peer-to-peer channel, etc.) by implementing this interface. + * + * Implementations that serialize the payloads themselves (rather than handing them to an + * SDK that already does it) can pass them through {@link toTransportJson} to convert native + * values such as BigInt into JSON-safe forms before persisting or transmitting them. + * + * @interface + * @template [TProposal=Record] + * @template [TMessage=MultisigTransportMessageInput] + * @template [TProposalResponse=MultisigTransportProposal] + * @template [TMessageResponse=MultisigTransportMessage] + */ +export class IMultisigTransport { + /** + * Submits a new transaction proposal so the other owners can confirm it. + * + * @param {TProposal} proposal - The signed transaction proposal to share. Opaque to the transport, which must persist it so {@link getProposal} can return it intact. + * @returns {Promise} + */ + async submitProposal (proposal) { + throw new NotImplementedError('submitProposal(proposal)') + } + + /** + * Returns a transaction proposal by its identifier. + * + * @param {string} proposalId - The proposal's identifier. + * @returns {Promise} The proposal, or null if it has not been found. + */ + async getProposal (proposalId) { + throw new NotImplementedError('getProposal(proposalId)') + } + + /** + * Adds an owner's confirmation (signature) to an existing transaction proposal. + * + * @param {string} proposalId - The proposal's identifier. + * @param {string} signature - The owner's signature over the proposal. + * @returns {Promise} + */ + async confirmProposal (proposalId, signature) { + throw new NotImplementedError('confirmProposal(proposalId, signature)') + } + + /** + * Submits a new message proposal so the other owners can confirm it. + * + * @param {string} accountAddress - The multisig account's address. + * @param {TMessage} message - The message proposal to share. + * @returns {Promise} + */ + async submitMessage (accountAddress, message) { + throw new NotImplementedError('submitMessage(accountAddress, message)') + } + + /** + * Returns a message proposal by its hash. + * + * @param {string} messageId - The message's hash. + * @returns {Promise} The message, or null if it has not been found. + */ + async getMessage (messageId) { + throw new NotImplementedError('getMessage(messageId)') + } + + /** + * Adds an owner's confirmation (signature) to an existing message proposal. + * + * @param {string} messageId - The message's hash. + * @param {string} signature - The owner's signature over the message. + * @returns {Promise} + */ + async confirmMessage (messageId, signature) { + throw new NotImplementedError('confirmMessage(messageId, signature)') + } +} diff --git a/src/multisig/index.js b/src/multisig/index.js new file mode 100644 index 0000000..d76ed4b --- /dev/null +++ b/src/multisig/index.js @@ -0,0 +1,37 @@ +// Copyright 2024 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +'use strict' + +/** @typedef {import('./wallet-account-read-only-multisig.js').MultisigInfo} MultisigInfo */ +/** @typedef {import('./wallet-account-read-only-multisig.js').MultisigProposal} MultisigProposal */ +/** @typedef {import('./wallet-account-read-only-multisig.js').MultisigMessage} MultisigMessage */ +/** @typedef {import('../wallet-account.js').KeyPair} KeyPair */ + +/** @typedef {import('./wallet-account-multisig.js').MultisigTransactionOptions} MultisigTransactionOptions */ +/** @typedef {import('./wallet-account-multisig.js').MultisigMessageProposal} MultisigMessageProposal */ +/** @typedef {import('./i-multisig-owner-management.js').MultisigOptions} MultisigOptions */ + +/** @typedef {import('./i-multisig-transport.js').MultisigTransportProposal} MultisigTransportProposal */ +/** @typedef {import('./i-multisig-transport.js').MultisigTransportMessage} MultisigTransportMessage */ +/** @typedef {import('./i-multisig-transport.js').MultisigTransportMessageInput} MultisigTransportMessageInput */ + +export { IWalletAccountReadOnlyMultisig } from './wallet-account-read-only-multisig.js' + +export { IWalletAccountMultisig } from './wallet-account-multisig.js' + +export { IMultisigOwnerManagement } from './i-multisig-owner-management.js' + +export { IMultisigTransport } from './i-multisig-transport.js' + +export { toTransportJson } from './transport-serialization.js' diff --git a/src/multisig/transport-serialization.js b/src/multisig/transport-serialization.js new file mode 100644 index 0000000..63f4bb9 --- /dev/null +++ b/src/multisig/transport-serialization.js @@ -0,0 +1,62 @@ +// Copyright 2024 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +'use strict' + +/** + * Converts a byte array to a 0x-prefixed lowercase hex string. + * + * @param {Uint8Array} bytes - The byte array to encode. + * @returns {string} The 0x-prefixed hex encoding. + */ +function bytesToHex (bytes) { + let hex = '0x' + for (const byte of bytes) { + hex += byte.toString(16).padStart(2, '0') + } + return hex +} + +/** + * Recursively converts a value into a JSON-safe form so it survives JSON.stringify: every BigInt + * becomes its decimal string and every byte array (Uint8Array, including Buffer) becomes a + * 0x-prefixed lowercase hex string. Arrays and plain objects are converted entry by entry; all + * other values are returned unchanged. + * + * The conversion is one-way: there is no generic inverse, so a consumer that needs the original + * types back restores them per field (it knows which fields are amounts, byte strings, etc.). + * + * @param {unknown} value - The value to convert (object, array, or primitive). + * @returns {unknown} A JSON-safe copy of the value. + */ +export function toTransportJson (value) { + if (typeof value === 'bigint') { + return value.toString() + } + + if (value instanceof Uint8Array) { + return bytesToHex(value) + } + + if (Array.isArray(value)) { + return value.map(toTransportJson) + } + + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, toTransportJson(entry)]) + ) + } + + return value +} diff --git a/src/multisig/wallet-account-multisig.js b/src/multisig/wallet-account-multisig.js new file mode 100644 index 0000000..bc7b851 --- /dev/null +++ b/src/multisig/wallet-account-multisig.js @@ -0,0 +1,145 @@ +// Copyright 2024 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +'use strict' + +import { IWalletAccountReadOnlyMultisig } from './wallet-account-read-only-multisig.js' + +import { NotImplementedError } from '../errors.js' + +/** @typedef {import('../wallet-account-read-only.js').Transaction} Transaction */ +/** @typedef {import('../wallet-account-read-only.js').TransactionResult} TransactionResult */ + +/** @typedef {import('../wallet-account.js').KeyPair} KeyPair */ + +/** @typedef {import('./wallet-account-read-only-multisig.js').MultisigProposal} MultisigProposal */ + +/** @typedef {import('../errors.js').SignerError} SignerError */ +/** @typedef {import('../errors.js').NoSuchElementError} NoSuchElementError */ +/** @typedef {import('../errors.js').ValueError} ValueError */ + +/** + * @typedef {Object} MultisigTransactionOptions + * @property {boolean} [autoExecute] - If true, automatically executes the transaction when the approval threshold is met (only takes effect if this signer's approval is the last one required). + */ + +/** + * @typedef {Object} MultisigMessageProposal + * @property {string} messageId - The message's hash. + * @property {string} signature - The signature of the caller. + * @property {number} confirmations - The current number of confirmations. + * @property {number} threshold - The minimum amount of confirmations to sign the message. + * @property {string | null} combinedSignature - The final combined signature when the threshold is met. + */ + +/** @interface */ +export class IWalletAccountMultisig extends IWalletAccountReadOnlyMultisig { + /** + * The derivation path's index of the signer associated with this account. + * + * @type {number} + */ + get index () { + throw new NotImplementedError('index') + } + + /** + * The derivation path of the signer associated with this account (see [BIP-44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki)). + * + * @type {string} + */ + get path () { + throw new NotImplementedError('path') + } + + /** + * The key pair of the signer associated with this account. + * + * @type {KeyPair} + */ + get signerKeyPair () { + throw new NotImplementedError('signerKeyPair') + } + + /** + * Proposes sending a transaction for the other owners to approve. Does not execute on-chain: + * the returned proposal must be approved up to the threshold and then executed via + * {@link executeProposal}. + * + * @param {Transaction} tx - The transaction. + * @param {MultisigTransactionOptions} [transactionOptions] - The multisig transaction's options. + * @returns {Promise} The created proposal; its `status` is `'executed'` when `autoExecute` ran to completion, otherwise `'pending'`. + * @throws {SignerError} If the signer is not an owner of the multisig account. + */ + async propose (tx, transactionOptions) { + throw new NotImplementedError('propose(tx, transactionOptions)') + } + + /** + * Proposes signing a message. + * + * @param {string} message - The message to sign. + * @returns {Promise} The multisig message proposal. + * @throws {SignerError} If the signer is not an owner of the multisig account. + */ + async proposeMessage (message) { + throw new NotImplementedError('proposeMessage(message)') + } + + /** + * Approves an existing message proposal. + * + * @param {string} messageId - The message's hash. + * @returns {Promise} The multisig message proposal. + * @throws {SignerError} If the signer is not an owner of the multisig account. + * @throws {NoSuchElementError} If no message exists for the given id. + */ + async approveMessage (messageId) { + throw new NotImplementedError('approveMessage(messageId)') + } + + /** + * Approves a pending proposal. + * + * @param {string} proposalId - The proposal's id. + * @returns {Promise} The multisig proposal. + * @throws {SignerError} If the signer is not an owner of the multisig account. + * @throws {NoSuchElementError} If no proposal exists for the given id. + */ + async approveProposal (proposalId) { + throw new NotImplementedError('approveProposal(proposalId)') + } + + /** + * Rejects a pending proposal. + * + * @param {string} proposalId - The proposal's id. + * @returns {Promise} The multisig proposal. + * @throws {NoSuchElementError} If no proposal exists for the given id. + */ + async rejectProposal (proposalId) { + throw new NotImplementedError('rejectProposal(proposalId)') + } + + /** + * Submits an approved proposal for on-chain execution. + * + * @param {string} proposalId - The proposal's id. + * @returns {Promise} The on-chain transaction's result. + * @throws {NoSuchElementError} If no proposal exists for the given id. + * @throws {ValueError} If the proposal has not reached the approval threshold. + */ + async executeProposal (proposalId) { + throw new NotImplementedError('executeProposal(proposalId)') + } +} diff --git a/src/multisig/wallet-account-read-only-multisig.js b/src/multisig/wallet-account-read-only-multisig.js new file mode 100644 index 0000000..e8e6f8d --- /dev/null +++ b/src/multisig/wallet-account-read-only-multisig.js @@ -0,0 +1,101 @@ +// Copyright 2024 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +'use strict' + +import { IWalletAccountReadOnlyBase } from '../wallet-account-read-only-base.js' + +import { NotImplementedError } from '../errors.js' + +/** @typedef {import('../errors.js').NoSuchElementError} NoSuchElementError */ + +/** @typedef {import('../wallet-account-read-only.js').TransactionResult} TransactionResult */ + +/** + * @typedef {Object} MultisigInfo + * @property {string} address - The multisig wallet account address. + * @property {string[]} owners - The owners of the multisig wallet account. + * @property {number} threshold - The minimum amount of signatures to execute a transaction. + */ + +/** + * + * @typedef {Object} MultisigProposal + * @property {string} proposalId - The proposal's id. + * @property {number} confirmations - The current number of confirmations. + * @property {number} threshold - The minimum amount of confirmations to execute the transaction. + * @property {'pending' | 'executed'} status - The proposal's lifecycle state: `'pending'` while it still awaits confirmations or on-chain execution, `'executed'` once it has been executed on-chain. + */ + +/** + * @typedef {Object} MultisigMessage + * @property {string} messageId - The message's hash. + * @property {string} message - The original message. + * @property {number} confirmations - The current number of confirmations. + * @property {number} threshold - The minimum amount of confirmations to sign the message. + * @property {string | null} combinedSignature - The final combined signature when the threshold is met. + */ + +/** @interface */ +export class IWalletAccountReadOnlyMultisig extends IWalletAccountReadOnlyBase { + /** + * Returns the address of the signer associated with this wallet account. + * + * @returns {Promise} The signer's address, or null if no signer is associated yet. + */ + async getSignerAddress () { + throw new NotImplementedError('getSignerAddress()') + } + + /** + * Returns the multisig wallet account info. + * + * @returns {Promise} The info. + */ + async getMultisigInfo () { + throw new NotImplementedError('getMultisigInfo()') + } + + /** + * Returns a list of proposals by their identifiers. + * + * @param {string[]} proposalIds - The list of proposal identifiers. + * @returns {Promise<(MultisigProposal | null)[]>} For each proposal id, the proposal details or + * null if the proposal has not been found. + */ + async getProposals (proposalIds) { + throw new NotImplementedError('getProposals(proposalIds)') + } + + /** + * Returns a list of message proposals by their hashes. + * + * @param {string[]} messageIds - The list of message hashes + * @returns {Promise<(MultisigMessage | null)[]>} For each message hash, the message details or + * null if the message has not been found. + */ + async getMessages (messageIds) { + throw new NotImplementedError('getMessages(messageIds)') + } + + /** + * Quotes the on-chain cost of executing a pending proposal. + * + * @param {string} proposalId - The proposal's id. + * @returns {Promise>} The execution cost estimate. + * @throws {NoSuchElementError} If no proposal exists for the given id. + */ + async quoteExecuteProposal (proposalId) { + throw new NotImplementedError('quoteExecuteProposal(proposalId)') + } +} diff --git a/src/wallet-account-read-only-base.js b/src/wallet-account-read-only-base.js new file mode 100644 index 0000000..586198a --- /dev/null +++ b/src/wallet-account-read-only-base.js @@ -0,0 +1,77 @@ +// Copyright 2024 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +'use strict' + +import { NotImplementedError } from './errors.js' + +/** + * The read-only members shared by every wallet account, single-signer or multisig. + * + * This is an internal base interface: it is not exported from the package entry point. + * Consumers use {@link IWalletAccountReadOnly} or {@link IWalletAccountReadOnlyMultisig}, + * which both extend it. + * + * @interface + */ +export class IWalletAccountReadOnlyBase { + /** + * Returns the account's address. + * + * @returns {Promise} The account's address. + */ + async getAddress () { + throw new NotImplementedError('getAddress()') + } + + /** + * Verifies a message's signature. + * + * @param {string} message - The original message. + * @param {string} signature - The signature to verify. + * @returns {Promise} True if the signature is valid. + * @throws {Error} If the read-only wallet account class is not able to provide an implementation for the method. + */ + async verify (message, signature) { + throw new NotImplementedError('verify(message, signature)') + } + + /** + * Returns the account's native token balance. + * + * @returns {Promise} The native token balance. + */ + async getBalance () { + throw new NotImplementedError('getBalance()') + } + + /** + * Returns the account balance for a specific token. + * + * @param {string} tokenAddress - The smart contract address of the token. + * @returns {Promise} The token balance. + */ + async getTokenBalance (tokenAddress) { + throw new NotImplementedError('getTokenBalance(tokenAddress)') + } + + /** + * Returns a transaction's receipt. + * + * @param {string} hash - The transaction's hash. + * @returns {Promise} The receipt, or null if the transaction has not been included in a block yet. + */ + async getTransactionReceipt (hash) { + throw new NotImplementedError('getTransactionReceipt(hash)') + } +} diff --git a/src/wallet-account-read-only.js b/src/wallet-account-read-only.js index 3129cd9..8544a7c 100644 --- a/src/wallet-account-read-only.js +++ b/src/wallet-account-read-only.js @@ -15,6 +15,8 @@ import { NotImplementedError } from './errors.js' +import { IWalletAccountReadOnlyBase } from './wallet-account-read-only-base.js' + /** * @typedef {Object} Transaction * @property {string} to - The transaction's recipient. @@ -41,47 +43,7 @@ import { NotImplementedError } from './errors.js' */ /** @interface */ -export class IWalletAccountReadOnly { - /** - * Returns the account's address. - * - * @returns {Promise} The account's address. - */ - async getAddress () { - throw new NotImplementedError('getAddress()') - } - - /** - * Verifies a message's signature. - * - * @param {string} message - The original message. - * @param {string} signature - The signature to verify. - * @returns {Promise} True if the signature is valid. - * @throws {Error} If the read-only wallet account class is not able to provide an implementation for the method. - */ - async verify (message, signature) { - throw new NotImplementedError('verify(message, signature)') - } - - /** - * Returns the account's native token balance. - * - * @returns {Promise} The native token balance. - */ - async getBalance () { - throw new NotImplementedError('getBalance()') - } - - /** - * Returns the account balance for a specific token. - * - * @param {string} tokenAddress - The smart contract address of the token. - * @returns {Promise} The token balance. - */ - async getTokenBalance (tokenAddress) { - throw new NotImplementedError('getTokenBalance(tokenAddress)') - } - +export class IWalletAccountReadOnly extends IWalletAccountReadOnlyBase { /** * Quotes the costs of a send transaction operation. * @@ -101,16 +63,6 @@ export class IWalletAccountReadOnly { async quoteTransfer (options) { throw new NotImplementedError('quoteTransfer(options)') } - - /** - * Returns a transaction's receipt. - * - * @param {string} hash - The transaction's hash. - * @returns {Promise} The receipt, or null if the transaction has not been included in a block yet. - */ - async getTransactionReceipt (hash) { - throw new NotImplementedError('getTransactionReceipt(hash)') - } } /** diff --git a/tests/transport-serialization.test.js b/tests/transport-serialization.test.js new file mode 100644 index 0000000..31cb89c --- /dev/null +++ b/tests/transport-serialization.test.js @@ -0,0 +1,57 @@ +import { describe, expect, test } from '@jest/globals' + +import { toTransportJson } from '../src/multisig/index.js' + +describe('toTransportJson', () => { + test('converts a top-level BigInt to its decimal string', () => { + expect(toTransportJson(10n)).toBe('10') + }) + + test('converts nested BigInts inside objects and arrays', () => { + const payload = { + maxFeePerGas: 1000000000n, + nested: { callGasLimit: 50000n }, + values: [1n, 2n, { preVerificationGas: 21000n }] + } + + expect(toTransportJson(payload)).toEqual({ + maxFeePerGas: '1000000000', + nested: { callGasLimit: '50000' }, + values: ['1', '2', { preVerificationGas: '21000' }] + }) + }) + + test('leaves JSON-native values untouched', () => { + const payload = { to: '0xabc', value: 0, ok: true, missing: null } + + expect(toTransportJson(payload)).toEqual(payload) + }) + + test('converts a Uint8Array to a 0x-prefixed hex string', () => { + expect(toTransportJson(new Uint8Array([0, 1, 171, 255]))).toBe('0x0001abff') + }) + + test('converts a Buffer and nested byte arrays to hex', () => { + const payload = { + data: Buffer.from([222, 173]), + nested: { signature: new Uint8Array([1, 2, 3]) } + } + + expect(toTransportJson(payload)).toEqual({ + data: '0xdead', + nested: { signature: '0x010203' } + }) + }) + + test('produces output that survives JSON.stringify', () => { + expect(JSON.stringify(toTransportJson({ gas: 42n }))).toBe('{"gas":"42"}') + }) + + test('does not mutate the input object', () => { + const payload = { gas: 7n } + + toTransportJson(payload) + + expect(payload.gas).toBe(7n) + }) +}) diff --git a/types/src/multisig/i-multisig-owner-management.d.ts b/types/src/multisig/i-multisig-owner-management.d.ts new file mode 100644 index 0000000..ff680f9 --- /dev/null +++ b/types/src/multisig/i-multisig-owner-management.d.ts @@ -0,0 +1,52 @@ +/** + * Optional owner-management surface for multisig accounts whose owner set is mutable + * (e.g. account-abstraction wallets). Chains whose owner set is fixed at creation — + * such as Bitcoin script multisig, where the participants are committed in the redeem + * script — do not implement this interface. + * + * @interface + */ +export interface IMultisigOwnerManagement { + /** + * Proposes adding a new owner to the multisig wallet account. + * + * @param {string} owner - The owner's address. + * @param {MultisigOptions} [options] - The multisig options. + * @returns {Promise} The multisig proposal. + * @throws {SignerError} If the signer is not an owner of the multisig account. + */ + addOwner(owner: string, options?: MultisigOptions): Promise; + /** + * Proposes removing an owner from the multisig wallet account. + * + * @param {string} owner - The owner's address. + * @param {MultisigOptions} [options] - The multisig options. + * @returns {Promise} The multisig proposal. + * @throws {SignerError} If the signer is not an owner of the multisig account. + */ + removeOwner(owner: string, options?: MultisigOptions): Promise; + /** + * Proposes replacing an owner with a different one. + * + * @param {string} oldOwner - The old owner. + * @param {string} newOwner - The new owner. + * @returns {Promise} The multisig proposal. + * @throws {SignerError} If the signer is not an owner of the multisig account. + */ + swapOwner(oldOwner: string, newOwner: string): Promise; + /** + * Proposes changing the signature threshold. + * + * @param {number} newThreshold - The new threshold. + * @returns {Promise} The multisig proposal. + * @throws {SignerError} If the signer is not an owner of the multisig account. + */ + changeThreshold(newThreshold: number): Promise; +} +export type MultisigProposal = import("./wallet-account-read-only-multisig.js").MultisigProposal; +export type MultisigOptions = { + /** + * - The new amount of approvals required to execute a transaction. + */ + threshold: number; +}; diff --git a/types/src/multisig/i-multisig-transport.d.ts b/types/src/multisig/i-multisig-transport.d.ts new file mode 100644 index 0000000..532eba2 --- /dev/null +++ b/types/src/multisig/i-multisig-transport.d.ts @@ -0,0 +1,126 @@ +/** + * A message proposal to share with the other owners for them to confirm. + * + * @typedef {Object} MultisigTransportMessageInput + * @property {string} message - The message to sign. + * @property {string} signature - The submitting owner's signature over the message. + */ +/** + * A shared transaction proposal returned by the transport. The concrete payload is + * chain-specific and opaque to the transport: an implementation persists whatever the + * chain's execution layer produced and returns it intact, alongside the owner + * confirmations collected so far. + * + * @typedef {Object} MultisigTransportProposal + * @property {unknown[]} confirmations - The owner confirmations (signatures) collected so far. + */ +/** + * A shared message proposal returned by the transport, alongside the owner confirmations + * collected so far. As with proposals, any further fields are chain-specific. + * + * @typedef {Object} MultisigTransportMessage + * @property {unknown[]} confirmations - The owner confirmations (signatures) collected so far. + */ +/** + * Transport for sharing multisig calldata between the owners of a multisig account. + * + * A transport distributes transaction proposals and message proposals (and their + * confirmations) amongst the owners of a multisig account, so that signers running on + * separate machines can coordinate without a shared process. It is chain-agnostic: the + * proposal and message payloads are opaque to the transport and interpreted by each + * chain's multisig package, so the same transport can back account-abstraction, UTXO + * (PSBT) or other multisig wallets. Plug in a custom backend (a hosted service, a + * database, a peer-to-peer channel, etc.) by implementing this interface. + * + * Implementations that serialize the payloads themselves (rather than handing them to an + * SDK that already does it) can pass them through {@link toTransportJson} to convert native + * values such as BigInt into JSON-safe forms before persisting or transmitting them. + * + * @interface + * @template [TProposal=Record] + * @template [TMessage=MultisigTransportMessageInput] + * @template [TProposalResponse=MultisigTransportProposal] + * @template [TMessageResponse=MultisigTransportMessage] + */ +export interface IMultisigTransport, TMessage = MultisigTransportMessageInput, TProposalResponse = MultisigTransportProposal, TMessageResponse = MultisigTransportMessage> { + /** + * Submits a new transaction proposal so the other owners can confirm it. + * + * @param {TProposal} proposal - The signed transaction proposal to share. Opaque to the transport, which must persist it so {@link getProposal} can return it intact. + * @returns {Promise} + */ + submitProposal(proposal: TProposal): Promise; + /** + * Returns a transaction proposal by its identifier. + * + * @param {string} proposalId - The proposal's identifier. + * @returns {Promise} The proposal, or null if it has not been found. + */ + getProposal(proposalId: string): Promise; + /** + * Adds an owner's confirmation (signature) to an existing transaction proposal. + * + * @param {string} proposalId - The proposal's identifier. + * @param {string} signature - The owner's signature over the proposal. + * @returns {Promise} + */ + confirmProposal(proposalId: string, signature: string): Promise; + /** + * Submits a new message proposal so the other owners can confirm it. + * + * @param {string} accountAddress - The multisig account's address. + * @param {TMessage} message - The message proposal to share. + * @returns {Promise} + */ + submitMessage(accountAddress: string, message: TMessage): Promise; + /** + * Returns a message proposal by its hash. + * + * @param {string} messageId - The message's hash. + * @returns {Promise} The message, or null if it has not been found. + */ + getMessage(messageId: string): Promise; + /** + * Adds an owner's confirmation (signature) to an existing message proposal. + * + * @param {string} messageId - The message's hash. + * @param {string} signature - The owner's signature over the message. + * @returns {Promise} + */ + confirmMessage(messageId: string, signature: string): Promise; +} +/** + * A message proposal to share with the other owners for them to confirm. + */ +export type MultisigTransportMessageInput = { + /** + * - The message to sign. + */ + message: string; + /** + * - The submitting owner's signature over the message. + */ + signature: string; +}; +/** + * A shared transaction proposal returned by the transport. The concrete payload is + * chain-specific and opaque to the transport: an implementation persists whatever the + * chain's execution layer produced and returns it intact, alongside the owner + * confirmations collected so far. + */ +export type MultisigTransportProposal = { + /** + * - The owner confirmations (signatures) collected so far. + */ + confirmations: unknown[]; +}; +/** + * A shared message proposal returned by the transport, alongside the owner confirmations + * collected so far. As with proposals, any further fields are chain-specific. + */ +export type MultisigTransportMessage = { + /** + * - The owner confirmations (signatures) collected so far. + */ + confirmations: unknown[]; +}; diff --git a/types/src/multisig/index.d.ts b/types/src/multisig/index.d.ts new file mode 100644 index 0000000..2df091c --- /dev/null +++ b/types/src/multisig/index.d.ts @@ -0,0 +1,15 @@ +export { IWalletAccountReadOnlyMultisig } from "./wallet-account-read-only-multisig.js"; +export { IWalletAccountMultisig } from "./wallet-account-multisig.js"; +export { IMultisigOwnerManagement } from "./i-multisig-owner-management.js"; +export { IMultisigTransport } from "./i-multisig-transport.js"; +export { toTransportJson } from "./transport-serialization.js"; +export type MultisigInfo = import("./wallet-account-read-only-multisig.js").MultisigInfo; +export type MultisigProposal = import("./wallet-account-read-only-multisig.js").MultisigProposal; +export type MultisigMessage = import("./wallet-account-read-only-multisig.js").MultisigMessage; +export type KeyPair = import("../wallet-account.js").KeyPair; +export type MultisigTransactionOptions = import("./wallet-account-multisig.js").MultisigTransactionOptions; +export type MultisigMessageProposal = import("./wallet-account-multisig.js").MultisigMessageProposal; +export type MultisigOptions = import("./i-multisig-owner-management.js").MultisigOptions; +export type MultisigTransportProposal = import("./i-multisig-transport.js").MultisigTransportProposal; +export type MultisigTransportMessage = import("./i-multisig-transport.js").MultisigTransportMessage; +export type MultisigTransportMessageInput = import("./i-multisig-transport.js").MultisigTransportMessageInput; diff --git a/types/src/multisig/transport-serialization.d.ts b/types/src/multisig/transport-serialization.d.ts new file mode 100644 index 0000000..5a8e8e5 --- /dev/null +++ b/types/src/multisig/transport-serialization.d.ts @@ -0,0 +1,13 @@ +/** + * Recursively converts a value into a JSON-safe form so it survives JSON.stringify: every BigInt + * becomes its decimal string and every byte array (Uint8Array, including Buffer) becomes a + * 0x-prefixed lowercase hex string. Arrays and plain objects are converted entry by entry; all + * other values are returned unchanged. + * + * The conversion is one-way: there is no generic inverse, so a consumer that needs the original + * types back restores them per field (it knows which fields are amounts, byte strings, etc.). + * + * @param {unknown} value - The value to convert (object, array, or primitive). + * @returns {unknown} A JSON-safe copy of the value. + */ +export function toTransportJson(value: unknown): unknown; diff --git a/types/src/multisig/wallet-account-multisig.d.ts b/types/src/multisig/wallet-account-multisig.d.ts new file mode 100644 index 0000000..5d865bc --- /dev/null +++ b/types/src/multisig/wallet-account-multisig.d.ts @@ -0,0 +1,108 @@ +/** @interface */ +export interface IWalletAccountMultisig extends IWalletAccountReadOnlyMultisig { + /** + * The derivation path's index of the signer associated with this account. + * + * @type {number} + */ + get index(): number; + /** + * The derivation path of the signer associated with this account (see [BIP-44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki)). + * + * @type {string} + */ + get path(): string; + /** + * The key pair of the signer associated with this account. + * + * @type {KeyPair} + */ + get signerKeyPair(): KeyPair; + /** + * Proposes sending a transaction for the other owners to approve. Does not execute on-chain: + * the returned proposal must be approved up to the threshold and then executed via + * {@link executeProposal}. + * + * @param {Transaction} tx - The transaction. + * @param {MultisigTransactionOptions} [transactionOptions] - The multisig transaction's options. + * @returns {Promise} The created proposal; its `status` is `'executed'` when `autoExecute` ran to completion, otherwise `'pending'`. + * @throws {SignerError} If the signer is not an owner of the multisig account. + */ + propose(tx: Transaction, transactionOptions?: MultisigTransactionOptions): Promise; + /** + * Proposes signing a message. + * + * @param {string} message - The message to sign. + * @returns {Promise} The multisig message proposal. + * @throws {SignerError} If the signer is not an owner of the multisig account. + */ + proposeMessage(message: string): Promise; + /** + * Approves an existing message proposal. + * + * @param {string} messageId - The message's hash. + * @returns {Promise} The multisig message proposal. + * @throws {SignerError} If the signer is not an owner of the multisig account. + * @throws {NoSuchElementError} If no message exists for the given id. + */ + approveMessage(messageId: string): Promise; + /** + * Approves a pending proposal. + * + * @param {string} proposalId - The proposal's id. + * @returns {Promise} The multisig proposal. + * @throws {SignerError} If the signer is not an owner of the multisig account. + * @throws {NoSuchElementError} If no proposal exists for the given id. + */ + approveProposal(proposalId: string): Promise; + /** + * Rejects a pending proposal. + * + * @param {string} proposalId - The proposal's id. + * @returns {Promise} The multisig proposal. + * @throws {NoSuchElementError} If no proposal exists for the given id. + */ + rejectProposal(proposalId: string): Promise; + /** + * Submits an approved proposal for on-chain execution. + * + * @param {string} proposalId - The proposal's id. + * @returns {Promise} The on-chain transaction's result. + * @throws {NoSuchElementError} If no proposal exists for the given id. + * @throws {ValueError} If the proposal has not reached the approval threshold. + */ + executeProposal(proposalId: string): Promise; +} +export type Transaction = import("../wallet-account-read-only.js").Transaction; +export type TransactionResult = import("../wallet-account-read-only.js").TransactionResult; +export type IWalletAccountReadOnlyMultisig = import("./wallet-account-read-only-multisig.js").IWalletAccountReadOnlyMultisig; +export type MultisigProposal = import("./wallet-account-read-only-multisig.js").MultisigProposal; +export type KeyPair = import("../wallet-account.js").KeyPair; +export type MultisigTransactionOptions = { + /** + * - If true, automatically executes the transaction when the approval threshold is met (only takes effect if this signer's approval is the last one required). + */ + autoExecute?: boolean; +}; +export type MultisigMessageProposal = { + /** + * - The message's hash. + */ + messageId: string; + /** + * - The signature of the caller. + */ + signature: string; + /** + * - The current number of confirmations. + */ + confirmations: number; + /** + * - The minimum amount of confirmations to sign the message. + */ + threshold: number; + /** + * - The final combined signature when the threshold is met. + */ + combinedSignature: string | null; +}; diff --git a/types/src/multisig/wallet-account-read-only-multisig.d.ts b/types/src/multisig/wallet-account-read-only-multisig.d.ts new file mode 100644 index 0000000..4f5ff7b --- /dev/null +++ b/types/src/multisig/wallet-account-read-only-multisig.d.ts @@ -0,0 +1,95 @@ +/** @interface */ +export interface IWalletAccountReadOnlyMultisig extends IWalletAccountReadOnlyBase { + /** + * Returns the address of the signer associated with this wallet account. + * + * @returns {Promise} The signer's address, or null if no signer is associated yet. + */ + getSignerAddress(): Promise; + /** + * Returns the multisig wallet account info. + * + * @returns {Promise} The info. + */ + getMultisigInfo(): Promise; + /** + * Returns a list of proposals by their identifiers. + * + * @param {string[]} proposalIds - The list of proposal identifiers. + * @returns {Promise<(MultisigProposal | null)[]>} For each proposal id, the proposal details or + * null if the proposal has not been found. + */ + getProposals(proposalIds: string[]): Promise<(MultisigProposal | null)[]>; + /** + * Returns a list of message proposals by their hashes. + * + * @param {string[]} messageIds - The list of message hashes + * @returns {Promise<(MultisigMessage | null)[]>} For each message hash, the message details or + * null if the message has not been found. + */ + getMessages(messageIds: string[]): Promise<(MultisigMessage | null)[]>; + /** + * Quotes the on-chain cost of executing a pending proposal. + * + * @param {string} proposalId - The proposal's id. + * @returns {Promise>} The execution cost estimate. + * @throws {NoSuchElementError} If no proposal exists for the given id. + */ + quoteExecuteProposal(proposalId: string): Promise>; +} +export type MultisigInfo = { + /** + * - The multisig wallet account address. + */ + address: string; + /** + * - The owners of the multisig wallet account. + */ + owners: string[]; + /** + * - The minimum amount of signatures to execute a transaction. + */ + threshold: number; +}; +export type MultisigProposal = { + /** + * - The proposal's id. + */ + proposalId: string; + /** + * - The current number of confirmations. + */ + confirmations: number; + /** + * - The minimum amount of confirmations to execute the transaction. + */ + threshold: number; + /** + * - The proposal's lifecycle state: `'pending'` while it still awaits confirmations or on-chain execution, `'executed'` once it has been executed on-chain. + */ + status: 'pending' | 'executed'; +}; +export type MultisigMessage = { + /** + * - The message's hash. + */ + messageId: string; + /** + * - The original message. + */ + message: string; + /** + * - The current number of confirmations. + */ + confirmations: number; + /** + * - The minimum amount of confirmations to sign the message. + */ + threshold: number; + /** + * - The final combined signature when the threshold is met. + */ + combinedSignature: string | null; +}; +export type TransactionResult = import("../wallet-account-read-only.js").TransactionResult; +import { IWalletAccountReadOnlyBase } from '../wallet-account-read-only-base.js'; diff --git a/types/src/wallet-account-read-only-base.d.ts b/types/src/wallet-account-read-only-base.d.ts new file mode 100644 index 0000000..5349476 --- /dev/null +++ b/types/src/wallet-account-read-only-base.d.ts @@ -0,0 +1,46 @@ +/** + * The read-only members shared by every wallet account, single-signer or multisig. + * + * This is an internal base interface: it is not exported from the package entry point. + * Consumers use {@link IWalletAccountReadOnly} or {@link IWalletAccountReadOnlyMultisig}, + * which both extend it. + * + * @interface + */ +export interface IWalletAccountReadOnlyBase { + /** + * Returns the account's address. + * + * @returns {Promise} The account's address. + */ + getAddress(): Promise; + /** + * Verifies a message's signature. + * + * @param {string} message - The original message. + * @param {string} signature - The signature to verify. + * @returns {Promise} True if the signature is valid. + * @throws {Error} If the read-only wallet account class is not able to provide an implementation for the method. + */ + verify(message: string, signature: string): Promise; + /** + * Returns the account's native token balance. + * + * @returns {Promise} The native token balance. + */ + getBalance(): Promise; + /** + * Returns the account balance for a specific token. + * + * @param {string} tokenAddress - The smart contract address of the token. + * @returns {Promise} The token balance. + */ + getTokenBalance(tokenAddress: string): Promise; + /** + * Returns a transaction's receipt. + * + * @param {string} hash - The transaction's hash. + * @returns {Promise} The receipt, or null if the transaction has not been included in a block yet. + */ + getTransactionReceipt(hash: string): Promise; +} diff --git a/types/src/wallet-account-read-only.d.ts b/types/src/wallet-account-read-only.d.ts index 4fb0d79..f9557c4 100644 --- a/types/src/wallet-account-read-only.d.ts +++ b/types/src/wallet-account-read-only.d.ts @@ -1,33 +1,5 @@ /** @interface */ -export interface IWalletAccountReadOnly { - /** - * Returns the account's address. - * - * @returns {Promise} The account's address. - */ - getAddress(): Promise; - /** - * Verifies a message's signature. - * - * @param {string} message - The original message. - * @param {string} signature - The signature to verify. - * @returns {Promise} True if the signature is valid. - * @throws {Error} If the read-only wallet account class is not able to provide an implementation for the method. - */ - verify(message: string, signature: string): Promise; - /** - * Returns the account's native token balance. - * - * @returns {Promise} The native token balance. - */ - getBalance(): Promise; - /** - * Returns the account balance for a specific token. - * - * @param {string} tokenAddress - The smart contract address of the token. - * @returns {Promise} The token balance. - */ - getTokenBalance(tokenAddress: string): Promise; +export interface IWalletAccountReadOnly extends IWalletAccountReadOnlyBase { /** * Quotes the costs of a send transaction operation. * @@ -42,13 +14,6 @@ export interface IWalletAccountReadOnly { * @returns {Promise>} The transfer's quotes. */ quoteTransfer(options: TransferOptions): Promise>; - /** - * Returns a transaction's receipt. - * - * @param {string} hash - The transaction's hash. - * @returns {Promise} The receipt, or null if the transaction has not been included in a block yet. - */ - getTransactionReceipt(hash: string): Promise; } /** * @abstract @@ -170,3 +135,4 @@ export type TransferResult = { */ fee: bigint; }; +import { IWalletAccountReadOnlyBase } from './wallet-account-read-only-base.js';