Skip to content
Open
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
226 changes: 226 additions & 0 deletions src/multisig/__tests__/configureMultisig.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Check failure on line 26 in src/multisig/__tests__/configureMultisig.test.ts

View workflow job for this annotation

GitHub Actions / Lint & type-check

Unexpected any. Specify a different type
}

// ---------------------------------------------------------------------------
// 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');
});
});
82 changes: 82 additions & 0 deletions src/multisig/builder.ts
Original file line number Diff line number Diff line change
@@ -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<MultisigResult>} accountId and the resulting transaction hash.
*/
export async function configureMultisig(
config: MultisigConfig,
server: Server,
networkPassphrase: string = Networks.TESTNET
): Promise<MultisigResult> {
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,
};
}
8 changes: 8 additions & 0 deletions src/multisig/index.ts
Original file line number Diff line number Diff line change
@@ -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';
70 changes: 70 additions & 0 deletions src/multisig/validation.ts
Original file line number Diff line number Diff line change
@@ -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}).`
);
}
}
Loading