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
228 changes: 228 additions & 0 deletions src/multiSignature.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

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<string, MultiSigSession>();
private readonly eventHandlers = new Set<MultiSigEventHandler>();
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);
}
}
159 changes: 159 additions & 0 deletions test/multiSignature.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});