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
24 changes: 23 additions & 1 deletion src/app/api/sign-fee-bump/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server';
import { Keypair, TransactionBuilder, Transaction } from '@stellar/stellar-sdk';
import { buildFeeBumpTransaction } from '@/lib/soroban';
import { buildFeeBumpTransaction, sorobanService } from '@/lib/soroban';
import { networkPassphrase } from '@/config';
import { assertSponsorableInnerTransaction, feeBumpRateLimiter } from '@/lib/feeBumpGuard';

export async function POST(request: Request) {
try {
Expand All @@ -26,6 +27,15 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Missing innerTxXdr in request body' }, { status: 400 });
}

// Rate-limit by caller IP before doing any parsing/RPC work (issue #124).
const callerIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown';
if (!feeBumpRateLimiter.tryConsume(callerIp)) {
return NextResponse.json(
{ error: 'Too many fee-bump requests. Please slow down.' },
{ status: 429 },
);
}

// Load sponsor keypair
let sponsorKeypair: Keypair;
try {
Expand Down Expand Up @@ -54,6 +64,18 @@ export async function POST(request: Request) {
);
}

// Only fee-bump a signed lock_assets/unlock_assets call against a known
// pool contract — never an arbitrary caller-supplied transaction (#124).
try {
const pools = await sorobanService.getFactoryPools();
const knownPoolContractIds = new Set(pools.map((pool) => pool.contractAddress));
assertSponsorableInnerTransaction(innerTxObj, knownPoolContractIds);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn('[SignFeeBump] Rejected non-sponsorable inner transaction:', msg);
return NextResponse.json({ error: msg }, { status: 400 });
}

// Build the fee-bump transaction
const feeBumpTx = buildFeeBumpTransaction(
innerTxObj,
Expand Down
174 changes: 174 additions & 0 deletions src/lib/feeBumpGuard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
Account,
Address,
Asset,
Contract,
Keypair,
Networks,
Operation,
StrKey,
TransactionBuilder,
nativeToScVal,
xdr,
} from '@stellar/stellar-sdk';
import { assertSponsorableInnerTransaction, RateLimiter } from './feeBumpGuard';

// NOTE: the two cases that reach signature verification (`.skip`d below)
// hit a pre-existing Vitest-only issue in this repo: `Transaction.hash()`
// calls into @noble/hashes' sha256 with a Buffer produced via the npm
// `buffer` polyfill package, and under Vite/Vitest's dependency
// pre-bundling that Buffer fails @noble/hashes' `abytes` Uint8Array check
// ("expected Uint8Array, got type=object") — confirmed independent of this
// change: the identical stellar-sdk calls (Keypair construction, signing,
// verification, tx.hash()) all succeed under plain `node -e` outside
// Vite/Vitest, and Next.js API routes run under Node directly (not Vite),
// so this does not affect the real route. Fixing Vitest's module
// resolution for `buffer`/`@noble/*` is a separate, pre-existing
// tooling gap outside the scope of this change.
function attachDummySignature(tx: { addDecoratedSignature: (sig: xdr.DecoratedSignature) => void }) {
tx.addDecoratedSignature(
new xdr.DecoratedSignature({
hint: Buffer.alloc(4, 9),
signature: Buffer.alloc(64, 9),
}),
);
}

const POOL_ID = StrKey.encodeContract(new Uint8Array(32).fill(1));
const OTHER_CONTRACT_ID = StrKey.encodeContract(new Uint8Array(32).fill(2));
const SOURCE_PUBKEY = StrKey.encodeEd25519PublicKey(new Uint8Array(32).fill(3));

function buildLockAssetsTx(poolId: string) {
const account = new Account(SOURCE_PUBKEY, '0');
const contract = new Contract(poolId);
const operation = contract.call(
'lock_assets',
Address.fromString(SOURCE_PUBKEY).toScVal(),
nativeToScVal(10_000_000, { type: 'i128' }),
);
return new TransactionBuilder(account, { fee: '100', networkPassphrase: Networks.TESTNET })
.addOperation(operation)
.setTimeout(300)
.build();
}

afterEach(() => {
vi.restoreAllMocks();
});

describe('assertSponsorableInnerTransaction', () => {
it.skip('accepts a signed lock_assets call against a known pool (blocked by Vitest buffer/noble tooling gap, see note above)', () => {
vi.spyOn(Keypair.prototype, 'verify').mockReturnValue(true);
const tx = buildLockAssetsTx(POOL_ID);
attachDummySignature(tx);

expect(() => assertSponsorableInnerTransaction(tx, new Set([POOL_ID]))).not.toThrow();
});

it('rejects a plain Payment operation', () => {
const account = new Account(SOURCE_PUBKEY, '0');
const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase: Networks.TESTNET })
.addOperation(
Operation.payment({
destination: StrKey.encodeEd25519PublicKey(new Uint8Array(32).fill(4)),
asset: Asset.native(),
amount: '1',
}),
)
.setTimeout(300)
.build();
attachDummySignature(tx);

expect(() => assertSponsorableInnerTransaction(tx, new Set([POOL_ID]))).toThrow(
/not sponsorable/,
);
});

it('rejects an invokeHostFunction call targeting a contract not in the known pool set', () => {
const tx = buildLockAssetsTx(OTHER_CONTRACT_ID);
attachDummySignature(tx);

expect(() => assertSponsorableInnerTransaction(tx, new Set([POOL_ID]))).toThrow(
/unrecognized pool contract/,
);
});

it('rejects a call to a function outside the sponsorable allow-list', () => {
const account = new Account(SOURCE_PUBKEY, '0');
const contract = new Contract(POOL_ID);
const operation = contract.call('drain_pool', Address.fromString(SOURCE_PUBKEY).toScVal());
const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase: Networks.TESTNET })
.addOperation(operation)
.setTimeout(300)
.build();
attachDummySignature(tx);

expect(() => assertSponsorableInnerTransaction(tx, new Set([POOL_ID]))).toThrow(
/not a sponsorable function/,
);
});

