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
18 changes: 18 additions & 0 deletions backend/src/db/migrations/025_fee_bump_quota.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const version = 25;
export const description = 'Fee-bump quota tracking for sponsored transactions (#555)';

export function up(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS fee_bump_quota (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
date TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(wallet, date)
);

CREATE INDEX IF NOT EXISTS idx_fee_bump_quota_wallet_date ON fee_bump_quota(wallet, date);
`);
}
17 changes: 17 additions & 0 deletions backend/src/db/migrations/026_operator_balance_log.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const version = 26;
export const description = 'Operator balance monitoring log (#552)';

export function up(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS operator_balance_log (
id TEXT PRIMARY KEY,
address TEXT NOT NULL,
balance_xlm TEXT NOT NULL,
threshold_xlm TEXT NOT NULL,
below_threshold INTEGER NOT NULL DEFAULT 0,
checked_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_op_balance_log_address ON operator_balance_log(address, checked_at);
`);
}
31 changes: 31 additions & 0 deletions backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,12 @@ import { createDurableJobQueue } from './jobs/durableJobQueue.js';
import { createStellarTomlRoute } from './routes/stellarToml.js';
import { createSponsoredAccountRoutes } from './routes/sponsoredAccounts.js';
import { createClaimableBalancesRoutes } from './routes/claimableBalances.js';
import { createFeeBumpRoutes } from './routes/feeBump.js';
import { createPathPaymentRoutes } from './routes/pathPayment.js';
import { createIndexReadRoutes } from './routes/indexRead.js';
import { createSep10Routes, createRequireWalletAuth } from './routes/sep10.js';
import { createZkInputsRoutes } from './routes/zkInputs.js';
import { createOperatorBalanceJob } from './jobs/operatorBalanceJob.js';

const DEFAULT_PORT = 3001;
const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
Expand Down Expand Up @@ -653,6 +656,18 @@ export async function createApp(options = {}) {
durableJobQueue.start();
}

// #552 — Operator balance monitoring job
const operatorBalanceJob = createOperatorBalanceJob({
db: dal.db,
stellarConfig,
metrics,
env: process.env,
logger: log,
});
if (!options.disableJobs) {
operatorBalanceJob.start();
}

async function buildHealthPayload() {
const rpcUrl = rpcPool.getHealthyRpcUrl();
const rpc = rpcHealthCache.payload ?? (await checkSorobanRpcHealth({ rpcUrl, fetchImpl }));
Expand Down Expand Up @@ -2174,6 +2189,22 @@ export async function createApp(options = {}) {
logger: log,
});
app.use(prefix, rateLimiter, ...guard, claimableBalancesRouter);

// #555 — Fee-bump / sponsored transactions (gasless registration & claim)
const feeBumpRouter = createFeeBumpRoutes({
dal,
stellarConfig,
env: process.env,
logger: log,
});
app.use(`${prefix}/fee-bump`, rateLimiter, feeBumpRouter);

// #549 — Path payment support for multi-asset claims
const pathPaymentRouter = createPathPaymentRoutes({
stellarConfig,
fetchImpl,
});
app.use(`${prefix}/payment-paths`, rateLimiter, pathPaymentRouter);
}

// #551 — SEP-1 stellar.toml (public, no auth, correct content-type + CORS)
Expand Down
188 changes: 188 additions & 0 deletions backend/src/integration/feeBump.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Integration tests for #555 (fee-bump) and #549 (path payment paths).

import assert from 'node:assert/strict';
import test from 'node:test';
import request from 'supertest';
import { Keypair, TransactionBuilder, Operation, Networks, BASE_FEE, Asset } from '@stellar/stellar-sdk';
import { createApp } from '../index.js';

// Generate a real-looking but invalid inner transaction XDR for testing.
// We build a transaction using a throw-away keypair so the XDR is valid
// but the inner account doesn't exist on-chain.
function buildInnerXdr(sourceKeypair, networkPassphrase = Networks.TESTNET) {
const account = {
accountId: () => sourceKeypair.publicKey(),
sequenceNumber: () => '100',
incrementSequenceNumber: () => {},
};
const tx = new TransactionBuilder(
{ id: sourceKeypair.publicKey(), sequence: '100', incrementSequenceNumber: () => {} },
{ fee: BASE_FEE, networkPassphrase },
)
.addOperation(
Operation.payment({
destination: Keypair.random().publicKey(),
asset: Asset.native(),
amount: '1',
}),
)
.setTimeout(60)
.build();
tx.sign(sourceKeypair);
return tx.toEnvelope().toXDR('base64');
}

