diff --git a/README.md b/README.md index c811c86..1793b1e 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,66 @@ const signature = signStellarTransaction( ); ``` +### Stellar Multisig Stealth Withdrawals + +Use the multisig helpers when a stealth source account is configured with +Stellar native signer weights. The withdrawal transaction uses `accountMerge`, +so all remaining native XLM is sent to the destination and the stealth account +is closed after submission. + +```ts +import { + addStealthMultisigSigner, + buildMultisigStealthWithdraw, + isStealthMultisigReady, +} from '@wraith-protocol/sdk/chains/stellar'; + +const signerPublicKeys = [ + signer1.publicKey(), + signer2.publicKey(), + signer3.publicKey(), + signer4.publicKey(), + signer5.publicKey(), +]; + +const tx = await buildMultisigStealthWithdraw({ + stealthAddress: matched[0].stealthAddress, + destination: treasury.publicKey(), + requiredWeight: 3, + signers: signerPublicKeys, + horizonUrl: 'https://horizon-futurenet.example', + networkPassphrase: process.env.FUTURENET_NETWORK_PASSPHRASE!, + timeout: 900, +}); + +addStealthMultisigSigner(tx, signer1); +addStealthMultisigSigner(tx, signer3); +console.log(isStealthMultisigReady(tx)); // false for this 3-of-5 setup + +addStealthMultisigSigner(tx, signer5); +console.log(isStealthMultisigReady(tx)); // true +``` + +For a 3-of-5 account, configure each of the five signers with weight `1` and +the account high threshold to `3`. Pass the five signer public keys to +`buildMultisigStealthWithdraw`; the helper loads the account from Horizon and +rejects signers that are not actually configured on-chain. + +The futurenet integration test is opt-in because `accountMerge` is destructive: + +```bash +INTEGRATION=1 \ +FUTURENET_HORIZON_URL="..." \ +FUTURENET_NETWORK_PASSPHRASE="..." \ +FUTURENET_STEALTH_ACCOUNT="G..." \ +FUTURENET_DESTINATION="G..." \ +FUTURENET_SIGNER_SECRETS="S...,S...,S..." \ +pnpm exec vitest run test/chains/stellar/multisig.integration.test.ts +``` + +Set `FUTURENET_SUBMIT=1` only when you intentionally want the test to submit +the destructive `accountMerge`. + ### Stellar Incremental Scanning Use ledger or timestamp bounds to scan only new Soroban announcement events. Persist `nextCursor` after a successful run and pass it back on the next scan; the cursor resumes pagination and takes precedence over `fromLedger`. diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index cde7aed..7d5ba57 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -14,6 +14,12 @@ export { checkStealthAddress, scanAnnouncements, scanAnnouncementsStream } from export { deriveStealthPrivateScalar, signStellarTransaction } from './spend'; export { buildStealthPayment, buildStealthAnnouncement } from './builders'; export type { BuildStealthPaymentOptions, BuildAnnouncementOptions } from './builders'; +export { + buildMultisigStealthWithdraw, + addStealthMultisigSigner, + isStealthMultisigReady, +} from './multisig'; +export type { BuildMultisigStealthWithdrawOptions } from './multisig'; export { seedToScalar, hashToScalar, @@ -57,10 +63,7 @@ export type { Announcement, MatchedAnnouncement, } from './types'; -export { - estimateStellarFee, - parseFeeStats, -} from './fee-estimation'; +export { estimateStellarFee, parseFeeStats } from './fee-estimation'; export type { EstimateFeeParams, FeeEstimate, diff --git a/src/chains/stellar/multisig.ts b/src/chains/stellar/multisig.ts new file mode 100644 index 0000000..11ad407 --- /dev/null +++ b/src/chains/stellar/multisig.ts @@ -0,0 +1,217 @@ +import { + Account, + Keypair, + Operation, + Transaction, + TransactionBuilder, + type Horizon, +} from '@stellar/stellar-sdk'; + +interface WeightedSigner { + key: string; + weight: number; +} + +interface AccountConfig { + sequence: string; + thresholds: { + med_threshold?: number; + high_threshold?: number; + }; + signers: WeightedSigner[]; +} + +export interface BuildMultisigStealthWithdrawOptions { + /** Stealth source account to close. */ + stealthAddress: string; + /** Destination that receives the stealth account's remaining native balance. */ + destination: string; + /** Signature weight required before submission. Defaults to the account high threshold. */ + requiredWeight?: number; + /** Signer public keys expected to approve this withdrawal. */ + signers: Array; + /** Network passphrase for the built transaction. */ + networkPassphrase: string; + /** Current stealth account sequence. Optional when account or horizonUrl is supplied. */ + sequence?: string; + /** Prefetched Horizon account record for on-chain validation without a network call. */ + account?: Pick; + /** Horizon URL used to load and validate the stealth account. */ + horizonUrl?: string; + /** Base fee in stroops. Defaults to 100. */ + fee?: string; + /** Transaction timeout in seconds. Defaults to 180. */ + timeout?: number; +} + +interface MultisigState { + requiredWeight: number; + signers: Map; +} + +const txState = new WeakMap(); + +/** + * Builds an unsigned account-merge withdrawal from a Stellar stealth account. + * + * The helper validates the requested signers against the stealth account's + * configured signer weights when `account` or `horizonUrl` is supplied. The + * resulting transaction closes the stealth account and sends its native XLM + * balance to `destination`. + */ +export async function buildMultisigStealthWithdraw( + options: BuildMultisigStealthWithdrawOptions, +): Promise { + const accountConfig = await resolveAccountConfig(options); + const requiredWeight = + options.requiredWeight ?? + accountConfig?.thresholds.high_threshold ?? + accountConfig?.thresholds.med_threshold; + + if (requiredWeight === undefined || requiredWeight <= 0) { + throw new Error('requiredWeight must be supplied or available from the account thresholds'); + } + + const expectedSigners = normalizeRequestedSigners(options.signers, accountConfig); + const availableWeight = [...expectedSigners.values()].reduce((sum, weight) => sum + weight, 0); + + if (availableWeight < requiredWeight) { + throw new Error( + `Requested signers only provide weight ${availableWeight}; required weight is ${requiredWeight}`, + ); + } + + const sequence = options.sequence ?? accountConfig?.sequence; + if (!sequence) { + throw new Error('sequence must be supplied or available from account/horizonUrl'); + } + + const source = new Account(options.stealthAddress, sequence); + const tx = new TransactionBuilder(source, { + fee: options.fee ?? '100', + networkPassphrase: options.networkPassphrase, + }) + .addOperation(Operation.accountMerge({ destination: options.destination })) + .setTimeout(options.timeout ?? 180) + .build(); + + txState.set(tx, { requiredWeight, signers: expectedSigners }); + return tx; +} + +/** + * Appends a signer signature to a multisig stealth withdrawal transaction. + * + * `signerKey` may be a Stellar `Keypair` or a secret seed string. The helper + * verifies that the signer was declared when the transaction was built. + */ +export function addStealthMultisigSigner( + tx: Transaction, + signerKey: Keypair | string, +): Transaction { + const keypair = typeof signerKey === 'string' ? Keypair.fromSecret(signerKey) : signerKey; + const publicKey = keypair.publicKey(); + const state = txState.get(tx); + + if (state && !state.signers.has(publicKey)) { + throw new Error(`Signer ${publicKey} is not authorized for this stealth withdrawal`); + } + + if (hasSignatureFrom(tx, publicKey)) { + return tx; + } + + tx.sign(keypair); + return tx; +} + +/** + * Returns true once appended signer weights meet the withdrawal threshold. + */ +export function isStealthMultisigReady(tx: Transaction): boolean { + const state = txState.get(tx); + if (!state) { + throw new Error( + 'Missing multisig metadata; build the transaction with buildMultisigStealthWithdraw', + ); + } + + let weight = 0; + for (const [publicKey, signerWeight] of state.signers) { + if (hasSignatureFrom(tx, publicKey)) { + weight += signerWeight; + } + } + return weight >= state.requiredWeight; +} + +async function resolveAccountConfig( + options: BuildMultisigStealthWithdrawOptions, +): Promise { + if (options.account) { + return accountRecordToConfig(options.account); + } + + if (!options.horizonUrl) { + return null; + } + + const res = await fetch( + `${options.horizonUrl.replace(/\/$/, '')}/accounts/${options.stealthAddress}`, + ); + if (!res.ok) { + throw new Error(`Horizon account lookup failed: ${res.status} ${res.statusText}`); + } + return accountRecordToConfig((await res.json()) as Horizon.ServerApi.AccountRecord); +} + +function accountRecordToConfig( + account: Pick, +): AccountConfig { + return { + sequence: account.sequence, + thresholds: account.thresholds, + signers: account.signers.map((signer) => ({ + key: signer.key, + weight: signer.weight, + })), + }; +} + +function normalizeRequestedSigners( + requested: Array, + accountConfig: AccountConfig | null, +): Map { + if (requested.length === 0) { + throw new Error('At least one signer is required'); + } + + const accountWeights = new Map( + accountConfig?.signers.map((signer) => [signer.key, signer.weight]) ?? [], + ); + const normalized = new Map(); + + for (const signer of requested) { + const key = typeof signer === 'string' ? signer : signer.key; + const declaredWeight = typeof signer === 'string' ? undefined : signer.weight; + const accountWeight = accountWeights.get(key); + + if (accountConfig && accountWeight === undefined) { + throw new Error(`Signer ${key} is not configured on the stealth account`); + } + + const weight = accountWeight ?? declaredWeight; + if (weight === undefined || weight <= 0) { + throw new Error(`Signer ${key} must have a positive weight`); + } + + normalized.set(key, weight); + } + + return normalized; +} + +function hasSignatureFrom(tx: Transaction, publicKey: string): boolean { + const hint = Keypair.fromPublicKey(publicKey).signatureHint().toString('hex'); + return tx.signatures.some((signature) => signature.hint().toString('hex') === hint); +} diff --git a/test/chains/stellar/multisig.integration.test.ts b/test/chains/stellar/multisig.integration.test.ts new file mode 100644 index 0000000..1a3e871 --- /dev/null +++ b/test/chains/stellar/multisig.integration.test.ts @@ -0,0 +1,75 @@ +/** + * Integration test: build a multisig stealth withdrawal against a real + * futurenet multisig account. + * + * Required: + * INTEGRATION=1 + * FUTURENET_HORIZON_URL= + * FUTURENET_NETWORK_PASSPHRASE= + * FUTURENET_STEALTH_ACCOUNT= + * FUTURENET_DESTINATION= + * FUTURENET_SIGNER_SECRETS= # enough signer weight + * + * Optional: + * FUTURENET_REQUIRED_WEIGHT=3 + * FUTURENET_SUBMIT=1 # destructive: submits accountMerge + */ + +import { describe, expect, it } from 'vitest'; +import { Keypair } from '@stellar/stellar-sdk'; +import { + addStealthMultisigSigner, + buildMultisigStealthWithdraw, + isStealthMultisigReady, +} from '../../../src/chains/stellar/multisig'; + +const SKIP = process.env['INTEGRATION'] !== '1'; + +describe('Integration: Stellar multisig stealth withdraw (futurenet)', { skip: SKIP }, () => { + it('validates account config and reaches threshold with real signers', async () => { + const horizonUrl = requireEnv('FUTURENET_HORIZON_URL'); + const networkPassphrase = requireEnv('FUTURENET_NETWORK_PASSPHRASE'); + const stealthAddress = requireEnv('FUTURENET_STEALTH_ACCOUNT'); + const destination = requireEnv('FUTURENET_DESTINATION'); + const signerSecrets = requireEnv('FUTURENET_SIGNER_SECRETS') + .split(',') + .map((secret) => secret.trim()) + .filter(Boolean); + const signers = signerSecrets.map((secret) => Keypair.fromSecret(secret)); + const requiredWeight = Number(process.env['FUTURENET_REQUIRED_WEIGHT'] ?? '3'); + + const tx = await buildMultisigStealthWithdraw({ + stealthAddress, + destination, + requiredWeight, + signers: signers.map((signer) => signer.publicKey()), + horizonUrl, + networkPassphrase, + timeout: 900, + }); + + for (const signer of signers) { + addStealthMultisigSigner(tx, signer); + if (isStealthMultisigReady(tx)) break; + } + + expect(isStealthMultisigReady(tx)).toBe(true); + + if (process.env['FUTURENET_SUBMIT'] === '1') { + const res = await fetch(`${horizonUrl.replace(/\/$/, '')}/transactions`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ tx: tx.toXDR() }), + }); + expect(res.ok).toBe(true); + } + }, 30_000); +}); + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required for the futurenet multisig integration test`); + } + return value; +} diff --git a/test/chains/stellar/multisig.test.ts b/test/chains/stellar/multisig.test.ts new file mode 100644 index 0000000..bc2ee82 --- /dev/null +++ b/test/chains/stellar/multisig.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { Keypair, Networks, Operation } from '@stellar/stellar-sdk'; +import { + addStealthMultisigSigner, + buildMultisigStealthWithdraw, + isStealthMultisigReady, +} from '../../../src/chains/stellar/multisig'; + +function accountConfig( + stealthAddress: string, + sequence: string, + signers: Array<{ key: string; weight: number }>, +) { + return { + sequence, + thresholds: { + low_threshold: 1, + med_threshold: 2, + high_threshold: 3, + }, + signers: [{ key: stealthAddress, weight: 0, type: 'ed25519_public_key' }, ...signers], + }; +} + +describe('Stellar multisig stealth withdraw', () => { + it('builds an accountMerge withdrawal and waits for enough signer weight', async () => { + const stealth = Keypair.random(); + const destination = Keypair.random(); + const signers = Array.from({ length: 5 }, () => Keypair.random()); + + const tx = await buildMultisigStealthWithdraw({ + stealthAddress: stealth.publicKey(), + destination: destination.publicKey(), + requiredWeight: 3, + signers: signers.map((signer) => signer.publicKey()), + account: accountConfig( + stealth.publicKey(), + '12345', + signers.map((signer) => ({ key: signer.publicKey(), weight: 1 })), + ), + networkPassphrase: Networks.TESTNET, + }); + + expect(tx.source).toBe(stealth.publicKey()); + expect(tx.operations).toHaveLength(1); + expect((tx.operations[0] as Operation.AccountMerge).type).toBe('accountMerge'); + expect((tx.operations[0] as Operation.AccountMerge).destination).toBe(destination.publicKey()); + expect(isStealthMultisigReady(tx)).toBe(false); + + addStealthMultisigSigner(tx, signers[0]); + addStealthMultisigSigner(tx, signers[1]); + expect(isStealthMultisigReady(tx)).toBe(false); + + addStealthMultisigSigner(tx, signers[2]); + expect(isStealthMultisigReady(tx)).toBe(true); + }); + + it('uses on-chain high threshold by default', async () => { + const stealth = Keypair.random(); + const signerA = Keypair.random(); + const signerB = Keypair.random(); + + const tx = await buildMultisigStealthWithdraw({ + stealthAddress: stealth.publicKey(), + destination: Keypair.random().publicKey(), + signers: [signerA.publicKey(), signerB.publicKey()], + account: accountConfig(stealth.publicKey(), '12345', [ + { key: signerA.publicKey(), weight: 2 }, + { key: signerB.publicKey(), weight: 1 }, + ]), + networkPassphrase: Networks.TESTNET, + }); + + addStealthMultisigSigner(tx, signerA); + expect(isStealthMultisigReady(tx)).toBe(false); + addStealthMultisigSigner(tx, signerB.secret()); + expect(isStealthMultisigReady(tx)).toBe(true); + }); + + it('rejects signers that are not configured on the account', async () => { + const stealth = Keypair.random(); + const configured = Keypair.random(); + const unknown = Keypair.random(); + + await expect( + buildMultisigStealthWithdraw({ + stealthAddress: stealth.publicKey(), + destination: Keypair.random().publicKey(), + requiredWeight: 1, + signers: [configured.publicKey(), unknown.publicKey()], + account: accountConfig(stealth.publicKey(), '12345', [ + { key: configured.publicKey(), weight: 1 }, + ]), + networkPassphrase: Networks.TESTNET, + }), + ).rejects.toThrow('is not configured'); + }); + + it('rejects signer sets that cannot meet the required weight', async () => { + const stealth = Keypair.random(); + const signer = Keypair.random(); + + await expect( + buildMultisigStealthWithdraw({ + stealthAddress: stealth.publicKey(), + destination: Keypair.random().publicKey(), + requiredWeight: 3, + signers: [signer.publicKey()], + account: accountConfig(stealth.publicKey(), '12345', [ + { key: signer.publicKey(), weight: 1 }, + ]), + networkPassphrase: Networks.TESTNET, + }), + ).rejects.toThrow('required weight is 3'); + }); + + it('does not append duplicate signatures for the same signer', async () => { + const stealth = Keypair.random(); + const signer = Keypair.random(); + + const tx = await buildMultisigStealthWithdraw({ + stealthAddress: stealth.publicKey(), + destination: Keypair.random().publicKey(), + requiredWeight: 1, + signers: [{ key: signer.publicKey(), weight: 1 }], + sequence: '12345', + networkPassphrase: Networks.TESTNET, + }); + + addStealthMultisigSigner(tx, signer); + addStealthMultisigSigner(tx, signer); + expect(tx.signatures).toHaveLength(1); + expect(isStealthMultisigReady(tx)).toBe(true); + }); +});