diff --git a/src/index.ts b/src/index.ts index 083567c..b800f4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,5 +50,5 @@ export { anchorTrustHash, verifyEventHash, } from './escrow'; -export { buildMultisigTransaction } from './transactions'; +export { buildMultisigTransaction, buildSetOptionsOp } from './transactions'; export { getMinimumReserve, generateKeypair } from './accounts'; diff --git a/src/transactions/builder.ts b/src/transactions/builder.ts new file mode 100644 index 0000000..98b3505 --- /dev/null +++ b/src/transactions/builder.ts @@ -0,0 +1,77 @@ +import { Operation, xdr } from '@stellar/stellar-sdk'; + +import { ValidationError } from '../utils/errors'; +import { isValidPublicKey } from '../utils/validation'; + +export interface SetOptionsSignerInput { + publicKey: string; + weight: number; +} + +export interface SetOptionsThresholdsInput { + low: number; + medium: number; + high: number; +} + +export interface BuildSetOptionsOpParams { + signers?: SetOptionsSignerInput[]; + thresholds?: SetOptionsThresholdsInput; + masterWeight?: number; +} + +function validateUint8Field(field: string, value: number): void { + if (!Number.isInteger(value) || value < 0 || value > 255) { + throw new ValidationError(field, 'Must be an integer between 0 and 255'); + } +} + +export function buildSetOptionsOp(params: BuildSetOptionsOpParams): xdr.Operation[] { + const operations: xdr.Operation[] = []; + + if (params.signers) { + params.signers.forEach((signer, index) => { + if (!isValidPublicKey(signer.publicKey)) { + throw new ValidationError(`signers[${index}].publicKey`, 'Invalid public key'); + } + + validateUint8Field(`signers[${index}].weight`, signer.weight); + + operations.push( + Operation.setOptions({ + signer: { + ed25519PublicKey: signer.publicKey, + weight: signer.weight, + }, + }), + ); + }); + } + + if (params.masterWeight !== undefined) { + validateUint8Field('masterWeight', params.masterWeight); + } + + if (params.thresholds) { + validateUint8Field('thresholds.low', params.thresholds.low); + validateUint8Field('thresholds.medium', params.thresholds.medium); + validateUint8Field('thresholds.high', params.thresholds.high); + } + + if (params.masterWeight !== undefined || params.thresholds) { + operations.push( + Operation.setOptions({ + ...(params.masterWeight !== undefined ? { masterWeight: params.masterWeight } : {}), + ...(params.thresholds + ? { + lowThreshold: params.thresholds.low, + medThreshold: params.thresholds.medium, + highThreshold: params.thresholds.high, + } + : {}), + }), + ); + } + + return operations; +} \ No newline at end of file diff --git a/src/transactions/index.ts b/src/transactions/index.ts index 3a7c3cb..095d3c8 100644 --- a/src/transactions/index.ts +++ b/src/transactions/index.ts @@ -1,6 +1,7 @@ import { HorizonSubmitError } from '../utils/errors'; import { TESTNET_HORIZON_URL } from '../utils/constants'; +export { buildSetOptionsOp } from './builder'; /** * Internal: Fetch a single transaction status from Horizon by hash. diff --git a/tests/unit/transactions/index.test.ts b/tests/unit/transactions/index.test.ts index e387720..faf251a 100644 --- a/tests/unit/transactions/index.test.ts +++ b/tests/unit/transactions/index.test.ts @@ -1,6 +1,8 @@ -import { buildMultisigTransaction, fetchTransactionOnce } from '../../../src/transactions'; -import { HorizonSubmitError } from '../../../src/utils/errors'; +import { Keypair, Operation } from '@stellar/stellar-sdk'; + +import { buildMultisigTransaction, buildSetOptionsOp, fetchTransactionOnce } from '../../../src/transactions'; +import { HorizonSubmitError, ValidationError } from '../../../src/utils/errors'; describe('transactions module placeholders', () => { it('exports callable placeholder function', () => { @@ -8,6 +10,89 @@ describe('transactions module placeholders', () => { }); }); +describe('buildSetOptionsOp', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('builds setOptions operation for adding a signer', () => { + const signerPublicKey = Keypair.random().publicKey(); + const setOptionsSpy = jest.spyOn(Operation, 'setOptions'); + + const operations = buildSetOptionsOp({ + signers: [{ publicKey: signerPublicKey, weight: 1 }], + }); + + expect(operations).toHaveLength(1); + expect(setOptionsSpy).toHaveBeenCalledWith({ + signer: { ed25519PublicKey: signerPublicKey, weight: 1 }, + }); + }); + + it('builds setOptions operation for removing a signer with weight 0', () => { + const signerPublicKey = Keypair.random().publicKey(); + const setOptionsSpy = jest.spyOn(Operation, 'setOptions'); + + const operations = buildSetOptionsOp({ + signers: [{ publicKey: signerPublicKey, weight: 0 }], + }); + + expect(operations).toHaveLength(1); + expect(setOptionsSpy).toHaveBeenCalledWith({ + signer: { ed25519PublicKey: signerPublicKey, weight: 0 }, + }); + }); + + it('builds setOptions operation for thresholds', () => { + const setOptionsSpy = jest.spyOn(Operation, 'setOptions'); + + const operations = buildSetOptionsOp({ + thresholds: { low: 1, medium: 2, high: 3 }, + }); + + expect(operations).toHaveLength(1); + expect(setOptionsSpy).toHaveBeenCalledWith({ + lowThreshold: 1, + medThreshold: 2, + highThreshold: 3, + }); + }); + + it('builds mixed setOptions operations for signers and thresholds', () => { + const signerPublicKey = Keypair.random().publicKey(); + const setOptionsSpy = jest.spyOn(Operation, 'setOptions'); + + const operations = buildSetOptionsOp({ + signers: [{ publicKey: signerPublicKey, weight: 2 }], + thresholds: { low: 1, medium: 2, high: 2 }, + masterWeight: 0, + }); + + expect(operations).toHaveLength(2); + expect(setOptionsSpy).toHaveBeenNthCalledWith(1, { + signer: { ed25519PublicKey: signerPublicKey, weight: 2 }, + }); + expect(setOptionsSpy).toHaveBeenNthCalledWith(2, { + masterWeight: 0, + lowThreshold: 1, + medThreshold: 2, + highThreshold: 2, + }); + }); + + it('throws ValidationError when a signer public key is invalid', () => { + const setOptionsSpy = jest.spyOn(Operation, 'setOptions'); + + expect(() => + buildSetOptionsOp({ + signers: [{ publicKey: 'INVALID_PUBLIC_KEY', weight: 1 }], + }), + ).toThrow(ValidationError); + + expect(setOptionsSpy).not.toHaveBeenCalled(); + }); +}); + describe('fetchTransactionOnce', () => { const hash = 'abc123'; const baseUrl = 'https://horizon-testnet.stellar.org/transactions/';