From 43c6c96a365c01b29938476e0b3624201b58df99 Mon Sep 17 00:00:00 2001 From: nasirudeenbadirudeen87-cloud <318769136+nasirudeenbadirudeen87-cloud@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:56:16 +0100 Subject: [PATCH 1/3] feat: add nonce reservation and signed transaction relay --- src/app.ts | 2 + src/modules/relay/relay.routes.ts | 45 ++++++++++++ src/modules/relay/relay.service.ts | 111 +++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 src/modules/relay/relay.routes.ts create mode 100644 src/modules/relay/relay.service.ts diff --git a/src/app.ts b/src/app.ts index 07d6f8e5..a4e9fc6b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -31,6 +31,7 @@ import { bodyParseErrorMiddleware } from './middlewares/body-parse-error.middlew import { routeBodySizeLimit } from './middlewares/body-size-limit.middleware'; import { envConfig } from './config'; import { logger } from './utils/logger.utils'; +import relayRouter from './modules/relay/relay.routes'; const app: Express = express(); @@ -118,6 +119,7 @@ app.use('/traders', routeBodySizeLimit('default'), tradersRouter); app.use('/keys', routeBodySizeLimit('default'), keysRouter); app.use('/swaps', routeBodySizeLimit('default'), swapsRouter); app.use('/events', routeBodySizeLimit('default'), eventsRouter); +app.use('/relay', routeBodySizeLimit('default'), relayRouter); // Catches body-parse errors (including entity.too.large from the per-group // JSON parsers mounted inside router) — must come after the router since diff --git a/src/modules/relay/relay.routes.ts b/src/modules/relay/relay.routes.ts new file mode 100644 index 00000000..dc220938 --- /dev/null +++ b/src/modules/relay/relay.routes.ts @@ -0,0 +1,45 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { isValidStellarAddress } from '../../modules/wallet/wallet.utils'; +import { reserveNonce, relaySignedTransaction } from './relay.service'; + +const relayRouter = Router(); +const submitSchema = z.object({ + wallet: z.string().trim().min(1), + nonce: z.string().regex(/^\d+$/), + signature: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/), + tx_params: z.string().min(1), +}); + +relayRouter.get('/nonce/:wallet', async (req, res) => { + const wallet = req.params.wallet; + if (!isValidStellarAddress(wallet)) { + res.status(400).json({ error: 'A valid Stellar wallet is required' }); + return; + } + try { + res.json({ wallet: wallet.toUpperCase(), nonce: await reserveNonce(wallet) }); + } catch (error) { + const failure = error as Error & { statusCode?: number }; + res.status(failure.statusCode ?? 502).json({ error: failure.message }); + } +}); + +relayRouter.post('/submit', async (req, res) => { + const parsed = submitSchema.safeParse(req.body); + if (!parsed.success || !isValidStellarAddress(parsed.data.wallet)) { + res.status(400).json({ error: 'Invalid relay payload' }); + return; + } + try { + res.json(await relaySignedTransaction(parsed.data)); + } catch (error) { + const failure = error as Error & { statusCode?: number; details?: unknown }; + res.status(failure.statusCode ?? 502).json({ + error: failure.message, + ...(failure.details ? { details: failure.details } : {}), + }); + } +}); + +export default relayRouter; diff --git a/src/modules/relay/relay.service.ts b/src/modules/relay/relay.service.ts new file mode 100644 index 00000000..f148210b --- /dev/null +++ b/src/modules/relay/relay.service.ts @@ -0,0 +1,111 @@ +import { randomUUID } from 'crypto'; +import { Keypair, Networks, TransactionBuilder } from '@stellar/stellar-base'; +import { envConfig } from '../../config'; +import { horizonGet } from '../../clients/horizon.client'; +import { sendTransaction } from '../../clients/soroban.client'; +import { getRedis } from '../../utils/redis.utils'; + +const RESERVATION_TTL_SECONDS = 5 * 60; +const RATE_LIMIT_WINDOW_MS = 60_000; +const MAX_RELAY_REQUESTS = 10; + +function reservationKey(wallet: string): string { + return `relay:nonce:${wallet}`; +} + +function networkPassphrase(): string { + return envConfig.STELLAR_NETWORK === 'mainnet' + ? Networks.PUBLIC + : Networks.TESTNET; +} + +export async function reserveNonce(walletAddress: string): Promise { + const wallet = walletAddress.trim().toUpperCase(); + const redis = getRedis(); + if (!redis) throw Object.assign(new Error('Nonce service is unavailable'), { statusCode: 503 }); + + const key = reservationKey(wallet); + const reserved = await redis.get(key); + if (reserved) return reserved; + + const response = await horizonGet( + `${envConfig.STELLAR_HORIZON_URL.replace(/\/$/, '')}/accounts/${encodeURIComponent(wallet)}` + ); + if (response.status === 404) { + throw Object.assign(new Error('Wallet does not exist on the Stellar network'), { statusCode: 404 }); + } + if (!response.ok) throw Object.assign(new Error('Unable to read wallet sequence'), { statusCode: 502 }); + + const account = (await response.json()) as { sequence?: string }; + if (!account.sequence || !/^\d+$/.test(account.sequence)) { + throw Object.assign(new Error('Wallet sequence is unavailable'), { statusCode: 502 }); + } + const nonce = (BigInt(account.sequence) + 1n).toString(); + const created = await redis.set(key, nonce, 'EX', RESERVATION_TTL_SECONDS, 'NX'); + if (created === 'OK') return nonce; + + const concurrentReservation = await redis.get(key); + if (concurrentReservation) return concurrentReservation; + throw Object.assign(new Error('Could not reserve a nonce; retry the request'), { statusCode: 409 }); +} + +async function enforceWalletRateLimit(wallet: string): Promise { + const redis = getRedis(); + if (!redis) throw Object.assign(new Error('Relay is unavailable'), { statusCode: 503 }); + const now = Date.now(); + const key = `relay:rate:${wallet}`; + const result = await redis.eval( + `local key = KEYS[1] + local now = tonumber(ARGV[1]) + local cutoff = now - tonumber(ARGV[2]) + redis.call('ZREMRANGEBYSCORE', key, '-inf', cutoff) + local count = redis.call('ZCARD', key) + if count >= tonumber(ARGV[3]) then return 0 end + redis.call('ZADD', key, now, ARGV[4]) + redis.call('PEXPIRE', key, ARGV[2]) + return 1`, + 1, + key, + String(now), + String(RATE_LIMIT_WINDOW_MS), + String(MAX_RELAY_REQUESTS), + `${now}:${randomUUID()}` + ); + if (result !== 1) throw Object.assign(new Error('Relay rate limit exceeded'), { statusCode: 429 }); +} + +export async function relaySignedTransaction(input: { + wallet: string; + nonce: string; + signature: string; + tx_params: string; +}): Promise<{ tx_hash: string }> { + const wallet = input.wallet.trim().toUpperCase(); + const redis = getRedis(); + if (!redis) throw Object.assign(new Error('Relay is unavailable'), { statusCode: 503 }); + await enforceWalletRateLimit(wallet); + + const keypair = Keypair.fromPublicKey(wallet); + const transaction = TransactionBuilder.fromXDR(input.tx_params, networkPassphrase()); + if (transaction.source !== wallet || transaction.sequence !== input.nonce) { + throw Object.assign(new Error('Transaction wallet or sequence does not match the reservation'), { statusCode: 400 }); + } + + const signature = Buffer.from(input.signature, 'base64'); + if (signature.length !== 64 || !keypair.verify(transaction.hash(), signature)) { + throw Object.assign(new Error('Invalid transaction signature'), { statusCode: 401 }); + } + transaction.addSignature(wallet, input.signature); + + const reservation = await redis.get(reservationKey(wallet)); + if (reservation !== input.nonce) { + throw Object.assign(new Error('Nonce reservation is missing or expired'), { statusCode: 409 }); + } + + const result = await sendTransaction(transaction.toXDR()); + if (result.status === 'ERROR' || !result.hash) { + throw Object.assign(new Error('Soroban RPC rejected the transaction'), { statusCode: 502, details: result }); + } + await redis.del(reservationKey(wallet)); + return { tx_hash: result.hash }; +} From 48d282bf4423c81e3b1c4d244bae2c06bd8e9eeb Mon Sep 17 00:00:00 2001 From: nasirudeenbadirudeen87-cloud <318769136+nasirudeenbadirudeen87-cloud@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:02:23 +0100 Subject: [PATCH 2/3] fix(relay): handle fee-bump transaction envelopes --- src/modules/relay/relay.service.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/modules/relay/relay.service.ts b/src/modules/relay/relay.service.ts index f148210b..a878eab0 100644 --- a/src/modules/relay/relay.service.ts +++ b/src/modules/relay/relay.service.ts @@ -86,7 +86,12 @@ export async function relaySignedTransaction(input: { await enforceWalletRateLimit(wallet); const keypair = Keypair.fromPublicKey(wallet); - const transaction = TransactionBuilder.fromXDR(input.tx_params, networkPassphrase()); + const envelope = TransactionBuilder.fromXDR( + input.tx_params, + networkPassphrase() + ); + const transaction = + 'innerTransaction' in envelope ? envelope.innerTransaction : envelope; if (transaction.source !== wallet || transaction.sequence !== input.nonce) { throw Object.assign(new Error('Transaction wallet or sequence does not match the reservation'), { statusCode: 400 }); } @@ -102,7 +107,7 @@ export async function relaySignedTransaction(input: { throw Object.assign(new Error('Nonce reservation is missing or expired'), { statusCode: 409 }); } - const result = await sendTransaction(transaction.toXDR()); + const result = await sendTransaction(envelope.toXDR()); if (result.status === 'ERROR' || !result.hash) { throw Object.assign(new Error('Soroban RPC rejected the transaction'), { statusCode: 502, details: result }); } From 49ba66cf33fbfbcecdcd4f30774aa6ea09d39a0f Mon Sep 17 00:00:00 2001 From: nasirudeenbadirudeen87-cloud <318769136+nasirudeenbadirudeen87-cloud@users.noreply.github.com> Date: Fri, 23 Oct 2026 05:27:37 -0700 Subject: [PATCH 3/3] fix(relay): validate and atomically claim signed submissions --- docs/relay.md | 59 ++++ src/modules/contracts/contract.types.ts | 4 +- src/modules/relay/relay.integration.test.ts | 339 ++++++++++++++++++++ src/modules/relay/relay.routes.ts | 44 ++- src/modules/relay/relay.service.ts | 203 ++++++++++-- 5 files changed, 605 insertions(+), 44 deletions(-) create mode 100644 docs/relay.md create mode 100644 src/modules/relay/relay.integration.test.ts diff --git a/docs/relay.md b/docs/relay.md new file mode 100644 index 00000000..66623fb9 --- /dev/null +++ b/docs/relay.md @@ -0,0 +1,59 @@ +# Signed transaction relay + +`GET /relay/nonce/:wallet` reserves the wallet's next Stellar account sequence +for five minutes. Repeated reads return the active reservation without extending +its expiry. The wallet must be a valid Ed25519 public key and exist on the +configured network. Nonces are decimal **strings**, preserving 64-bit precision. + +```json +{ "wallet": "G...", "nonce": "12345678901234567" } +``` + +`POST /relay/submit` accepts: + +```json +{ + "wallet": "G...", + "nonce": "12345678901234567", + "signature": "base64 Ed25519 signature over the inner transaction hash", + "tx_params": "base64 transaction-envelope XDR" +} +``` + +The network passphrase follows `STELLAR_NETWORK`. The transaction source and +sequence must match the wallet and reservation. The detached wallet signature +must verify before any submission. Standard envelopes receive that signature +if absent. For gasless transactions, callers supply a fully sponsored fee-bump +envelope: sign the inner transaction with the wallet **before** wrapping and +signing with the fee sponsor. The embedded wallet signature must match the +detached signature; the relay preserves the envelope to avoid invalidating the +sponsor signature. This endpoint does not generate or store a sponsor secret. + +Both endpoints share a rolling limit of 10 requests per wallet per minute, +including rejected submissions. Redis is required (`ENABLE_REDIS_CACHE=true`); +unavailability fails closed with `503`. Atomic Redis claims prevent concurrent +submissions. Submission uses the existing contract service for retries, +transaction persistence, confirmation polling, and resolution events, with +operation `relay`. Success returns `200` and `{"tx_hash":"..."}` after confirmation. +The next nonce becomes available once Horizon observes the prior sequence. + +Failures return `{"error":"..."}`: `400` invalid payload/XDR or source/sequence +mismatch; `401` invalid signature; `404` wallet not found; `409` expired, claimed, +or pending reservation; `429` wallet rate limit; `502` upstream/relay failure; +`503` unavailable infrastructure. Relay failures may include +`details: {tx_hash, error_kind}`; raw provider errors are not returned. + +Failed or uncertain submissions remain claimed until their five-minute claim +expires. Reconcile any returned hash before retrying: a timeout does not prove +that the network rejected a transaction. Chain sequence validation remains the +durable replay boundary; Redis reservations are not authorization to bypass it. + +Offline HTTP tests cover reservation expiry, signature verification, replay, +concurrency, sponsored envelopes, and wallet rate limits: + +```bash +pnpm test --runInBand --testPathPatterns=relay +``` + +These tests mock Redis and the contract-service boundary. They do not exercise a +live Redis server, funded wallet, or deployed Soroban RPC. diff --git a/src/modules/contracts/contract.types.ts b/src/modules/contracts/contract.types.ts index 95fc19a5..a15d0a23 100644 --- a/src/modules/contracts/contract.types.ts +++ b/src/modules/contracts/contract.types.ts @@ -13,7 +13,8 @@ export type ContractOperation = | 'governance' | 'claim' | 'qualify_wallet' - | 'mint_badge'; + | 'mint_badge' + | 'relay'; export const CONTRACT_OPERATIONS: ContractOperation[] = [ 'buy', @@ -23,6 +24,7 @@ export const CONTRACT_OPERATIONS: ContractOperation[] = [ 'claim', 'qualify_wallet', 'mint_badge', + 'relay', ]; export function isContractOperation(value: string): value is ContractOperation { diff --git a/src/modules/relay/relay.integration.test.ts b/src/modules/relay/relay.integration.test.ts new file mode 100644 index 00000000..d8887574 --- /dev/null +++ b/src/modules/relay/relay.integration.test.ts @@ -0,0 +1,339 @@ +import express from 'express'; +import request from 'supertest'; +import { + Account, + Keypair, + Networks, + Operation, + TransactionBuilder, +} from '@stellar/stellar-base'; +import relayRouter from './relay.routes'; +import { horizonGet } from '../../clients/horizon.client'; +import { submitContractCall } from '../contracts/contract.service'; +import { getRedis } from '../../utils/redis.utils'; + +jest.mock('../../clients/horizon.client', () => ({ horizonGet: jest.fn() })); +jest.mock('../contracts/contract.service', () => ({ + submitContractCall: jest.fn(), +})); +jest.mock('../../utils/redis.utils', () => ({ getRedis: jest.fn() })); + +const walletKey = Keypair.random(); +const wallet = walletKey.publicKey(); +const other = Keypair.random(); +const app = express(); +app.use(express.json()); +app.use('/relay', relayRouter); +const entries = new Map(); +const rates = new Map(); +let now: number; +function read(key: string): string | null { + const item = entries.get(key); + if (!item || item.expires <= now) { + entries.delete(key); + return null; + } + return item.value; +} +const redis = { + get: jest.fn(async (key: string) => read(key)), + set: jest.fn( + async ( + key: string, + value: string, + _ex: string, + seconds: number, + nx?: string + ) => { + if (nx && read(key) !== null) return null; + entries.set(key, { value, expires: now + Number(seconds) * 1000 }); + return 'OK'; + } + ), + // In-memory test double models Redis's atomic rate/compare-and-set behavior. + eval: jest.fn( + async ( + _script: string, + _count: number, + key: string, + ...args: string[] + ) => { + if (key.startsWith('relay:rate:')) { + const recent = (rates.get(key) ?? []).filter( + t => t > Number(args[0]) - Number(args[1]) + ); + if (recent.length >= Number(args[2])) return 0; + recent.push(Number(args[0])); + rates.set(key, recent); + return 1; + } + if (read(key) !== args[0]) return 0; + entries.set(key, { + value: args[1], + expires: now + Number(args[2]) * 1000, + }); + return 1; + } + ), +}; +function payload(key = walletKey, sequence = '9') { + const tx = new TransactionBuilder(new Account(key.publicKey(), sequence), { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.manageData({ name: 'relay-test', value: 'value' }) + ) + .setTimeout(300) + .build(); + return { + wallet: key.publicKey(), + nonce: tx.sequence, + signature: key.sign(tx.hash()).toString('base64'), + tx_params: tx.toXDR(), + }; +} +async function reserve() { + const res = await request(app).get(`/relay/nonce/${wallet}`); + expect(res.status).toBe(200); + return res; +} +beforeEach(() => { + jest.clearAllMocks(); + entries.clear(); + rates.clear(); + now = 1_792_756_800_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + jest.mocked(getRedis).mockReturnValue(redis as any); + jest.mocked(horizonGet).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ sequence: '9' }), + } as Response); + jest.mocked(submitContractCall).mockResolvedValue({ + status: 'CONFIRMED', + txHash: 'confirmed-hash', + ledger: 10, + attempts: 1, + }); +}); +afterEach(() => jest.restoreAllMocks()); + +it('reserves the next chain sequence as a decimal string with a five-minute TTL', async () => { + const res = await reserve(); + expect(res.body).toEqual({ wallet, nonce: '10' }); + expect(redis.set).toHaveBeenCalledWith( + `relay:nonce:${wallet}`, + '10', + 'EX', + 300, + 'NX' + ); + await reserve(); + expect(horizonGet).toHaveBeenCalledTimes(1); +}); +it('frees expired reservations and reads the latest sequence', async () => { + await reserve(); + now += 300_001; + jest.mocked(horizonGet).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ sequence: '10' }), + } as Response); + expect((await reserve()).body.nonce).toBe('11'); +}); +it('preserves large sequence numbers without Number precision loss', async () => { + jest.mocked(horizonGet).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ sequence: '9007199254740993' }), + } as Response); + expect((await reserve()).body.nonce).toBe('9007199254740994'); +}); +it('rejects expired submissions before invoking the contract service', async () => { + const body = payload(); + await reserve(); + now += 300_001; + expect((await request(app).post('/relay/submit').send(body)).status).toBe( + 409 + ); + expect(submitContractCall).not.toHaveBeenCalled(); +}); +it('verifies and attaches a detached signature before submitting', async () => { + await reserve(); + const body = payload(); + const res = await request(app).post('/relay/submit').send(body); + expect(res.status).toBe(200); + expect(res.body).toEqual({ tx_hash: 'confirmed-hash' }); + const input = jest.mocked(submitContractCall).mock.calls[0][0]; + expect(input.operation).toBe('relay'); + expect(input.submitterWallet).toBe(wallet); + const envelope = TransactionBuilder.fromXDR( + input.transactionXdr, + Networks.TESTNET + ); + expect( + envelope.signatures.some(s => + walletKey.verify(envelope.hash(), s.signature()) + ) + ).toBe(true); +}); +it('rejects a forged signature and a payload changed after signing', async () => { + await reserve(); + const body = payload(); + body.signature = other.sign(Buffer.from('forged')).toString('base64'); + expect((await request(app).post('/relay/submit').send(body)).status).toBe( + 401 + ); + expect(submitContractCall).not.toHaveBeenCalled(); +}); +it('rejects mismatched wallet and sequence', async () => { + await reserve(); + expect( + ( + await request(app) + .post('/relay/submit') + .send({ ...payload(), nonce: '11' }) + ).status + ).toBe(400); + expect( + ( + await request(app) + .post('/relay/submit') + .send({ ...payload(), wallet: other.publicKey() }) + ).status + ).toBe(400); + expect(submitContractCall).not.toHaveBeenCalled(); +}); +it('allows only one concurrent submission and rejects replay', async () => { + await reserve(); + const body = payload(); + const responses = await Promise.all([ + request(app).post('/relay/submit').send(body), + request(app).post('/relay/submit').send(body), + ]); + expect(responses.map(r => r.status).sort()).toEqual([200, 409]); + expect(submitContractCall).toHaveBeenCalledTimes(1); + expect((await request(app).post('/relay/submit').send(body)).status).toBe( + 409 + ); +}); +it('keeps a failed or uncertain submission claimed', async () => { + await reserve(); + jest.mocked(submitContractCall).mockResolvedValue({ + status: 'FAILED', + txHash: 'pending-hash', + errorKind: 'transient', + lastError: 'private rpc data', + attempts: 1, + }); + const res = await request(app).post('/relay/submit').send(payload()); + expect(res.status).toBe(502); + expect(res.body).toEqual({ + error: 'Relay transaction failed', + details: { tx_hash: 'pending-hash', error_kind: 'transient' }, + }); + expect( + (await request(app).post('/relay/submit').send(payload())).status + ).toBe(409); + expect((await request(app).get(`/relay/nonce/${wallet}`)).status).toBe(409); +}); +it('reserves a new nonce only after the previous sequence advances on-chain', async () => { + await reserve(); + await request(app).post('/relay/submit').send(payload()); + expect((await request(app).get(`/relay/nonce/${wallet}`)).status).toBe(409); + jest.mocked(horizonGet).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ sequence: '10' }), + } as Response); + expect((await reserve()).body.nonce).toBe('11'); +}); +it('limits both endpoints together to ten requests per wallet per rolling minute', async () => { + await reserve(); + for (let i = 0; i < 9; i++) + expect( + (await request(app).post('/relay/submit').send(payload())).status + ).not.toBe(429); + const denied = await request(app).post('/relay/submit').send(payload()); + expect(denied.status).toBe(429); + expect(denied.headers['retry-after']).toBe('60'); + expect((await request(app).get(`/relay/nonce/${wallet}`)).status).toBe(429); + now += 60_001; + expect((await request(app).get(`/relay/nonce/${wallet}`)).status).not.toBe( + 429 + ); +}); +it('fails closed when Redis is unavailable', async () => { + jest.mocked(getRedis).mockReturnValue(null); + expect((await request(app).get(`/relay/nonce/${wallet}`)).status).toBe(503); + expect( + (await request(app).post('/relay/submit').send(payload())).status + ).toBe(503); + expect(submitContractCall).not.toHaveBeenCalled(); +}); +it('rejects malformed input, invalid checksums, and invalid XDR', async () => { + expect( + (await request(app).get(`/relay/nonce/G${'A'.repeat(55)}`)).status + ).toBe(400); + expect((await request(app).post('/relay/submit').send({})).status).toBe(400); + expect( + ( + await request(app) + .post('/relay/submit') + .send({ ...payload(), tx_params: 'invalid' }) + ).status + ).toBe(400); + expect(submitContractCall).not.toHaveBeenCalled(); +}); +it('accepts a sponsored fee-bump without invalidating the sponsor signature', async () => { + await reserve(); + const inner = new TransactionBuilder(new Account(wallet, '9'), { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.manageData({ name: 'relay-test', value: 'value' }) + ) + .setTimeout(300) + .build(); + const signature = walletKey.sign(inner.hash()).toString('base64'); + inner.sign(walletKey); + const envelope = TransactionBuilder.buildFeeBumpTransaction( + other, + '100', + inner, + Networks.TESTNET + ); + envelope.sign(other); + const original = envelope.toXDR(); + const res = await request(app) + .post('/relay/submit') + .send({ wallet, nonce: '10', signature, tx_params: original }); + expect(res.status).toBe(200); + expect(jest.mocked(submitContractCall).mock.calls[0][0].transactionXdr).toBe( + original + ); +}); +it('rejects fee-bumps missing the embedded inner signature', async () => { + await reserve(); + const body = payload(); + const inner = TransactionBuilder.fromXDR(body.tx_params, Networks.TESTNET); + if ('innerTransaction' in inner) + throw new Error('Expected standard transaction'); + const envelope = TransactionBuilder.buildFeeBumpTransaction( + other, + '100', + inner, + Networks.TESTNET + ); + envelope.sign(other); + expect( + ( + await request(app) + .post('/relay/submit') + .send({ ...body, tx_params: envelope.toXDR() }) + ).status + ).toBe(400); + expect(submitContractCall).not.toHaveBeenCalled(); +}); diff --git a/src/modules/relay/relay.routes.ts b/src/modules/relay/relay.routes.ts index dc220938..856c833c 100644 --- a/src/modules/relay/relay.routes.ts +++ b/src/modules/relay/relay.routes.ts @@ -1,42 +1,62 @@ import { Router } from 'express'; import { z } from 'zod'; -import { isValidStellarAddress } from '../../modules/wallet/wallet.utils'; +import { StrKey } from '@stellar/stellar-base'; import { reserveNonce, relaySignedTransaction } from './relay.service'; const relayRouter = Router(); const submitSchema = z.object({ - wallet: z.string().trim().min(1), - nonce: z.string().regex(/^\d+$/), - signature: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/), - tx_params: z.string().min(1), + wallet: z.string().trim().refine(StrKey.isValidEd25519PublicKey), + nonce: z + .string() + .max(19) + .regex(/^(0|[1-9]\d*)$/), + signature: z + .string() + .length(88) + .regex(/^[A-Za-z0-9+/]{86}==$/), + tx_params: z.string().min(1).max(100_000), }); relayRouter.get('/nonce/:wallet', async (req, res) => { const wallet = req.params.wallet; - if (!isValidStellarAddress(wallet)) { + if (!StrKey.isValidEd25519PublicKey(wallet)) { res.status(400).json({ error: 'A valid Stellar wallet is required' }); return; } try { - res.json({ wallet: wallet.toUpperCase(), nonce: await reserveNonce(wallet) }); + res.json({ + wallet: wallet.toUpperCase(), + nonce: await reserveNonce(wallet), + }); } catch (error) { const failure = error as Error & { statusCode?: number }; - res.status(failure.statusCode ?? 502).json({ error: failure.message }); + if (failure.statusCode === 429) res.setHeader('Retry-After', '60'); + res.status(failure.statusCode ?? 503).json({ + error: failure.statusCode + ? failure.message + : 'Nonce service is unavailable', + }); } }); relayRouter.post('/submit', async (req, res) => { const parsed = submitSchema.safeParse(req.body); - if (!parsed.success || !isValidStellarAddress(parsed.data.wallet)) { + if (!parsed.success || !StrKey.isValidEd25519PublicKey(parsed.data.wallet)) { res.status(400).json({ error: 'Invalid relay payload' }); return; } try { res.json(await relaySignedTransaction(parsed.data)); } catch (error) { - const failure = error as Error & { statusCode?: number; details?: unknown }; - res.status(failure.statusCode ?? 502).json({ - error: failure.message, + const failure = error as Error & { + statusCode?: number; + details?: unknown; + }; + if (failure.statusCode === 429) res.setHeader('Retry-After', '60'); + res.status(failure.statusCode ?? 503).json({ + error: failure.statusCode + ? failure.message + : 'Relay service is unavailable', ...(failure.details ? { details: failure.details } : {}), }); } diff --git a/src/modules/relay/relay.service.ts b/src/modules/relay/relay.service.ts index a878eab0..4bf34635 100644 --- a/src/modules/relay/relay.service.ts +++ b/src/modules/relay/relay.service.ts @@ -1,8 +1,13 @@ import { randomUUID } from 'crypto'; -import { Keypair, Networks, TransactionBuilder } from '@stellar/stellar-base'; +import { + Keypair, + Networks, + StrKey, + TransactionBuilder, +} from '@stellar/stellar-base'; import { envConfig } from '../../config'; import { horizonGet } from '../../clients/horizon.client'; -import { sendTransaction } from '../../clients/soroban.client'; +import { submitContractCall } from '../contracts/contract.service'; import { getRedis } from '../../utils/redis.utils'; const RESERVATION_TTL_SECONDS = 5 * 60; @@ -19,39 +24,102 @@ function networkPassphrase(): string { : Networks.TESTNET; } +function normalizeWallet(value: string): string { + const wallet = value.trim(); + if (!StrKey.isValidEd25519PublicKey(wallet)) { + throw Object.assign(new Error('A valid Stellar wallet is required'), { + statusCode: 400, + }); + } + return wallet; +} + export async function reserveNonce(walletAddress: string): Promise { - const wallet = walletAddress.trim().toUpperCase(); + const wallet = normalizeWallet(walletAddress); + await enforceWalletRateLimit(wallet); const redis = getRedis(); - if (!redis) throw Object.assign(new Error('Nonce service is unavailable'), { statusCode: 503 }); + if (!redis) + throw Object.assign(new Error('Nonce service is unavailable'), { + statusCode: 503, + }); const key = reservationKey(wallet); const reserved = await redis.get(key); - if (reserved) return reserved; + if (reserved && /^\d+$/.test(reserved)) return reserved; + if (reserved?.startsWith('submitting:')) { + throw Object.assign( + new Error('A relay submission is already in progress'), + { statusCode: 409 } + ); + } const response = await horizonGet( `${envConfig.STELLAR_HORIZON_URL.replace(/\/$/, '')}/accounts/${encodeURIComponent(wallet)}` ); if (response.status === 404) { - throw Object.assign(new Error('Wallet does not exist on the Stellar network'), { statusCode: 404 }); + throw Object.assign( + new Error('Wallet does not exist on the Stellar network'), + { statusCode: 404 } + ); } - if (!response.ok) throw Object.assign(new Error('Unable to read wallet sequence'), { statusCode: 502 }); + if (!response.ok) + throw Object.assign(new Error('Unable to read wallet sequence'), { + statusCode: 502, + }); const account = (await response.json()) as { sequence?: string }; if (!account.sequence || !/^\d+$/.test(account.sequence)) { - throw Object.assign(new Error('Wallet sequence is unavailable'), { statusCode: 502 }); + throw Object.assign(new Error('Wallet sequence is unavailable'), { + statusCode: 502, + }); } const nonce = (BigInt(account.sequence) + 1n).toString(); - const created = await redis.set(key, nonce, 'EX', RESERVATION_TTL_SECONDS, 'NX'); + if (reserved?.startsWith('submitted:')) { + if (BigInt(nonce) <= BigInt(reserved.slice('submitted:'.length))) { + throw Object.assign( + new Error('Previous transaction is awaiting ledger inclusion'), + { statusCode: 409 } + ); + } + const replaced = await redis.eval( + `if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end + redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3]) + return 1`, + 1, + key, + reserved, + nonce, + String(RESERVATION_TTL_SECONDS) + ); + if (replaced === 1) return nonce; + throw Object.assign(new Error('Reservation changed; retry the request'), { + statusCode: 409, + }); + } + const created = await redis.set( + key, + nonce, + 'EX', + RESERVATION_TTL_SECONDS, + 'NX' + ); if (created === 'OK') return nonce; const concurrentReservation = await redis.get(key); - if (concurrentReservation) return concurrentReservation; - throw Object.assign(new Error('Could not reserve a nonce; retry the request'), { statusCode: 409 }); + if (concurrentReservation && /^\d+$/.test(concurrentReservation)) + return concurrentReservation; + throw Object.assign( + new Error('Could not reserve a nonce; retry the request'), + { statusCode: 409 } + ); } async function enforceWalletRateLimit(wallet: string): Promise { const redis = getRedis(); - if (!redis) throw Object.assign(new Error('Relay is unavailable'), { statusCode: 503 }); + if (!redis) + throw Object.assign(new Error('Relay is unavailable'), { + statusCode: 503, + }); const now = Date.now(); const key = `relay:rate:${wallet}`; const result = await redis.eval( @@ -71,7 +139,10 @@ async function enforceWalletRateLimit(wallet: string): Promise { String(MAX_RELAY_REQUESTS), `${now}:${randomUUID()}` ); - if (result !== 1) throw Object.assign(new Error('Relay rate limit exceeded'), { statusCode: 429 }); + if (result !== 1) + throw Object.assign(new Error('Relay rate limit exceeded'), { + statusCode: 429, + }); } export async function relaySignedTransaction(input: { @@ -80,37 +151,107 @@ export async function relaySignedTransaction(input: { signature: string; tx_params: string; }): Promise<{ tx_hash: string }> { - const wallet = input.wallet.trim().toUpperCase(); + const wallet = normalizeWallet(input.wallet); const redis = getRedis(); - if (!redis) throw Object.assign(new Error('Relay is unavailable'), { statusCode: 503 }); + if (!redis) + throw Object.assign(new Error('Relay is unavailable'), { + statusCode: 503, + }); await enforceWalletRateLimit(wallet); const keypair = Keypair.fromPublicKey(wallet); - const envelope = TransactionBuilder.fromXDR( - input.tx_params, - networkPassphrase() - ); + let envelope; + try { + envelope = TransactionBuilder.fromXDR( + input.tx_params, + networkPassphrase() + ); + } catch { + throw Object.assign(new Error('Invalid transaction XDR'), { + statusCode: 400, + }); + } const transaction = 'innerTransaction' in envelope ? envelope.innerTransaction : envelope; if (transaction.source !== wallet || transaction.sequence !== input.nonce) { - throw Object.assign(new Error('Transaction wallet or sequence does not match the reservation'), { statusCode: 400 }); + throw Object.assign( + new Error( + 'Transaction wallet or sequence does not match the reservation' + ), + { statusCode: 400 } + ); } const signature = Buffer.from(input.signature, 'base64'); - if (signature.length !== 64 || !keypair.verify(transaction.hash(), signature)) { - throw Object.assign(new Error('Invalid transaction signature'), { statusCode: 401 }); + if ( + signature.length !== 64 || + !keypair.verify(transaction.hash(), signature) + ) { + throw Object.assign(new Error('Invalid transaction signature'), { + statusCode: 401, + }); } - transaction.addSignature(wallet, input.signature); + const hasSignature = transaction.signatures.some( + s => + s.hint().equals(keypair.signatureHint()) && + s.signature().equals(signature) + ); + // Changing an inner envelope invalidates an existing fee-sponsor signature. + if ('innerTransaction' in envelope && !hasSignature) { + throw Object.assign( + new Error( + 'Fee-bump inner transaction must already include the wallet signature' + ), + { statusCode: 400 } + ); + } + if (!hasSignature) transaction.addSignature(wallet, input.signature); - const reservation = await redis.get(reservationKey(wallet)); - if (reservation !== input.nonce) { - throw Object.assign(new Error('Nonce reservation is missing or expired'), { statusCode: 409 }); + const key = reservationKey(wallet); + // Claim atomically before any network call; concurrent submits cannot both win. + const claim = `submitting:${input.nonce}:${randomUUID()}`; + const claimed = await redis.eval( + `if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end + redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3]) + return 1`, + 1, + key, + input.nonce, + claim, + String(RESERVATION_TTL_SECONDS) + ); + if (claimed !== 1) { + throw Object.assign( + new Error('Nonce reservation is missing, expired, or already claimed'), + { statusCode: 409 } + ); } - const result = await sendTransaction(envelope.toXDR()); - if (result.status === 'ERROR' || !result.hash) { - throw Object.assign(new Error('Soroban RPC rejected the transaction'), { statusCode: 502, details: result }); + // The shared service handles retries, persistence, and ledger confirmation. + // On ambiguous failure leave the claim intact until expiry: fail closed. + const result = await submitContractCall( + { + operation: 'relay', + submitterWallet: wallet, + transactionXdr: envelope.toXDR(), + }, + {} + ); + if (result.status !== 'CONFIRMED') { + throw Object.assign(new Error('Relay transaction failed'), { + statusCode: 502, + details: { tx_hash: result.txHash, error_kind: result.errorKind }, + }); } - await redis.del(reservationKey(wallet)); - return { tx_hash: result.hash }; + await redis.eval( + `if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end + redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3]) + return 1`, + 1, + key, + claim, + `submitted:${input.nonce}`, + String(RESERVATION_TTL_SECONDS) + ); + return { tx_hash: result.txHash }; }