Skip to content
Merged
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
67 changes: 67 additions & 0 deletions backend/src/routes/escrow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
235 changes: 71 additions & 164 deletions backend/src/routes/relayer.ts
Original file line number Diff line number Diff line change
@@ -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<typeof evmRelayRequestSchema>;
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' });
})
);
Loading
Loading