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 227be5f0..16814283 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -48,9 +48,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 @@ -60,8 +60,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. @@ -100,7 +100,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"), @@ -197,6 +197,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"), @@ -250,6 +299,15 @@ export const envSchema = z SOROBAN_BILLING_NETWORK_PASSPHRASE: z.string().min(1).optional(), SOROBAN_BILLING_BACKEND_SECRET_KEY: secretKey.optional(), SOROBAN_BILLING_RPC_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), + 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_FEE_ACCOUNT_SECRET_KEY: z.string().min(1).optional(), + STELLAR_FEE_ACCOUNT_PUBLIC_KEY: z.string().min(1).optional(), SOROBAN_BILLING_BALANCE_FN: z.string().min(1).default("balance"), SOROBAN_BILLING_DEDUCT_FN: z.string().min(1).default("deduct"), @@ -586,47 +644,12 @@ export const envSchema = z .transform((v) => v === "true") .default(false), }) - .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) { + .superRefine((data, ctx) => { + if (data.FEE_ABSTRACTION_RATE_DENOMINATOR === 0) { 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", + path: ["FEE_ABSTRACTION_RATE_DENOMINATOR"], + message: "FEE_ABSTRACTION_RATE_DENOMINATOR must be greater than zero", }); } @@ -642,7 +665,7 @@ export const envSchema = z // IMPORTANT: never interpolate a secret value into an issue message. // Only the variable name and the failing rule are reported. // ────────────────────────────────────────────────────────────────────── - const activeNetwork = values.STELLAR_NETWORK ?? values.SOROBAN_NETWORK; + const activeNetwork = data.STELLAR_NETWORK ?? data.SOROBAN_NETWORK; const activeNetworkPassphrase = activeNetwork === "mainnet" @@ -653,23 +676,23 @@ export const envSchema = z const activeNetworkRpcUrl = activeNetwork === "mainnet" - ? values.SOROBAN_MAINNET_RPC_URL + ? data.SOROBAN_MAINNET_RPC_URL : activeNetwork === "testnet" - ? values.SOROBAN_TESTNET_RPC_URL + ? data.SOROBAN_TESTNET_RPC_URL : undefined; const activeVaultContractId = activeNetwork === "mainnet" - ? values.STELLAR_MAINNET_VAULT_CONTRACT_ID + ? data.STELLAR_MAINNET_VAULT_CONTRACT_ID : activeNetwork === "testnet" - ? values.STELLAR_TESTNET_VAULT_CONTRACT_ID + ? data.STELLAR_TESTNET_VAULT_CONTRACT_ID : undefined; // 1. Billing passphrase must match the active network when both are set. if ( - values.SOROBAN_BILLING_NETWORK_PASSPHRASE !== undefined && + data.SOROBAN_BILLING_NETWORK_PASSPHRASE !== undefined && activeNetworkPassphrase !== undefined && - values.SOROBAN_BILLING_NETWORK_PASSPHRASE !== activeNetworkPassphrase + data.SOROBAN_BILLING_NETWORK_PASSPHRASE !== activeNetworkPassphrase ) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -681,9 +704,9 @@ export const envSchema = z // 2. Production must not silently run against the testnet passphrase. if ( - values.NODE_ENV === "production" && + data.NODE_ENV === "production" && activeNetwork === "testnet" && - !values.ALLOW_TESTNET_IN_PRODUCTION + !data.ALLOW_TESTNET_IN_PRODUCTION ) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -695,9 +718,9 @@ export const envSchema = z // 3. Production must not fall back to the localhost Soroban RPC. if ( - values.NODE_ENV === "production" && - values.SOROBAN_BILLING_RPC_URL === undefined && - values.SOROBAN_RPC_URL === undefined && + data.NODE_ENV === "production" && + data.SOROBAN_BILLING_RPC_URL === undefined && + data.SOROBAN_RPC_URL === undefined && activeNetworkRpcUrl === undefined ) { ctx.addIssue({ @@ -712,9 +735,9 @@ export const envSchema = z // differs it is treated as an explicit override and logged once at // startup (see src/services/createSorobanBillingService.ts). if ( - values.SOROBAN_BILLING_CONTRACT_ID !== undefined && + data.SOROBAN_BILLING_CONTRACT_ID !== undefined && activeVaultContractId !== undefined && - values.SOROBAN_BILLING_CONTRACT_ID !== activeVaultContractId + data.SOROBAN_BILLING_CONTRACT_ID !== activeVaultContractId ) { // No issue: this is a supported override. The warning is emitted by // the billing service factory so it fires exactly once per process. @@ -724,20 +747,20 @@ export const envSchema = z // The public key is computed lazily here to avoid pulling the StrKey // dependency into the hot path when the secret is unset. if ( - values.SOROBAN_BILLING_BACKEND_SECRET_KEY !== undefined && - values.SOROBAN_BILLING_SOURCE_ACCOUNT !== undefined + data.SOROBAN_BILLING_BACKEND_SECRET_KEY !== undefined && + data.SOROBAN_BILLING_SOURCE_ACCOUNT !== undefined ) { let derivedPublicKey: string | undefined; try { derivedPublicKey = Keypair.fromSecret( - values.SOROBAN_BILLING_BACKEND_SECRET_KEY, + data.SOROBAN_BILLING_BACKEND_SECRET_KEY, ).publicKey(); } catch { derivedPublicKey = undefined; } if ( derivedPublicKey !== undefined && - derivedPublicKey !== values.SOROBAN_BILLING_SOURCE_ACCOUNT + derivedPublicKey !== data.SOROBAN_BILLING_SOURCE_ACCOUNT ) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -749,14 +772,6 @@ export const envSchema = z } }); -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); \ No newline at end of file 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, );