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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
11 changes: 7 additions & 4 deletions src/chains/stellar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
217 changes: 217 additions & 0 deletions src/chains/stellar/multisig.ts
Original file line number Diff line number Diff line change
@@ -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<string | WeightedSigner>;
/** 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.ServerApi.AccountRecord, 'sequence' | 'thresholds' | 'signers'>;
/** 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<string, number>;
}

const txState = new WeakMap<Transaction, MultisigState>();

/**
* 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<Transaction> {
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<AccountConfig | null> {
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<Horizon.ServerApi.AccountRecord, 'sequence' | 'thresholds' | 'signers'>,
): AccountConfig {
return {
sequence: account.sequence,
thresholds: account.thresholds,
signers: account.signers.map((signer) => ({
key: signer.key,
weight: signer.weight,
})),
};
}

function normalizeRequestedSigners(
requested: Array<string | WeightedSigner>,
accountConfig: AccountConfig | null,
): Map<string, number> {
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<string, number>();

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);
}
75 changes: 75 additions & 0 deletions test/chains/stellar/multisig.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Integration test: build a multisig stealth withdrawal against a real
* futurenet multisig account.
*
* Required:
* INTEGRATION=1
* FUTURENET_HORIZON_URL=<current futurenet Horizon URL>
* FUTURENET_NETWORK_PASSPHRASE=<current futurenet network passphrase>
* FUTURENET_STEALTH_ACCOUNT=<G... multisig account to withdraw from>
* FUTURENET_DESTINATION=<G... account receiving accountMerge funds>
* FUTURENET_SIGNER_SECRETS=<S...,S...,S...> # 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;
}
Loading