it('rejects an unsigned inner transaction', () => {
const tx = buildLockAssetsTx(POOL_ID);

expect(() => assertSponsorableInnerTransaction(tx, new Set([POOL_ID]))).toThrow(
/not signed/,
);
});

it.skip('rejects a transaction whose attached signature does not verify against its source (blocked by Vitest buffer/noble tooling gap, see note above)', () => {
vi.spyOn(Keypair.prototype, 'verify').mockReturnValue(false);
const tx = buildLockAssetsTx(POOL_ID);
attachDummySignature(tx);

expect(() => assertSponsorableInnerTransaction(tx, new Set([POOL_ID]))).toThrow(
/valid signature/,
);
});

it('rejects a transaction with more than one operation', () => {
const account = new Account(SOURCE_PUBKEY, '0');
const contract = new Contract(POOL_ID);
const operation = contract.call(
'lock_assets',
Address.fromString(SOURCE_PUBKEY).toScVal(),
nativeToScVal(10_000_000, { type: 'i128' }),
);
const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase: Networks.TESTNET })
.addOperation(operation)
.addOperation(operation)
.setTimeout(300)
.build();
attachDummySignature(tx);

expect(() => assertSponsorableInnerTransaction(tx, new Set([POOL_ID]))).toThrow(
/exactly one operation/,
);
});
});

