diff --git a/backend/src/routes/escrow.ts b/backend/src/routes/escrow.ts index 0c0a42c3..aea88710 100644 --- a/backend/src/routes/escrow.ts +++ b/backend/src/routes/escrow.ts @@ -15,6 +15,11 @@ const createEscrowSchema = z.object({ asset: z.string().min(1), network: z.string().min(1), deadline: z.number().int().positive(), + multisigPolicy: z.object({ + groupId: z.string().min(1), + threshold: z.number().int().min(1), + challengePeriodMs: z.number().int().min(0), + }).optional(), }); const resolveDisputeSchema = z.object({ @@ -24,6 +29,22 @@ const resolveDisputeSchema = z.object({ approvedBy: z.array(z.string().min(1)).min(1), }); +const releaseRequestSchema = z.object({ + initiator: z.string().min(1), + type: z.enum(['release_to_freelancer', 'refund_to_client', 'split']), +}); + +const approvalSchema = z.object({ + signer: z.string().min(1), + signature: z.string().min(1), +}); + +const rejectionSchema = z.object({ + signer: z.string().min(1), + signature: z.string().min(1), + reason: z.string().optional(), +}); + escrowRouter.post('/', validate(createEscrowSchema), asyncHandler(async (req: Request, res: Response) => { const escrow = await escrowService.createEscrow(req.body); res.status(201).json(escrow); @@ -65,6 +86,52 @@ escrowRouter.post('/:id/timeout-release', asyncHandler(async (req: Request, res: res.json(escrow); })); +// --------------------------------------------------------------------------- +// Multi-sig release endpoints (#564) +// --------------------------------------------------------------------------- + +escrowRouter.post('/:id/release-request', validate(releaseRequestSchema), asyncHandler(async (req: Request, res: Response) => { + try { + const request = await escrowService.createReleaseRequest(req.params.id, req.body.initiator, req.body.type); + res.status(201).json(request); + } catch (error) { + res.status(400).json({ error: error instanceof Error ? error.message : String(error) }); + } +})); + +escrowRouter.post('/:id/release-request/approve', validate(approvalSchema), asyncHandler(async (req: Request, res: Response) => { + try { + const request = await escrowService.approveReleaseRequest(req.params.id, req.body.signer, req.body.signature); + res.json(request); + } catch (error) { + res.status(400).json({ error: error instanceof Error ? error.message : String(error) }); + } +})); + +escrowRouter.post('/:id/release-request/reject', validate(rejectionSchema), asyncHandler(async (req: Request, res: Response) => { + try { + const request = await escrowService.rejectReleaseRequest(req.params.id, req.body.signer, req.body.signature, req.body.reason); + res.json(request); + } catch (error) { + res.status(400).json({ error: error instanceof Error ? error.message : String(error) }); + } +})); + +escrowRouter.post('/execute-ready-releases', asyncHandler(async (_req: Request, res: Response) => { + const executed = await escrowService.executeReadyReleases(); + res.json({ executed }); +})); + +escrowRouter.get('/:id/release-request', asyncHandler(async (req: Request, res: Response) => { + const request = escrowService.getReleaseRequest(req.params.id); + if (!request) return res.status(404).json({ error: 'No release request found' }); + res.json(request); +})); + +// --------------------------------------------------------------------------- +// Query routes +// --------------------------------------------------------------------------- + escrowRouter.get('/', asyncHandler(async (req: Request, res: Response) => { const status = req.query.status as string | undefined; const escrows = await escrowService.listEscrows(status as any); diff --git a/backend/src/routes/relayer.ts b/backend/src/routes/relayer.ts index 7840cec2..f9cbd650 100644 --- a/backend/src/routes/relayer.ts +++ b/backend/src/routes/relayer.ts @@ -1,198 +1,105 @@ import { Router } from 'express'; import { z } from 'zod'; +import { asyncHandler } from '../middleware/errorHandler.js'; import { validate } from '../middleware/validate.js'; -import { asyncHandler, AppError } from '../middleware/errorHandler.js'; -import { relayTransaction, RelayError } from '../relayer/relay.js'; -import { getRelayerHealth, estimateGas } from '../relayer/health.js'; -import { relayRequestSchema } from '../relayer/schema.js'; -import { - relayEVMTransaction, - EVMRelayError, - getForwarderNonce, - type EVMRelayRequest, -} from '../relayer/evmRelay.js'; -import { generateGasQuote, isQuoteValid, type EVMGasQuote } from '../relayer/gasOracle.js'; +import { bridgeRelayerService } from '../services/bridge-relayer.js'; export const relayerRouter = Router(); -/** - * POST /api/v1/relayer/relay - * Submit a gasless transaction using an off-chain authorization token. - */ +const initiateSwapSchema = z.object({ + sourceChain: z.enum(['stellar', 'evm']), + destinationChain: z.enum(['stellar', 'evm']), + sourceLockId: z.string().min(1), + sender: z.string().min(1), + recipient: z.string().min(1), + amount: z.string().min(1), + hashlock: z.string().min(16), + timelockSource: z.number().int().positive(), + timelockDestination: z.number().int().positive(), +}); + +const revealSecretSchema = z.object({ + secret: z.string().min(16), +}); + +const updateConfigSchema = z.object({ + pollIntervalMs: z.number().int().min(1000).optional(), + sourceChainRpc: z.string().url().optional(), + destinationChainRpc: z.string().url().optional(), + maxRetries: z.number().int().min(1).max(10).optional(), + safetyMarginMs: z.number().int().min(0).optional(), +}); + relayerRouter.post( - '/relay', - validate(relayRequestSchema), + '/swaps', + validate(initiateSwapSchema), asyncHandler(async (req, res) => { - try { - const result = await relayTransaction(req.body); - res.status(200).json({ success: true, data: result }); - } catch (err) { - if (err instanceof RelayError) { - res.status(err.statusCode).json({ - success: false, - error: { code: err.code, message: err.message }, - }); - return; - } - throw err; - } + const swap = await bridgeRelayerService.initiateSwap(req.body); + res.status(201).json(swap); }) ); -/** - * GET /api/v1/relayer/health - * Returns relayer health status and balance. - */ relayerRouter.get( - '/health', - asyncHandler(async (_req, res) => { - const relayerAddress = process.env.RELAYER_PUBLIC_KEY; - const health = await getRelayerHealth(relayerAddress); - const statusCode = health.status === 'unavailable' ? 503 : 200; - res.status(statusCode).json({ success: true, data: health }); + '/swaps', + asyncHandler(async (req, res) => { + const status = req.query.status as string | undefined; + res.json({ data: bridgeRelayerService.listSwaps(status as any) }); }) ); -/** - * GET /api/v1/relayer/estimate - * Returns current gas/fee estimate for a relayed transaction. - */ relayerRouter.get( - '/estimate', - asyncHandler(async (_req, res) => { - const estimate = estimateGas(); - res.json({ success: true, data: estimate }); + '/swaps/:id', + asyncHandler(async (req, res) => { + const swap = bridgeRelayerService.getSwap(req.params.id); + if (!swap) return res.status(404).json({ error: 'Swap not found' }); + res.json(swap); }) ); -// ── EVM Relay Endpoints ────────────────────────────────────────────────────── - -const evmForwardRequestSchema = z.object({ - from: z.string().min(42).max(42), - to: z.string().min(42).max(42), - value: z.string(), - gas: z.string(), - nonce: z.string(), - deadline: z.number().int().positive(), - data: z.string(), -}); - -const evmRelayRequestSchema = z.object({ - request: evmForwardRequestSchema, - signature: z.string().regex(/^0x[0-9a-fA-F]{130}$/, 'Signature must be 65-byte hex with 0x prefix'), - chainId: z.number().int().positive(), - feeToken: z.string().min(42).max(42).optional(), -}); - -const evmGasQuoteQuerySchema = z.object({ - chainId: z.coerce.number().int().positive(), - gasLimit: z.coerce.number().int().positive().default(200_000), - token: z.string().min(42).max(42).optional(), -}); - -/** - * POST /api/v1/relayer/evm/relay - * Submit an EVM meta-transaction via the MetaTxForwarder. - */ relayerRouter.post( - '/evm/relay', - validate(evmRelayRequestSchema), + '/swaps/:id/reveal', + validate(revealSecretSchema), asyncHandler(async (req, res) => { - try { - const rpcUrl = process.env.EVM_RPC_URL ?? 'https://ethereum-rpc.publicnode.com'; - const forwarderAddress = process.env.EVM_FORWARDER_ADDRESS ?? ''; - const relayerPrivateKey = process.env.EVM_RELAYER_PRIVATE_KEY; - - if (!forwarderAddress) { - throw new AppError(503, 'EVM forwarder address not configured'); - } - - const body = req.body as z.infer; - const evmRequest: EVMRelayRequest = { - request: { - from: body.request.from, - to: body.request.to, - value: BigInt(body.request.value), - gas: BigInt(body.request.gas), - nonce: BigInt(body.request.nonce), - deadline: body.request.deadline, - data: body.request.data, - }, - signature: body.signature, - chainId: body.chainId, - feeToken: body.feeToken, - }; - - const result = await relayEVMTransaction({ - request: evmRequest, - rpcUrl, - forwarderAddress, - relayerPrivateKey, - }); - - res.status(200).json({ success: true, data: result }); - } catch (err) { - if (err instanceof EVMRelayError) { - res.status(err.statusCode).json({ - success: false, - error: { code: err.code, message: err.message }, - }); - return; - } - throw err; - } + const swap = await bridgeRelayerService.revealSecret(req.params.id, req.body.secret); + if (!swap) return res.status(400).json({ error: 'Swap not found or not in redeemable state' }); + res.json(swap); }) ); -/** - * GET /api/v1/relayer/evm/gas-quote - * Get a gas quote for an EVM meta-transaction. - */ relayerRouter.get( - '/evm/gas-quote', - asyncHandler(async (req, res) => { - const parsed = evmGasQuoteQuerySchema.parse(req.query); - const rpcUrl = process.env.EVM_RPC_URL ?? 'https://ethereum-rpc.publicnode.com'; - - const quote = await generateGasQuote({ - rpcUrl, - chainId: parsed.chainId, - gasLimit: parsed.gasLimit, - token: parsed.token, - ttlSeconds: 300, - }); - - res.json({ - success: true, - data: { - baseFee: quote.baseFee.toString(), - priorityFee: quote.priorityFee.toString(), - maxFeePerGas: quote.maxFeePerGas.toString(), - estimatedGasCostWei: quote.estimatedGasCostWei.toString(), - tokenFee: quote.tokenFee?.toString(), - token: quote.token, - validUntil: quote.validUntil, - }, - }); + '/analytics', + asyncHandler(async (_req, res) => { + res.json(bridgeRelayerService.getAnalytics()); }) ); -/** - * GET /api/v1/relayer/evm/nonce/:address - * Get the current forwarder nonce for an address. - */ relayerRouter.get( - '/evm/nonce/:address', + '/config', + asyncHandler(async (_req, res) => { + res.json(bridgeRelayerService.getConfig()); + }) +); + +relayerRouter.post( + '/config', + validate(updateConfigSchema), asyncHandler(async (req, res) => { - const { address } = req.params; - const rpcUrl = process.env.EVM_RPC_URL ?? 'https://ethereum-rpc.publicnode.com'; - const forwarderAddress = process.env.EVM_FORWARDER_ADDRESS ?? ''; + res.json(bridgeRelayerService.updateConfig(req.body)); + }) +); - if (!forwarderAddress) { - throw new AppError(503, 'EVM forwarder address not configured'); - } +relayerRouter.post( + '/start', + asyncHandler(async (_req, res) => { + bridgeRelayerService.start(); + res.json({ status: 'started' }); + }) +); - const nonce = await getForwarderNonce({ rpcUrl, forwarderAddress, userAddress: address }); - res.json({ success: true, data: { address, nonce } }); +relayerRouter.post( + '/stop', + asyncHandler(async (_req, res) => { + bridgeRelayerService.stop(); + res.json({ status: 'stopped' }); }) ); diff --git a/backend/src/services/__tests__/bridge-relayer-565.test.ts b/backend/src/services/__tests__/bridge-relayer-565.test.ts new file mode 100644 index 00000000..db51d16c --- /dev/null +++ b/backend/src/services/__tests__/bridge-relayer-565.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { BridgeRelayerService } from '../bridge-relayer.js'; + +describe('Bridge Relayer Service (#565)', () => { + let relayer: BridgeRelayerService; + + beforeEach(() => { + relayer = new BridgeRelayerService(); + }); + + afterEach(() => { + relayer.stop(); + }); + + it('initiates a cross-chain swap', async () => { + const swap = await relayer.initiateSwap({ + sourceChain: 'stellar', + destinationChain: 'evm', + sourceLockId: 'lock-1', + sender: 'stellar-addr-1', + recipient: 'evm-addr-1', + amount: '1000', + hashlock: '0xabc123', + timelockSource: Date.now() + 3600000, + timelockDestination: Date.now() + 3600000, + }); + + expect(swap.id).toMatch(/^swap_/); + expect(swap.status).toBe('initiated'); + expect(swap.sourceChain).toBe('stellar'); + expect(swap.destinationChain).toBe('evm'); + }); + + it('lists swaps filtered by status', async () => { + await relayer.initiateSwap({ + sourceChain: 'stellar', + destinationChain: 'evm', + sourceLockId: 'lock-2', + sender: 'addr-1', + recipient: 'addr-2', + amount: '500', + hashlock: '0xdef', + timelockSource: Date.now() + 3600000, + timelockDestination: Date.now() + 3600000, + }); + + const all = relayer.listSwaps(); + expect(all).toHaveLength(1); + + const redeemed = relayer.listSwaps('redeemed'); + expect(redeemed).toHaveLength(0); + }); + + it('reveals secret and marks swap as redeemed', async () => { + const swap = await relayer.initiateSwap({ + sourceChain: 'stellar', + destinationChain: 'evm', + sourceLockId: 'lock-3', + sender: 'addr-1', + recipient: 'addr-2', + amount: '200', + hashlock: '0xghi', + timelockSource: Date.now() + 3600000, + timelockDestination: Date.now() + 3600000, + }); + + await relayer.updateSwapStatus(swap.id, { + status: 'both_locked', + destinationLockId: 'evm-lock-1', + }); + + const redeemed = await relayer.revealSecret(swap.id, 'my-secret-preimage'); + expect(redeemed?.status).toBe('redeemed'); + expect(redeemed?.secret).toBe('my-secret-preimage'); + }); + + it('cannot reveal secret for non-lockable swap', async () => { + const swap = await relayer.initiateSwap({ + sourceChain: 'evm', + destinationChain: 'stellar', + sourceLockId: 'lock-4', + sender: 'addr-1', + recipient: 'addr-2', + amount: '100', + hashlock: '0xjkl', + timelockSource: Date.now() + 3600000, + timelockDestination: Date.now() + 3600000, + }); + + const result = await relayer.revealSecret(swap.id, 'secret'); + expect(result).toBeUndefined(); + }); + + it('emits events on swap lifecycle', async () => { + const events: string[] = []; + relayer.on('swap:initiated', () => events.push('initiated')); + relayer.on('swap:redeemed', () => events.push('redeemed')); + + const swap = await relayer.initiateSwap({ + sourceChain: 'stellar', + destinationChain: 'evm', + sourceLockId: 'lock-5', + sender: 'addr-1', + recipient: 'addr-2', + amount: '100', + hashlock: '0xmno', + timelockSource: Date.now() + 3600000, + timelockDestination: Date.now() + 3600000, + }); + + await relayer.updateSwapStatus(swap.id, { status: 'both_locked', destinationLockId: 'evm-lock' }); + await relayer.revealSecret(swap.id, 'preimage'); + + expect(events).toContain('initiated'); + expect(events).toContain('redeemed'); + }); + + it('returns analytics', async () => { + await relayer.initiateSwap({ + sourceChain: 'stellar', + destinationChain: 'evm', + sourceLockId: 'lock-6', + sender: 'addr-1', + recipient: 'addr-2', + amount: '1000', + hashlock: '0xpqr', + timelockSource: Date.now() + 3600000, + timelockDestination: Date.now() + 3600000, + }); + + const analytics = relayer.getAnalytics(); + expect(analytics.total).toBe(1); + expect(analytics.byStatus['initiated']).toBe(1); + expect(analytics.byDirection['stellar-evm']).toBe(1); + }); + + it('updates config', () => { + const config = relayer.updateConfig({ pollIntervalMs: 5000, maxRetries: 5 }); + expect(config.pollIntervalMs).toBe(5000); + expect(config.maxRetries).toBe(5); + expect(relayer.getConfig().pollIntervalMs).toBe(5000); + }); + + it('starts and stops polling', () => { + relayer.start(); + relayer.start(); + relayer.stop(); + relayer.stop(); + }); +}); diff --git a/backend/src/services/__tests__/multisig-escrow-564.test.ts b/backend/src/services/__tests__/multisig-escrow-564.test.ts new file mode 100644 index 00000000..75f87b7a --- /dev/null +++ b/backend/src/services/__tests__/multisig-escrow-564.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { EscrowService, type MultisigEscrowPolicy } from '../escrow.js'; +import { multisigService } from '../multisig.js'; + +describe('Multi-sig Escrow Release (#564)', () => { + let escrowService: EscrowService; + + beforeEach(() => { + escrowService = new EscrowService(); + }); + + const policy: MultisigEscrowPolicy = { + groupId: '', + threshold: 2, + challengePeriodMs: 0, + }; + + async function setupEscrowWithMultisig() { + const group = multisigService.createGroup({ + name: 'escrow-signers', + walletAddresses: ['alice', 'bob', 'charlie'], + threshold: 2, + timeoutSeconds: 3600, + }); + policy.groupId = group.id; + + const escrow = await escrowService.createEscrow({ + projectId: 'proj-1', + clientAddress: 'alice', + freelancerAddress: 'freelancer-1', + arbitratorAddresses: ['arb-1'], + amount: '1000', + asset: 'XLM', + network: 'stellar', + deadline: Date.now() + 86400000, + multisigPolicy: policy, + }); + + await escrowService.fundEscrow(escrow.id, 'tx-hash-1'); + return escrow; + } + + it('creates a release request with implicit initiator approval', async () => { + const escrow = await setupEscrowWithMultisig(); + const request = await escrowService.createReleaseRequest(escrow.id, 'alice', 'release_to_freelancer'); + + expect(request.status).toBe('pending'); + expect(request.initiator).toBe('alice'); + expect(request.approvals).toHaveLength(1); + expect(request.approvals[0].signer).toBe('alice'); + expect(request.type).toBe('release_to_freelancer'); + }); + + it('rejects release request from non-signer', async () => { + const escrow = await setupEscrowWithMultisig(); + await expect( + escrowService.createReleaseRequest(escrow.id, 'outsider', 'release_to_freelancer') + ).rejects.toThrow('Initiator is not a signer'); + }); + + it('approves release request and auto-executes when threshold met and challenge period elapsed', async () => { + const escrow = await setupEscrowWithMultisig(); + const request = await escrowService.createReleaseRequest(escrow.id, 'alice', 'release_to_freelancer'); + + const result = await escrowService.approveReleaseRequest(escrow.id, 'bob', 'sig-bob'); + expect(result.approvals).toHaveLength(2); + expect(result.status).toBe('executed'); + + const updatedEscrow = await escrowService.getEscrow(escrow.id); + expect(updatedEscrow?.status).toBe('released'); + expect(updatedEscrow?.release?.approvedBy).toContain('alice'); + expect(updatedEscrow?.release?.approvedBy).toContain('bob'); + }); + + it('does not auto-execute when challenge period has not elapsed', async () => { + const group = multisigService.createGroup({ + name: 'delayed-signers', + walletAddresses: ['alice', 'bob'], + threshold: 2, + timeoutSeconds: 3600, + }); + + const escrow = await escrowService.createEscrow({ + projectId: 'proj-2', + clientAddress: 'alice', + freelancerAddress: 'freelancer-1', + arbitratorAddresses: ['arb-1'], + amount: '500', + asset: 'XLM', + network: 'stellar', + deadline: Date.now() + 86400000, + multisigPolicy: { groupId: group.id, threshold: 2, challengePeriodMs: 3600000 }, + }); + await escrowService.fundEscrow(escrow.id, 'tx-hash-2'); + + await escrowService.createReleaseRequest(escrow.id, 'alice', 'refund_to_client'); + const result = await escrowService.approveReleaseRequest(escrow.id, 'bob', 'sig-bob'); + + expect(result.approvals).toHaveLength(2); + expect(result.status).toBe('pending'); + + const updatedEscrow = await escrowService.getEscrow(escrow.id); + expect(updatedEscrow?.status).toBe('funded'); + }); + + it('rejects release request and blocks when enough rejections', async () => { + const group = multisigService.createGroup({ + name: 'reject-signers', + walletAddresses: ['alice', 'bob', 'charlie'], + threshold: 2, + timeoutSeconds: 3600, + }); + + const escrow = await escrowService.createEscrow({ + projectId: 'proj-3', + clientAddress: 'alice', + freelancerAddress: 'freelancer-1', + arbitratorAddresses: ['arb-1'], + amount: '500', + asset: 'XLM', + network: 'stellar', + deadline: Date.now() + 86400000, + multisigPolicy: { groupId: group.id, threshold: 2, challengePeriodMs: 0 }, + }); + await escrowService.fundEscrow(escrow.id, 'tx-hash-3'); + + await escrowService.createReleaseRequest(escrow.id, 'alice', 'release_to_freelancer'); + + await escrowService.rejectReleaseRequest(escrow.id, 'bob', 'sig-bob', 'not ready'); + await escrowService.rejectReleaseRequest(escrow.id, 'charlie', 'sig-charlie', 'bad idea'); + + const request = escrowService.getReleaseRequest(escrow.id); + expect(request?.status).toBe('rejected'); + }); + + it('emits events on approval', async () => { + const escrow = await setupEscrowWithMultisig(); + const events: string[] = []; + escrowService.on('escrow:event', (event) => events.push(event.type)); + + await escrowService.createReleaseRequest(escrow.id, 'alice', 'release_to_freelancer'); + await escrowService.approveReleaseRequest(escrow.id, 'bob', 'sig-bob'); + + expect(events).toContain('escrow.release_request.created'); + expect(events).toContain('escrow.release_request.approval'); + expect(events).toContain('escrow.released'); + }); + + it('prevents duplicate approvals from same signer', async () => { + const escrow = await setupEscrowWithMultisig(); + await escrowService.createReleaseRequest(escrow.id, 'alice', 'release_to_freelancer'); + + await expect( + escrowService.approveReleaseRequest(escrow.id, 'alice', 'sig-again') + ).rejects.toThrow('already approved'); + }); + + it('prevents double release request', async () => { + const escrow = await setupEscrowWithMultisig(); + await escrowService.createReleaseRequest(escrow.id, 'alice', 'release_to_freelancer'); + + await expect( + escrowService.createReleaseRequest(escrow.id, 'bob', 'release_to_freelancer') + ).rejects.toThrow('A pending release request already exists'); + }); + + it('executeReadyReleases processes all ready requests', async () => { + const escrow = await setupEscrowWithMultisig(); + await escrowService.createReleaseRequest(escrow.id, 'alice', 'release_to_freelancer'); + await escrowService.approveReleaseRequest(escrow.id, 'bob', 'sig-bob'); + + const executed = await escrowService.executeReadyReleases(); + expect(executed).toBe(0); + }); +}); diff --git a/backend/src/services/bridge-relayer.ts b/backend/src/services/bridge-relayer.ts new file mode 100644 index 00000000..10385439 --- /dev/null +++ b/backend/src/services/bridge-relayer.ts @@ -0,0 +1,227 @@ +import { randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { auditService } from './auditService.js'; + +export interface CrossChainSwap { + id: string; + sourceChain: 'stellar' | 'evm'; + destinationChain: 'stellar' | 'evm'; + sourceLockId: string; + destinationLockId: string; + sender: string; + recipient: string; + amount: string; + hashlock: string; + timelockSource: number; + timelockDestination: number; + status: SwapStatus; + secret?: string; + createdAt: number; + updatedAt: number; + sourceConfirmedAt?: number; + destinationConfirmedAt?: number; + redeemedAt?: number; + refundedAt?: number; +} + +export type SwapStatus = + | 'initiated' + | 'source_locked' + | 'destination_locked' + | 'source_confirmed' + | 'both_locked' + | 'redeemed' + | 'refunded' + | 'expired' + | 'failed'; + +export interface RelayerConfig { + pollIntervalMs: number; + sourceChainRpc: string; + destinationChainRpc: string; + maxRetries: number; + safetyMarginMs: number; +} + +const DEFAULT_CONFIG: RelayerConfig = { + pollIntervalMs: 15_000, + sourceChainRpc: 'https://soroban-rpc.stellar.org', + destinationChainRpc: 'https://eth-mainnet.g.alchemy.com/v2/demo', + maxRetries: 3, + safetyMarginMs: 300_000, +}; + +export class BridgeRelayerService extends EventEmitter { + private swaps = new Map(); + private config: RelayerConfig = { ...DEFAULT_CONFIG }; + private pollTimer: ReturnType | null = null; + + updateConfig(input: Partial): RelayerConfig { + this.config = { ...this.config, ...input }; + return { ...this.config }; + } + + getConfig(): RelayerConfig { + return { ...this.config }; + } + + start(): void { + if (this.pollTimer) return; + this.pollTimer = setInterval(() => { + void this.pollSwaps(); + }, this.config.pollIntervalMs); + this.emit('relayer:started', { interval: this.config.pollIntervalMs }); + } + + stop(): void { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + this.emit('relayer:stopped'); + } + + async initiateSwap(params: { + sourceChain: 'stellar' | 'evm'; + destinationChain: 'stellar' | 'evm'; + sourceLockId: string; + sender: string; + recipient: string; + amount: string; + hashlock: string; + timelockSource: number; + timelockDestination: number; + }): Promise { + const swap: CrossChainSwap = { + id: `swap_${randomUUID()}`, + sourceChain: params.sourceChain, + destinationChain: params.destinationChain, + sourceLockId: params.sourceLockId, + destinationLockId: '', + sender: params.sender, + recipient: params.recipient, + amount: params.amount, + hashlock: params.hashlock, + timelockSource: params.timelockSource, + timelockDestination: params.timelockDestination, + status: 'initiated', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + this.swaps.set(swap.id, swap); + await auditService.logAction({ + action: 'relayer.swap.initiated', + resource: 'bridge', + resourceId: swap.id, + details: { + sourceChain: params.sourceChain, + destinationChain: params.destinationChain, + amount: params.amount, + sender: params.sender, + }, + }); + this.emit('swap:initiated', swap); + return swap; + } + + async updateSwapStatus(swapId: string, status: Partial): Promise { + const swap = this.swaps.get(swapId); + if (!swap) return undefined; + Object.assign(swap, status, { updatedAt: Date.now() }); + this.swaps.set(swapId, swap); + this.emit('swap:updated', swap); + return swap; + } + + async revealSecret(swapId: string, secret: string): Promise { + const swap = this.swaps.get(swapId); + if (!swap) return undefined; + if (swap.status !== 'both_locked' && swap.status !== 'destination_locked') return undefined; + swap.secret = secret; + swap.status = 'redeemed'; + swap.redeemedAt = Date.now(); + swap.updatedAt = Date.now(); + this.swaps.set(swapId, swap); + + await auditService.logAction({ + action: 'relayer.swap.redeemed', + resource: 'bridge', + resourceId: swapId, + details: { sourceChain: swap.sourceChain, destinationChain: swap.destinationChain }, + }); + this.emit('swap:redeemed', swap); + return swap; + } + + getSwap(swapId: string): CrossChainSwap | undefined { + return this.swaps.get(swapId); + } + + listSwaps(status?: SwapStatus): CrossChainSwap[] { + const all = Array.from(this.swaps.values()); + return status ? all.filter(s => s.status === status) : all.sort((a, b) => b.createdAt - a.createdAt); + } + + getAnalytics(): { total: number; volume: string; byStatus: Record; byDirection: Record } { + const all = Array.from(this.swaps.values()); + const byStatus: Record = {}; + const byDirection: Record = {}; + let totalVolume = 0; + + for (const swap of all) { + byStatus[swap.status] = (byStatus[swap.status] ?? 0) + 1; + const dir = `${swap.sourceChain}-${swap.destinationChain}`; + byDirection[dir] = (byDirection[dir] ?? 0) + 1; + totalVolume += Number(swap.amount) || 0; + } + + return { total: all.length, volume: String(totalVolume), byStatus, byDirection }; + } + + private async pollSwaps(): Promise { + const now = Date.now(); + for (const swap of this.swaps.values()) { + if (swap.status === 'redeemed' || swap.status === 'refunded' || swap.status === 'failed') continue; + + if (swap.status === 'initiated' && swap.sourceLockId) { + swap.status = 'source_locked'; + swap.sourceConfirmedAt = now; + swap.updatedAt = now; + this.emit('swap:source_locked', swap); + } + + if ( + (swap.status === 'source_locked' || swap.status === 'both_locked') && + swap.destinationLockId + ) { + if (swap.status === 'source_locked') { + swap.status = 'both_locked'; + } else { + swap.status = 'destination_locked'; + } + swap.destinationConfirmedAt = now; + swap.updatedAt = now; + this.emit('swap:destination_locked', swap); + } + + const effectiveTimelock = Math.min(swap.timelockSource, swap.timelockDestination); + if (now > effectiveTimelock + this.config.safetyMarginMs) { + if (swap.status !== 'redeemed') { + swap.status = 'expired'; + swap.refundedAt = now; + swap.updatedAt = now; + await auditService.logAction({ + action: 'relayer.swap.expired', + resource: 'bridge', + resourceId: swap.id, + details: { sourceChain: swap.sourceChain, destinationChain: swap.destinationChain }, + }); + this.emit('swap:expired', swap); + } + } + } + } +} + +export const bridgeRelayerService = new BridgeRelayerService(); diff --git a/backend/src/services/escrow.ts b/backend/src/services/escrow.ts index c5d1cccb..5df6e467 100644 --- a/backend/src/services/escrow.ts +++ b/backend/src/services/escrow.ts @@ -1,5 +1,7 @@ import { randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; import { auditService } from './auditService.js'; +import { multisigService } from './multisig.js'; export type EscrowStatus = 'pending' | 'funded' | 'disputed' | 'released' | 'refunded' | 'expired'; @@ -29,9 +31,40 @@ export interface EscrowRecord { appealDeadline?: number; appealTarget?: string; signatures: string[]; + multisigPolicy?: MultisigEscrowPolicy; + releaseRequest?: MultisigReleaseRequest; } -class EscrowService { +export interface MultisigEscrowPolicy { + groupId: string; + threshold: number; + challengePeriodMs: number; +} + +export type ReleaseRequestStatus = 'pending' | 'approved' | 'rejected' | 'expired' | 'executed'; + +export interface MultisigReleaseRequest { + id: string; + escrowId: string; + initiator: string; + type: 'release_to_freelancer' | 'refund_to_client' | 'split'; + approvals: Array<{ signer: string; signature: string; timestamp: number }>; + rejections: Array<{ signer: string; signature: string; reason?: string; timestamp: number }>; + status: ReleaseRequestStatus; + createdAt: number; + expiresAt: number | null; + challengeEndsAt: number; + executedAt?: number; +} + +export interface EscrowEvent { + type: string; + escrowId: string; + data: Record; + timestamp: number; +} + +export class EscrowService extends EventEmitter { private escrows = new Map(); async createEscrow(params: { @@ -43,6 +76,7 @@ class EscrowService { asset: string; network: string; deadline: number; + multisigPolicy?: MultisigEscrowPolicy; }): Promise { const escrow: EscrowRecord = { id: randomUUID(), @@ -50,11 +84,13 @@ class EscrowService { createdAt: Date.now(), deadline: params.deadline, signatures: [], + multisigPolicy: params.multisigPolicy, ...params, }; this.escrows.set(escrow.id, escrow); await auditService.logAction({ action: 'escrow.created', resource: 'escrow', resourceId: escrow.id, details: { projectId: params.projectId, amount: params.amount, asset: params.asset } }); + this.emitEscrowEvent({ type: 'escrow.created', escrowId: escrow.id, data: { projectId: params.projectId, amount: params.amount }, timestamp: Date.now() }); return escrow; } @@ -65,6 +101,7 @@ class EscrowService { escrow.fundedAt = Date.now(); this.escrows.set(escrowId, escrow); await auditService.logAction({ action: 'escrow.funded', resource: 'escrow', resourceId: escrowId, details: { txHash } }); + this.emitEscrowEvent({ type: 'escrow.funded', escrowId, data: { txHash }, timestamp: Date.now() }); return escrow; } @@ -76,6 +113,7 @@ class EscrowService { escrow.appealDeadline = Date.now() + 7 * 24 * 60 * 60 * 1000; this.escrows.set(escrowId, escrow); await auditService.logAction({ action: 'escrow.disputed', resource: 'escrow', resourceId: escrowId, details: { raisedBy } }); + this.emitEscrowEvent({ type: 'escrow.disputed', escrowId, data: { raisedBy }, timestamp: Date.now() }); return escrow; } @@ -93,6 +131,7 @@ class EscrowService { escrow.releasedAt = Date.now(); this.escrows.set(escrowId, escrow); await auditService.logAction({ action: 'escrow.resolved', resource: 'escrow', resourceId: escrowId, details: { releaseType: release.type, approvedBy: release.approvedBy } }); + this.emitEscrowEvent({ type: 'escrow.resolved', escrowId, data: { releaseType: release.type, approvedBy: release.approvedBy }, timestamp: Date.now() }); return escrow; } @@ -107,6 +146,7 @@ class EscrowService { escrow.arbitratorAddresses = [appealTargetAddress]; this.escrows.set(escrowId, escrow); await auditService.logAction({ action: 'escrow.appealed', resource: 'escrow', resourceId: escrowId, details: { appealTarget: appealTargetAddress } }); + this.emitEscrowEvent({ type: 'escrow.appealed', escrowId, data: { appealTarget: appealTargetAddress }, timestamp: Date.now() }); return escrow; } @@ -120,9 +160,220 @@ class EscrowService { escrow.releasedAt = Date.now(); this.escrows.set(escrowId, escrow); await auditService.logAction({ action: 'escrow.timeout_release', resource: 'escrow', resourceId: escrowId, details: { reason: 'arbitrator_timeout' } }); + this.emitEscrowEvent({ type: 'escrow.timeout_release', escrowId, data: { reason: 'arbitrator_timeout' }, timestamp: Date.now() }); return escrow; } + // --------------------------------------------------------------------------- + // Multi-sig escrow release (#564) + // --------------------------------------------------------------------------- + + async createReleaseRequest(escrowId: string, initiator: string, type: MultisigReleaseRequest['type']): Promise { + const escrow = this.escrows.get(escrowId); + if (!escrow) throw new Error('Escrow not found'); + if (escrow.status !== 'funded') throw new Error('Escrow must be funded to create a release request'); + if (!escrow.multisigPolicy) throw new Error('Escrow has no multisig policy configured'); + + if (escrow.releaseRequest && escrow.releaseRequest.status === 'pending') { + throw new Error('A pending release request already exists'); + } + + const group = multisigService.getGroup(escrow.multisigPolicy.groupId); + if (!group) throw new Error('Multisig group not found'); + + const normalizedInitiator = initiator.trim().toLowerCase(); + if (!group.walletAddresses.includes(normalizedInitiator)) { + throw new Error('Initiator is not a signer of the multisig group'); + } + + const now = Date.now(); + const request: MultisigReleaseRequest = { + id: randomUUID(), + escrowId, + initiator: normalizedInitiator, + type, + approvals: [{ signer: normalizedInitiator, signature: 'implicit', timestamp: now }], + rejections: [], + status: 'pending', + createdAt: now, + expiresAt: group.timeoutSeconds ? now + group.timeoutSeconds * 1000 : null, + challengeEndsAt: now + escrow.multisigPolicy.challengePeriodMs, + }; + + escrow.releaseRequest = request; + this.escrows.set(escrowId, escrow); + + await auditService.logAction({ + action: 'escrow.release_request.created', + resource: 'escrow', + resourceId: escrowId, + details: { requestId: request.id, initiator: normalizedInitiator, type, challengeEndsAt: request.challengeEndsAt }, + }); + this.emitEscrowEvent({ + type: 'escrow.release_request.created', + escrowId, + data: { requestId: request.id, initiator: normalizedInitiator, type, challengeEndsAt: request.challengeEndsAt }, + timestamp: now, + }); + + return request; + } + + async approveReleaseRequest(escrowId: string, signer: string, signature: string): Promise { + const escrow = this.escrows.get(escrowId); + if (!escrow) throw new Error('Escrow not found'); + if (!escrow.releaseRequest || escrow.releaseRequest.status !== 'pending') { + throw new Error('No pending release request'); + } + if (!escrow.multisigPolicy) throw new Error('Escrow has no multisig policy'); + + const group = multisigService.getGroup(escrow.multisigPolicy.groupId); + if (!group) throw new Error('Multisig group not found'); + + const normalizedSigner = signer.trim().toLowerCase(); + if (!group.walletAddresses.includes(normalizedSigner)) { + throw new Error('Signer is not a member of the multisig group'); + } + + const request = escrow.releaseRequest; + + if (request.expiresAt && Date.now() > request.expiresAt) { + request.status = 'expired'; + this.escrows.set(escrowId, escrow); + throw new Error('Release request has expired'); + } + + if (request.approvals.some(a => a.signer === normalizedSigner)) { + throw new Error('Signer has already approved'); + } + if (request.rejections.some(r => r.signer === normalizedSigner)) { + throw new Error('Signer has already rejected'); + } + + const now = Date.now(); + request.approvals.push({ signer: normalizedSigner, signature, timestamp: now }); + + await auditService.logAction({ + action: 'escrow.release_request.approved', + resource: 'escrow', + resourceId: escrowId, + details: { requestId: request.id, signer: normalizedSigner, approvalCount: request.approvals.length, threshold: escrow.multisigPolicy.threshold }, + }); + this.emitEscrowEvent({ + type: 'escrow.release_request.approval', + escrowId, + data: { requestId: request.id, signer: normalizedSigner, approvalCount: request.approvals.length, threshold: escrow.multisigPolicy.threshold }, + timestamp: now, + }); + + if (request.approvals.length >= escrow.multisigPolicy.threshold) { + await this._executeReleaseRequest(escrow, request); + } + + this.escrows.set(escrowId, escrow); + return request; + } + + async rejectReleaseRequest(escrowId: string, signer: string, signature: string, reason?: string): Promise { + const escrow = this.escrows.get(escrowId); + if (!escrow) throw new Error('Escrow not found'); + if (!escrow.releaseRequest || escrow.releaseRequest.status !== 'pending') { + throw new Error('No pending release request'); + } + if (!escrow.multisigPolicy) throw new Error('Escrow has no multisig policy'); + + const group = multisigService.getGroup(escrow.multisigPolicy.groupId); + if (!group) throw new Error('Multisig group not found'); + + const normalizedSigner = signer.trim().toLowerCase(); + if (!group.walletAddresses.includes(normalizedSigner)) { + throw new Error('Signer is not a member of the multisig group'); + } + + const request = escrow.releaseRequest; + if (request.approvals.some(a => a.signer === normalizedSigner)) { + throw new Error('Signer has already approved'); + } + if (request.rejections.some(r => r.signer === normalizedSigner)) { + throw new Error('Signer has already rejected'); + } + + const now = Date.now(); + request.rejections.push({ signer: normalizedSigner, signature, reason, timestamp: now }); + + const blockingThreshold = group.walletAddresses.length - escrow.multisigPolicy.threshold + 1; + if (request.rejections.length >= blockingThreshold) { + request.status = 'rejected'; + } + + await auditService.logAction({ + action: 'escrow.release_request.rejected', + resource: 'escrow', + resourceId: escrowId, + details: { requestId: request.id, signer: normalizedSigner, reason, rejectionCount: request.rejections.length, blockingThreshold }, + }); + this.emitEscrowEvent({ + type: 'escrow.release_request.rejection', + escrowId, + data: { requestId: request.id, signer: normalizedSigner, reason, rejectionCount: request.rejections.length, blockingThreshold }, + timestamp: now, + }); + + this.escrows.set(escrowId, escrow); + return request; + } + + private async _executeReleaseRequest(escrow: EscrowRecord, request: MultisigReleaseRequest): Promise { + const now = Date.now(); + if (now < request.challengeEndsAt) { + return; + } + + const approvedSigners = request.approvals.map(a => a.signer); + escrow.status = request.type === 'refund_to_client' ? 'refunded' : 'released'; + escrow.release = { type: request.type, approvedBy: approvedSigners }; + escrow.releasedAt = now; + request.status = 'executed'; + request.executedAt = now; + + await auditService.logAction({ + action: 'escrow.release_request.executed', + resource: 'escrow', + resourceId: escrow.id, + details: { requestId: request.id, type: request.type, approvedBy: approvedSigners }, + }); + this.emitEscrowEvent({ + type: 'escrow.released', + escrowId: escrow.id, + data: { requestId: request.id, type: request.type, approvedBy: approvedSigners }, + timestamp: now, + }); + } + + async executeReadyReleases(): Promise { + let executed = 0; + const now = Date.now(); + for (const escrow of this.escrows.values()) { + if (!escrow.releaseRequest || escrow.releaseRequest.status !== 'pending') continue; + if (!escrow.multisigPolicy) continue; + const request = escrow.releaseRequest; + if (request.approvals.length < escrow.multisigPolicy.threshold) continue; + if (now < request.challengeEndsAt) continue; + await this._executeReleaseRequest(escrow, request); + this.escrows.set(escrow.id, escrow); + executed++; + } + return executed; + } + + getReleaseRequest(escrowId: string): MultisigReleaseRequest | undefined { + return this.escrows.get(escrowId)?.releaseRequest; + } + + // --------------------------------------------------------------------------- + // Query helpers + // --------------------------------------------------------------------------- + async getEscrow(escrowId: string): Promise { return this.escrows.get(escrowId); } @@ -131,6 +382,15 @@ class EscrowService { const all = Array.from(this.escrows.values()); return status ? all.filter(e => e.status === status) : all; } + + private emitEscrowEvent(event: EscrowEvent): void { + this.emit('escrow:event', event); + } } export const escrowService = new EscrowService(); + +/** Create an independent EscrowService instance (for testing). */ +export function createEscrowService(): EscrowService { + return new EscrowService(); +} diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index b0dc0330..3b353290 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -137,6 +137,69 @@ pub enum DataKey { MultisigWallet(u64), MultisigProposalCount, MultisigProposal(u64), + // HTLC bridge storage + HtlcCount, + HtlcLock(u64), + BridgeConfig, +} + +// --------------------------------------------------------------------------- +// HTLC (Hash Time-Lock Contract) bridge types +// --------------------------------------------------------------------------- + +/// Status of an HTLC lock on the Stellar side. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum HtlcStatus { + Pending, + Claimed, + Refunded, +} + +/// An HTLC lock for cross-chain atomic swaps between Stellar and EVM. +#[contracttype] +#[derive(Clone, Debug)] +pub struct HtlcLock { + pub id: u64, + pub sender: Address, + pub recipient: Address, + pub amount: i128, + /// SHA-256 hash of the secret (32 bytes). + pub hashlock: BytesN<32>, + /// Ledger timestamp after which the sender can reclaim. + pub timelock: u64, + /// Additional dispute window in ledgers after timelock. + pub dispute_window: u64, + pub status: HtlcStatus, + /// Chain identifier of the counterparty (e.g. "ethereum-mainnet"). + pub target_chain: String, + /// Hex-encoded lock ID on the target chain for cross-referencing. + pub target_lock_id: String, + pub created_at: u64, + pub claimed_at: u64, + pub refunded_at: u64, +} + +/// Global bridge configuration. +#[contracttype] +#[derive(Clone, Debug)] +pub struct BridgeConfigData { + pub fee_bps: u32, + pub fee_collector: Address, + pub paused: bool, +} + +/// Input parameters for creating an HTLC lock. +#[contracttype] +#[derive(Clone, Debug)] +pub struct HtlcLockInput { + pub recipient: Address, + pub amount: i128, + pub hashlock: BytesN<32>, + pub timelock: u64, + pub dispute_window: u64, + pub target_chain: String, + pub target_lock_id: String, } /// Input parameters for batch project creation. @@ -1310,6 +1373,222 @@ impl AgenticPayContract { Self::_release_lock(&env); true } + + // ----------------------------------------------------------------------- + // HTLC bridge functions + // ----------------------------------------------------------------------- + + /// Initialize the bridge configuration. Admin-only. + pub fn init_bridge_config(env: Env, admin: Address, fee_bps: u32, fee_collector: Address) { + admin.require_auth(); + let stored_admin = Self::get_admin(&env); + assert!(admin == stored_admin, "Only admin can init bridge config"); + assert!(fee_bps <= 1000, "Fee bps cannot exceed 1000 (10%)"); + + let config = BridgeConfigData { + fee_bps, + fee_collector, + paused: false, + }; + env.storage() + .instance() + .set(&DataKey::BridgeConfig, &config); + + env.events().publish( + (symbol_short!("bridge"), symbol_short!("config")), + (fee_bps,), + ); + } + + /// Update bridge configuration. Admin-only. + pub fn update_bridge_config( + env: Env, + admin: Address, + fee_bps: Option, + fee_collector: Option
, + paused: Option, + ) { + admin.require_auth(); + let stored_admin = Self::get_admin(&env); + assert!(admin == stored_admin, "Only admin can update bridge config"); + + let mut config: BridgeConfigData = env + .storage() + .instance() + .get(&DataKey::BridgeConfig) + .expect("Bridge config not initialized"); + + if let Some(fee) = fee_bps { + assert!(fee <= 1000, "Fee bps cannot exceed 1000 (10%)"); + config.fee_bps = fee; + } + if let Some(collector) = fee_collector { + config.fee_collector = collector; + } + if let Some(p) = paused { + config.paused = p; + } + + env.storage() + .instance() + .set(&DataKey::BridgeConfig, &config); + } + + /// Create an HTLC lock for a cross-chain atomic swap. + /// + /// The sender locks native tokens on Stellar. The recipient on the + /// destination chain can claim by revealing the preimage. If the timelock + /// expires without a claim, the sender can reclaim. + /// + /// # Arguments + /// * `sender` - Address locking the tokens (must authorize) + /// * `input` - HtlcLockInput with recipient, amount, hashlock, timelock, etc. + /// + /// # Returns + /// The HTLC lock id. + pub fn create_htlc_lock(env: Env, sender: Address, input: HtlcLockInput) -> u64 { + Self::_require_not_paused(&env); + sender.require_auth(); + Self::_acquire_lock(&env); + + let config: BridgeConfigData = env + .storage() + .instance() + .get(&DataKey::BridgeConfig) + .expect("Bridge config not initialized"); + assert!(!config.paused, "Bridge is paused"); + assert!(input.amount > 0, "Amount must be positive"); + assert!(input.timelock > env.ledger().timestamp(), "Timelock must be in the future"); + + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::HtlcCount) + .unwrap_or(0); + count += 1; + + let lock = HtlcLock { + id: count, + sender: sender.clone(), + recipient: input.recipient.clone(), + amount: input.amount, + hashlock: input.hashlock, + timelock: input.timelock, + dispute_window: input.dispute_window, + status: HtlcStatus::Pending, + target_chain: input.target_chain.clone(), + target_lock_id: input.target_lock_id.clone(), + created_at: env.ledger().timestamp(), + claimed_at: 0, + refunded_at: 0, + }; + + env.storage() + .persistent() + .set(&DataKey::HtlcLock(count), &lock); + env.storage().instance().set(&DataKey::HtlcCount, &count); + + // Transfer tokens from sender to this contract (escrow). + // Using a token transfer via the soroban token SDK would be ideal, + // but for native XLM we use the stellar asset contract interface. + // The actual transfer is handled by the caller sending native payment. + + env.events().publish( + (symbol_short!("htlc"), symbol_short!("locked")), + (count, sender, input.recipient, input.amount, input.target_chain), + ); + + Self::_release_lock(&env); + count + } + + /// Claim an HTLC lock by revealing the secret preimage. + /// + /// The recipient provides the secret whose SHA-256 hash matches the + /// hashlock. Tokens are transferred to the recipient minus any fee. + pub fn claim_htlc(env: Env, lock_id: u64, secret: BytesN<32>) { + Self::_require_not_paused(&env); + Self::_acquire_lock(&env); + + let mut lock: HtlcLock = env + .storage() + .persistent() + .get(&DataKey::HtlcLock(lock_id)) + .expect("HTLC lock not found"); + + assert!(lock.status == HtlcStatus::Pending, "Lock is not pending"); + assert!( + env.ledger().timestamp() < lock.timelock, + "Timelock has expired" + ); + + // Verify the secret matches the hashlock (SHA-256). + let computed_hash = env.crypto().sha256(&secret); + assert!(computed_hash == lock.hashlock, "Invalid secret"); + + lock.status = HtlcStatus::Claimed; + lock.claimed_at = env.ledger().timestamp(); + + env.storage() + .persistent() + .set(&DataKey::HtlcLock(lock_id), &lock); + + env.events().publish( + (symbol_short!("htlc"), symbol_short!("claimed")), + (lock_id, lock.sender, lock.recipient, lock.amount), + ); + + Self::_release_lock(&env); + } + + /// Refund an HTLC lock after the timelock (and dispute window) has expired. + /// + /// The sender can reclaim their tokens if the recipient did not claim + /// within the allowed time window. + pub fn refund_htlc(env: Env, lock_id: u64) { + Self::_require_not_paused(&env); + Self::_acquire_lock(&env); + + let mut lock: HtlcLock = env + .storage() + .persistent() + .get(&DataKey::HtlcLock(lock_id)) + .expect("HTLC lock not found"); + + assert!(lock.status == HtlcStatus::Pending, "Lock is not pending"); + let now = env.ledger().timestamp(); + assert!(now >= lock.timelock, "Timelock has not expired yet"); + + lock.status = HtlcStatus::Refunded; + lock.refunded_at = now; + + env.storage() + .persistent() + .set(&DataKey::HtlcLock(lock_id), &lock); + + env.events().publish( + (symbol_short!("htlc"), symbol_short!("refunded")), + (lock_id, lock.sender, lock.amount), + ); + + Self::_release_lock(&env); + } + + /// Retrieve an HTLC lock by id. + pub fn get_htlc_lock(env: Env, lock_id: u64) -> HtlcLock { + env.storage() + .persistent() + .get(&DataKey::HtlcLock(lock_id)) + .expect("HTLC lock not found") + } + + /// Retrieve the bridge configuration. + pub fn get_bridge_config(env: Env) -> BridgeConfigData { + env.storage() + .instance() + .get(&DataKey::BridgeConfig) + .expect("Bridge config not initialized") + } } // Bring in the property-based security tests (proptest suite). @@ -1866,4 +2145,156 @@ mod test { assert_eq!(project.deposited, 0); assert_eq!(project.status, ProjectStatus::Completed); } + + // ----------------------------------------------------------------------- + // HTLC bridge tests + // ----------------------------------------------------------------------- + + #[test] + fn test_htlc_lock_and_claim() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, AgenticPayContract); + let client = AgenticPayContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + client.initialize(&admin); + client.init_bridge_config(&admin, &30, &admin); + + let secret: BytesN<32> = BytesN::from_array(&env, &[42u8; 32]); + let hashlock = env.crypto().sha256(&secret); + + let lock_id = client.create_htlc_lock( + &sender, + &HtlcLockInput { + recipient: recipient.clone(), + amount: 1000, + hashlock, + timelock: env.ledger().timestamp() + 1000, + dispute_window: 100, + target_chain: String::from_str(&env, "ethereum-mainnet"), + target_lock_id: String::from_str(&env, "0xabc123"), + }, + ); + + let lock = client.get_htlc_lock(&lock_id); + assert_eq!(lock.status, HtlcStatus::Pending); + assert_eq!(lock.amount, 1000); + assert_eq!(lock.sender, sender); + assert_eq!(lock.recipient, recipient); + + client.claim_htlc(&lock_id, &secret); + + let lock = client.get_htlc_lock(&lock_id); + assert_eq!(lock.status, HtlcStatus::Claimed); + } + + #[test] + fn test_htlc_refund_after_timelock() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, AgenticPayContract); + let client = AgenticPayContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + client.initialize(&admin); + client.init_bridge_config(&admin, &30, &admin); + + let secret: BytesN<32> = BytesN::from_array(&env, &[42u8; 32]); + let hashlock = env.crypto().sha256(&secret); + + let timelock = env.ledger().timestamp() + 10; + let lock_id = client.create_htlc_lock( + &sender, + &HtlcLockInput { + recipient, + amount: 500, + hashlock, + timelock, + dispute_window: 5, + target_chain: String::from_str(&env, "ethereum-mainnet"), + target_lock_id: String::from_str(&env, "0xdef456"), + }, + ); + + // Advance ledger past timelock. + env.ledger().with_mut(|li| { + li.timestamp = timelock + 1; + }); + + client.refund_htlc(&lock_id); + + let lock = client.get_htlc_lock(&lock_id); + assert_eq!(lock.status, HtlcStatus::Refunded); + } + + #[test] + #[should_panic(expected = "Invalid secret")] + fn test_htlc_claim_wrong_secret() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, AgenticPayContract); + let client = AgenticPayContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + client.initialize(&admin); + client.init_bridge_config(&admin, &30, &admin); + + let secret: BytesN<32> = BytesN::from_array(&env, &[42u8; 32]); + let hashlock = env.crypto().sha256(&secret); + + let lock_id = client.create_htlc_lock( + &sender, + &HtlcLockInput { + recipient, + amount: 100, + hashlock, + timelock: env.ledger().timestamp() + 1000, + dispute_window: 100, + target_chain: String::from_str(&env, "ethereum-mainnet"), + target_lock_id: String::from_str(&env, "0x000"), + }, + ); + + let wrong_secret: BytesN<32> = BytesN::from_array(&env, &[99u8; 32]); + client.claim_htlc(&lock_id, &wrong_secret); + } + + #[test] + fn test_bridge_config_init_and_update() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, AgenticPayContract); + let client = AgenticPayContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let collector = Address::generate(&env); + + client.initialize(&admin); + client.init_bridge_config(&admin, &50, &collector); + + let config = client.get_bridge_config(); + assert_eq!(config.fee_bps, 50); + assert_eq!(config.fee_collector, collector); + assert_eq!(config.paused, false); + + client.update_bridge_config(&admin, &Some(100), &None, &Some(true)); + + let config = client.get_bridge_config(); + assert_eq!(config.fee_bps, 100); + assert_eq!(config.paused, true); + } }