diff --git a/src/multisig/__tests__/configureMultisig.test.ts b/src/multisig/__tests__/configureMultisig.test.ts new file mode 100644 index 0000000..24c69d9 --- /dev/null +++ b/src/multisig/__tests__/configureMultisig.test.ts @@ -0,0 +1,226 @@ +/** + * Unit tests for configureMultisig() + * + * Uses Jest (the test runner already configured in the repo). + * All Horizon network calls are mocked so tests run fully offline. + */ +import { Keypair, Networks } from '@stellar/stellar-sdk'; +import { configureMultisig } from '../builder'; +import { validateMultisigConfig, ValidationError } from '../validation'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Generate a fresh random Stellar keypair for test use. */ +const makeKeypair = () => Keypair.random(); + +/** Build a minimal mock Server object. */ +function makeMockServer(overrides: Partial<{ + loadAccount: jest.Mock; + submitTransaction: jest.Mock; +}> = {}) { + return { + loadAccount: overrides.loadAccount ?? jest.fn(), + submitTransaction: overrides.submitTransaction ?? jest.fn(), + } as any; +} + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- +const master = makeKeypair(); // master key (signs tx) +const signer1 = makeKeypair(); // signer 1 +const signer2 = makeKeypair(); // signer 2 + +const validThresholds = { low: 1, medium: 2, high: 3 }; + +// --------------------------------------------------------------------------- +// 1. Invalid signer public key throws ValidationError +// --------------------------------------------------------------------------- +describe('validateMultisigConfig - invalid signer key', () => { + it('throws ValidationError when a signer publicKey is not a valid Ed25519 key', () => { + expect(() => + validateMultisigConfig({ + accountId: signer1.publicKey(), + signers: [{ publicKey: 'NOT_A_VALID_KEY', weight: 10 }], + thresholds: validThresholds, + masterKey: master.publicKey(), + }) + ).toThrow(ValidationError); + }); + + it('throws ValidationError when masterKey is invalid', () => { + expect(() => + validateMultisigConfig({ + accountId: signer1.publicKey(), + signers: [{ publicKey: signer1.publicKey(), weight: 10 }], + thresholds: validThresholds, + masterKey: 'BAD_MASTER_KEY', + }) + ).toThrow(ValidationError); + }); + + it('error message mentions the invalid key', () => { + try { + validateMultisigConfig({ + accountId: signer1.publicKey(), + signers: [{ publicKey: 'INVALID', weight: 10 }], + thresholds: validThresholds, + masterKey: master.publicKey(), + }); + fail('Expected ValidationError to be thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).message).toContain('INVALID'); + } + }); +}); + +// --------------------------------------------------------------------------- +// 2. Insufficient signer weight throws ValidationError +// --------------------------------------------------------------------------- +describe('validateMultisigConfig - insufficient weight', () => { + it('throws ValidationError when total weight equals high threshold', () => { + expect(() => + validateMultisigConfig({ + accountId: signer1.publicKey(), + signers: [{ publicKey: signer1.publicKey(), weight: 3 }], + thresholds: { low: 1, medium: 2, high: 3 }, + masterKey: master.publicKey(), + }) + ).toThrow(ValidationError); + }); + + it('throws ValidationError when total weight is below high threshold', () => { + expect(() => + validateMultisigConfig({ + accountId: signer1.publicKey(), + signers: [ + { publicKey: signer1.publicKey(), weight: 1 }, + { publicKey: signer2.publicKey(), weight: 1 }, + ], + thresholds: { low: 1, medium: 2, high: 5 }, + masterKey: master.publicKey(), + }) + ).toThrow(ValidationError); + }); + + it('does NOT throw when total weight is strictly greater than high threshold', () => { + expect(() => + validateMultisigConfig({ + accountId: signer1.publicKey(), + signers: [{ publicKey: signer1.publicKey(), weight: 10 }], + thresholds: { low: 1, medium: 2, high: 3 }, + masterKey: master.publicKey(), + }) + ).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Successful configureMultisig returns { accountId, transactionHash } +// --------------------------------------------------------------------------- +describe('configureMultisig - success path', () => { + const FAKE_HASH = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; + + /** Minimal AccountResponse-like object that TransactionBuilder needs. */ + function makeMockAccount(publicKey: string) { + return { + id: publicKey, + accountId: () => publicKey, + sequenceNumber: () => '100', + incrementSequenceNumber: jest.fn(), + sequence: '100', + }; + } + + it('returns accountId and transactionHash on success', async () => { + const mockAccount = makeMockAccount(signer1.publicKey()); + const mockSubmitResult = { hash: FAKE_HASH }; + + const mockServer = makeMockServer({ + loadAccount: jest.fn().mockResolvedValue(mockAccount), + submitTransaction: jest.fn().mockResolvedValue(mockSubmitResult), + }); + + const result = await configureMultisig( + { + accountId: signer1.publicKey(), + signers: [{ publicKey: signer2.publicKey(), weight: 10 }], + thresholds: { low: 1, medium: 2, high: 3 }, + masterKey: master.secret(), + }, + mockServer, + Networks.TESTNET + ); + + expect(result).toEqual({ + accountId: signer1.publicKey(), + transactionHash: FAKE_HASH, + }); + }); + + it('calls server.loadAccount with the correct accountId', async () => { + const mockAccount = makeMockAccount(signer1.publicKey()); + const mockServer = makeMockServer({ + loadAccount: jest.fn().mockResolvedValue(mockAccount), + submitTransaction: jest.fn().mockResolvedValue({ hash: FAKE_HASH }), + }); + + await configureMultisig( + { + accountId: signer1.publicKey(), + signers: [{ publicKey: signer2.publicKey(), weight: 10 }], + thresholds: { low: 1, medium: 2, high: 3 }, + masterKey: master.secret(), + }, + mockServer, + Networks.TESTNET + ); + + expect(mockServer.loadAccount).toHaveBeenCalledWith(signer1.publicKey()); + }); + + it('calls server.submitTransaction once', async () => { + const mockAccount = makeMockAccount(signer1.publicKey()); + const mockServer = makeMockServer({ + loadAccount: jest.fn().mockResolvedValue(mockAccount), + submitTransaction: jest.fn().mockResolvedValue({ hash: FAKE_HASH }), + }); + + await configureMultisig( + { + accountId: signer1.publicKey(), + signers: [{ publicKey: signer2.publicKey(), weight: 10 }], + thresholds: { low: 1, medium: 2, high: 3 }, + masterKey: master.secret(), + }, + mockServer, + Networks.TESTNET + ); + + expect(mockServer.submitTransaction).toHaveBeenCalledTimes(1); + }); + + it('propagates errors thrown by server.loadAccount', async () => { + const networkError = new Error('Network unavailable'); + const mockServer = makeMockServer({ + loadAccount: jest.fn().mockRejectedValue(networkError), + submitTransaction: jest.fn(), + }); + + await expect( + configureMultisig( + { + accountId: signer1.publicKey(), + signers: [{ publicKey: signer2.publicKey(), weight: 10 }], + thresholds: { low: 1, medium: 2, high: 3 }, + masterKey: master.secret(), + }, + mockServer, + Networks.TESTNET + ) + ).rejects.toThrow('Network unavailable'); + }); +}); diff --git a/src/multisig/builder.ts b/src/multisig/builder.ts new file mode 100644 index 0000000..075bfb5 --- /dev/null +++ b/src/multisig/builder.ts @@ -0,0 +1,82 @@ +import { + Keypair, + Networks, + Server, + TransactionBuilder, + Operation, + BASE_FEE, +} from '@stellar/stellar-sdk'; +import { MultisigConfig, validateMultisigConfig } from './validation'; + +export interface MultisigResult { + accountId: string; + transactionHash: string; +} + +/** + * Builds, signs, and submits a SetOptions transaction that configures + * multi-signature on a Stellar account. + * + * A single TransactionBuilder with a single SetOptions operation is used so + * that all signers and thresholds are applied atomically on-chain. + * + * @param config - The multisig configuration (signers, thresholds, masterKey). + * @param server - A horizon Server instance (injectable for testing). + * @param networkPassphrase - Stellar network passphrase (defaults to Testnet). + * @returns {Promise} accountId and the resulting transaction hash. + */ +export async function configureMultisig( + config: MultisigConfig, + server: Server, + networkPassphrase: string = Networks.TESTNET +): Promise { + const { accountId, signers, thresholds, masterKey } = config; + + // --- Step 1: Validate inputs --- + validateMultisigConfig(config); + + // --- Step 2: Load account from network --- + const account = await server.loadAccount(accountId); + + // --- Step 3: Build ONE transaction with SetOptions operations --- + const transactionBuilder = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase, + }); + + // Add thresholds in the first SetOptions operation + transactionBuilder.addOperation( + Operation.setOptions({ + lowThreshold: thresholds.low, + medThreshold: thresholds.medium, + highThreshold: thresholds.high, + }) + ); + + // Add each signer via additional SetOptions calls on the + // same TransactionBuilder (all in one transaction envelope). + for (const signer of signers) { + transactionBuilder.addOperation( + Operation.setOptions({ + signer: { + ed25519PublicKey: signer.publicKey, + weight: signer.weight, + }, + }) + ); + } + + const transaction = transactionBuilder.setTimeout(30).build(); + + // --- Step 4: Sign with master key --- + const masterKeypair = Keypair.fromSecret(masterKey); + transaction.sign(masterKeypair); + + // --- Step 5: Submit transaction --- + const result = await server.submitTransaction(transaction); + + return { + accountId, + transactionHash: result.hash, + }; +} diff --git a/src/multisig/index.ts b/src/multisig/index.ts new file mode 100644 index 0000000..e4310e2 --- /dev/null +++ b/src/multisig/index.ts @@ -0,0 +1,8 @@ +/** + * Barrel export for the multisig module. + * + * Usage: + * import { configureMultisig, ValidationError } from './multisig'; + */ +export { configureMultisig, MultisigResult } from './builder'; +export { validateMultisigConfig, ValidationError, MultisigConfig, SignerConfig, Thresholds } from './validation'; diff --git a/src/multisig/validation.ts b/src/multisig/validation.ts new file mode 100644 index 0000000..366cbee --- /dev/null +++ b/src/multisig/validation.ts @@ -0,0 +1,70 @@ +import { StrKey } from '@stellar/stellar-sdk'; + +export interface SignerConfig { + publicKey: string; + weight: number; +} + +export interface Thresholds { + low: number; + medium: number; + high: number; +} + +export interface MultisigConfig { + accountId: string; + signers: SignerConfig[]; + thresholds: Thresholds; + masterKey: string; +} + +/** + * Custom error class for validation failures in configureMultisig. + */ +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + // Restore prototype chain for instanceof checks (TypeScript / ES5 target) + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * Validates the multisig configuration before building a transaction. + * + * Rules: + * - Every signer.publicKey must be a valid Ed25519 public key. + * - masterKey must be a valid Ed25519 public key (used later as a secret). + * - The sum of all signer weights must be strictly greater than thresholds.high. + * + * @throws {ValidationError} when any rule is violated. + */ +export function validateMultisigConfig(config: MultisigConfig): void { + const { signers, thresholds, masterKey } = config; + + // Validate master key + if (!StrKey.isValidEd25519PublicKey(masterKey)) { + throw new ValidationError( + `Invalid masterKey: "${masterKey}" is not a valid Ed25519 public key.` + ); + } + + // Validate each signer public key + for (const signer of signers) { + if (!StrKey.isValidEd25519PublicKey(signer.publicKey)) { + throw new ValidationError( + `Invalid signer publicKey: "${signer.publicKey}" is not a valid Ed25519 public key.` + ); + } + } + + // Ensure total signer weight exceeds high threshold + const totalWeight = signers.reduce((sum, s) => sum + s.weight, 0); + if (totalWeight <= thresholds.high) { + throw new ValidationError( + `Insufficient signer weight: total weight (${totalWeight}) must be greater than ` + + `the high threshold (${thresholds.high}).` + ); + } +}