From 2f8ea11393e4292e4e48d5fa5151021d67f08d0b Mon Sep 17 00:00:00 2001 From: mercyJJ Date: Wed, 30 Sep 2026 06:55:23 +0000 Subject: [PATCH] feat: add SDK multi-signature transaction support (#975) --- src/multiSignature.ts | 228 ++++++++++++++++++++++++++++++++++++ test/multiSignature.test.ts | 159 +++++++++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 src/multiSignature.ts create mode 100644 test/multiSignature.test.ts diff --git a/src/multiSignature.ts b/src/multiSignature.ts new file mode 100644 index 0000000..8f04970 --- /dev/null +++ b/src/multiSignature.ts @@ -0,0 +1,228 @@ +/** + * Multi-Signature Transaction Support for StellarSplit SDK. + * + * Provides a coordinator for collecting signatures from multiple signers + * before a Stellar transaction can be submitted. Supports threshold-based + * authorization and per-signer weights. + */ + +export interface SignerInfo { + /** Stellar G... public key */ + publicKey: string; + /** Signing weight (default: 1) */ + weight: number; +} + +export interface MultiSigConfig { + /** Ordered list of authorized signers and their weights */ + signers: SignerInfo[]; + /** Combined weight required to authorize the transaction */ + threshold: number; +} + +export type MultiSigStatus = + | 'pending' // Awaiting signatures + | 'authorized' // Threshold reached + | 'expired' // Collection window passed + | 'rejected'; // Explicitly rejected + +export interface SignatureEntry { + publicKey: string; + signature: string; + timestamp: number; + weight: number; +} + +export interface MultiSigSession { + id: string; + txHash: string; + txXdr: string; + config: MultiSigConfig; + signatures: SignatureEntry[]; + status: MultiSigStatus; + createdAt: number; + expiresAt?: number; +} + +export interface MultiSigSessionOptions { + /** Expiry unix timestamp (ms). No expiry if omitted. */ + expiresAt?: number; +} + +export type MultiSigEventType = + | 'signature:added' + | 'threshold:reached' + | 'session:expired' + | 'session:rejected'; + +export interface MultiSigEvent { + type: MultiSigEventType; + sessionId: string; + timestamp: number; + detail?: Record; +} + +export type MultiSigEventHandler = (event: MultiSigEvent) => void; + +/** + * Compute the total accumulated signing weight for a session. + */ +export function computeAccumulatedWeight(session: MultiSigSession): number { + return session.signatures.reduce((sum, sig) => sum + sig.weight, 0); +} + +/** + * Check whether a session has reached its signing threshold. + */ +export function isThresholdReached(session: MultiSigSession): boolean { + return computeAccumulatedWeight(session) >= session.config.threshold; +} + +/** + * MultiSigCoordinator collects signatures for Stellar transactions + * requiring multiple signers, enforcing a configurable weight threshold. + */ +export class MultiSigCoordinator { + private readonly sessions = new Map(); + private readonly eventHandlers = new Set(); + private sessionCounter = 0; + + /** + * Create a new multi-sig session for a transaction. + */ + createSession( + txHash: string, + txXdr: string, + config: MultiSigConfig, + options: MultiSigSessionOptions = {} + ): MultiSigSession { + if (config.signers.length === 0) { + throw new Error('Multi-sig config must include at least one signer'); + } + if (config.threshold <= 0) { + throw new Error('Threshold must be positive'); + } + const totalWeight = config.signers.reduce((s, signer) => s + signer.weight, 0); + if (totalWeight < config.threshold) { + throw new Error( + `Total signer weight (${totalWeight}) cannot satisfy threshold (${config.threshold})` + ); + } + + const id = `multisig-${++this.sessionCounter}`; + const session: MultiSigSession = { + id, + txHash, + txXdr, + config, + signatures: [], + status: 'pending', + createdAt: Date.now(), + expiresAt: options.expiresAt, + }; + this.sessions.set(id, session); + return session; + } + + /** + * Get a session by ID. + */ + getSession(sessionId: string): MultiSigSession | undefined { + return this.sessions.get(sessionId); + } + + /** + * List all session IDs. + */ + listSessionIds(): string[] { + return Array.from(this.sessions.keys()); + } + + /** + * Subscribe to multi-sig events. Returns an unsubscribe function. + */ + onEvent(handler: MultiSigEventHandler): () => void { + this.eventHandlers.add(handler); + return () => this.eventHandlers.delete(handler); + } + + private emit(event: MultiSigEvent): void { + for (const handler of this.eventHandlers) { + try { handler(event); } catch { /* ignore */ } + } + } + + /** + * Add a signature to the session. + * - The signer must be in the authorized signers list. + * - Each signer may only sign once per session. + * - The session must be in 'pending' status. + * Returns true if the threshold was reached after this signature. + */ + addSignature(sessionId: string, publicKey: string, signature: string): boolean { + const session = this.sessions.get(sessionId); + if (!session) throw new Error(`Session '${sessionId}' not found`); + + if (session.status !== 'pending') { + throw new Error(`Session '${sessionId}' is not pending (status: ${session.status})`); + } + + // Check expiry + if (session.expiresAt !== undefined && Date.now() > session.expiresAt) { + session.status = 'expired'; + this.emit({ type: 'session:expired', sessionId, timestamp: Date.now() }); + throw new Error(`Session '${sessionId}' has expired`); + } + + // Find authorized signer + const signerInfo = session.config.signers.find((s) => s.publicKey === publicKey); + if (!signerInfo) { + throw new Error(`Signer '${publicKey}' is not authorized for session '${sessionId}'`); + } + + // Prevent duplicate signatures + const alreadySigned = session.signatures.some((s) => s.publicKey === publicKey); + if (alreadySigned) { + throw new Error(`Signer '${publicKey}' has already signed session '${sessionId}'`); + } + + session.signatures.push({ + publicKey, + signature, + timestamp: Date.now(), + weight: signerInfo.weight, + }); + + this.emit({ type: 'signature:added', sessionId, timestamp: Date.now(), detail: { publicKey, weight: signerInfo.weight } }); + + if (isThresholdReached(session)) { + session.status = 'authorized'; + this.emit({ type: 'threshold:reached', sessionId, timestamp: Date.now(), detail: { totalWeight: computeAccumulatedWeight(session) } }); + return true; + } + return false; + } + + /** + * Reject a pending session. + */ + rejectSession(sessionId: string, reason?: string): void { + const session = this.sessions.get(sessionId); + if (!session) throw new Error(`Session '${sessionId}' not found`); + if (session.status !== 'pending') { + throw new Error(`Session '${sessionId}' is not pending`); + } + session.status = 'rejected'; + this.emit({ type: 'session:rejected', sessionId, timestamp: Date.now(), detail: { reason } }); + } + + /** + * Returns how much more weight is needed to reach threshold. + */ + remainingWeightNeeded(sessionId: string): number { + const session = this.sessions.get(sessionId); + if (!session) throw new Error(`Session '${sessionId}' not found`); + const accumulated = computeAccumulatedWeight(session); + return Math.max(0, session.config.threshold - accumulated); + } +} diff --git a/test/multiSignature.test.ts b/test/multiSignature.test.ts new file mode 100644 index 0000000..e6db7a4 --- /dev/null +++ b/test/multiSignature.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + MultiSigCoordinator, + computeAccumulatedWeight, + isThresholdReached, + type MultiSigConfig, + type MultiSigSession, +} from '../src/multiSignature'; + +const cfg: MultiSigConfig = { + signers: [ + { publicKey: 'GA1111', weight: 1 }, + { publicKey: 'GA2222', weight: 1 }, + { publicKey: 'GA3333', weight: 2 }, + ], + threshold: 3, +}; + +const makeCoordinator = () => new MultiSigCoordinator(); + +describe('computeAccumulatedWeight / isThresholdReached', () => { + const session: MultiSigSession = { + id: 's1', + txHash: 'hash1', + txXdr: 'xdr1', + config: cfg, + signatures: [], + status: 'pending', + createdAt: Date.now(), + }; + + it('returns 0 for no signatures', () => { + expect(computeAccumulatedWeight(session)).toBe(0); + }); + + it('threshold not reached with insufficient weight', () => { + const s = { ...session, signatures: [{ publicKey: 'GA1111', signature: 'sig1', timestamp: Date.now(), weight: 1 }] }; + expect(isThresholdReached(s)).toBe(false); + }); + + it('threshold reached at exact weight', () => { + const s = { + ...session, + signatures: [ + { publicKey: 'GA1111', signature: 'sig1', timestamp: Date.now(), weight: 1 }, + { publicKey: 'GA3333', signature: 'sig3', timestamp: Date.now(), weight: 2 }, + ], + }; + expect(isThresholdReached(s)).toBe(true); + }); +}); + +describe('MultiSigCoordinator', () => { + it('creates a session with correct initial state', () => { + const coord = makeCoordinator(); + const session = coord.createSession('hash1', 'xdr1', cfg); + expect(session.status).toBe('pending'); + expect(session.signatures).toHaveLength(0); + expect(session.txHash).toBe('hash1'); + }); + + it('throws when config has no signers', () => { + const coord = makeCoordinator(); + expect(() => coord.createSession('h', 'x', { signers: [], threshold: 1 })).toThrow(); + }); + + it('throws when total weight cannot meet threshold', () => { + const coord = makeCoordinator(); + expect(() => + coord.createSession('h', 'x', { signers: [{ publicKey: 'GA1', weight: 1 }], threshold: 5 }) + ).toThrow(); + }); + + it('addSignature accepts valid signer', () => { + const coord = makeCoordinator(); + const session = coord.createSession('hash1', 'xdr1', cfg); + coord.addSignature(session.id, 'GA1111', 'sig1'); + expect(coord.getSession(session.id)?.signatures).toHaveLength(1); + }); + + it('addSignature returns true when threshold reached', () => { + const coord = makeCoordinator(); + const session = coord.createSession('hash1', 'xdr1', cfg); + coord.addSignature(session.id, 'GA1111', 'sig1'); + const reached = coord.addSignature(session.id, 'GA3333', 'sig3'); + expect(reached).toBe(true); + expect(coord.getSession(session.id)?.status).toBe('authorized'); + }); + + it('addSignature throws for unauthorized signer', () => { + const coord = makeCoordinator(); + const session = coord.createSession('hash1', 'xdr1', cfg); + expect(() => coord.addSignature(session.id, 'GUNKNOWN', 'sig')).toThrow('not authorized'); + }); + + it('addSignature throws on duplicate signature', () => { + const coord = makeCoordinator(); + const session = coord.createSession('hash1', 'xdr1', cfg); + coord.addSignature(session.id, 'GA1111', 'sig1'); + expect(() => coord.addSignature(session.id, 'GA1111', 'sig1')).toThrow('already signed'); + }); + + it('addSignature throws when session is not pending', () => { + const coord = makeCoordinator(); + const session = coord.createSession('hash1', 'xdr1', cfg); + coord.rejectSession(session.id); + expect(() => coord.addSignature(session.id, 'GA1111', 'sig1')).toThrow('not pending'); + }); + + it('rejectSession marks session rejected', () => { + const coord = makeCoordinator(); + const session = coord.createSession('hash1', 'xdr1', cfg); + coord.rejectSession(session.id, 'policy violation'); + expect(coord.getSession(session.id)?.status).toBe('rejected'); + }); + + it('remainingWeightNeeded decrements correctly', () => { + const coord = makeCoordinator(); + const session = coord.createSession('hash1', 'xdr1', cfg); + expect(coord.remainingWeightNeeded(session.id)).toBe(3); + coord.addSignature(session.id, 'GA1111', 'sig1'); + expect(coord.remainingWeightNeeded(session.id)).toBe(2); + }); + + it('emits signature:added and threshold:reached events', () => { + const coord = makeCoordinator(); + const session = coord.createSession('hash1', 'xdr1', cfg); + const events: string[] = []; + coord.onEvent((e) => events.push(e.type)); + coord.addSignature(session.id, 'GA1111', 'sig1'); + coord.addSignature(session.id, 'GA3333', 'sig3'); + expect(events).toContain('signature:added'); + expect(events).toContain('threshold:reached'); + }); + + it('onEvent unsubscribe stops events', () => { + const coord = makeCoordinator(); + const session = coord.createSession('hash1', 'xdr1', cfg); + const events: string[] = []; + const unsub = coord.onEvent((e) => events.push(e.type)); + unsub(); + coord.addSignature(session.id, 'GA1111', 'sig1'); + expect(events).toHaveLength(0); + }); + + it('expired session transitions to expired on addSignature', () => { + const coord = makeCoordinator(); + const session = coord.createSession('hash1', 'xdr1', cfg, { expiresAt: Date.now() - 1000 }); + expect(() => coord.addSignature(session.id, 'GA1111', 'sig1')).toThrow('expired'); + expect(coord.getSession(session.id)?.status).toBe('expired'); + }); + + it('listSessionIds returns all created sessions', () => { + const coord = makeCoordinator(); + coord.createSession('h1', 'x1', cfg); + coord.createSession('h2', 'x2', cfg); + expect(coord.listSessionIds()).toHaveLength(2); + }); +});