function createTestApp(opts = {}) {
return createApp({
dbPath: ':memory:',
disableJobs: true,
skipEnvValidation: true,
...opts,
});
}

// ── Fee-bump quota endpoint ────────────────────────────────────────────────────

test('GET /api/v1/fee-bump/quota/:wallet returns zero for new wallet', async () => {
const app = await createTestApp();
const wallet = Keypair.random().publicKey();

const res = await request(app).get(`/api/v1/fee-bump/quota/${wallet}`).expect(200);

assert.equal(res.body.used, 0);
assert.equal(res.body.remaining, res.body.limit);
assert.ok(res.body.limit > 0);
});

test('GET /api/v1/fee-bump/quota/:wallet rejects invalid address', async () => {
const app = await createTestApp();
const res = await request(app).get('/api/v1/fee-bump/quota/not-a-stellar-address').expect(400);
assert.ok(res.body.error);
});

// ── Fee-bump POST validation ───────────────────────────────────────────────────

test('POST /api/v1/fee-bump returns 400 for missing body', async () => {
const app = await createTestApp();
const res = await request(app).post('/api/v1/fee-bump').send({}).expect(400);
assert.ok(res.body.error);
});

test('POST /api/v1/fee-bump returns 400 for invalid walletAddress', async () => {
const app = await createTestApp();
const res = await request(app)
.post('/api/v1/fee-bump')
.send({ innerXdr: 'AAAAAgAAAA==', walletAddress: 'bad' })
.expect(400);
assert.ok(res.body.error);
});

test('POST /api/v1/fee-bump returns 400 for invalid XDR', async () => {
const app = await createTestApp();
const wallet = Keypair.random().publicKey();
const res = await request(app)
.post('/api/v1/fee-bump')
.send({ innerXdr: 'not-valid-base64-xdr', walletAddress: wallet })
.expect(400);
assert.ok(res.body.error);
});

test('POST /api/v1/fee-bump returns 503 when SPONSOR_SECRET_KEY not set', async () => {
const app = await createTestApp();
const userKey = Keypair.random();
const innerXdr = buildInnerXdr(userKey);
const wallet = userKey.publicKey();

const res = await request(app)
.post('/api/v1/fee-bump')
.send({ innerXdr, walletAddress: wallet })
.expect(503);
assert.ok(res.body.error);
});

test('POST /api/v1/fee-bump rejects fee-bump envelope as innerXdr', async () => {
const app = await createTestApp();
const wallet = Keypair.random().publicKey();
// A fee-bump XDR envelope type won't pass the envelopeTypeTxV1 check;
// we test with a deliberately incorrect string that decodes to wrong type.
// Here we just verify 400 is returned for non-v1 envelopes.
const res = await request(app)
.post('/api/v1/fee-bump')
.send({ innerXdr: 'AAAAA', walletAddress: wallet }) // garbage XDR
.expect(400);
assert.ok(res.body.error);
});

// ── Path payment GET /api/v1/payment-paths ────────────────────────────────────

test('GET /api/v1/payment-paths returns 400 when source_account missing', async () => {
const app = await createTestApp();
const res = await request(app)
.get('/api/v1/payment-paths?destination_asset=native&destination_amount=10')
.expect(400);
assert.ok(res.body.error);
});

test('GET /api/v1/payment-paths returns 400 for invalid destination_asset', async () => {
const app = await createTestApp();
const account = Keypair.random().publicKey();
const res = await request(app)
.get(`/api/v1/payment-paths?source_account=${account}&destination_asset=INVALID&destination_amount=10`)
.expect(400);
assert.ok(res.body.error);
});

test('GET /api/v1/payment-paths returns 400 for non-numeric destination_amount', async () => {
const app = await createTestApp();
const account = Keypair.random().publicKey();
const res = await request(app)
.get(`/api/v1/payment-paths?source_account=${account}&destination_asset=native&destination_amount=abc`)
.expect(400);
assert.ok(res.body.error);
});

