From 555f9b9010d314b2d1a7b543d3fa23f03df1fb66 Mon Sep 17 00:00:00 2001 From: MyouzzZ Date: Thu, 25 Jun 2026 01:16:27 +0800 Subject: [PATCH] feat: add escrow release milestone wrapper --- src/contract/build.ts | 11 ++++++- src/escrow/index.ts | 1 + src/escrow/release.ts | 33 ++++++++++++++++++- src/types.ts | 7 ++++ tests/escrow.test.ts | 76 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/contract/build.ts b/src/contract/build.ts index c290340..dc8f76d 100644 --- a/src/contract/build.ts +++ b/src/contract/build.ts @@ -1,5 +1,5 @@ import { Address, nativeToScVal } from '@stellar/stellar-sdk'; -import type { CreateEscrowParams } from '../types'; +import type { CreateEscrowParams, ReleaseMilestoneParams } from '../types'; export function buildCreateEscrowArgs(params: CreateEscrowParams): unknown[] { return [ @@ -14,6 +14,15 @@ export function buildReleaseArgs(escrowId: string, caller: string): unknown[] { return [nativeToScVal(escrowId, { type: 'string' }), new Address(caller).toScVal()]; } +export function buildReleaseMilestoneArgs(params: ReleaseMilestoneParams): unknown[] { + return [ + nativeToScVal(params.escrowId, { type: 'string' }), + nativeToScVal(params.milestoneId, { type: 'u32' }), + nativeToScVal(params.amountStroops, { type: 'i128' }), + new Address(params.caller).toScVal(), + ]; +} + export function buildDisputeArgs(escrowId: string, reason: string): unknown[] { return [nativeToScVal(escrowId, { type: 'string' }), nativeToScVal(reason, { type: 'string' })]; } diff --git a/src/escrow/index.ts b/src/escrow/index.ts index 1eaaa63..43482c8 100644 --- a/src/escrow/index.ts +++ b/src/escrow/index.ts @@ -3,3 +3,4 @@ export { EscrowBuilder } from './builder'; export { EscrowMonitor } from './monitor'; export { DisputeClient } from './dispute'; export { MultiSigEscrowClient } from './multisig'; +export { releaseEscrow, releaseMilestone } from './release'; diff --git a/src/escrow/release.ts b/src/escrow/release.ts index b00c8fc..7bef5ea 100644 --- a/src/escrow/release.ts +++ b/src/escrow/release.ts @@ -1,11 +1,13 @@ import type { TrustFlowClient } from '../client'; -import type { ReleaseEscrowParams } from '../types/index'; +import type { ReleaseEscrowParams, ReleaseMilestoneParams } from '../types'; import { TrustFlowError } from '../errors'; +import { buildReleaseMilestoneArgs } from '../contract/build'; export async function releaseEscrow( client: TrustFlowClient, params: ReleaseEscrowParams, ): Promise { + void client; if (!params.escrowId) { throw TrustFlowError.validation('escrowId', 'Required'); } @@ -16,3 +18,32 @@ export async function releaseEscrow( // Returns transaction hash return `tx_release_${params.escrowId}_${Date.now()}`; } + +/** + * Builds the contract arguments for approving and releasing a single escrow + * milestone payment. + * + * The wrapper targets the Soroban `release_milestone` entrypoint with the + * argument order `(escrow_id, milestone_id, amount_stroops, caller)`. + */ +export async function releaseMilestone( + client: TrustFlowClient, + params: ReleaseMilestoneParams, +): Promise { + void client; + if (!params.escrowId) { + throw TrustFlowError.validation('escrowId', 'Required'); + } + if (!Number.isInteger(params.milestoneId) || params.milestoneId < 0) { + throw TrustFlowError.validation('milestoneId', 'Must be a non-negative integer'); + } + if (params.amountStroops <= 0n) { + throw TrustFlowError.validation('amountStroops', 'Must be greater than zero'); + } + if (!params.caller) { + throw TrustFlowError.unauthorized('release milestone'); + } + + buildReleaseMilestoneArgs(params); + return `tx_release_milestone_${params.escrowId}_${params.milestoneId}_${Date.now()}`; +} diff --git a/src/types.ts b/src/types.ts index ce713ba..e9af9ef 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,6 +40,13 @@ export interface ReleaseEscrowParams { caller: string; } +export interface ReleaseMilestoneParams { + escrowId: string; + milestoneId: number; + amountStroops: bigint; + caller: string; +} + export interface DisputeEscrowParams { escrowId: string; caller: string; diff --git a/tests/escrow.test.ts b/tests/escrow.test.ts index bae4317..f3f032c 100644 --- a/tests/escrow.test.ts +++ b/tests/escrow.test.ts @@ -1,4 +1,8 @@ import { EscrowBuilder } from '../src/escrow/builder'; +import { nativeToScVal, Address, Keypair } from '@stellar/stellar-sdk'; +import { buildReleaseMilestoneArgs } from '../src/contract/build'; +import { releaseMilestone } from '../src/escrow/release'; +import { TrustFlowClient } from '../src/client'; describe('EscrowBuilder', () => { const ADDR_A = 'G' + 'A'.repeat(55); @@ -19,3 +23,75 @@ describe('EscrowBuilder', () => { expect(p.deadlineBlocks).toBe(1000); }); }); + +describe('releaseMilestone', () => { + const CALLER = Keypair.random().publicKey(); + const client = new TrustFlowClient({ + contractId: 'C' + 'D'.repeat(55), + network: 'TESTNET', + }); + + it('builds release_milestone contract args in the expected XDR order', () => { + const args = buildReleaseMilestoneArgs({ + escrowId: 'esc-42', + milestoneId: 3, + amountStroops: 25_000_000n, + caller: CALLER, + }); + + const expected = [ + nativeToScVal('esc-42', { type: 'string' }), + nativeToScVal(3, { type: 'u32' }), + nativeToScVal(25_000_000n, { type: 'i128' }), + new Address(CALLER).toScVal(), + ]; + + expect(args.map((arg) => (arg as { toXDR: (format: 'base64') => string }).toXDR('base64'))).toEqual( + expected.map((arg) => arg.toXDR('base64')), + ); + }); + + it('returns a milestone release transaction placeholder for valid input', async () => { + jest.spyOn(Date, 'now').mockReturnValue(123); + + await expect( + releaseMilestone(client, { + escrowId: 'esc-42', + milestoneId: 3, + amountStroops: 25_000_000n, + caller: CALLER, + }), + ).resolves.toBe('tx_release_milestone_esc-42_3_123'); + + jest.restoreAllMocks(); + }); + + it('rejects invalid milestone release params', async () => { + await expect( + releaseMilestone(client, { + escrowId: '', + milestoneId: 0, + amountStroops: 1n, + caller: CALLER, + }), + ).rejects.toThrow('escrowId'); + + await expect( + releaseMilestone(client, { + escrowId: 'esc-42', + milestoneId: -1, + amountStroops: 1n, + caller: CALLER, + }), + ).rejects.toThrow('milestoneId'); + + await expect( + releaseMilestone(client, { + escrowId: 'esc-42', + milestoneId: 1, + amountStroops: 0n, + caller: CALLER, + }), + ).rejects.toThrow('amountStroops'); + }); +});