From a9436e4fc556c6586ac28052ffe3bda74628c29a Mon Sep 17 00:00:00 2001 From: Otowo Samuel Date: Fri, 16 Oct 2026 20:15:07 +0000 Subject: [PATCH] fix: address bridge and backend issue reports --- backend/openapi.json | 45 +++---- backend/package.json | 2 +- .../migration.sql | 2 + backend/prisma/schema.prisma | 1 - .../scripts/backfill-webhook-secret-hashes.js | 34 +++++ backend/scripts/check-migrations.js | 4 + backend/src/__tests__/feeCalculator.test.ts | 17 ++- backend/src/__tests__/sanitization.test.ts | 12 +- .../__tests__/transferOrchestrator.test.ts | 19 +++ .../src/__tests__/webhookVerification.test.ts | 35 +++++ backend/src/feeCalculator.ts | 37 ++++- backend/src/sanitization.ts | 9 +- backend/src/sorobanClient.ts | 6 +- backend/src/swagger.ts | 21 ++- backend/src/transferOrchestrator.ts | 19 +-- backend/src/webhookDelivery.ts | 11 +- contracts/bridge-compat/src/lib.rs | 126 ++++++++++++++---- 17 files changed, 302 insertions(+), 98 deletions(-) create mode 100644 backend/prisma/migrations/20261016000000_remove_webhook_plaintext_secret/migration.sql create mode 100644 backend/scripts/backfill-webhook-secret-hashes.js diff --git a/backend/openapi.json b/backend/openapi.json index a334cba3a..617156ff5 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -85,11 +85,17 @@ "ErrorEnvelope": { "type": "object", "required": [ + "error", "status", + "code", "message", - "error" + "retryable" ], "properties": { + "error": { + "type": "string", + "example": "Bad Request" + }, "status": { "type": "integer", "example": 400 @@ -98,30 +104,21 @@ "type": "string", "example": "Some of the information you provided is invalid. Please check and try again." }, - "error": { + "code": { + "type": "string", + "example": "VALIDATION_ERROR" + }, + "retryable": { + "type": "boolean", + "example": false + }, + "correlationId": { + "type": "string", + "format": "uuid" + }, + "details": { "type": "object", - "required": [ - "code", - "message" - ], - "properties": { - "code": { - "type": "string", - "example": "VALIDATION_ERROR" - }, - "message": { - "type": "string", - "example": "Validation failed" - }, - "correlationId": { - "type": "string", - "format": "uuid" - }, - "details": { - "type": "object", - "nullable": true - } - } + "nullable": true } } }, diff --git a/backend/package.json b/backend/package.json index 486b53801..39511349e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -34,7 +34,7 @@ "prisma:generate": "prisma generate --schema prisma/schema.prisma", "prisma:schema-check": "prisma validate --schema prisma/schema.prisma && prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --exit-code", "prisma:migrate": "prisma migrate dev --schema prisma/schema.prisma", - "prisma:deploy": "prisma migrate deploy --schema prisma/schema.prisma", + "prisma:deploy": "node scripts/backfill-webhook-secret-hashes.js && prisma migrate deploy --schema prisma/schema.prisma", "prisma:status": "prisma migrate status --schema prisma/schema.prisma", "prisma:rollback": "node scripts/prisma-rollback.js", "seed": "tsx prisma/seed.ts", diff --git a/backend/prisma/migrations/20261016000000_remove_webhook_plaintext_secret/migration.sql b/backend/prisma/migrations/20261016000000_remove_webhook_plaintext_secret/migration.sql new file mode 100644 index 000000000..03c63b2ca --- /dev/null +++ b/backend/prisma/migrations/20261016000000_remove_webhook_plaintext_secret/migration.sql @@ -0,0 +1,2 @@ +-- migration-safety: allow-webhook-secret-drop +ALTER TABLE "WebhookEndpoint" DROP COLUMN "secret"; \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 766d29ba2..151c82c85 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -236,7 +236,6 @@ model WebhookEndpoint { url String eventTypes String // JSON array enabled Boolean @default(true) - secret String? secretHash String? verificationStatus String @default("pending") challengeTokenHash String? diff --git a/backend/scripts/backfill-webhook-secret-hashes.js b/backend/scripts/backfill-webhook-secret-hashes.js new file mode 100644 index 000000000..ca0f304a0 --- /dev/null +++ b/backend/scripts/backfill-webhook-secret-hashes.js @@ -0,0 +1,34 @@ +'use strict'; + +const crypto = require('crypto'); +const { PrismaClient } = require('@prisma/client'); + +const prisma = new PrismaClient(); + +async function main() { + const columns = await prisma.$queryRawUnsafe('PRAGMA table_info("WebhookEndpoint")'); + const columnNames = new Set(columns.map((column) => column.name)); + if (!columnNames.has('secret') || !columnNames.has('secretHash')) return; + + const endpoints = await prisma.$queryRawUnsafe( + 'SELECT id, secret FROM "WebhookEndpoint" WHERE secret IS NOT NULL AND secretHash IS NULL', + ); + + for (const endpoint of endpoints) { + const secretHash = crypto.createHash('sha256').update(endpoint.secret).digest('hex'); + await prisma.$executeRawUnsafe( + 'UPDATE "WebhookEndpoint" SET secretHash = ? WHERE id = ?', + secretHash, + endpoint.id, + ); + } +} + +main() + .catch((error) => { + console.error('Webhook secret hash backfill failed:', error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); \ No newline at end of file diff --git a/backend/scripts/check-migrations.js b/backend/scripts/check-migrations.js index 05280a551..79c0d3a0f 100644 --- a/backend/scripts/check-migrations.js +++ b/backend/scripts/check-migrations.js @@ -100,6 +100,9 @@ function checkFile(file) { const allowsBootstrapIndexes = lowered.includes( 'migration-safety: allow-nonconcurrent-indexes' ); + const allowsWebhookSecretDrop = + lowered.includes('migration-safety: allow-webhook-secret-drop') && + /alter\s+table\s+["`]?webhookendpoint["`]?\s+drop\s+column\s+["`]?secret["`]?/i.test(content); const isCanarySafeOptIn = lowered.includes( 'migration-safety: canary-safe' ); @@ -114,6 +117,7 @@ function checkFile(file) { ]; for (const rule of highRiskPatterns) { + if (allowsWebhookSecretDrop && rule.label === 'blocking alter/drop change') continue; if (rule.pattern.test(content)) { results.push({ file, severity: 'error', message: rule.label }); } diff --git a/backend/src/__tests__/feeCalculator.test.ts b/backend/src/__tests__/feeCalculator.test.ts index bbb2f57c6..f39fce5dc 100644 --- a/backend/src/__tests__/feeCalculator.test.ts +++ b/backend/src/__tests__/feeCalculator.test.ts @@ -1,5 +1,9 @@ // src/__tests__/feeCalculator.test.ts -import { getDeterministicBaseFee } from '../feeCalculator'; +import { + getDeterministicBaseFee, + moneyAmountToBaseUnits, + parseMoneyAmount, +} from '../feeCalculator'; describe('getDeterministicBaseFee', () => { const originalEnv = process.env.FIXED_BASE_FEE; @@ -26,4 +30,15 @@ describe('getDeterministicBaseFee', () => { process.env.FIXED_BASE_FEE = 'abc'; expect(() => getDeterministicBaseFee()).toThrow('FIXED_BASE_FEE must be a positive integer string'); }); + + test('converts supported decimal precision to base units without rounding', () => { + expect(moneyAmountToBaseUnits('1.2345678')).toBe('12345678'); + }); + + test('rejects amounts requiring rounding or exceeding i128 capacity', () => { + expect(() => parseMoneyAmount('1.23456789')).toThrow('7 decimal places'); + expect(() => parseMoneyAmount('17014118346046923173168730371588.4105728')).toThrow( + 'maximum supported value', + ); + }); }); diff --git a/backend/src/__tests__/sanitization.test.ts b/backend/src/__tests__/sanitization.test.ts index e98d0f34e..0ab99f2c1 100644 --- a/backend/src/__tests__/sanitization.test.ts +++ b/backend/src/__tests__/sanitization.test.ts @@ -1,10 +1,12 @@ import request from 'supertest'; import express, { Request, Response, NextFunction } from 'express'; import { sanitizationMiddleware, isValidStellarAddress } from '../sanitization'; +import { correlationIdMiddleware } from '../middleware/correlationId'; const app = express(); app.use(express.json({ limit: '1kb' })); +app.use(correlationIdMiddleware); app.use(sanitizationMiddleware); app.post('/test', (req: Request, res: Response) => { @@ -51,9 +53,17 @@ describe('Sanitization Middleware', () => { it('should return 400 for numbers out of safe range', async () => { const payload = { num: Number.MAX_SAFE_INTEGER + 10 }; - const response = await request(app).post('/test').send(payload); + const correlationId = '123e4567-e89b-12d3-a456-426614174000'; + const response = await request(app).post('/test').set('X-Correlation-ID', correlationId).send(payload); expect(response.status).toBe(400); expect(response.body.message).toContain('Numeric parameter is out of acceptable range'); + expect(response.body).toMatchObject({ + error: 'Bad Request', + status: 400, + code: 'VALIDATION_ERROR', + correlationId, + }); + expect(response.body.retryable).toBe(false); }); it('should return 413 for oversized payloads', async () => { diff --git a/backend/src/__tests__/transferOrchestrator.test.ts b/backend/src/__tests__/transferOrchestrator.test.ts index 304eff338..d149b9681 100644 --- a/backend/src/__tests__/transferOrchestrator.test.ts +++ b/backend/src/__tests__/transferOrchestrator.test.ts @@ -90,6 +90,17 @@ describe('transferOrchestrator', () => { expect(res.outcome).toBe('submitted'); }); + test('preserves the smallest supported token amount exactly', async () => { + await orchestrateTransfer({ ...params, amount: '0.0000001' }, freshKey()); + + expect(sorobanClient.submitVaultOperation).toHaveBeenCalledWith( + 'deposit', + VALID_TEST_WALLET, + '0.0000001', + 'USDC', + ); + }); + test('a repeat with the same key replays without a second submission', async () => { const key = freshKey(); const first = await orchestrateTransfer(params, key); @@ -228,6 +239,14 @@ describe('transferOrchestrator', () => { test.each([ ['a negative amount', { amount: '-1' }, 'TRANSFER_AMOUNT_INVALID'], ['a zero amount', { amount: '0' }, 'TRANSFER_AMOUNT_INVALID'], + ['an amount with excessive precision', { amount: '1.12345678' }, 'TRANSFER_AMOUNT_INVALID'], + [ + 'an amount above i128 base-unit capacity', + { amount: '17014118346046923173168730371588.4105728' }, + 'TRANSFER_AMOUNT_INVALID', + ], + ['an amount with excessive precision', { amount: '1.12345678' }, 'TRANSFER_AMOUNT_INVALID'], + ['an amount above i128 base-unit capacity', { amount: '17014118346046923173168730371.5884106' }, 'TRANSFER_AMOUNT_INVALID'], ['a NaN amount', { amount: 'NaN' }, 'TRANSFER_AMOUNT_INVALID'], ['an Infinity amount', { amount: 'Infinity' }, 'TRANSFER_AMOUNT_INVALID'], ['an exponent amount', { amount: '1e3' }, 'TRANSFER_AMOUNT_INVALID'], diff --git a/backend/src/__tests__/webhookVerification.test.ts b/backend/src/__tests__/webhookVerification.test.ts index 80609381b..0448ce92a 100644 --- a/backend/src/__tests__/webhookVerification.test.ts +++ b/backend/src/__tests__/webhookVerification.test.ts @@ -1,3 +1,4 @@ +import crypto from 'crypto'; import { registerWebhookEndpoint, verifyWebhookEndpoint, @@ -6,6 +7,7 @@ import { buildWebhookSignedEnvelope, createWebhookSignature, markWebhookDeliverySeen, + verifyIncomingWebhookPayload, } from '../webhookDelivery'; describe('Webhook endpoint verification handshake', () => { @@ -93,4 +95,37 @@ describe('Webhook endpoint verification handshake', () => { expect(markWebhookDeliverySeen(delivery.endpointId, delivery.id, envelope.sentAt)).toBe(true); expect(markWebhookDeliverySeen(delivery.endpointId, delivery.id, envelope.sentAt)).toBe(false); }); + + it('stores no plaintext secret and verifies signatures using the persisted hash', () => { + const secret = 'test-key-42'; + const endpoint = registerWebhookEndpoint({ + url: 'https://example.com/hook', + enabled: true, + secret, + }); + const delivery = { + id: 'whd_hash_test', + endpointId: endpoint.id, + endpointUrl: endpoint.url, + eventType: 'transaction.deposit.created' as const, + status: 'pending' as const, + attempts: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const envelope = buildWebhookSignedEnvelope(delivery, { + transactionId: 'tx-hash-test', + amount: '100', + asset: 'USDC', + walletAddress: `G${'A'.repeat(55)}`, + transactionHash: '0xdef', + status: 'pending', + timestamp: new Date().toISOString(), + }); + const secretHash = crypto.createHash('sha256').update(secret).digest('hex'); + const signature = createWebhookSignature(secretHash, envelope); + + expect(JSON.stringify(endpoint)).not.toContain(secret); + expect(verifyIncomingWebhookPayload(endpoint.id, envelope, signature).verified).toBe(true); + }); }); diff --git a/backend/src/feeCalculator.ts b/backend/src/feeCalculator.ts index b7bd1d00e..4a1b6b77a 100644 --- a/backend/src/feeCalculator.ts +++ b/backend/src/feeCalculator.ts @@ -1,4 +1,33 @@ // src/feeCalculator.ts +import Decimal from 'decimal.js'; + +const MoneyDecimal = Decimal.clone({ precision: 50 }); +const TOKEN_SCALE = new MoneyDecimal(10).pow(7); +const MAX_I128 = new MoneyDecimal('170141183460469231731687303715884105727'); +const AMOUNT_PATTERN = /^\d+(\.\d+)?$/; + +export function parseMoneyAmount(value: unknown): Decimal { + if (typeof value !== 'string' || !AMOUNT_PATTERN.test(value.trim())) { + throw new Error('amount must be a plain decimal string'); + } + + const amount = new MoneyDecimal(value.trim()); + if (!amount.isFinite() || amount.lte(0)) { + throw new Error('amount must be greater than zero'); + } + if (amount.decimalPlaces() > 7) { + throw new Error('amount must not exceed 7 decimal places'); + } + if (amount.times(TOKEN_SCALE).gt(MAX_I128)) { + throw new Error('amount exceeds the maximum supported value'); + } + return amount; +} + +export function moneyAmountToBaseUnits(value: string): string { + return parseMoneyAmount(value).times(TOKEN_SCALE).toFixed(0); +} + /** * Deterministic fee calculator. * Returns a base fee that is stable across runs. @@ -8,8 +37,12 @@ export function getDeterministicBaseFee(): string { const envFee = process.env.FIXED_BASE_FEE; if (envFee) { - // Basic validation – ensure it's a positive integer string. - if (!/^[0-9]+$/.test(envFee)) { + // Base fees are integer stroops and must fit the contract's i128 amount. + if (!/^\d+$/.test(envFee)) { + throw new Error('FIXED_BASE_FEE must be a positive integer string'); + } + const fee = new MoneyDecimal(envFee); + if (fee.lte(0) || fee.gt(MAX_I128)) { throw new Error('FIXED_BASE_FEE must be a positive integer string'); } return envFee; diff --git a/backend/src/sanitization.ts b/backend/src/sanitization.ts index 3fbc86c89..0e40d7312 100644 --- a/backend/src/sanitization.ts +++ b/backend/src/sanitization.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { StrKey } from '@stellar/stellar-base'; +import { sendApiError } from './middleware/apiError'; const MAX_SAFE_NUMBER = Number.MAX_SAFE_INTEGER; const MIN_SAFE_NUMBER = Number.MIN_SAFE_INTEGER; @@ -32,11 +33,11 @@ export const sanitizationMiddleware = (req: Request, res: Response, next: NextFu try { sanitizeObject(req.body); next(); - } catch (error: any) { - res.status(400).json({ - error: 'Bad Request', + } catch (error: unknown) { + sendApiError(req, res, { status: 400, - message: error.message || 'Invalid input parameters detected', + code: 'VALIDATION_ERROR', + message: error instanceof Error ? error.message : 'Invalid input parameters detected', }); } }; diff --git a/backend/src/sorobanClient.ts b/backend/src/sorobanClient.ts index 0c3b8a01b..bfca75060 100644 --- a/backend/src/sorobanClient.ts +++ b/backend/src/sorobanClient.ts @@ -13,7 +13,7 @@ import { StrKey, TransactionBuilder, } from '@stellar/stellar-sdk'; -import { getDeterministicBaseFee } from './feeCalculator'; +import { getDeterministicBaseFee, moneyAmountToBaseUnits } from './feeCalculator'; import { logger } from './middleware/structuredLogging'; import { getCurrentTraceId } from './tracing'; import { sorobanRetryBudget } from './retryBudget'; @@ -201,10 +201,10 @@ export async function submitVaultOperation( const sourceAccount = await rpcClient.getAccount(sourceKeypair.publicKey()); const contract = new Contract(contractId); - // Convert the amount string to BigInt so nativeToScVal produces a valid i128. + // Convert the decimal token amount to exact 7-decimal base units. let amountBigInt: bigint; try { - amountBigInt = BigInt(Math.round(Number(amount))); + amountBigInt = BigInt(moneyAmountToBaseUnits(amount)); } catch { throw new SorobanSimulationError( `Invalid amount value: ${amount}`, diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts index c24613499..b63be75cc 100644 --- a/backend/src/swagger.ts +++ b/backend/src/swagger.ts @@ -35,8 +35,8 @@ const options: swaggerJsdoc.Options = { description: 'API documentation for the YieldVault Stellar RWA backend. ' + 'The API exposes vault deposits/withdrawals, portfolio reads, referrals, ' + - 'webhooks, and operational admin endpoints. All responses use the standard ' + - 'error envelope `{ status, message, error: { code, message, correlationId } }`.\n\n' + + 'webhooks, and operational admin endpoints. Errors use the standard envelope ' + + '`{ error, status, code, message, retryable, correlationId }`.\n\n' + '**Rate limiting:** ' + 'Global limiter is 100 requests / 15 minutes per IP; authenticated API endpoints ' + 'are limited to 30 requests / minute per API key. Exceeding a limit returns `429` ' + @@ -130,24 +130,19 @@ const options: swaggerJsdoc.Options = { schemas: { ErrorEnvelope: { type: 'object', - required: ['status', 'message', 'error'], + required: ['error', 'status', 'code', 'message', 'retryable'], properties: { + error: { type: 'string', example: 'Bad Request' }, status: { type: 'integer', example: 400 }, message: { type: 'string', example: 'Some of the information you provided is invalid. Please check and try again.', }, - error: { - type: 'object', - required: ['code', 'message'], - properties: { - code: { type: 'string', example: 'VALIDATION_ERROR' }, - message: { type: 'string', example: 'Validation failed' }, - correlationId: { type: 'string', format: 'uuid' }, - details: { type: 'object', nullable: true }, - }, - }, + code: { type: 'string', example: 'VALIDATION_ERROR' }, + retryable: { type: 'boolean', example: false }, + correlationId: { type: 'string', format: 'uuid' }, + details: { type: 'object', nullable: true }, }, }, PaginationMeta: { diff --git a/backend/src/transferOrchestrator.ts b/backend/src/transferOrchestrator.ts index ee3f11a42..4075417df 100644 --- a/backend/src/transferOrchestrator.ts +++ b/backend/src/transferOrchestrator.ts @@ -49,6 +49,7 @@ import { sorobanCircuitBreaker, CircuitOpenError } from './circuitBreaker'; import { isValidStellarAddress, normalizeWalletAddress } from './walletUtils'; import { logger } from './middleware/structuredLogging'; import { getCurrentTraceId } from './tracing'; +import { parseMoneyAmount } from './feeCalculator'; import { transferOrchestrationTotal, transferOrchestrationReplayTotal, @@ -374,8 +375,6 @@ export function resetTransferOrchestratorForTests(): void { * plain positive decimal only — no exponents, no signs, no `Infinity`/`NaN`, * all of which `Number()` would happily coerce further down the stack. */ -const AMOUNT_PATTERN = /^\d+(\.\d+)?$/; - function assertValidKey(idempotencyKey: unknown): string { if (typeof idempotencyKey !== 'string' || idempotencyKey.trim() === '') { throw new TransferValidationError( @@ -421,16 +420,12 @@ function assertValidParams(params: TransferParams): TransferParams { 422, ); } - if (typeof params.amount !== 'string' || !AMOUNT_PATTERN.test(params.amount.trim())) { - throw new TransferValidationError( - 'amount must be a positive decimal string', - 'TRANSFER_AMOUNT_INVALID', - 422, - ); - } - if (Number(params.amount) <= 0) { + let amount: string; + try { + amount = parseMoneyAmount(params.amount).toFixed(); + } catch (error) { throw new TransferValidationError( - 'amount must be greater than zero', + error instanceof Error ? error.message : 'amount is invalid', 'TRANSFER_AMOUNT_INVALID', 422, ); @@ -443,7 +438,7 @@ function assertValidParams(params: TransferParams): TransferParams { return { operationType: params.operationType, walletAddress: normalizeWalletAddress(params.walletAddress), - amount: params.amount.trim(), + amount, asset: params.asset.trim().toUpperCase(), }; } diff --git a/backend/src/webhookDelivery.ts b/backend/src/webhookDelivery.ts index 5d5aebd5c..c256090d0 100644 --- a/backend/src/webhookDelivery.ts +++ b/backend/src/webhookDelivery.ts @@ -49,7 +49,6 @@ interface InternalWebhookEndpoint { url: string; eventTypes: TransactionEventType[]; enabled: boolean; - secret?: string; secretHash?: string; verificationStatus: WebhookVerificationStatus; challengeToken?: string; @@ -270,7 +269,6 @@ export function registerWebhookEndpoint(input: RegisterWebhookInput): WebhookEnd ? input.eventTypes : DEFAULT_WEBHOOK_EVENT_TYPES, enabled: input.enabled ?? isUnverifiedDeliveryAllowed(), - secret: input.secret, secretHash: input.secret ? hashWebhookSecret(input.secret) : undefined, verificationStatus: 'pending', challengeToken, @@ -303,7 +301,6 @@ export function updateWebhookEndpoint( ...existing, enabled: input.enabled ?? existing.enabled, eventTypes: input.eventTypes ?? existing.eventTypes, - secret: input.secret ?? existing.secret, secretHash: typeof input.secret === 'string' ? hashWebhookSecret(input.secret) : existing.secretHash, updatedAt: new Date().toISOString(), @@ -637,7 +634,7 @@ export function verifyIncomingWebhookPayload( if (!endpoint || endpoint.deletedAt) { return { verified: false, reason: 'unknown-endpoint' }; } - if (!endpoint.secret) { + if (!endpoint.secretHash) { return { verified: false, reason: 'missing-secret' }; } if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) { @@ -661,7 +658,7 @@ export function verifyIncomingWebhookPayload( if ( typeof signature !== 'string' || - !verifyWebhookSignature(endpoint.secret, envelope, signature) + !verifyWebhookSignature(endpoint.secretHash, envelope, signature) ) { return { verified: false, reason: 'invalid-signature' }; } @@ -775,8 +772,8 @@ async function deliverWithRetry( [WEBHOOK_HEADERS.deliveryId]: delivery.id, }; - if (endpoint.secret) { - headers[WEBHOOK_HEADERS.signature] = createWebhookSignature(endpoint.secret, envelope); + if (endpoint.secretHash) { + headers[WEBHOOK_HEADERS.signature] = createWebhookSignature(endpoint.secretHash, envelope); } const controller = new AbortController(); diff --git a/contracts/bridge-compat/src/lib.rs b/contracts/bridge-compat/src/lib.rs index a9cf949ea..b11716fab 100644 --- a/contracts/bridge-compat/src/lib.rs +++ b/contracts/bridge-compat/src/lib.rs @@ -157,14 +157,24 @@ enum DataKey { UserTransferCount(Address), EpochVolume, EpochStart, + EpochAccounting, Limits, DefaultProvider, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +struct EpochAccounting { + start: u64, + volume: i128, +} + // ── Constants ────────────────────────────────────────────────────────────── const STELLAR_CHAIN_ID: u32 = 0; const BPS_DENOMINATOR: i128 = 10_000; +const STORAGE_TTL_THRESHOLD: u32 = 10_000; +const STORAGE_TTL_EXTENSION: u32 = 100_000; // ── Contract ─────────────────────────────────────────────────────────────── @@ -245,9 +255,7 @@ impl BridgeCompat { supported_chains, }; - env.storage() - .instance() - .set(&DataKey::Provider(provider_id), &provider); + Self::set_provider(&env, provider_id, &provider); env.storage() .instance() .set(&DataKey::ProviderNonce, &provider_id); @@ -277,9 +285,7 @@ impl BridgeCompat { Self::require_admin(&env)?; let mut provider = Self::get_provider(&env, provider_id)?; provider.enabled = enabled; - env.storage() - .instance() - .set(&DataKey::Provider(provider_id), &provider); + Self::set_provider(&env, provider_id, &provider); env.events() .publish((symbol_short!("brgtog"),), (provider_id, enabled)); Ok(()) @@ -293,9 +299,7 @@ impl BridgeCompat { } let mut provider = Self::get_provider(&env, provider_id)?; provider.fee_bps = fee_bps; - env.storage() - .instance() - .set(&DataKey::Provider(provider_id), &provider); + Self::set_provider(&env, provider_id, &provider); Ok(()) } @@ -596,10 +600,35 @@ impl BridgeCompat { } fn get_provider(env: &Env, provider_id: u32) -> Result { - env.storage() + let key = DataKey::Provider(provider_id); + if let Some(provider) = env.storage().persistent().get(&key) { + env.storage().persistent().extend_ttl( + &key, + STORAGE_TTL_THRESHOLD, + STORAGE_TTL_EXTENSION, + ); + return Ok(provider); + } + + // Migrate providers created by earlier releases out of instance storage. + let provider: BridgeProvider = env + .storage() .instance() - .get(&DataKey::Provider(provider_id)) - .ok_or(BridgeError::ProviderNotFound) + .get(&key) + .ok_or(BridgeError::ProviderNotFound)?; + Self::set_provider(env, provider_id, &provider); + env.storage().instance().remove(&key); + Ok(provider) + } + + fn set_provider(env: &Env, provider_id: u32, provider: &BridgeProvider) { + let key = DataKey::Provider(provider_id); + env.storage().persistent().set(&key, provider); + env.storage().persistent().extend_ttl( + &key, + STORAGE_TTL_THRESHOLD, + STORAGE_TTL_EXTENSION, + ); } fn check_epoch_volume( @@ -608,33 +637,49 @@ impl BridgeCompat { limits: &TransferLimits, ) -> Result<(), BridgeError> { let now = env.ledger().timestamp(); - let epoch_start: u64 = env - .storage() - .instance() - .get(&DataKey::EpochStart) - .unwrap_or(0); - let mut epoch_volume: i128 = env - .storage() - .instance() - .get(&DataKey::EpochVolume) - .unwrap_or(0); + let persistent = env.storage().persistent(); + let accounting_key = DataKey::EpochAccounting; + let mut accounting: EpochAccounting = if let Some(accounting) = + persistent.get(&accounting_key) + { + accounting + } else { + let instance = env.storage().instance(); + let legacy_start: Option = instance.get(&DataKey::EpochStart); + let legacy_volume: Option = instance.get(&DataKey::EpochVolume); + if legacy_start.is_some() { + instance.remove(&DataKey::EpochStart); + } + if legacy_volume.is_some() { + instance.remove(&DataKey::EpochVolume); + } + EpochAccounting { + start: legacy_start.unwrap_or(0), + volume: legacy_volume.unwrap_or(0), + } + }; // Reset epoch if expired - if now >= epoch_start + limits.epoch_duration { - epoch_volume = 0; - env.storage().instance().set(&DataKey::EpochStart, &now); + if now >= accounting.start + limits.epoch_duration { + accounting.volume = 0; + accounting.start = now; } - let new_volume = epoch_volume + let new_volume = accounting + .volume .checked_add(amount) .ok_or(BridgeError::TransferLimitExceeded)?; if new_volume > limits.epoch_volume_limit { return Err(BridgeError::TransferLimitExceeded); } - env.storage() - .instance() - .set(&DataKey::EpochVolume, &new_volume); + accounting.volume = new_volume; + persistent.set(&accounting_key, &accounting); + persistent.extend_ttl( + &accounting_key, + STORAGE_TTL_THRESHOLD, + STORAGE_TTL_EXTENSION, + ); Ok(()) } } @@ -731,4 +776,27 @@ mod tests { assert_eq!(limits.epoch_volume_limit, 10_000_000_000_000); assert_eq!(limits.epoch_duration, 86_400); } + + #[test] + fn test_epoch_accounting_uses_one_packed_entry() { + let env = Env::default(); + let contract_id = env.register(BridgeCompat, ()); + env.as_contract(&contract_id, || { + let limits = TransferLimits { + per_transfer_limit: 100, + epoch_volume_limit: 1_000, + epoch_duration: 86_400, + }; + BridgeCompat::check_epoch_volume(&env, 25, &limits).unwrap(); + + let accounting: EpochAccounting = env + .storage() + .persistent() + .get(&DataKey::EpochAccounting) + .unwrap(); + assert_eq!(accounting.volume, 25); + assert!(!env.storage().instance().has(&DataKey::EpochStart)); + assert!(!env.storage().instance().has(&DataKey::EpochVolume)); + }); + } }