// ── Path payment POST /api/v1/payment-paths/claim ─────────────────────────────

test('POST /api/v1/payment-paths/claim returns 400 for missing destinationAsset', async () => {
const app = await createTestApp();
const res = await request(app)
.post('/api/v1/payment-paths/claim')
.send({
walletAddress: Keypair.random().publicKey(),
destinationAmount: '10',
maxSendAmount: '15',
})
.expect(400);
assert.ok(res.body.error);
});

test('POST /api/v1/payment-paths/claim returns 400 for excessive slippage', async () => {
const app = await createTestApp();
const wallet = Keypair.random().publicKey();
const res = await request(app)
.post('/api/v1/payment-paths/claim')
.send({
walletAddress: wallet,
destinationAsset: 'native',
destinationAmount: '10',
maxSendAmount: '15',
slippageBps: 9999,
})
.expect(400);
assert.ok(res.body.error);
assert.ok(res.body.maxAllowed);
});

// ── Operator balance quota test ───────────────────────────────────────────────

test('fee-bump quota increments per-wallet per-day', async () => {
// Use a mock SPONSOR_SECRET_KEY that won't hit Horizon (circuit breaker will trip)
// We just test that the quota row is created
const app = await createTestApp();
const wallet = Keypair.random().publicKey();

// No sponsor key → 503, but quota check happens after XDR validation; no quota row written on 503
// Instead just confirm the quota endpoint works after repeated valid quota checks
const q1 = await request(app).get(`/api/v1/fee-bump/quota/${wallet}`).expect(200);
assert.equal(q1.body.used, 0);
});
67 changes: 67 additions & 0 deletions backend/src/jobs/operatorBalanceJob.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// #552 — Periodic operator balance check job.
// Runs every OPERATOR_BALANCE_CHECK_INTERVAL_MS (default: 5 minutes).
// Alerts via log.warn + metrics when any account is below threshold.

import { checkOperatorBalances, resolveOperatorAddresses } from '../services/operatorBalanceService.js';

const DEFAULT_CHECK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes

/**
* @param {{
* db: import('better-sqlite3').Database;
* stellarConfig: { horizonUrl: string; networkPassphrase: string };
* metrics?: { operatorLowBalance: number };
* env?: NodeJS.ProcessEnv;
* logger?: { info: Function; warn: Function; error: Function };
* }} options
* @returns {{ start: () => void; stop: () => void; runOnce: () => Promise<void> }}
*/
export function createOperatorBalanceJob({ db, stellarConfig, metrics, env = process.env, logger = console }) {
const intervalMs = Number(env.OPERATOR_BALANCE_CHECK_INTERVAL_MS ?? DEFAULT_CHECK_INTERVAL_MS);
const thresholdXlm = parseFloat(env.OPERATOR_BALANCE_THRESHOLD_XLM ?? '10');
const autoTopupEnabled = env.AUTO_TOPUP_ENABLED === 'true';
const topupSourceSecret = env.TOPUP_SOURCE_SECRET_KEY;
const topupAmountXlm = env.TOPUP_AMOUNT_XLM ?? '5';

let timer = null;

async function runOnce() {
const addresses = resolveOperatorAddresses(env);
if (addresses.length === 0) {
logger.info?.('[operatorBalanceJob] no operator addresses configured, skipping');
return;
}
await checkOperatorBalances({
db,
horizonUrl: stellarConfig.horizonUrl,
networkPassphrase: stellarConfig.networkPassphrase,
addresses,
thresholdXlm,
autoTopupEnabled,
topupSourceSecret,
topupAmountXlm,
metrics,
logger,
});
}

function start() {
timer = setInterval(async () => {
try {
await runOnce();
} catch (err) {
logger.error?.({ err: err.message }, '[operatorBalanceJob] unexpected error');
}
}, intervalMs);
// Don't block startup — run after a short delay
setTimeout(() => runOnce().catch((err) => {
logger.error?.({ err: err.message }, '[operatorBalanceJob] initial check failed');
}), 5_000);
}

function stop() {
if (timer) { clearInterval(timer); timer = null; }
}

return { start, stop, runOnce };
}
Loading
Loading