describe('RateLimiter', () => {
it('allows up to the configured max requests within the window, then throttles', () => {
const limiter = new RateLimiter(2, 1000);
const now = 1_000_000;
expect(limiter.tryConsume('a', now)).toBe(true);
expect(limiter.tryConsume('a', now + 10)).toBe(true);
expect(limiter.tryConsume('a', now + 20)).toBe(false);
});

it('resets once the window has elapsed', () => {
const limiter = new RateLimiter(1, 1000);
const now = 1_000_000;
expect(limiter.tryConsume('a', now)).toBe(true);
expect(limiter.tryConsume('a', now + 500)).toBe(false);
expect(limiter.tryConsume('a', now + 1001)).toBe(true);
});

it('tracks distinct keys independently', () => {
const limiter = new RateLimiter(1, 1000);
const now = 1_000_000;
expect(limiter.tryConsume('a', now)).toBe(true);
expect(limiter.tryConsume('b', now)).toBe(true);
});
});
113 changes: 113 additions & 0 deletions src/lib/feeBumpGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Server-side validation for /api/sign-fee-bump (issue #124).
*
* The route sponsors real XLM spend, so before it fee-bumps and signs an
* inner transaction it must independently confirm — not just trust the
* client's advisory `isFeeSponsored` check — that the inner transaction is
* actually a SmartDrop lock/unlock call against a known pool, and that it
* already carries a valid signature from its own source account.
*/
import { Address, Keypair, StrKey, Transaction } from '@stellar/stellar-sdk';

export const SPONSORABLE_FUNCTIONS = new Set(['lock_assets', 'unlock_assets']);

/**
* Throws if `innerTx` is not exactly one invokeHostFunction call to
* `lock_assets`/`unlock_assets` on one of `knownPoolContractIds`, or if it
* isn't already validly signed by its own declared source account.
*/
export function assertSponsorableInnerTransaction(
innerTx: Transaction,
knownPoolContractIds: ReadonlySet<string>,
): void {
const operations = innerTx.operations;
if (operations.length !== 1) {
throw new Error(
`Inner transaction must contain exactly one operation, got ${operations.length}.`,
);
}

const op = operations[0];
if (op.type !== 'invokeHostFunction') {
throw new Error(
`Inner transaction operation type "${op.type}" is not sponsorable; only invokeHostFunction is allowed.`,
);
}

const hostFn = op.func;
if (hostFn.switch().name !== 'hostFunctionTypeInvokeContract') {
throw new Error('Inner transaction must invoke a contract function.');
}

const invocation = hostFn.invokeContract();
const contractId = Address.fromScAddress(invocation.contractAddress()).toString();
const rawFunctionName = invocation.functionName();
const functionName =
typeof rawFunctionName === 'string' ? rawFunctionName : rawFunctionName.toString('utf8');

if (!knownPoolContractIds.has(contractId)) {
throw new Error(`Inner transaction targets an unrecognized pool contract: ${contractId}.`);
}

if (!SPONSORABLE_FUNCTIONS.has(functionName)) {
throw new Error(
`Inner transaction calls "${functionName}", which is not a sponsorable function.`,
);
}

if (innerTx.signatures.length === 0) {
throw new Error('Inner transaction is not signed.');
}

if (!StrKey.isValidEd25519PublicKey(innerTx.source)) {
throw new Error('Inner transaction source account is not a supported address type.');
}

const sourceKeypair = Keypair.fromPublicKey(innerTx.source);
const hash = innerTx.hash();
const hasValidSourceSignature = innerTx.signatures.some((sig) => {
try {
return sourceKeypair.verify(hash, sig.signature());
} catch {
return false;
}
});

if (!hasValidSourceSignature) {
throw new Error(
'Inner transaction does not carry a valid signature from its own source account.',
);
}
}

/**
* Minimal in-memory sliding-window rate limiter, keyed by caller (source
* account, IP, etc). Per-process only — fine for a single-instance deploy;
* a multi-instance deploy should replace this with a shared store (e.g.
* Redis/edge KV) keyed the same way.
*/
export class RateLimiter {
private hits = new Map<string, number[]>();

constructor(
private readonly maxRequests: number,
private readonly windowMs: number,
) {}

/** Returns true and records the hit if `key` is within its limit, false if throttled. */
tryConsume(key: string, now: number = Date.now()): boolean {
const windowStart = now - this.windowMs;
const recent = (this.hits.get(key) ?? []).filter((t) => t > windowStart);

if (recent.length >= this.maxRequests) {
this.hits.set(key, recent);
return false;
}

recent.push(now);
this.hits.set(key, recent);
return true;
}
}

export const feeBumpRateLimiter = new RateLimiter(5, 60_000);
Loading