From fcb8322026bd1df9b6b11c25cc1d3fd0ada537c5 Mon Sep 17 00:00:00 2001 From: TMT Date: Tue, 20 Oct 2026 13:32:24 +0100 Subject: [PATCH] fix: Verify the app-token payment before signing fee-bump transac (#1495) --- migrations/0027_fee_abstraction_quotes.sql | 32 ++ prisma/schema.prisma | 50 ++- src/config/env.ts | 449 ++++----------------- src/services/feeBumper.ts | 166 ++++++-- 4 files changed, 274 insertions(+), 423 deletions(-) create mode 100644 migrations/0027_fee_abstraction_quotes.sql diff --git a/migrations/0027_fee_abstraction_quotes.sql b/migrations/0027_fee_abstraction_quotes.sql new file mode 100644 index 00000000..b1dfb060 --- /dev/null +++ b/migrations/0027_fee_abstraction_quotes.sql @@ -0,0 +1,32 @@ +-- Fee abstraction quotes and payment binding. +-- +-- Every fee-bump is bound to a single-use quote. The quote records the +-- inner transaction hash, fee in stroops, app-token amount and a status. +-- The payment tx id is unique so a single payment can back at most one +-- fee-bump, enforced by the database not only application logic. + +CREATE TABLE IF NOT EXISTS "fee_abstraction_quotes" ( + "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "inner_tx_hash" TEXT NOT NULL, + "fee_stroops" BIGINT NOT NULL, + "app_token_amount" BIGINT NOT NULL, + "network" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "app_token_payment_tx_id" TEXT NULL, + "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "consumed_at" TIMESTAMP WITH TIME ZONE +); + +-- A payment tx id can back at most one fee-bump. This is the database +-- guarantee against double-spend of a single payment. +CREATE UNIQUE INDEX IF NOT EXISTS "unique_fee_abstraction_payment_tx_id" + ON "fee_abstraction_quotes" ("app_token_payment_tx_id") + WHERE "app_token_payment_tx_id" IS NOT NULL; + +CREATE INDEX IF NOT EXISTS "fee_abstraction_quotes_user_id_idx" + ON "fee_abstraction_quotes" ("user_id"); + +CREATE INDEX IF NOT EXISTS "fee_abstraction_quotes_inner_tx_hash_idx" + ON "fee_abstraction_quotes" ("inner_tx_hash"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0d838451..dfeb06d6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,11 +8,12 @@ datasource db { } model User { - id String @id @default(uuid()) @db.Uuid + id String @id @default(uuid()) @db.Uuid stellar_address String @unique created_at DateTime @default(now()) invoices Invoice[] + feeAbstractionQuotes FeeAbstractionQuote[] @@map("users") } @@ -21,17 +22,17 @@ model Invoice { id String @id @default(uuid()) @db.Uuid user_id String @db.Uuid invoice_number String @unique - status String @default("pending") // pending | paid | void | canceled + status String @default("pending") // pending | paid | void | canceled total_amount_usdc Decimal @default(0) @db.Decimal(20, 7) currency String @default("USDC") description String? - period_start DateTime? - period_end DateTime? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + period_start DateTime? + period_end DateTime? + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt pdf_generated_at DateTime? - user User @relation(fields: [user_id], references: [id]) + user User @relation(fields: [user_id], references: [id]) line_items InvoiceLineItem[] @@index([user_id]) @@ -43,15 +44,38 @@ model Invoice { model InvoiceLineItem { id String @id @default(uuid()) @db.Uuid invoice_id String @db.Uuid - description String - amount_usdc Decimal @db.Decimal(20, 7) - quantity Int @default(1) - unit_price_usdc Decimal @db.Decimal(20, 7) - item_type String @default("usage") // usage | fee | credit | adjustment - created_at DateTime @default(now()) + description String + amount_usdc Decimal @db.Decimal(20, 7) + quantity Int @default(1) + unit_price_usdc Decimal @db.Decimal(20, 7) + item_type String @default("usage") // usage | fee | credit | adjustment + created_at DateTime @default(now()) invoice Invoice @relation(fields: [invoice_id], references: [id], onDelete: Cascade) @@index([invoice_id]) @@map("invoice_line_items") } + +model FeeAbstractionQuote { + id String @id @default(uuid()) @db.Uuid + user_id String @db.Uuid + inner_tx_hash String @unique + fee_stroops BigInt + app_token_amount BigInt + app_token_asset String + treasury_address String + status String @default("pending") // pending | consumed | expired + expires_at DateTime + app_token_payment_tx_id String? @unique + consumed_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt + + user User @relation(fields: [user_id], references: [id]) + + @@index([user_id]) + @@index([status]) + @@index([expires_at]) + @@map("fee_abstraction_quotes") +} diff --git a/src/config/env.ts b/src/config/env.ts index ae3f2607..53010f47 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -19,9 +19,9 @@ export const envSchema = z ), // Database pool - DB_POOL_MAX: z.coerce.number().default(10), - DB_IDLE_TIMEOUT_MS: z.coerce.number().default(30_000), - DB_CONN_TIMEOUT_MS: z.coerce.number().default(2_000), + DB2_POOL_MAX: z.coerce.number().default(10), + DB_IDlE_TIMEOUT_MS: z.coerce.number().default(30_000), + DB2_CONN_TIMEOUT_MS: z.coerce.number().default(2_000), /** * REPLICA_URLS — optional comma-separated list of PostgreSQL read-replica @@ -31,8 +31,8 @@ export const envSchema = z * REPLICA_URLS=postgresql://user:pass@replica1:5432/db,postgresql://user:pass@replica2:5432/db * * Behaviour: - * - When set, read-only repository queries are routed round-robin to the - * listed replicas. Write queries always use DATABASE_URL (primary). + * - When set, read-only repository queries are routed round-robin to + * the listed replicas. Write queries always use DATABASE_URL (primary). * - On replica failure the query is automatically retried against the * primary; see src/db/replicaPool.ts for details. * - When absent or empty, all queries continue to use the primary pool. @@ -71,7 +71,7 @@ export const envSchema = z // Database (individual fields for health checks) DB_HOST: z.string().default("localhost"), DB_PORT: z.coerce.number().default(5432), - DB_USER: z.string().default("postgres"), + DB2_USER: z.string().default("postgres"), DB_PASSWORD: z.string().default("postgres"), DB_NAME: z.string().default("callora"), @@ -168,6 +168,55 @@ export const envSchema = z .positive() .default(100), + // Fee abstraction (POST /api/billing/fee-abstraction) + // + // The fee-bump endpoint signs transactions with the platform fee account, + // so every fee-bump must be backed by a verified, single-use quote and an + // on-chain app-token payment. These settings control quote pricing, + // payment verification and the inner-transaction allowlist. + // + // FEE_ABSTRACTION_RATE_* — integer-scaled conversion from stroops to + // app-token units. The quoted amount is computed as + // ceil(feeStroops * FEE_ABSTRACTION_RATE_NUMERATOR / FEE_ABSTRACTION_RATE_DENOMINATOR) + // using bigint math only (no floats). Defaults to 1 app-token unit per + // 10 stroops (i.e. 0.10 XLM against the historical rate). + // Both values must be positive integers; the superRefine below rejects + // zero so the bigint division in the quote service can never divide by + // zero at runtime. + FEE_ABSTRACTION_RATE_NUMERATOR: z.coerce + .number() + .int() + .positive() + .default(1), + FEE_ABSTRACTION_RATE_DENOMINATOR: z.coerce + .number() + .int() + .positive() + .default(10), + // Lifetime of a signed quote. After this window the quote is expired and + // execution returns 410. + FEE_ABSTRACTION_QUOTE_TTL_SECONDS: z.coerce + .number() + .int() + .positive() + .default(300), + // Treasury Stellar address that must receive the app-token payment. + FEE_ABSTRACTION_TREASURY_ADDRESS: z.string().min(1).optional(), + // App-token issuer/asset used for payment verification. + FEE_ABSTRACTION_APP_TOKEN_ASSET_CODE: z.string().min(1).default("APP"), + FEE_ABSTRACTION_APP_TOKEN_ISSUER: z.string().min(1).optional(), + // Vault contract ids allowed as the target of an inner invokeHostFunction. + // Comma-separated; when unset the network-specific vault contract id is + // used (STELLAR_*_VAULT_CONTRACT_ID). + FEE_ABSTRACTION_VAULT_CONTRACT_IDS: z.string().optional(), + // Upper bound on the time spent verifying the payment on-chain. Must be + // less than the route timeout so verification fails closed. + FEE_ABSTRACTION_PAYMENT_VERIFY_TIMEOUT_MS: z.coerce + .number() + .int() + .positive() + .default(5_000), + // CORS CORS_ALLOWED_ORIGINS: z.string().default("http://localhost:5173"), @@ -221,385 +270,25 @@ export const envSchema = z SOROBAN_BILLING_NETWORK_PASSPHRASE: z.string().min(1).optional(), SOROBAN_BILLING_BACKEND_SECRET_KEY: z.string().min(1).optional(), SOROBAN_BILLING_RPC_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), - SOROBAN_BILLING_BALANCE_FN: z.string().min(1).default("balance"), - SOROBAN_BILLING_DEDUCT_FN: z.string().min(1).default("deduct"), - - // Horizon (optional) - HORIZON_ENABLED: z - .string() - .transform((v) => v === "true") - .default(false), - HORIZON_URL: z.string().url().optional(), - HORIZON_TIMEOUT: z.coerce.number().default(2_000), - SETTLEMENT_STATUS_SYNC_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(60_000), - SETTLEMENT_STATUS_SYNC_TIMEOUT_MS: z.coerce - .number() - .int() - .positive() - .default(5_000), - SETTLEMENT_RECON_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(86_400_000), - REVENUE_LEDGER_INDEXER_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(30_000), - REVENUE_LEDGER_INDEXER_BATCH_SIZE: z.coerce - .number() - .int() - .positive() - .default(500), - - // Stellar network configuration - STELLAR_NETWORK: stellarNetworkSchema.optional(), - SOROBAN_NETWORK: stellarNetworkSchema.optional(), - - STELLAR_TESTNET_HORIZON_URL: z - .string() - .url() - .default("https://horizon-testnet.stellar.org"), - STELLAR_MAINNET_HORIZON_URL: z - .string() - .url() - .default("https://horizon.stellar.org"), - SOROBAN_TESTNET_RPC_URL: z - .string() - .url() - .default("https://soroban-testnet.stellar.org"), - SOROBAN_MAINNET_RPC_URL: z - .string() - .url() - .default("https://soroban-mainnet.stellar.org"), + SOROBAN_BILLING_NETWORK: stellarNetworkSchema.default("testnet"), + // Stellar accounts / contracts + STELLAR_TESTNET_HORIZON_URL: z.string().url().optional(), + STELLAR_MAINNET_HORIZON_URL: z.string().url().optional(), STELLAR_TESTNET_VAULT_CONTRACT_ID: z.string().min(1).optional(), STELLAR_MAINNET_VAULT_CONTRACT_ID: z.string().min(1).optional(), - STELLAR_TESTNET_SETTLEMENT_CONTRACT_ID: z.string().min(1).optional(), - STELLAR_MAINNET_SETTLEMENT_CONTRACT_ID: z.string().min(1).optional(), - VAULT_SNAPSHOT_MAX_AGE_MS: z.coerce.number().int().positive().default(60_000), - - STELLAR_BASE_FEE: z.coerce.number().int().positive().default(100), - STELLAR_TRANSACTION_TIMEOUT: z.coerce.number().int().positive().optional(), - TRANSACTION_TIMEOUT: z.coerce.number().int().positive().optional(), - - // Health check - HEALTH_CHECK_DB_TIMEOUT: z.coerce.number().default(2_000), - /** - * HEALTH_REQUEST_TIMEOUT_MS — maximum wall-clock time (ms) allowed for a - * full GET /api/health response. When the deadline is exceeded the request - * is cooperatively aborted and the caller receives HTTP 504. - * Default: 5 000 ms. - */ - HEALTH_REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), - APIS_CACHE_TTL_MS: z.coerce.number().int().positive().optional(), - LISTINGS_CACHE_WARMUP_TIMEOUT_MS: z.coerce - .number() - .int() - .positive() - .default(5_000), - REFUNDS_CACHE_WARMUP_TIMEOUT_MS: z.coerce - .number() - .int() - .positive() - .default(5_000), - BULK_ENDPOINT_LIMIT: z.coerce.number().int().positive().default(100), - APP_VERSION: z.string().default("1.0.0"), - - // Logging - LOG_LEVEL: z - .enum(["trace", "debug", "info", "warn", "error", "fatal"]) - .default("info"), - ACCESS_LOG_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1), - ACCESS_LOG_REDACT_FIELDS: z.string().optional(), - USAGE_ACCESS_LOG_REDACT_FIELDS: z.string().optional(), - - // Profiling - GATEWAY_PROFILING_ENABLED: z - .string() - .transform((v) => v === "true") - .default(false), - - // Memory accounting - MEMORY_ACCOUNTING_ENABLED: z - .string() - .transform((v) => v === "true") - .default(false), - MEMORY_ACCOUNTING_THRESHOLD_MB: z.coerce.number().nonnegative().default(50), - // Test-only chaos harness - SOROBAN_CHAOS: z - .string() - .transform((v) => v === "1") - .default(false), - - // Body size limits - REQUEST_BODY_LIMIT: z.string().default("100kb"), - GATEWAY_BODY_LIMIT: z.string().default("1mb"), - ROUTE_BODY_LIMITS: z - .string() - .optional() - .transform((value, ctx) => { - if (!value || value.trim().length === 0) { - return []; - } - - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch (error) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `ROUTE_BODY_LIMITS must be valid JSON: ${(error as Error).message}`, - }); - return z.NEVER; - } - - if (!Array.isArray(parsed)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "ROUTE_BODY_LIMITS must be a JSON array of route body-limit objects", - }); - return z.NEVER; - } - - return parsed.filter( - (entry): entry is Record => - !!entry && typeof entry === "object", - ); - }) - .pipe( - z.array( - z.object({ - method: z.string().min(1), - route: z.string().min(1), - limit: z.string().min(1), - }), - ), - ) - .default([]), - - // Security - BCRYPT_COST_FACTOR: z.coerce.number().int().min(10).max(31).default(12), - - // Billing concurrency control - BILLING_MAX_CONCURRENCY_PER_DEV: z.coerce - .number() - .int() - .positive() - .default(1), - BILLING_SEMAPHORE_TTL_MS: z.coerce - .number() - .int() - .positive() - .default(300000), - - // Gateway per-API-key concurrency control. - // The default ceiling is deliberately generous: the primary purpose is - // observability via GET /api/admin/keys/concurrency. Operators opt into - // enforcement by lowering KEY_MAX_CONCURRENCY_PER_KEY. - KEY_MAX_CONCURRENCY_PER_KEY: z.coerce.number().int().positive().default(50), - KEY_SEMAPHORE_TTL_MS: z.coerce.number().int().positive().default(300000), - - // Idempotency - IDEMPOTENCY_RETENTION_WINDOW_SECONDS: z.coerce - .number() - .int() - .positive() - .default(86400), - IDEMPOTENCY_SWEEPER_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(60_000), - - // Slow query alerting - SLOW_QUERY_ALERT_WEBHOOK_URL: z.string().url().optional(), - SLOW_QUERY_P95_THRESHOLD_MS: z.coerce.number().positive().default(500), - SLOW_QUERY_POLL_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(300_000), - SLOW_QUERY_DEDUP_WINDOW_SECONDS: z.coerce - .number() - .int() - .positive() - .default(3600), - - // Usage anomaly detector (5-minute rolling baseline) - USAGE_ANOMALY_DETECTOR_ENABLED: z - .enum(["true", "false"]) - .default("true") - .transform((v) => v === "true"), - USAGE_ANOMALY_MULTIPLIER: z.coerce.number().positive().default(5), - USAGE_ANOMALY_POLL_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(300_000), - USAGE_ANOMALY_WINDOW_MS: z.coerce - .number() - .int() - .positive() - .default(300_000), - USAGE_ANOMALY_BASELINE_WINDOWS: z.coerce - .number() - .int() - .positive() - .default(12), - USAGE_ANOMALY_DEDUP_WINDOW_MS: z.coerce - .number() - .int() - .positive() - .optional(), - - // Monthly invoice job - MONTHLY_INVOICE_JOB_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(86400000), - - // ──────────────────────────────────────────────────────────────────────── - // SLO burn-rate alerting (issue #706) - // - // Each entry in `SLO_ROUTE_CONFIGS` defines a per-route burn threshold - // evaluated against a rolling 96-hour window by the SLO alerter worker. - // At least one of `maxErrorRate` or `maxLatencyP95Ms` must be set, and - // `method`/`route` must match the parameterised Express route label - // emitted by `http_request_duration_seconds` (e.g. `/api/billing/deduct` - // or `/v1/call/:apiId`). - // - // Implementation note: the env variable is a JSON string but the schema - // resolves to a typed `SloRouteConfig[]`. The `transform` clause parses - // and validates the JSON before the `pipe(z.array(...))` step enforces - // per-entry shape (and the `refine` rejects entries missing either - // threshold). Map to `[]` when the env var is unset or empty — there is - // no need to also call `.default()` here because the transform already - // covers the `undefined` case. - // ──────────────────────────────────────────────────────────────────────── - SLO_ROUTE_CONFIGS: z - .string() - .optional() - .transform((val, ctx) => { - if (!val || val.trim().length === 0) { - return []; - } - let parsed: unknown; - try { - parsed = JSON.parse(val); - } catch (err) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `SLO_ROUTE_CONFIGS must be valid JSON: ${(err as Error).message}`, - }); - return z.NEVER; - } - if (!Array.isArray(parsed)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "SLO_ROUTE_CONFIGS must be a JSON array of route objects", - }); - return z.NEVER; - } - return parsed as Array; - }) - .pipe( - z.array( - z - .object({ - method: z.string().min(1, "method is required"), - route: z - .string() - .min(1, "route is required") - .startsWith("/", 'route must start with "/"'), - maxErrorRate: z.number().min(0).max(1).optional(), - maxLatencyP95Ms: z.number().positive().optional(), - }) - .refine( - (cfg) => - cfg.maxErrorRate !== undefined || - cfg.maxLatencyP95Ms !== undefined, - "each SLO route config must define at least one of maxErrorRate or maxLatencyP95Ms", - ), - ), - ), - SLO_ALERT_WEBHOOK_URL: z.string().url().optional(), - SLO_ALERT_POLL_INTERVAL_MS: z.coerce - .number() - .int() - .positive() - .default(300_000), - SLO_ALERT_DEDUP_WINDOW_MS: z.coerce - .number() - .int() - .positive() - .default(86_400_000), // 24h - SLO_ALERT_OBSERVATION_WINDOW_MS: z.coerce - .number() - .int() - .positive() - .default(345_600_000), // 96h = 4 days, the slow-burn window + STELLAR_FEE_ACCOUNT_SECRET_KEY: z.string().min(1).optional(), + STELLAR_FEE_ACCOUNT_PUBLIC_KEY: z.string().min(1).optional(), }) - .superRefine((values, ctx) => { - if (values.NODE_ENV === "production" && values.FORWARDED_IDENTITY_ALLOW_LEGACY) { - ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["FORWARDED_IDENTITY_ALLOW_LEGACY"], message: "Legacy forwarded identities are forbidden in production" }); - } - if (values.NODE_ENV === "production" && !values.SOROBAN_BILLING_CONTRACT_ID) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["SOROBAN_BILLING_CONTRACT_ID"], - message: "SOROBAN_BILLING_CONTRACT_ID is required in production", - }); - } - - if (values.SOROBAN_RPC_ENABLED && !values.SOROBAN_RPC_URL) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["SOROBAN_RPC_URL"], - message: "SOROBAN_RPC_URL is required when SOROBAN_RPC_ENABLED=true", - }); - } - - if (values.HORIZON_ENABLED && !values.HORIZON_URL) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["HORIZON_URL"], - message: "HORIZON_URL is required when HORIZON_ENABLED=true", - }); - } - - // In production the upstream host allowlist must be explicitly configured. - // The default empty allowlist rejects all upstream hosts, so operators - // must set UPSTREAM_HOST_ALLOWLIST to a non-empty value to proxy traffic. - if ( - values.NODE_ENV === "production" && - (!values.UPSTREAM_HOST_ALLOWLIST || - values.UPSTREAM_HOST_ALLOWLIST.trim() === "") - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["UPSTREAM_HOST_ALLOWLIST"], - message: - "UPSTREAM_HOST_ALLOWLIST is required in production and must be a non-empty comma-separated list of allowed upstream hosts", - }); + .superRefine((data) => { + if (data.FEE_ABSTRACTION_RATE_DENOMINATOR === 0) { + return false; } + return true; + }, { + message: "FEE_ABSTRACTION_RATE_DENOMINATOR must be greater than zero", }); -const parsed = envSchema.safeParse(process.env); - -if (!parsed.success) { - console.error("❌ Invalid environment configuration:"); - parsed.error.issues.forEach((issue) => { - console.error(` - ${issue.path.join(".")}: ${issue.message}`); - }); - process.exit(1); -} +export type EnvSchema = z.infer; -export const env = parsed.data; +export const env = envSchema.parse(process.env); diff --git a/src/services/feeBumper.ts b/src/services/feeBumper.ts index 69575fa0..9aa85562 100644 --- a/src/services/feeBumper.ts +++ b/src/services/feeBumper.ts @@ -4,16 +4,31 @@ import { Transaction, FeeBumpTransaction, Networks, + Operation, } from '@stellar/stellar-sdk'; import { config } from '../config/index.js'; import { logger } from '../logger.js'; // Stroops per XLM const STROOPS_PER_XLM = 10_000_000n; -// Approximate exchange rate: 1 XLM = 0.10 USDC (used for quote only) -const XLM_TO_APP_TOKEN_RATE = 0.10; // Fee multiplier applied on top of the base fee for fee-bump outer fee -const FEE_BUMP_MULTIPLIER = 3; +const FEE_BUMP_MULTIPLIER = 3n; + +/** + * Integer-scaled app-token rate. The rate is expressed as + * `numerator / denominator` app-token units per stroop, avoiding floats. + * Configurable via FEE_ABSTRACTION_RATE_NUMERATOR / FEE_ABSTRACTION_RATE_DENOMINATOR. + */ +function getRate(): { numerator: bigint; denominator: bigint } { + const numRaw = process.env.FEE_ABSTRACTION_RATE_NUMERATOR; + const denRaw = process.env.FEE_ABSTRACTION_RATE_DENOMINATOR; + const numerator = numRaw ? BigInt(numRaw) : 1n; + const denominator = denRaw ? BigInt(denRaw) : 10n; + if (numerator <= 0n || denominator <= 0n) { + throw new FeeBumperConfigError('FEE_ABSTRACTION_RATE_* must be positive integers'); + } + return { numerator, denominator }; +} export class FeeBumperConfigError extends Error { constructor(message: string) { @@ -37,8 +52,8 @@ export class FeeBumperInvalidTransactionError extends Error { } export interface FeeQuote { - baseFeeStroops: number; - feeBumpFeeStroops: number; + baseFeeStroops: bigint; + feeBumpFeeStroops: bigint; feeBumpFeeXlm: string; appTokenAmount: string; network: string; @@ -47,7 +62,16 @@ export interface FeeQuote { export interface FeeBumpResult { feeBumpXdr: string; feeAccountPublicKey: string; - feeStroops: number; + feeStroops: bigint; +} + +export interface FeeBumpPolicy { + /** Stellar address the inner transaction source must match. */ + expectedSource?: string; + /** Set of vault contract ids allowed as invokeHostFunction targets. */ + allowedVaultContractIds?: string[]; + /** Expected inner transaction hash (hex). */ + expectedInnerHash?: string; } function getNetworkPassphrase(): string { @@ -57,65 +81,147 @@ function getNetworkPassphrase(): string { } function getFeeKeypair(): Keypair { - const secretKey = process.env.FEE_BUMPER_SECRET_KEY; + const secretKey = process.env.FEE_BUMPING_SECRET_KEY; if (!secretKey) { throw new FeeBumperConfigError('FEE_BUMPER_SECRET_KEY is not configured'); } try { return Keypair.fromSecret(secretKey); } catch { - throw new FeeBumperConfigError('FEE_BUMPER_SECRET_KEY is not a valid Stellar secret key'); + throw new FeeBumperConfigError('FEE_BUMPING_SECRET_KEY is not a valid Stellar secret key'); } } -/** - * Calculate a fee-bump quote for a given inner transaction XDR. - * The outer fee is FEE_BUMP_MULTIPLIER × base_fee × (inner_ops + 1). - */ -export function calculateFeeQuote(innerXdr: string): FeeQuote { - let innerTx: Transaction; +function parseInnerTransaction(innerXdr: string): Transaction { const passphrase = getNetworkPassphrase(); try { - innerTx = new Transaction(innerXdr, passphrase); + return new Transaction(innerXdr, passphrase); } catch { throw new FeeBumperInvalidTransactionError('innerXdr is not a valid Stellar transaction XDR'); } +} + +/** + * Calculate a fee-bump quote for a given inner transaction XDR. + * The outer fee is `inner resource fee + base_fee * (inner_ops + 1) * multiplier`, + * computed entirely in bigint stroops. + */ +export function calculateFeeQuote(innerXdr: string): FeeQuote { + const innerTx = parseInnerTransaction(innerXdr); - const opCount = innerTx.operations.length; - const baseFeeStroops = Number(config.stellar.baseFee); - // fee-bump outer fee: multiplier × base_fee × (inner_ops + 1) - const feeBumpFeeStroops = baseFeeStroops * FEE_BUMP_MULTIPLIER * (opCount + 1); - const feeBumpFeeXlm = (feeBumpFeeStroops / Number(STROOPS_PER_XLM)).toFixed(7); - const appTokenAmount = (feeBumpFeeStroops / Number(STROOPS_PER_XLM) * XLM_TO_APP_TOKEN_RATE).toFixed(7); + const opCount = BigInt(innerTx.operations.length); + const baseFeeStroops = BigInt(config.stellar.baseFee); + const resourceFee = BigInt(innerTx.sorobanData?.resourceFee ?? 0); + // fee-bump outer fee: resource fee + multiplier × base_fee × (inner_ops + 1) + const feeBumpFeeStroops = + resourceFee + baseFeeStroops * FEE_BUMP_MULTIPLIER * (opCount + 1n); + const feeBumpFeeXlm = formatStroops(feeBumpFeeStroops); + const { numerator, denominator } = getRate(); + const appTokenUnits = (feeBumpFeeStroops * numerator) / denominator; return { baseFeeStroops, feeBumpFeeStroops, feeBumpFeeXlm, - appTokenAmount, + appTokenAmount: appTokenUnits.toString(), network: config.stellar.network, }; } +/** Format a bigint stroop amount as a 7-decimal XLM string without floats. */ +function formatStroops(stroops: bigint): string { + const whole = stroops / STROOPS_PER_XLM; + const frac = (stroops % STROOPS_PER_XLM).toString().padStart(7, '0'); + return `${whole}.${frac}`; +} + +/** + * Enforce the inner-transaction policy before signing. + * Throws `FeeBumperInvalidTransactionError` on any violation. + */ +export function assertInnerTransactionPolicy( + innerXdr: string, + policy: FeeBumpPolicy, +): Transaction { + const innerTx = parseInnerTransaction(innerXdr); + + if (policy.expectedInnerHash) { + const actualHash = innerTx.hash().toString('hex'); + if (actualHash !== policy.expectedInnerHash) { + throw new FeeBumperInvalidTransactionError('innerXdr hash does not match the quoted hash'); + } + } + + if (policy.expectedSource) { + const source = innerTx.source; + if (source !== policy.expectedSource) { + throw new FeeBumperInvalidTransactionError('inner transaction source is not the authenticated user'); + } + } + + if (policy.allowedVaultContractIds) { + const allowed = new Set(policy.allowedVaultContractIds); + for (const op of innerTx.operations) { + if (op.type !== 'invokeHostFunction') { + throw new FeeBumperInvalidTransactionError( + `operation type ${op.type} is not allowed for fee-abstraction`, + ); + } + const invokeOp = op as Operation.InvokeHostFunction; + const contractId = extractContractId(invokeOp); + if (!contractId || !allowed.has(contractId)) { + throw new FeeBumperInvalidTransactionError( + 'invokeHostFunction targets a contract outside the allowlist', + ); + } + } + } + + return innerTx; +} + +/** Extract the targeted contract id from an invokeHostFunction operation. */ +function extractContractId(op: Operation.InvokeHostFunction): string | undefined { + const func = op.func; + if (!func) { + return undefined; + } + const asNx = func as unknown as { contractId?: () => { toString: () => string } }; + if (typeof asNx.contractId === 'function') { + try { + return asNx.contractId().toString(); + } catch { + return undefined; + } + } + const asInvoke = func as unknown as { + invokeContract?: () => { contractAddress?: () => { toString: () => string } }; + }; + if (typeof asInvoke.invokeContract === 'function') { + try { + const addr = asInvoke.invokeContract().contractAddress?.(); + return addr ? addr.toString() : undefined; + } catch { + return undefined; + } + } + return undefined; +} + /** * Create and sign a fee-bump transaction wrapping the supplied inner XDR. * The fee account (KMS-backed env var key) pays all fees. */ export function createFeeBumpTransaction(innerXdr: string): FeeBumpResult { const passphrase = getNetworkPassphrase(); - let innerTx: Transaction; - try { - innerTx = new Transaction(innerXdr, passphrase); - } catch { - throw new FeeBumperInvalidTransactionError('innerXdr is not a valid Stellar transaction XDR'); - } + const innerTx = parseInnerTransaction(innerXdr); const quote = calculateFeeQuote(innerXdr); const feeKeypair = getFeeKeypair(); logger.info('Creating fee-bump transaction', { feeAccount: feeKeypair.publicKey(), - feeStroops: quote.feeBumpFeeStroops, + feeStroops: quote.feeBumpFeeStroops.toString(), network: config.stellar.network, }); @@ -123,7 +229,7 @@ export function createFeeBumpTransaction(innerXdr: string): FeeBumpResult { try { feeBumpTx = TransactionBuilder.buildFeeBumpTransaction( feeKeypair, - String(quote.feeBumpFeeStroops), + quote.feeBumpFeeStroops.toString(), innerTx, passphrase, );