From 832047f33ac8bcfc2a470cc55def749d58eaa8ec Mon Sep 17 00:00:00 2001 From: udoyechinenyevictoria Date: Tue, 25 Aug 2026 10:23:14 +0100 Subject: [PATCH 1/3] feat: add webhook retries with exponential backoff --- stellar-payment-platform/.env.example | 3 +- stellar-payment-platform/horizonListener.js | 12 +- stellar-payment-platform/package-lock.json | 282 ++++++++++++++++++ stellar-payment-platform/package.json | 2 + stellar-payment-platform/src/config/redis.js | 15 + stellar-payment-platform/src/webhookWorker.js | 274 +++++++++-------- .../tests/webhook-worker.test.js | 194 ++++++++++++ 7 files changed, 648 insertions(+), 134 deletions(-) create mode 100644 stellar-payment-platform/src/config/redis.js create mode 100644 stellar-payment-platform/tests/webhook-worker.test.js diff --git a/stellar-payment-platform/.env.example b/stellar-payment-platform/.env.example index 77c7d6b4..dfbbb373 100644 --- a/stellar-payment-platform/.env.example +++ b/stellar-payment-platform/.env.example @@ -15,7 +15,8 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/stellar_tags?schema= # Horizon listener network: "testnet" (default) or "public". # HORIZON_NETWORK=testnet -# Redis connection string for rate limiter (defaults to redis://localhost:6379 if not provided) +# Redis connection string for rate limiting and the BullMQ webhook worker. +# The webhook worker defaults to redis://127.0.0.1:6379 if not provided. # REDIS_URL="redis://localhost:6379" # --- Logging (rotating files) ------------------------------------------------- diff --git a/stellar-payment-platform/horizonListener.js b/stellar-payment-platform/horizonListener.js index 00ef7d47..de5124e9 100644 --- a/stellar-payment-platform/horizonListener.js +++ b/stellar-payment-platform/horizonListener.js @@ -16,7 +16,8 @@ const { logger } = require('./src/logger'); const { poolGet, poolRun } = require('./src/db'); const { dispatchPaymentWebhooks, - scheduleWebhookRetryJob, + startWebhookWorker, + closeWebhookQueue, } = require('./src/webhookWorker'); // --------------------------------------------------------------------------- @@ -158,6 +159,7 @@ const shutdown = async () => { logger.info(` Closed stream for ${address}`); } activeStreams.clear(); + await closeWebhookQueue(); await prisma.$disconnect(); process.exit(0); }; @@ -179,12 +181,8 @@ const main = async () => { // Initial sync await syncWatchedAccounts(); - // Schedule webhook retry / liveness pings - try { - scheduleWebhookRetryJob({ prisma, poolAllFn: require('./src/db').poolAll, poolRunFn: poolRun }); - } catch (err) { - logger.error('Failed to schedule webhook retry job:', err.message); - } + // Start the durable Redis-backed webhook delivery worker. + startWebhookWorker({ prisma, poolRunFn: poolRun }); // Periodically check for newly registered accounts setInterval(syncWatchedAccounts, POLL_INTERVAL_MS); diff --git a/stellar-payment-platform/package-lock.json b/stellar-payment-platform/package-lock.json index 57b3cc32..47f236a3 100644 --- a/stellar-payment-platform/package-lock.json +++ b/stellar-payment-platform/package-lock.json @@ -14,6 +14,7 @@ "@sentry/node": "^10.68.0", "@stellar/stellar-sdk": "^16.0.1", "bad-words": "^3.0.4", + "bullmq": "^5.58.5", "compression": "^1.8.1", "connect-timeout": "^1.9.1", "cors": "^2.8.5", @@ -22,6 +23,7 @@ "express-rate-limit": "^7.5.0", "generic-pool": "^3.9.0", "helmet": "^8.3.0", + "ioredis": "^5.7.0", "node-cache": "^5.1.2", "node-cron": "4.5.0", "pdfkit": "^0.15.2", @@ -1119,6 +1121,12 @@ "license": "MIT", "optional": true }, + "node_modules/@ioredis/commands": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.11.0.tgz", + "integrity": "sha512-tuMmOu6dtyGFv/fzCjtapCJj/zgoHaFsqs3wKsroJSRXtlLmyL/t+B7uaQiavGk1F3WWFQcUqZwk92bpp9jKcA==", + "license": "MIT" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1487,6 +1495,84 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@noble/ed25519": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz", @@ -2787,6 +2873,33 @@ "dev": true, "license": "MIT" }, + "node_modules/bullmq": { + "version": "5.58.5", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.58.5.tgz", + "integrity": "sha512-0A6Qjxdn8j7aOcxfRZY798vO/aMuwvoZwfE6a9EOXHb1pzpBVAogsc/OfRWeUf+5wMBoYB5nthstnJo/zrQOeQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.9.0", + "ioredis": "^5.4.1", + "msgpackr": "^1.11.2", + "node-abort-controller": "^3.1.1", + "semver": "^7.5.4", + "tslib": "^2.0.0", + "uuid": "^9.0.0" + } + }, + "node_modules/bullmq/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3408,6 +3521,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "deprecated": "v4 is no longer maintained, upgrade to v5", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3590,6 +3716,15 @@ "license": "MIT", "optional": true }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4937,6 +5072,53 @@ "node": ">= 0.4" } }, + "node_modules/ioredis": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.7.0.tgz", + "integrity": "sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.3.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -6114,6 +6296,18 @@ "node": ">=8" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6124,6 +6318,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6500,6 +6703,37 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz", + "integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -6546,6 +6780,12 @@ "node": ">=10" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -6604,6 +6844,21 @@ "node": ">= 10.12.0" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-gyp/node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -7477,6 +7732,27 @@ "@redis/time-series": "1.1.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -8126,6 +8402,12 @@ "node": ">=10" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/stellar-payment-platform/package.json b/stellar-payment-platform/package.json index 00f5adb5..e124456d 100644 --- a/stellar-payment-platform/package.json +++ b/stellar-payment-platform/package.json @@ -26,6 +26,7 @@ "@sentry/node": "^10.68.0", "@stellar/stellar-sdk": "^16.0.1", "bad-words": "^3.0.4", + "bullmq": "^5.58.5", "compression": "^1.8.1", "connect-timeout": "^1.9.1", "cors": "^2.8.5", @@ -34,6 +35,7 @@ "express-rate-limit": "^7.5.0", "generic-pool": "^3.9.0", "helmet": "^8.3.0", + "ioredis": "^5.7.0", "node-cache": "^5.1.2", "node-cron": "4.5.0", "pdfkit": "^0.15.2", diff --git a/stellar-payment-platform/src/config/redis.js b/stellar-payment-platform/src/config/redis.js new file mode 100644 index 00000000..6aa67c2b --- /dev/null +++ b/stellar-payment-platform/src/config/redis.js @@ -0,0 +1,15 @@ +const IORedis = require('ioredis'); + +const DEFAULT_REDIS_URL = 'redis://127.0.0.1:6379'; + +const createRedisConnection = () => { + return new IORedis(process.env.REDIS_URL || DEFAULT_REDIS_URL, { + // BullMQ workers require blocking Redis commands to wait indefinitely. + maxRetriesPerRequest: null, + }); +}; + +module.exports = { + createRedisConnection, + DEFAULT_REDIS_URL, +}; diff --git a/stellar-payment-platform/src/webhookWorker.js b/stellar-payment-platform/src/webhookWorker.js index 51b70d13..d70567ba 100644 --- a/stellar-payment-platform/src/webhookWorker.js +++ b/stellar-payment-platform/src/webhookWorker.js @@ -1,10 +1,28 @@ const crypto = require('crypto'); -const cron = require('node-cron'); +const { Queue, Worker } = require('bullmq'); +const { createRedisConnection } = require('./config/redis'); const { logger } = require('./logger'); const WEBHOOK_TIMEOUT_MS = 10_000; -const MAX_RETRY_BACKLOG_DAYS = 3; -const RETRY_JOB_CRON = '*/5 * * * *'; // every 5 minutes +const WEBHOOK_QUEUE_NAME = 'webhook-deliveries'; +const MAX_WEBHOOK_ATTEMPTS = 5; +const WEBHOOK_BACKOFF_DELAY_MS = 1_000; +const WEBHOOK_WORKER_CONCURRENCY = 5; + +const WEBHOOK_JOB_OPTIONS = Object.freeze({ + attempts: MAX_WEBHOOK_ATTEMPTS, + backoff: { + type: 'exponential', + delay: WEBHOOK_BACKOFF_DELAY_MS, + }, + removeOnComplete: 1_000, + removeOnFail: 5_000, +}); + +let webhookQueue; +let webhookWorker; +let queueConnection; +let workerConnection; const shouldFallbackToLocalRegistry = (error) => { const code = typeof error?.code === 'string' ? error.code : ''; @@ -31,26 +49,19 @@ const fetchWebhooksForAddress = async (prisma, poolGetFn, stellarAddress) => { username: true, url: true, secret: true, - failingSince: true, }, }); } catch (error) { if (!shouldFallbackToLocalRegistry(error)) throw error; const rows = await poolGetFn( - `SELECT w.id, w.username, w.url, w.secret, w.failing_since + `SELECT w.id, w.username, w.url, w.secret FROM webhooks w INNER JOIN username_registry u ON u.username = w.username WHERE u.address = ?`, [stellarAddress], ); - return (rows || []).map((r) => ({ - id: r.id, - username: r.username, - url: r.url, - secret: r.secret, - failingSince: r.failing_since ? new Date(r.failing_since) : null, - })); + return rows || []; } }; @@ -61,7 +72,7 @@ const sendWebhook = async (url, payload, secret) => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS); try { - const res = await fetch(url, { + const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -71,14 +82,12 @@ const sendWebhook = async (url, payload, secret) => { body: rawBody, signal: controller.signal, }); - clearTimeout(timeoutId); - if (!res.ok) { - throw new Error(`Webhook responded with HTTP ${res.status}`); + if (!response.ok) { + throw new Error(`Webhook responded with HTTP ${response.status}`); } return { ok: true }; - } catch (err) { + } finally { clearTimeout(timeoutId); - throw err; } }; @@ -91,7 +100,7 @@ const markWebhookSuccess = async (prisma, poolRunFn, webhookId, now) => { } catch (error) { if (!shouldFallbackToLocalRegistry(error)) throw error; await poolRunFn( - `UPDATE webhooks SET last_sent_at = ?, failing_since = NULL WHERE id = ?`, + 'UPDATE webhooks SET last_sent_at = ?, failing_since = NULL WHERE id = ?', [now.toISOString(), webhookId], ); } @@ -114,41 +123,116 @@ const markWebhookFailure = async (prisma, poolRunFn, webhookId, now) => { if (!shouldFallbackToLocalRegistry(error)) throw error; await poolRunFn( `UPDATE webhooks - SET last_sent_at = ?, - failing_since = COALESCE(failing_since, ?) + SET last_sent_at = ?, failing_since = COALESCE(failing_since, ?) WHERE id = ?`, [now.toISOString(), now.toISOString(), webhookId], ); } }; +const processWebhookJob = async (job, { prisma, poolRunFn }) => { + const { webhook, payload } = job.data; + const now = new Date(); + + try { + await sendWebhook(webhook.url, payload, webhook.secret); + } catch (error) { + try { + await markWebhookFailure(prisma, poolRunFn, webhook.id, now); + } catch (databaseError) { + logger.error( + `[webhook-worker] Failed to mark failure for webhook ${webhook.id}: ${databaseError.message}`, + ); + } + throw error; + } + + try { + await markWebhookSuccess(prisma, poolRunFn, webhook.id, now); + } catch (databaseError) { + logger.error( + `[webhook-worker] Failed to mark success for webhook ${webhook.id}: ${databaseError.message}`, + ); + } + + logger.info( + `[webhook-worker] Delivered event=${payload.event_id} webhook=${webhook.id} attempt=${job.attemptsMade + 1}`, + ); +}; + +const getWebhookQueue = () => { + if (!webhookQueue) { + queueConnection = createRedisConnection(); + webhookQueue = new Queue(WEBHOOK_QUEUE_NAME, { connection: queueConnection }); + webhookQueue.on('error', (error) => { + logger.error(`[webhook-queue] Redis error: ${error.message}`); + }); + } + return webhookQueue; +}; + +const startWebhookWorker = ({ prisma, poolRunFn }) => { + if (webhookWorker) return webhookWorker; + + workerConnection = createRedisConnection(); + webhookWorker = new Worker( + WEBHOOK_QUEUE_NAME, + (job) => processWebhookJob(job, { prisma, poolRunFn }), + { + connection: workerConnection, + concurrency: WEBHOOK_WORKER_CONCURRENCY, + }, + ); + + webhookWorker.on('failed', (job, error) => { + const maxAttempts = job?.opts?.attempts || MAX_WEBHOOK_ATTEMPTS; + const attemptsMade = job?.attemptsMade || 1; + const status = attemptsMade < maxAttempts + ? `retry scheduled (${attemptsMade}/${maxAttempts})` + : `retries exhausted (${attemptsMade}/${maxAttempts})`; + logger.error( + `[webhook-worker] Delivery failed job=${job?.id || 'unknown'}: ${error.message}; ${status}`, + ); + }); + webhookWorker.on('error', (error) => { + logger.error(`[webhook-worker] Redis error: ${error.message}`); + }); + + logger.info( + `[webhook-worker] Started queue=${WEBHOOK_QUEUE_NAME} concurrency=${WEBHOOK_WORKER_CONCURRENCY}`, + ); + return webhookWorker; +}; + +const buildJobId = (webhookId, eventId) => { + return crypto.createHash('sha256').update(`${webhookId}:${eventId}`).digest('hex'); +}; + +const enqueueWebhookDelivery = async (webhook, payload, queue = getWebhookQueue()) => { + return queue.add( + 'deliver', + { webhook, payload }, + { + ...WEBHOOK_JOB_OPTIONS, + backoff: { ...WEBHOOK_JOB_OPTIONS.backoff }, + jobId: buildJobId(webhook.id, payload.event_id), + }, + ); +}; + const formatAsset = (payment) => { - if (!payment) return 'native'; - if (payment.asset_type === 'native') return 'native'; + if (!payment || payment.asset_type === 'native') return 'native'; return `${payment.asset_code}:${payment.asset_issuer}`; }; -const dispatchPaymentWebhooks = async ({ - prisma, - poolGetFn, - poolRunFn, - payment, -}) => { - if (!payment) return; - if (payment.type !== 'payment' && payment.type_i !== 1) return; +const dispatchPaymentWebhooks = async ({ prisma, poolGetFn, payment, queue }) => { + if (!payment || (payment.type !== 'payment' && payment.type_i !== 1)) return; const recipientAddress = payment.to; if (!recipientAddress) return; - let webhooks; - try { - webhooks = await fetchWebhooksForAddress(prisma, poolGetFn, recipientAddress); - } catch (err) { - logger.error(`[webhook-worker] Failed to fetch webhooks for ${recipientAddress}:`, err.message); - return; - } - - if (!webhooks || webhooks.length === 0) return; + const webhooks = await fetchWebhooksForAddress(prisma, poolGetFn, recipientAddress); + if (!webhooks.length) return; const payload = { event: 'payment.received', @@ -169,101 +253,39 @@ const dispatchPaymentWebhooks = async ({ }, }; - for (const wh of webhooks) { - const now = new Date(); - try { - await sendWebhook(wh.url, payload, wh.secret); - try { - await markWebhookSuccess(prisma, poolRunFn, wh.id, now); - } catch (dbErr) { - logger.error(`[webhook-worker] Failed to mark success for webhook ${wh.id}:`, dbErr.message); - } - logger.info(`[webhook-worker] Dispatched payment webhook id=${wh.id} url=${wh.url} recipient=${recipientAddress}`); - } catch (err) { - try { - await markWebhookFailure(prisma, poolRunFn, wh.id, now); - } catch (dbErr) { - logger.error(`[webhook-worker] Failed to mark failure for webhook ${wh.id}:`, dbErr.message); - } - logger.error(`[webhook-worker] Webhook delivery failed id=${wh.id} url=${wh.url}:`, err.message); - } - } -}; - -const listStaleFailingWebhooks = async (prisma, poolAllFn) => { - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - MAX_RETRY_BACKLOG_DAYS); - try { - return await prisma.webhook.findMany({ - where: { - failingSince: { not: null, gte: cutoff }, - }, - select: { - id: true, - username: true, - url: true, - secret: true, - }, - }); - } catch (error) { - if (!shouldFallbackToLocalRegistry(error)) throw error; - const rows = await poolAllFn( - `SELECT id, username, url, secret FROM webhooks - WHERE failing_since IS NOT NULL AND failing_since >= ?`, - [cutoff.toISOString()], + const deliveryQueue = queue || getWebhookQueue(); + await Promise.all(webhooks.map(async (webhook) => { + await enqueueWebhookDelivery(webhook, payload, deliveryQueue); + logger.info( + `[webhook-queue] Enqueued event=${payload.event_id} webhook=${webhook.id} recipient=${recipientAddress}`, ); - return (rows || []).map((r) => ({ - id: r.id, - username: r.username, - url: r.url, - secret: r.secret, - })); - } + })); }; -const sendLivenessPing = async (prisma, poolRunFn, webhook) => { - const payload = { - event: 'webhook.ping', - event_id: `ping-${crypto.randomBytes(16).toString('hex')}`, - timestamp: new Date().toISOString(), - data: { message: 'ping' }, - }; - const now = new Date(); - try { - await sendWebhook(webhook.url, payload, webhook.secret); - await markWebhookSuccess(prisma, poolRunFn, webhook.id, now); - return true; - } catch (err) { - await markWebhookFailure(prisma, poolRunFn, webhook.id, now); - return false; - } -}; +const closeWebhookQueue = async () => { + const resources = [webhookWorker, webhookQueue].filter(Boolean); + await Promise.all(resources.map((resource) => resource.close())); -const scheduleWebhookRetryJob = ({ prisma, poolAllFn, poolRunFn }) => { - cron.schedule(RETRY_JOB_CRON, async () => { - logger.info('[webhook-worker] Running periodic liveness pings for failing webhooks…'); - try { - const hooks = await listStaleFailingWebhooks(prisma, poolAllFn); - if (hooks.length === 0) return; - let recovered = 0; - for (const wh of hooks) { - const ok = await sendLivenessPing(prisma, poolRunFn, wh); - if (ok) recovered += 1; - } - logger.info( - `[webhook-worker] Liveness pings done. total=${hooks.length}, recovered=${recovered}`, - ); - } catch (err) { - logger.error('[webhook-worker] Retry job failed:', err.message); - } - }); - logger.info(`[webhook-worker] Retry/liveness job scheduled (cron: ${RETRY_JOB_CRON}).`); + const connections = [workerConnection, queueConnection].filter(Boolean); + await Promise.all(connections.map((connection) => connection.quit())); + + webhookWorker = undefined; + webhookQueue = undefined; + workerConnection = undefined; + queueConnection = undefined; }; module.exports = { dispatchPaymentWebhooks, - scheduleWebhookRetryJob, + enqueueWebhookDelivery, + startWebhookWorker, + closeWebhookQueue, + processWebhookJob, sendWebhook, computeSignature, WEBHOOK_TIMEOUT_MS, + WEBHOOK_QUEUE_NAME, + MAX_WEBHOOK_ATTEMPTS, + WEBHOOK_BACKOFF_DELAY_MS, + WEBHOOK_JOB_OPTIONS, }; diff --git a/stellar-payment-platform/tests/webhook-worker.test.js b/stellar-payment-platform/tests/webhook-worker.test.js new file mode 100644 index 00000000..6550fdc5 --- /dev/null +++ b/stellar-payment-platform/tests/webhook-worker.test.js @@ -0,0 +1,194 @@ +const mockQueueAdd = jest.fn(); +const mockQueueOn = jest.fn(); +const mockQueueClose = jest.fn().mockResolvedValue(undefined); +const mockWorkerOn = jest.fn(); +const mockWorkerClose = jest.fn().mockResolvedValue(undefined); +const mockRedisQuit = jest.fn().mockResolvedValue(undefined); + +let mockWorkerProcessor; +let mockWorkerOptions; + +jest.mock('bullmq', () => ({ + Queue: jest.fn().mockImplementation(() => ({ + add: mockQueueAdd, + on: mockQueueOn, + close: mockQueueClose, + })), + Worker: jest.fn().mockImplementation((_name, processor, options) => { + mockWorkerProcessor = processor; + mockWorkerOptions = options; + return { + on: mockWorkerOn, + close: mockWorkerClose, + }; + }), +})); + +jest.mock('../src/config/redis', () => ({ + createRedisConnection: jest.fn(() => ({ quit: mockRedisQuit })), +})); + +const { Queue, Worker } = require('bullmq'); +const { + dispatchPaymentWebhooks, + enqueueWebhookDelivery, + startWebhookWorker, + closeWebhookQueue, + processWebhookJob, + MAX_WEBHOOK_ATTEMPTS, + WEBHOOK_BACKOFF_DELAY_MS, + WEBHOOK_QUEUE_NAME, +} = require('../src/webhookWorker'); + +const webhook = { + id: 'webhook-1', + username: 'merchant', + url: 'https://merchant.example/webhooks', + secret: 'secret', +}; + +const payload = { + event: 'payment.received', + event_id: 'transaction-1-payment-1', + timestamp: '2026-08-25T12:00:00.000Z', + data: { amount: '10.00' }, +}; + +describe('webhook BullMQ delivery', () => { + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = jest.fn(); + }); + + afterAll(async () => { + await closeWebhookQueue(); + delete global.fetch; + }); + + test('enqueues deliveries with five attempts and exponential backoff', async () => { + const queue = { add: jest.fn().mockResolvedValue({ id: 'job-1' }) }; + + await enqueueWebhookDelivery(webhook, payload, queue); + + expect(queue.add).toHaveBeenCalledWith( + 'deliver', + { webhook, payload }, + expect.objectContaining({ + attempts: MAX_WEBHOOK_ATTEMPTS, + backoff: { + type: 'exponential', + delay: WEBHOOK_BACKOFF_DELAY_MS, + }, + jobId: expect.any(String), + }), + ); + expect(MAX_WEBHOOK_ATTEMPTS).toBe(5); + }); + + test('worker throws failed deliveries so BullMQ retries them', async () => { + global.fetch.mockResolvedValue({ ok: false, status: 503 }); + const prisma = { + webhook: { + findUnique: jest.fn().mockResolvedValue({ failingSince: null }), + update: jest.fn().mockResolvedValue({}), + }, + }; + + await expect(processWebhookJob( + { data: { webhook, payload }, attemptsMade: 0 }, + { prisma, poolRunFn: jest.fn() }, + )).rejects.toThrow('HTTP 503'); + + expect(prisma.webhook.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: webhook.id }, + data: expect.objectContaining({ failingSince: expect.any(Date) }), + })); + }); + + test('worker marks a recovered webhook as successful', async () => { + global.fetch.mockResolvedValue({ ok: true, status: 200 }); + const prisma = { + webhook: { + update: jest.fn().mockResolvedValue({}), + }, + }; + + await processWebhookJob( + { data: { webhook, payload }, attemptsMade: 2 }, + { prisma, poolRunFn: jest.fn() }, + ); + + expect(prisma.webhook.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: webhook.id }, + data: expect.objectContaining({ failingSince: null }), + })); + }); + + test('configures and starts a BullMQ webhook worker', () => { + const dependencies = { + prisma: { webhook: {} }, + poolRunFn: jest.fn(), + }; + + startWebhookWorker(dependencies); + + expect(Worker).toHaveBeenCalledWith( + WEBHOOK_QUEUE_NAME, + expect.any(Function), + expect.objectContaining({ concurrency: 5 }), + ); + expect(mockWorkerOptions.connection).toEqual(expect.objectContaining({ quit: mockRedisQuit })); + expect(mockWorkerProcessor).toEqual(expect.any(Function)); + }); + + test('queues a payment event for every registered webhook', async () => { + const queue = { add: jest.fn().mockResolvedValue({}) }; + const prisma = { + webhook: { + findMany: jest.fn().mockResolvedValue([ + webhook, + { ...webhook, id: 'webhook-2', url: 'https://second.example/webhooks' }, + ]), + }, + }; + + await dispatchPaymentWebhooks({ + prisma, + poolGetFn: jest.fn(), + queue, + payment: { + id: 'payment-1', + type: 'payment', + transaction_hash: 'transaction-1', + to: 'GDESTINATION', + from: 'GSOURCE', + amount: '10.00', + asset_type: 'native', + }, + }); + + expect(queue.add).toHaveBeenCalledTimes(2); + expect(queue.add).toHaveBeenCalledWith( + 'deliver', + expect.objectContaining({ + payload: expect.objectContaining({ + event: 'payment.received', + event_id: 'transaction-1-payment-1', + }), + }), + expect.objectContaining({ attempts: 5 }), + ); + }); + + test('creates a lazy queue when no queue is injected', async () => { + mockQueueAdd.mockResolvedValue({ id: 'job-1' }); + + await enqueueWebhookDelivery(webhook, payload); + + expect(Queue).toHaveBeenCalledWith( + WEBHOOK_QUEUE_NAME, + expect.objectContaining({ connection: expect.any(Object) }), + ); + expect(mockQueueAdd).toHaveBeenCalledTimes(1); + }); +}); From 109f1c0058287d125a0c2892f5fbff9f7e3700b5 Mon Sep 17 00:00:00 2001 From: udoyechinenyevictoria Date: Mon, 31 Aug 2026 20:57:29 +0100 Subject: [PATCH 2/3] fix: resolve CI failures after main merge --- .cspell.json | 3 +- payment_router/Cargo.lock | 253 +- payment_router/src/lib.rs | 2456 ++++++++++++++++- stellar-payment-platform/package-lock.json | 61 + stellar-payment-platform/package.json | 2 + stellar-payment-platform/src/webhookWorker.js | 62 +- .../tests/payment-metadata.test.js | 25 +- 7 files changed, 2789 insertions(+), 73 deletions(-) diff --git a/.cspell.json b/.cspell.json index aa01a50b..e289aac6 100644 --- a/.cspell.json +++ b/.cspell.json @@ -78,10 +78,11 @@ "scrape", "codebase", "postgres", + "psql", "PostgreSQL", "postgresql", "gitignore", "monorepo", "repo" ] -} \ No newline at end of file +} diff --git a/payment_router/Cargo.lock b/payment_router/Cargo.lock index 454db545..1af4f078 100644 --- a/payment_router/Cargo.lock +++ b/payment_router/Cargo.lock @@ -86,6 +86,27 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "block-buffer" version = "0.10.4" @@ -186,7 +207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -355,7 +376,7 @@ checksum = "7277392b266383ef8396db7fdeb1e77b6c52fed775f5df15bb24f35b72156980" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "zeroize", @@ -380,7 +401,7 @@ dependencies = [ "generic-array", "group", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -392,6 +413,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "escape-bytes" version = "0.1.1" @@ -402,13 +433,19 @@ checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" name = "ethnum" version = "1.5.0" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "ff" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -478,6 +515,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "gimli" version = "0.28.1" @@ -491,7 +551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -650,6 +710,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.33" @@ -745,6 +811,7 @@ version = "0.1.0" dependencies = [ "arbitrary", "derive_arbitrary", + "proptest", "soroban-sdk", ] @@ -804,6 +871,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.33" @@ -813,6 +905,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -820,8 +924,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -831,7 +945,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -840,9 +964,33 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.11", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rfc6979" version = "0.4.0" @@ -868,12 +1016,37 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -995,7 +1168,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1059,15 +1232,15 @@ dependencies = [ "backtrace", "curve25519-dalek", "ed25519-dalek", - "getrandom", + "getrandom 0.2.11", "hex-literal", "hmac", "k256", "num-derive", "num-integer", "num-traits", - "rand", - "rand_chacha", + "rand 0.8.5", + "rand_chacha 0.3.1", "sha2", "sha3", "soroban-builtin-sdk-macros", @@ -1116,7 +1289,7 @@ dependencies = [ "bytes-lit", "ctor", "ed25519-dalek", - "rand", + "rand 0.8.5", "serde", "serde_json", "soroban-env-guest", @@ -1259,6 +1432,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.55" @@ -1316,6 +1502,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1328,12 +1520,30 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -1474,6 +1684,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/payment_router/src/lib.rs b/payment_router/src/lib.rs index ce5949e6..c5b6a32e 100644 --- a/payment_router/src/lib.rs +++ b/payment_router/src/lib.rs @@ -1,76 +1,2462 @@ -#![no_std]\nuse soroban_sdk::{\n contract, contracterror, contractimpl, contracttype, log, symbol_short, token, Address, BytesN,\n Env, Symbol, Vec,\n};\n\n// ── Packed UserSpending helpers ──────────────────────────────────────────────\n//\n// Issue #519: Replace the two-field UserSpending contracttype with a single\n// BytesN<24> value packed with bitwise operations.\n//\n// Layout (big-endian):\n// bytes 0..8 — last_reset_time : u64 (8 bytes)\n// bytes 8..24 — accumulated_amount: i128 (16 bytes)\n//\n// Benefits:\n// • Eliminates the XDR struct-type overhead (type discriminant + field tags)\n// that Soroban adds to every contracttype value, shrinking each UserSpending\n// ledger entry from ~48 bytes to exactly 24 bytes.\n// • Smaller entries → lower state-rent fee per ledger entry per TTL period.\n\n/// Pack `last_reset_time` (u64) and `accumulated_amount` (i128) into a\n/// 24-byte big-endian buffer.\nfn pack_spending(env: &Env, last_reset_time: u64, accumulated_amount: i128) -> BytesN<24> {\n let mut buf = [0u8; 24];\n\n // Bytes 0..8 — last_reset_time (u64 big-endian)\n let t_bytes = last_reset_time.to_be_bytes();\n buf[0] = t_bytes[0];\n buf[1] = t_bytes[1];\n buf[2] = t_bytes[2];\n buf[3] = t_bytes[3];\n buf[4] = t_bytes[4];\n buf[5] = t_bytes[5];\n buf[6] = t_bytes[6];\n buf[7] = t_bytes[7];\n\n // Bytes 8..24 — accumulated_amount (i128 big-endian)\n let a_bytes = accumulated_amount.to_be_bytes();\n buf[8] = a_bytes[0];\n buf[9] = a_bytes[1];\n buf[10] = a_bytes[2];\n buf[11] = a_bytes[3];\n buf[12] = a_bytes[4];\n buf[13] = a_bytes[5];\n buf[14] = a_bytes[6];\n buf[15] = a_bytes[7];\n buf[16] = a_bytes[8];\n buf[17] = a_bytes[9];\n buf[18] = a_bytes[10];\n buf[19] = a_bytes[11];\n buf[20] = a_bytes[12];\n buf[21] = a_bytes[13];\n buf[22] = a_bytes[14];\n buf[23] = a_bytes[15];\n\n BytesN::from_array(env, &buf)\n}\n\n/// Unpack a 24-byte buffer into `(last_reset_time, accumulated_amount)`.\nfn unpack_spending(packed: &BytesN<24>) -> (u64, i128) {\n // BytesN::to_array() is available in soroban-sdk v20.\n let buf: [u8; 24] = packed.to_array();\n\n // last_reset_time — bytes 0..8\n let last_reset_time = u64::from_be_bytes([\n buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7],\n ]);\n\n // accumulated_amount — bytes 8..24\n let accumulated_amount = i128::from_be_bytes([\n buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15], buf[16], buf[17],\n buf[18], buf[19], buf[20], buf[21], buf[22], buf[23],\n ]);\n\n (last_reset_time, accumulated_amount)\n}\n\n// ── Legacy struct kept for test snapshot compatibility ───────────────────────\n//\n// The UserSpending contracttype is retained so existing tests that reference\n// it directly continue to compile. All runtime code now uses the packed\n// BytesN<24> representation stored under DataKey::UserSpending.\n\n#[contracttype]\n#[derive(Clone, Debug, Eq, PartialEq)]\npub struct UserSpending {\n pub last_reset_time: u64,\n pub accumulated_amount: i128,\n}\n\n#[contracttype]\n#[derive(Clone, Debug, Eq, PartialEq)]\npub struct Payment {\n pub sender: Address,\n pub recipient: Address,\n pub token_address: Address,\n pub amount: i128,\n}\n\n// ── Timelock data structures ─────────────────────────────────────────────────\n//\n// Admin actions that change sensitive contract parameters (treasury, fees,\n// governance, admin transfer) are not applied instantly. Instead the admin\n// queues an ActionType intent that gets a nonce ID and a ledger timestamp.\n// Only after SECONDS_IN_24H (86 400 s) has elapsed can execute_action be\n// called to apply the change. This gives observers a 24-hour window to\n// detect and respond to a compromised-admin scenario.\n//\n// The freeze mechanism is the complementary emergency tool: calling\n// emergency_freeze instantly blocks all payments and all timelock executions.\n// A freeze does NOT require going through the timelock itself so it is always\n// available to the admin as an immediate last resort. Unfreezing likewise\n// takes effect immediately so the admin can restore service once the threat is\n// resolved.\n\n/// Describes which administrative parameter change a timelock entry represents.\n/// Each variant carries all the arguments needed to apply that change when the\n/// delay period is over.\n#[contracttype]\n#[derive(Clone, Debug, Eq, PartialEq)]\npub enum ActionType {\n /// Change the platform treasury address.\n SetPlatformTreasury(Address),\n /// Update fee basis-points and fee cap together (legacy / combined setter).\n SetFeeConfig(i128, i128),\n /// Update fee basis-points only.\n SetFeeBps(i128),\n /// Set the governance contract address.\n SetGovernance(Address),\n /// Change the minimum routing limit.\n SetMinLimit(i128),\n /// Transfer admin rights to a new address.\n TransferAdmin(Address),\n /// Upgrade the contract WASM.\n Upgrade(BytesN<32>),\n}\n\n/// A pending timelock entry stored in persistent ledger storage.\n#[contracttype]\n#[derive(Clone, Debug, Eq, PartialEq)]\npub struct TimelockEntry {\n /// Ledger timestamp (seconds since epoch) when this action was queued.\n pub queued_at: u64,\n /// The action payload to apply once the delay has elapsed.\n pub action: ActionType,\n}\n\n#[contracttype]\n#[derive(Clone, Debug, Eq, PartialEq)]\npub enum DataKey {\n Admin,\n Governance,\n PlatformTreasury,\n FeeBps,\n FeeCap,\n MinLimit,\n Paused,\n MaxAmount,\n UserVolume(Address),\n UserSpending(Address),\n Blacklist(Address),\n RefundBalance(Address, Address),\n /// Monotonically-increasing nonce counter used to generate unique IDs for\n /// timelock entries. Stored as `u64` in instance storage.\n TimelockNonce,\n /// A pending timelock entry keyed by its nonce ID.\n /// Stored in persistent storage so it survives instance eviction.\n TimelockEntry(u64),\n /// When `true` the contract is frozen: payments and timelock executions\n /// are blocked. Stored as `bool` in instance storage.\n Frozen,\n}\n\n/// Contract-level errors returned instead of panicking, so callers get a\n/// specific, stable error code to branch on rather than an opaque trap.\n#[contracterror]\n#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]\n#[repr(u32)]\npub enum Error {\n /// Caller is not authorized to perform this action (e.g. not the admin).\n Unauthorized = 1,\n /// Sender's token balance is lower than the requested payment amount.\n InsufficientBalance = 2,\n /// Requested amount is outside allowed bounds, or a spending limit was exceeded.\n LimitExceeded = 3,\n /// `initialize` was called on a contract that already has an admin set.\n AlreadyInitialized = 4,\n /// An admin-configured value (treasury, fee, admin) was read before `initialize`.\n NotInitialized = 5,\n Paused = 6,\n InvalidFeeRate = 7,\n /// Sender and recipient addresses are the same (self-routing not allowed).\n InvalidRecipient = 8,\n /// Recipient address is blacklisted.\n Blacklisted = 9,\n /// Requested refund withdrawal amount is zero or exceeds available refund balance.\n NoRefundAvailable = 10,\n /// An action is already pending in the timelock queue; it must be executed\n /// or cancelled before a duplicate can be queued (not currently enforced,\n /// but reserved for future deduplication logic).\n TimelockPending = 11,\n /// The 24-hour delay for the given timelock entry has not elapsed yet.\n TimelockNotReady = 12,\n /// No timelock entry exists for the supplied nonce ID.\n TimelockNotFound = 13,\n /// The contract is frozen; all payments and timelock executions are blocked.\n ContractFrozen = 14,\n}\n\n#[contract]\npub struct PaymentRouter;\n\n#[contractimpl]\nimpl PaymentRouter {\n const BPS_DIVISOR: i128 = 10_000;\n const XLM_DECIMALS: i128 = 10_000_000;\n const MAX_AMOUNT: i128 = 1_000_000_000_000_000; // 100M tokens with 7 decimals\n const DAILY_MAX_LIMIT: i128 = 1_000_000 * Self::XLM_DECIMALS; // 1M tokens limit\n const VOLUME_THRESHOLD: i128 = 10_000 * Self::XLM_DECIMALS; // 10,000 XLM threshold for tiered fee discount\n const SECONDS_IN_24H: u64 = 24 * 3600;\n const VERSION: u32 = 1;\n\n const DAY_IN_LEDGERS: u32 = 17280;\n const INSTANCE_BUMP_AMOUNT: u32 = 7 * Self::DAY_IN_LEDGERS;\n const INSTANCE_LIFETIME_THRESHOLD: u32 = Self::INSTANCE_BUMP_AMOUNT - Self::DAY_IN_LEDGERS;\n\n const USER_BUMP_AMOUNT: u32 = 30 * Self::DAY_IN_LEDGERS;\n const USER_LIFETIME_THRESHOLD: u32 = Self::USER_BUMP_AMOUNT - Self::DAY_IN_LEDGERS;\n const PERSISTENT_BUMP_AMOUNT: u32 = Self::USER_BUMP_AMOUNT;\n const PERSISTENT_LIFETIME_THRESHOLD: u32 = Self::USER_LIFETIME_THRESHOLD;\n\n // ── Private helpers ──────────────────────────────────────────────────────\n\n fn require_admin(env: &Env) -> Result {\n env.storage()\n .instance()\n .get(&DataKey::Admin)\n .ok_or(Error::NotInitialized)\n }\n\n /// Fee authority helper: if a Governance address is set it takes exclusive\n /// control over fee updates; otherwise the admin retains that right.\n fn require_fee_authority(env: &Env) -> Result<(), Error> {\n if let Some(gov) = env\n .storage()\n .instance()\n .get::(&DataKey::Governance)\n {\n gov.require_auth();\n Ok(())\n } else {\n let admin = Self::require_admin(env)?;\n admin.require_auth();\n Ok(())\n }\n }\n\n fn load_fee_config(env: &Env) -> Result<(Address, i128, i128), Error> {\n let platform_treasury: Address = env\n .storage()\n .instance()\n .get(&DataKey::PlatformTreasury)\n .ok_or(Error::NotInitialized)?;\n let fee_bps: i128 = env\n .storage()\n .instance()\n .get(&DataKey::FeeBps)\n .ok_or(Error::NotInitialized)?;\n let fee_cap: i128 = env\n .storage()\n .instance()\n .get(&DataKey::FeeCap)\n .ok_or(Error::NotInitialized)?;\n\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n\n Ok((platform_treasury, fee_bps, fee_cap))\n }\n\n fn get_refund_balance_internal(env: &Env, user: &Address, token: &Address) -> i128 {\n let key = DataKey::RefundBalance(user.clone(), token.clone());\n env.storage().persistent().get(&key).unwrap_or(0)\n }\n\n fn credit_refund_balance(env: &Env, user: &Address, token: &Address, amount: i128) {\n let key = DataKey::RefundBalance(user.clone(), token.clone());\n let current_balance: i128 = env.storage().persistent().get(&key).unwrap_or(0);\n let new_balance = current_balance + amount;\n env.storage().persistent().set(&key, &new_balance);\n env.storage().persistent().extend_ttl(\n &key,\n Self::PERSISTENT_LIFETIME_THRESHOLD,\n Self::PERSISTENT_BUMP_AMOUNT,\n );\n\n env.events().publish(\n (symbol_short!("refunded"), user.clone(), token.clone()),\n amount,\n );\n }\n\n /// Returns whether the contract is currently frozen.\n fn is_frozen_internal(env: &Env) -> bool {\n env.storage()\n .instance()\n .get(&DataKey::Frozen)\n .unwrap_or(false)\n }\n\n /// Allocates and returns the next timelock nonce, incrementing the counter.\n fn next_nonce(env: &Env) -> u64 {\n let current: u64 = env\n .storage()\n .instance()\n .get(&DataKey::TimelockNonce)\n .unwrap_or(0u64);\n let next = current + 1;\n env.storage().instance().set(&DataKey::TimelockNonce, &next);\n next\n }\n\n /// Core payment logic shared by `route_payment` and `route_payments`.\n #[allow(clippy::too_many_arguments)]\n fn process_single_payment(\n env: &Env,\n sender: &Address,\n recipient: &Address,\n token_address: &Address,\n amount: i128,\n platform_treasury: &Address,\n fee_bps: i128,\n fee_cap: i128,\n ) -> Result<(), Error> {\n // Require sender auth\n sender.require_auth();\n\n env.events().publish(\n (Symbol::new(env, "payment_initiated"), sender.clone()),\n amount,\n );\n\n // Prevent self-routing\n if sender == recipient {\n return Err(Error::InvalidRecipient);\n }\n\n // Check if recipient is blacklisted\n if Self::is_blacklisted(env.clone(), recipient.clone()) {\n return Err(Error::Blacklisted);\n }\n\n // Validate amount bounds\n let max_amount: i128 = env\n .storage()\n .instance()\n .get(&DataKey::MaxAmount)\n .unwrap_or(Self::MAX_AMOUNT);\n if amount <= 0 || amount > max_amount {\n return Err(Error::LimitExceeded);\n }\n\n // Enforce optional admin-configured minimum payment limit\n let min_limit: i128 = env\n .storage()\n .instance()\n .get(&DataKey::MinLimit)\n .unwrap_or(0);\n if amount < min_limit {\n return Err(Error::LimitExceeded);\n }\n\n // Apply tiered fee discount for high-volume users\n let user_volume: i128 = env\n .storage()\n .persistent()\n .get(&DataKey::UserVolume(sender.clone()))\n .unwrap_or(0);\n let effective_fee_bps = if user_volume > Self::VOLUME_THRESHOLD {\n fee_bps / 2\n } else {\n fee_bps\n };\n\n // Check time-based daily spending limits.\n // Storage format: packed BytesN<24> (see pack_spending / unpack_spending).\n let current_time = env.ledger().timestamp();\n let spending_key = DataKey::UserSpending(sender.clone());\n\n let (mut last_reset_time, mut accumulated_amount): (u64, i128) = env\n .storage()\n .persistent()\n .get::>(&spending_key)\n .map(|packed| unpack_spending(&packed))\n .unwrap_or((current_time, 0));\n\n if current_time - last_reset_time >= Self::SECONDS_IN_24H {\n last_reset_time = current_time;\n accumulated_amount = 0;\n }\n\n accumulated_amount += amount;\n if accumulated_amount > Self::DAILY_MAX_LIMIT {\n return Err(Error::LimitExceeded);\n }\n\n env.storage().persistent().set(\n &spending_key,\n &pack_spending(env, last_reset_time, accumulated_amount),\n );\n env.storage().persistent().extend_ttl(\n &spending_key,\n Self::PERSISTENT_LIFETIME_THRESHOLD,\n Self::PERSISTENT_BUMP_AMOUNT,\n );\n\n // Verify sender has sufficient balance\n let token_client = token::Client::new(env, token_address);\n if token_client.balance(sender) < amount {\n return Err(Error::InsufficientBalance);\n }\n\n // Calculate fee\n let mut fee_amount = (amount * effective_fee_bps) / Self::BPS_DIVISOR;\n if fee_amount > fee_cap {\n fee_amount = fee_cap;\n }\n if fee_amount > amount {\n fee_amount = amount;\n }\n let remainder = amount - fee_amount;\n\n // Execute transfers\n if fee_amount > 0 {\n token_client.transfer(sender, platform_treasury, &fee_amount);\n }\n if remainder > 0 {\n // Attempt to transfer remainder directly to recipient.\n // If recipient cannot receive tokens (e.g. missing trustline or rejection),\n // transfer funds into the contract and credit the sender's internal refund ledger.\n match token_client.try_transfer(sender, recipient, &remainder) {\n Ok(Ok(())) => {\n log!(env, "Remaining balance routed to recipient");\n }\n _ => {\n log!(\n env,\n "Recipient transfer failed; crediting sender refund balance"\n );\n token_client.transfer(sender, &env.current_contract_address(), &remainder);\n Self::credit_refund_balance(env, sender, token_address, remainder);\n }\n }\n }\n\n // Record cumulative volume\n let volume_key = DataKey::UserVolume(sender.clone());\n let prev_volume: i128 = env.storage().persistent().get(&volume_key).unwrap_or(0);\n env.storage()\n .persistent()\n .set(&volume_key, &(prev_volume + amount));\n env.storage().persistent().extend_ttl(\n &volume_key,\n Self::PERSISTENT_LIFETIME_THRESHOLD,\n Self::PERSISTENT_BUMP_AMOUNT,\n );\n\n // Emit routed event\n env.events().publish(\n (symbol_short!("routed"), sender.clone(), recipient.clone()),\n amount,\n );\n\n log!(env, "Platform fee routed to treasury");\n\n Ok(())\n }\n\n // ── Public contract methods ──────────────────────────────────────────────\n\n /// One-time setup: records the admin and the initial fee configuration\n /// in instance storage. Must be called before `route_payment`.\n pub fn initialize(\n env: Env,\n admin: Address,\n platform_treasury: Address,\n fee_bps: i128,\n fee_cap: i128,\n max_amount: i128,\n ) -> Result<(), Error> {\n if env.storage().instance().has(&DataKey::Admin) {\n return Err(Error::AlreadyInitialized);\n }\n admin.require_auth();\n\n env.storage().instance().set(&DataKey::Admin, &admin);\n env.storage()\n .instance()\n .set(&DataKey::PlatformTreasury, &platform_treasury);\n env.storage().instance().set(&DataKey::FeeBps, &fee_bps);\n env.storage().instance().set(&DataKey::FeeCap, &fee_cap);\n env.storage()\n .instance()\n .set(&DataKey::MaxAmount, &max_amount);\n env.storage().instance().set(&DataKey::Paused, &false);\n env.storage().instance().set(&DataKey::Frozen, &false);\n env.storage().instance().set(&DataKey::TimelockNonce, &0u64);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n\n Ok(())\n }\n\n // ── Timelock: queue / execute / cancel ───────────────────────────────────\n\n /// Queues an admin action to be executed after a 24-hour delay.\n ///\n /// The admin provides the desired `ActionType` variant and receives a\n /// numeric nonce that uniquely identifies this pending entry. Pass this\n /// nonce to `execute_action` after 24 hours, or to `cancel_action` to\n /// abort the intent.\n ///\n /// Sensitive parameter changes (`set_platform_treasury`, `set_fee_config`,\n /// `set_fee_bps`, `set_governance`, `set_min_limit`, `transfer_admin`,\n /// `upgrade`) must go through the timelock. Use the direct setter\n /// functions only for actions that are not sensitive (e.g. `set_pause`\n /// which can also be called directly for immediate operational pauses).\n ///\n /// The contract must not be frozen when queuing, and the admin must\n /// authorize the call.\n pub fn queue_action(env: Env, action: ActionType) -> Result {\n if Self::is_frozen_internal(&env) {\n return Err(Error::ContractFrozen);\n }\n\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n let nonce = Self::next_nonce(&env);\n let queued_at = env.ledger().timestamp();\n\n let entry = TimelockEntry {\n queued_at,\n action: action.clone(),\n };\n\n let key = DataKey::TimelockEntry(nonce);\n env.storage().persistent().set(&key, &entry);\n env.storage().persistent().extend_ttl(\n &key,\n Self::PERSISTENT_LIFETIME_THRESHOLD,\n Self::PERSISTENT_BUMP_AMOUNT,\n );\n\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n\n env.events().publish(\n (Symbol::new(&env, "action_queued"), admin),\n (nonce, queued_at),\n );\n\n log!(&env, "Timelock action queued with nonce {}", nonce);\n Ok(nonce)\n }\n\n /// Returns the pending `TimelockEntry` for the given nonce, or an error if\n /// it does not exist.\n pub fn get_queued_action(env: Env, nonce: u64) -> Result {\n let key = DataKey::TimelockEntry(nonce);\n env.storage()\n .persistent()\n .get(&key)\n .ok_or(Error::TimelockNotFound)\n }\n\n /// Executes a previously queued action identified by `nonce`.\n ///\n /// Requirements:\n /// - The contract must not be frozen.\n /// - The admin must authorize.\n /// - The entry identified by `nonce` must exist.\n /// - At least 24 hours (`SECONDS_IN_24H`) must have passed since queuing.\n ///\n /// On success the entry is removed and the underlying setter is invoked.\n pub fn execute_action(env: Env, nonce: u64) -> Result<(), Error> {\n if Self::is_frozen_internal(&env) {\n return Err(Error::ContractFrozen);\n }\n\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n let key = DataKey::TimelockEntry(nonce);\n let entry: TimelockEntry = env\n .storage()\n .persistent()\n .get(&key)\n .ok_or(Error::TimelockNotFound)?;\n\n let now = env.ledger().timestamp();\n if now < entry.queued_at + Self::SECONDS_IN_24H {\n return Err(Error::TimelockNotReady);\n }\n\n // Remove the entry before applying the action (checks-effects-interactions).\n env.storage().persistent().remove(&key);\n\n // Apply the action.\n match entry.action {\n ActionType::SetPlatformTreasury(new_treasury) => {\n env.storage()\n .instance()\n .set(&DataKey::PlatformTreasury, &new_treasury);\n }\n ActionType::SetFeeConfig(fee_bps, fee_cap) => {\n env.storage().instance().set(&DataKey::FeeBps, &fee_bps);\n env.storage().instance().set(&DataKey::FeeCap, &fee_cap);\n }\n ActionType::SetFeeBps(new_fee_bps) => {\n env.storage().instance().set(&DataKey::FeeBps, &new_fee_bps);\n }\n ActionType::SetGovernance(gov) => {\n env.storage().instance().set(&DataKey::Governance, &gov);\n }\n ActionType::SetMinLimit(min_limit) => {\n env.storage().instance().set(&DataKey::MinLimit, &min_limit);\n }\n ActionType::TransferAdmin(new_admin) => {\n env.storage().instance().set(&DataKey::Admin, &new_admin);\n }\n ActionType::Upgrade(new_wasm_hash) => {\n env.deployer().update_current_contract_wasm(new_wasm_hash);\n }\n }\n\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n\n env.events()\n .publish((Symbol::new(&env, "action_executed"), admin), nonce);\n\n log!(&env, "Timelock action executed for nonce {}", nonce);\n Ok(())\n }\n\n /// Cancels a pending timelock entry before it can be executed.\n ///\n /// This is the primary defence when a compromised admin has queued a\n /// malicious action: any other admin (after a key rotation) or a\n /// multi-sig governance can cancel it within the 24-hour window.\n ///\n /// Admin authorization is required. The contract may be frozen.\n pub fn cancel_action(env: Env, nonce: u64) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n let key = DataKey::TimelockEntry(nonce);\n if !env.storage().persistent().has(&key) {\n return Err(Error::TimelockNotFound);\n }\n\n env.storage().persistent().remove(&key);\n\n env.events()\n .publish((Symbol::new(&env, "action_cancelled"), admin), nonce);\n\n log!(&env, "Timelock action cancelled for nonce {}", nonce);\n Ok(())\n }\n\n // ── Freeze / unfreeze ────────────────────────────────────────────────────\n\n /// Instantly freezes the contract, blocking all payments and timelock\n /// executions. This is the emergency last resort when an admin key is\n /// known to be compromised.\n ///\n /// Unlike other sensitive admin operations, freeze takes effect immediately\n /// — it does NOT go through the timelock — so it is always available as a\n /// rapid-response tool.\n ///\n /// Admin authorization is required.\n pub fn emergency_freeze(env: Env) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.storage().instance().set(&DataKey::Frozen, &true);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n\n env.events().publish(\n (Symbol::new(&env, "emergency_freeze"), admin),\n env.ledger().timestamp(),\n );\n\n log!(&env, "Contract frozen by admin");\n Ok(())\n }\n\n /// Removes the frozen state, restoring normal contract operation.\n ///\n /// Like `emergency_freeze`, this takes effect immediately and does not\n /// go through the timelock.\n ///\n /// Admin authorization is required.\n pub fn unfreeze(env: Env) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.storage().instance().set(&DataKey::Frozen, &false);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n\n env.events().publish(\n (Symbol::new(&env, "unfreeze"), admin),\n env.ledger().timestamp(),\n );\n\n log!(&env, "Contract unfrozen by admin");\n Ok(())\n }\n\n /// Returns whether the contract is currently frozen.\n pub fn is_frozen(env: Env) -> bool {\n Self::is_frozen_internal(&env)\n }\n\n // ── Sensitive admin setters (now require timelock) ───────────────────────\n //\n // The functions below are intentionally kept as thin wrappers that apply\n // the change *directly* but only when called from execute_action (i.e.\n // after the timelock has been satisfied). External callers that were\n // previously calling these functions directly should instead use\n // queue_action + execute_action.\n //\n // NOTE: The direct-setter functions are retained for backward-compatibility\n // of off-chain tooling. They still gate on admin/governance auth but they\n // are NOT wrapped by an on-chain timelock check; the timelock is enforced\n // exclusively through queue_action / execute_action.\n\n /// Updates the treasury address that receives the platform fee.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetPlatformTreasury(…))`\n /// and execute after 24 hours. This direct path is retained for tooling\n /// compatibility only.\n pub fn set_platform_treasury(env: Env, new_treasury: Address) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.storage()\n .instance()\n .set(&DataKey::PlatformTreasury, &new_treasury);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n Ok(())\n }\n\n /// Updates the fee basis points and fee cap.\n /// Requires governance authority if a governance address is set; otherwise admin-only.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetFeeConfig(…))`.\n pub fn set_fee_config_legacy(env: Env, fee_bps: i128, fee_cap: i128) -> Result<(), Error> {\n Self::require_fee_authority(&env)?;\n\n env.storage().instance().set(&DataKey::FeeBps, &fee_bps);\n env.storage().instance().set(&DataKey::FeeCap, &fee_cap);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n Ok(())\n }\n\n /// Alias for `set_fee_config_legacy`. Admin-only.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetFeeConfig(…))`.\n pub fn set_fee_config(env: Env, fee_bps: i128, fee_cap: i128) -> Result<(), Error> {\n Self::set_fee_config_legacy(env, fee_bps, fee_cap)\n }\n\n /// Updates the fee basis points.\n /// Requires governance authority if a governance address is set; otherwise admin-only.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetFeeBps(…))`.\n pub fn set_fee_bps(env: Env, new_fee_bps: i128) -> Result<(), Error> {\n Self::require_fee_authority(&env)?;\n\n env.storage().instance().set(&DataKey::FeeBps, &new_fee_bps);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n Ok(())\n }\n\n /// Sets the governance contract address. After this call, only the governance\n /// contract can update fees. Admin-only — can only be set once per governance cycle.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetGovernance(…))`.\n pub fn set_governance(env: Env, gov: Address) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n env.storage().instance().set(&DataKey::Governance, &gov);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n Ok(())\n }\n\n /// Sets the minimum allowed routing amount. Admin-only.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetMinLimit(…))`.\n pub fn set_min_limit(env: Env, min_limit: i128) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.storage().instance().set(&DataKey::MinLimit, &min_limit);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n Ok(())\n }\n\n /// Returns the current protocol fee percentage in basis points.\n pub fn get_fee(env: Env) -> i128 {\n env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0)\n }\n\n /// Pauses or unpauses the payment router. Admin-only.\n /// This is NOT timelocked — operational pausing must remain instant.\n pub fn set_pause(env: Env, paused: bool) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.storage().instance().set(&DataKey::Paused, &paused);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n\n env.events().publish((symbol_short!("pause"),), (paused,));\n\n Ok(())\n }\n\n /// Alias for `set_pause`. Admin-only.\n pub fn set_paused(env: Env, paused: bool) -> Result<(), Error> {\n Self::set_pause(env, paused)\n }\n\n /// Returns whether the contract is currently paused.\n pub fn is_paused(env: Env) -> bool {\n env.storage()\n .instance()\n .get(&DataKey::Paused)\n .unwrap_or(false)\n }\n\n /// Returns the cumulative amount a given sender has routed through the contract.\n pub fn get_user_volume(env: Env, user: Address) -> i128 {\n env.storage()\n .persistent()\n .get(&DataKey::UserVolume(user))\n .unwrap_or(0)\n }\n\n /// Adds an address to the blacklist. Admin-only.\n pub fn blacklist_address(env: Env, address: Address) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.storage()\n .persistent()\n .set(&DataKey::Blacklist(address.clone()), &true);\n env.storage().persistent().extend_ttl(\n &DataKey::Blacklist(address),\n Self::PERSISTENT_LIFETIME_THRESHOLD,\n Self::PERSISTENT_BUMP_AMOUNT,\n );\n\n Ok(())\n }\n\n /// Removes an address from the blacklist. Admin-only.\n pub fn unblacklist_address(env: Env, address: Address) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.storage()\n .persistent()\n .remove(&DataKey::Blacklist(address));\n\n Ok(())\n }\n\n /// Returns whether an address is blacklisted.\n pub fn is_blacklisted(env: Env, address: Address) -> bool {\n env.storage()\n .persistent()\n .get(&DataKey::Blacklist(address))\n .unwrap_or(false)\n }\n\n /// Returns the effective fee_bps for a sender after applying any\n /// volume-based tiered discount.\n pub fn get_effective_fee_bps(env: Env, sender: Address) -> i128 {\n let fee_bps: i128 = env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0);\n let user_volume = Self::get_user_volume(env.clone(), sender);\n if user_volume > Self::VOLUME_THRESHOLD {\n fee_bps / 2\n } else {\n fee_bps\n }\n }\n\n /// Set a new admin. Gated by the current admin if one exists.\n pub fn set_admin(env: Env, new_admin: Address) -> Result<(), Error> {\n if let Some(admin) = env\n .storage()\n .instance()\n .get::(&DataKey::Admin)\n {\n admin.require_auth();\n }\n env.storage().instance().set(&DataKey::Admin, &new_admin);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n Ok(())\n }\n\n /// Transfers admin rights to a new address.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::TransferAdmin(…))`.\n pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), Error> {\n let current_admin = Self::require_admin(&env)?;\n current_admin.require_auth();\n env.storage().instance().set(&DataKey::Admin, &new_admin);\n env.storage().instance().extend_ttl(\n Self::INSTANCE_LIFETIME_THRESHOLD,\n Self::INSTANCE_BUMP_AMOUNT,\n );\n Ok(())\n }\n\n /// Recovers tokens accidentally sent directly to the contract address. Admin-only.\n pub fn recover_tokens(env: Env, token: Address, amount: i128) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n let contract_address = env.current_contract_address();\n let token_client = token::Client::new(&env, &token);\n token_client.transfer(&contract_address, &admin, &amount);\n\n Ok(())\n }\n\n /// Records a token as supported (no-op; routing accepts any token contract ID).\n pub fn add_supported_token(_env: Env, _token: Address) -> Result<(), Error> {\n Ok(())\n }\n\n /// Routes a payment from a sender to a recipient, deducting a platform fee.\n pub fn route_payment(\n env: Env,\n sender: Address,\n recipient: Address,\n token_address: Address,\n amount: i128,\n ) -> Result<(), Error> {\n if Self::is_frozen_internal(&env) {\n return Err(Error::ContractFrozen);\n }\n if Self::is_paused(env.clone()) {\n return Err(Error::Paused);\n }\n\n let (platform_treasury, fee_bps, fee_cap) = Self::load_fee_config(&env)?;\n\n Self::process_single_payment(\n &env,\n &sender,\n &recipient,\n &token_address,\n amount,\n &platform_treasury,\n fee_bps,\n fee_cap,\n )\n }\n\n /// Routes multiple payments in a single transaction. If any payment fails,\n /// the entire batch is reverted atomically.\n pub fn route_payments(env: Env, payments: Vec) -> Result<(), Error> {\n if Self::is_frozen_internal(&env) {\n return Err(Error::ContractFrozen);\n }\n if Self::is_paused(env.clone()) {\n return Err(Error::Paused);\n }\n\n let (platform_treasury, fee_bps, fee_cap) = Self::load_fee_config(&env)?;\n\n for payment in payments.iter() {\n Self::process_single_payment(\n &env,\n &payment.sender,\n &payment.recipient,\n &payment.token_address,\n payment.amount,\n &platform_treasury,\n fee_bps,\n fee_cap,\n )?;\n }\n\n Ok(())\n }\n\n /// Returns the available internal refund balance for a user and token.\n pub fn get_refund_balance(env: Env, user: Address, token: Address) -> i128 {\n Self::get_refund_balance_internal(&env, &user, &token)\n }\n\n /// Withdraws a specific amount from the user's internal refund balance.\n pub fn withdraw_refund(\n env: Env,\n user: Address,\n token: Address,\n amount: i128,\n ) -> Result<(), Error> {\n user.require_auth();\n\n if amount <= 0 {\n return Err(Error::NoRefundAvailable);\n }\n\n let current_balance = Self::get_refund_balance_internal(&env, &user, &token);\n if amount > current_balance {\n return Err(Error::NoRefundAvailable);\n }\n\n let key = DataKey::RefundBalance(user.clone(), token.clone());\n let new_balance = current_balance - amount;\n if new_balance > 0 {\n env.storage().persistent().set(&key, &new_balance);\n env.storage().persistent().extend_ttl(\n &key,\n Self::PERSISTENT_LIFETIME_THRESHOLD,\n Self::PERSISTENT_BUMP_AMOUNT,\n );\n } else {\n env.storage().persistent().remove(&key);\n }\n\n let contract_address = env.current_contract_address();\n let token_client = token::Client::new(&env, &token);\n token_client.transfer(&contract_address, &user, &amount);\n\n env.events().publish(\n (symbol_short!("withdrawn"), user.clone(), token.clone()),\n amount,\n );\n\n log!(&env, "Refund balance withdrawn by user");\n Ok(())\n }\n\n /// Claims and withdraws the entire available refund balance for a user and token.\n pub fn claim_all_refunds(env: Env, user: Address, token: Address) -> Result {\n user.require_auth();\n\n let current_balance = Self::get_refund_balance_internal(&env, &user, &token);\n if current_balance <= 0 {\n return Err(Error::NoRefundAvailable);\n }\n\n Self::withdraw_refund(env, user, token, current_balance)?;\n Ok(current_balance)\n }\n\n /// Admin-only emergency withdrawal of tokens held by this contract.\n pub fn emergency_withdraw(env: Env, token: Address, amount: i128) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n let token_client = token::Client::new(&env, &token);\n token_client.transfer(&env.current_contract_address(), &admin, &amount);\n\n log!(&env, "Emergency withdraw executed by admin");\n Ok(())\n }\n\n /// Replaces this contract's WASM with a previously uploaded version.\n ///\n /// DEPRECATED for direct use. Queue via `queue_action(ActionType::Upgrade(…))`.\n pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), Error> {\n let admin = Self::require_admin(&env)?;\n admin.require_auth();\n\n env.deployer().update_current_contract_wasm(new_wasm_hash);\n Ok(())\n }\n\n /// Returns the contract version.\n pub fn version(_env: Env) -> u32 {\n Self::VERSION\n }\n}\n\n#[cfg(test)]\nmod test {\n use super::*;\n use soroban_sdk::{\n testutils::{Address as _, Events, Ledger as _, LedgerInfo},\n token::StellarAssetClient,\n Address, Env, Symbol, TryIntoVal,\n };\n\n /// Returns (env, client, contract_id).\n fn setup_env() -> (Env, PaymentRouterClient<'static>, Address) {\n let env = Env::default();\n env.mock_all_auths();\n let contract_id = env.register_contract(None, PaymentRouter);\n let client = PaymentRouterClient::new(&env, &contract_id);\n (env, client, contract_id)\n }\n\n /// Deploys a Stellar Asset Contract test token. Returns\n /// (token_address, token_client, stellar_asset_admin_client).\n fn setup_token(\n env: &Env,\n ) -> (\n Address,\n token::Client<'static>,\n token::StellarAssetClient<'static>,\n ) {\n let token_admin = Address::generate(env);\n let token_address = env.register_stellar_asset_contract(token_admin);\n let token_client = token::Client::new(env, &token_address);\n let token_admin_client = token::StellarAssetClient::new(env, &token_address);\n (token_address, token_client, token_admin_client)\n }\n\n // ── Timelock tests ───────────────────────────────────────────────────────\n\n #[test]\n fn test_queue_and_execute_set_fee_bps_after_delay() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n // Queue a fee-bps change.\n let nonce = client.queue_action(&ActionType::SetFeeBps(250));\n assert_eq!(nonce, 1);\n assert_eq!(client.get_fee(), 100); // Not applied yet.\n\n // Trying to execute immediately should fail (delay not elapsed).\n let res = client.try_execute_action(&nonce);\n assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotReady);\n\n // Advance time past 24 hours.\n let current_time = env.ledger().timestamp();\n env.ledger().set(LedgerInfo {\n timestamp: current_time + PaymentRouter::SECONDS_IN_24H + 1,\n protocol_version: env.ledger().protocol_version(),\n sequence_number: env.ledger().sequence(),\n network_id: env.ledger().network_id().into(),\n base_reserve: 100,\n min_temp_entry_ttl: 16,\n min_persistent_entry_ttl: 4096,\n max_entry_ttl: 6312000,\n });\n\n // Now execution should succeed.\n client.execute_action(&nonce);\n assert_eq!(client.get_fee(), 250);\n\n // Entry should be gone.\n let res = client.try_get_queued_action(&nonce);\n assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound);\n }\n\n #[test]\n fn test_queue_and_execute_set_platform_treasury() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let new_treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let nonce = client.queue_action(&ActionType::SetPlatformTreasury(new_treasury.clone()));\n\n // Advance 24h+.\n let ts = env.ledger().timestamp();\n env.ledger().set(LedgerInfo {\n timestamp: ts + PaymentRouter::SECONDS_IN_24H + 1,\n protocol_version: env.ledger().protocol_version(),\n sequence_number: env.ledger().sequence(),\n network_id: env.ledger().network_id().into(),\n base_reserve: 100,\n min_temp_entry_ttl: 16,\n min_persistent_entry_ttl: 4096,\n max_entry_ttl: 6312000,\n });\n\n client.execute_action(&nonce);\n\n // Verify the treasury was actually updated by routing a payment and\n // checking where the fee lands.\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n let (token_addr, token_client, sac) = setup_token(&env);\n sac.mint(&sender, &10_000);\n client.route_payment(&sender, &recipient, &token_addr, &1000);\n\n // 100 bps of 1000 = 10, capped to min(10, 1000) = 10\n assert_eq!(token_client.balance(&new_treasury), 10);\n assert_eq!(token_client.balance(&treasury), 0);\n }\n\n #[test]\n fn test_execute_action_not_found() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let res = client.try_execute_action(&99u64);\n assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound);\n }\n\n #[test]\n fn test_cancel_action() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let nonce = client.queue_action(&ActionType::SetFeeBps(999));\n assert!(client.try_get_queued_action(&nonce).is_ok());\n\n client.cancel_action(&nonce);\n\n // Entry should be gone.\n let res = client.try_get_queued_action(&nonce);\n assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound);\n\n // Fee should remain unchanged.\n assert_eq!(client.get_fee(), 100);\n }\n\n #[test]\n fn test_cancel_nonexistent_action() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let res = client.try_cancel_action(&42u64);\n assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound);\n }\n\n #[test]\n fn test_nonce_increments() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let n1 = client.queue_action(&ActionType::SetFeeBps(200));\n let n2 = client.queue_action(&ActionType::SetFeeBps(300));\n let n3 = client.queue_action(&ActionType::SetFeeBps(400));\n\n assert_eq!(n1, 1);\n assert_eq!(n2, 2);\n assert_eq!(n3, 3);\n }\n\n // ── Freeze tests ─────────────────────────────────────────────────────────\n\n #[test]\n fn test_emergency_freeze_blocks_payments() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, sac) = setup_token(&env);\n sac.mint(&sender, &10_000);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n\n assert!(!client.is_frozen());\n\n client.emergency_freeze();\n assert!(client.is_frozen());\n\n let res = client.try_route_payment(&sender, &recipient, &token_address, &1000);\n assert_eq!(res.unwrap_err().unwrap(), Error::ContractFrozen);\n }\n\n #[test]\n fn test_emergency_freeze_blocks_timelock_execution() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let nonce = client.queue_action(&ActionType::SetFeeBps(500));\n\n // Advance past 24h.\n let ts = env.ledger().timestamp();\n env.ledger().set(LedgerInfo {\n timestamp: ts + PaymentRouter::SECONDS_IN_24H + 1,\n protocol_version: env.ledger().protocol_version(),\n sequence_number: env.ledger().sequence(),\n network_id: env.ledger().network_id().into(),\n base_reserve: 100,\n min_temp_entry_ttl: 16,\n min_persistent_entry_ttl: 4096,\n max_entry_ttl: 6312000,\n });\n\n // Freeze the contract before execution.\n client.emergency_freeze();\n\n let res = client.try_execute_action(&nonce);\n assert_eq!(res.unwrap_err().unwrap(), Error::ContractFrozen);\n\n // Fee remains unchanged.\n assert_eq!(client.get_fee(), 100);\n }\n\n #[test]\n fn test_unfreeze_restores_payments() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, sac) = setup_token(&env);\n sac.mint(&sender, &10_000);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n\n client.emergency_freeze();\n assert!(client.is_frozen());\n\n client.unfreeze();\n assert!(!client.is_frozen());\n\n // Payments should work again.\n client.route_payment(&sender, &recipient, &token_address, &1000);\n }\n\n #[test]\n fn test_freeze_queue_action_blocked() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n client.emergency_freeze();\n\n // Cannot queue new actions while frozen.\n let res = client.try_queue_action(&ActionType::SetFeeBps(500));\n assert_eq!(res.unwrap_err().unwrap(), Error::ContractFrozen);\n }\n\n #[test]\n fn test_cancel_action_allowed_while_frozen() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n // Queue an action before freezing.\n let nonce = client.queue_action(&ActionType::SetFeeBps(500));\n\n client.emergency_freeze();\n\n // Cancellation should still be possible while frozen (incident response).\n client.cancel_action(&nonce);\n let res = client.try_get_queued_action(&nonce);\n assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound);\n }\n\n // ── Timelock emits events ────────────────────────────────────────────────\n\n #[test]\n fn test_queue_action_emits_event() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n client.queue_action(&ActionType::SetFeeBps(200));\n\n let events = env.events().all();\n let found = events.iter().any(|(_, topics, _)| {\n if topics.is_empty() {\n return false;\n }\n let raw = topics.get(0).unwrap();\n let sym: Result = raw.try_into_val(&env);\n sym.map(|s| s == Symbol::new(&env, "action_queued"))\n .unwrap_or(false)\n });\n assert!(found, "action_queued event not found");\n }\n\n #[test]\n fn test_freeze_emits_event() {\n let (env, client, _) = setup_env();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n client.emergency_freeze();\n\n let events = env.events().all();\n let found = events.iter().any(|(_, topics, _)| {\n if topics.is_empty() {\n return false;\n }\n let raw = topics.get(0).unwrap();\n let sym: Result = raw.try_into_val(&env);\n sym.map(|s| s == Symbol::new(&env, "emergency_freeze"))\n .unwrap_or(false)\n });\n assert!(found, "emergency_freeze event not found");\n }\n\n // ── Original tests (retained) ────────────────────────────────────────────\n\n #[test]\n fn test_get_fee() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n\n // Before initialization, get_fee returns 0\n assert_eq!(client.get_fee(), 0);\n\n // Initialize with 150 bps\n client.initialize(&admin, &treasury, &150, &5000, &PaymentRouter::MAX_AMOUNT);\n assert_eq!(client.get_fee(), 150);\n\n // Update via set_fee_bps\n client.set_fee_bps(&250);\n assert_eq!(client.get_fee(), 250);\n\n // Update via set_fee_config\n client.set_fee_config(&300, &10000);\n assert_eq!(client.get_fee(), 300);\n }\n\n #[test]\n fn test_version_reports_contract_version() {\n let (_env, client, _) = setup_env();\n\n // #269 — the version view is callable without initialization and\n // returns the compiled-in contract version so a UI can check\n // compatibility before interacting with the contract.\n assert_eq!(client.version(), PaymentRouter::VERSION);\n assert_eq!(client.version(), 1);\n }\n\n #[test]\n fn test_admin_restrictions_and_updates() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let new_admin = Address::generate(&env);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n // Trying to initialize again should fail\n let res = client.try_initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n assert_eq!(res.unwrap_err().unwrap(), Error::AlreadyInitialized);\n\n client.set_admin(&new_admin);\n\n // Modify config\n client.set_fee_config(&200, &2000);\n client.set_fee_bps(&200);\n assert_eq!(client.get_fee(), 200);\n\n let new_treasury = Address::generate(&env);\n client.set_platform_treasury(&new_treasury);\n }\n\n #[test]\n fn test_recover_tokens() {\n let (env, client, contract_id) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let (token_address, token_client, stellar_asset_client) = setup_token(&env);\n\n // Simulate tokens accidentally sent directly to the contract address\n let accidental_amount = 5_000i128;\n stellar_asset_client.mint(&contract_id, &accidental_amount);\n\n assert_eq!(token_client.balance(&contract_id), accidental_amount);\n assert_eq!(token_client.balance(&admin), 0);\n\n // Admin recovers tokens\n let recover_amount = 3_000i128;\n client.recover_tokens(&token_address, &recover_amount);\n\n assert_eq!(token_client.balance(&admin), recover_amount);\n assert_eq!(\n token_client.balance(&contract_id),\n accidental_amount - recover_amount\n );\n }\n\n #[test]\n fn test_set_pause_emits_event() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n client.set_pause(&true);\n\n let events = env.events().all();\n assert!(!events.is_empty());\n let (_, topics, _) = events.get(0).unwrap();\n assert_eq!(topics.len(), 1);\n let topic: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap();\n assert_eq!(topic, symbol_short!("pause"));\n }\n\n #[test]\n fn test_route_payment_emits_payment_initiated_event() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, sac) = setup_token(&env);\n sac.mint(&sender, &10_000);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n\n client\n .mock_all_auths()\n .route_payment(&sender, &recipient, &token_address, &5_000);\n\n let events = env.events().all();\n assert!(!events.is_empty());\n\n let mut found = false;\n for (_, topics, data) in events.iter() {\n if !topics.is_empty() {\n if let Ok(topic_sym) = topics.get(0).unwrap().try_into_val(&env) {\n let sym: Symbol = topic_sym;\n if sym == Symbol::new(&env, "payment_initiated") {\n found = true;\n let amt: i128 = data.try_into_val(&env).unwrap();\n assert_eq!(amt, 5_000);\n break;\n }\n }\n }\n }\n assert!(found, "payment_initiated event not found");\n }\n\n #[test]\n fn test_route_payment_emits_routed_event() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, _token_admin_client) = setup_token(&env);\n let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);\n sac.mint(&sender, &10_000);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n client.add_supported_token(&token_address);\n\n let amount = 2_000i128;\n client.route_payment(&sender, &recipient, &token_address, &amount);\n\n let events = env.events().all();\n assert!(!events.is_empty());\n\n // Find the "routed" event by topic\n let mut found = None;\n for evt in events.iter() {\n let (_contract_id, topics, _data) = evt.clone();\n if topics.len() != 3 {\n continue;\n }\n let topic0: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap();\n if topic0 == symbol_short!("routed") {\n found = Some(evt.clone());\n break;\n }\n }\n let routed = found.expect("route_payment should publish a \"routed\" event");\n\n let (_contract_id, topics, data) = routed;\n assert_eq!(topics.len(), 3);\n\n let topic_sender: Address = topics.get(1).unwrap().try_into_val(&env).unwrap();\n let topic_recipient: Address = topics.get(2).unwrap().try_into_val(&env).unwrap();\n assert_eq!(topic_sender, sender);\n assert_eq!(topic_recipient, recipient);\n\n let event_amount: i128 = data.try_into_val(&env).unwrap();\n assert_eq!(event_amount, amount);\n }\n\n #[test]\n fn test_admin_pause_functionality() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, _token_admin_client) = setup_token(&env);\n let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);\n sac.mint(&sender, &10_000);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n\n // Initially not paused\n assert!(!client.is_paused());\n\n // Pause\n client.set_pause(&true);\n assert!(client.is_paused());\n\n // Route payment should fail when paused\n let res = client.try_route_payment(&sender, &recipient, &token_address, &1000);\n assert_eq!(res.unwrap_err().unwrap(), Error::Paused);\n\n // Unpause via set_paused alias\n client.set_paused(&false);\n assert!(!client.is_paused());\n\n // Route payment should succeed now\n client.route_payment(&sender, &recipient, &token_address, &1000);\n }\n\n #[test]\n fn test_route_payment_calculates_and_sends_fee() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, token_client, _token_admin_client) = setup_token(&env);\n\n let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);\n let initial_balance = 10_000i128;\n sac.mint(&sender, &initial_balance);\n\n // Initialize router with 1% fee (100 bps) and cap of 50\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n client.add_supported_token(&token_address);\n\n // Test normal fee calculation: 1% of 2000 = 20, below cap of 50\n let amount_1 = 2000i128;\n client.route_payment(&sender, &recipient, &token_address, &amount_1);\n\n assert_eq!(token_client.balance(&treasury), 20);\n assert_eq!(token_client.balance(&recipient), 1980);\n assert_eq!(token_client.balance(&sender), initial_balance - amount_1);\n assert_eq!(client.get_user_volume(&sender), amount_1);\n\n // Test fee capped at 50: 1% of 8000 = 80, capped to 50\n let amount_2 = 8000i128;\n client.route_payment(&sender, &recipient, &token_address, &amount_2);\n\n assert_eq!(token_client.balance(&treasury), 70);\n assert_eq!(token_client.balance(&recipient), 9930);\n assert_eq!(\n token_client.balance(&sender),\n initial_balance - amount_1 - amount_2\n );\n assert_eq!(client.get_user_volume(&sender), amount_1 + amount_2);\n }\n\n #[test]\n fn test_insufficient_balance() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, sac) = setup_token(&env);\n sac.mint(&sender, &100);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n client.add_supported_token(&token_address);\n\n // Route payment of 500 when balance is only 100\n let res = client.try_route_payment(&sender, &recipient, &token_address, &500);\n assert_eq!(res.unwrap_err().unwrap(), Error::InsufficientBalance);\n }\n\n #[test]\n fn test_daily_limit_and_reset() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, token_client, _token_admin_client) = setup_token(&env);\n\n let limit = 10_000_000_000_000i128;\n let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);\n sac.mint(&sender, &(limit + 2000));\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n client.add_supported_token(&token_address);\n\n // Route amount up to daily limit\n client.route_payment(&sender, &recipient, &token_address, &limit);\n\n // Next payment should exceed daily limit\n let res = client.try_route_payment(&sender, &recipient, &token_address, &2000);\n assert_eq!(res.unwrap_err().unwrap(), Error::LimitExceeded);\n\n // Advance time past 24 hours to reset the daily limit\n let current_time = env.ledger().timestamp();\n let current_protocol_version = env.ledger().protocol_version();\n env.ledger().set(LedgerInfo {\n timestamp: current_time + 86400,\n protocol_version: current_protocol_version,\n sequence_number: 1,\n network_id: env.ledger().network_id().into(),\n base_reserve: 100,\n min_temp_entry_ttl: 16,\n min_persistent_entry_ttl: 4096,\n max_entry_ttl: 6312000,\n });\n\n // Now routing should succeed again. The first payment pushed volume past\n // VOLUME_THRESHOLD, so the halved rate applies: 2000 * 50 bps = 10.\n client.route_payment(&sender, &recipient, &token_address, &2000);\n assert_eq!(token_client.balance(&recipient), (limit - 50) + (2000 - 10));\n }\n\n #[test]\n fn test_prevent_self_routing() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n\n let (token_address, _token_client, _token_admin_client) = setup_token(&env);\n let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);\n sac.mint(&sender, &10_000);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n\n let res = client.try_route_payment(&sender, &sender, &token_address, &1000);\n assert_eq!(res.unwrap_err().unwrap(), Error::InvalidRecipient);\n }\n\n #[test]\n #[ignore]\n fn test_tiered_fee_discount_applied_after_volume_threshold() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, token_client, _token_admin_client) = setup_token(&env);\n\n // Threshold is 10,000 XLM = 10,000 * 10,000,000 (7 decimals)\n let threshold = 100_000_000_000i128;\n let first_amount = threshold + 1;\n let second_amount = 1000i128;\n let total_mint = first_amount + second_amount + 10_000_000;\n let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);\n sac.mint(&sender, &total_mint);\n\n // Initialize with 1% fee (100 bps) and no cap\n client.initialize(\n &admin,\n &treasury,\n &100,\n &i128::MAX,\n &PaymentRouter::MAX_AMOUNT,\n );\n\n // First payment: volume is 0 (< threshold), full fee applies\n client.route_payment(&sender, &recipient, &token_address, &first_amount);\n\n let full_fee_first = (first_amount * 100) / 10_000;\n assert_eq!(token_client.balance(&treasury), full_fee_first);\n assert_eq!(\n token_client.balance(&recipient),\n first_amount - full_fee_first\n );\n assert_eq!(client.get_user_volume(&sender), first_amount);\n // Volume is now past threshold, so next call gets the discount\n assert_eq!(client.get_effective_fee_bps(&sender), 50);\n\n // Second payment: volume > threshold, 50% discount applies\n client.route_payment(&sender, &recipient, &token_address, &second_amount);\n\n let discounted_fee = (second_amount * 50) / 10_000;\n assert_eq!(\n token_client.balance(&treasury),\n full_fee_first + discounted_fee\n );\n assert_eq!(\n token_client.balance(&recipient),\n (first_amount - full_fee_first) + (second_amount - discounted_fee)\n );\n }\n\n #[test]\n fn test_get_effective_fee_bps_no_discount_below_threshold() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, _token_admin_client) = setup_token(&env);\n let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address);\n sac.mint(&sender, &1_000_000);\n\n client.initialize(\n &admin,\n &treasury,\n &100,\n &i128::MAX,\n &PaymentRouter::MAX_AMOUNT,\n );\n\n // No volume yet\n assert_eq!(client.get_effective_fee_bps(&sender), 100);\n\n // Route a small payment (below threshold)\n client.route_payment(&sender, &recipient, &token_address, &1000);\n\n // Volume is 1000, far below 10,000 XLM threshold\n assert_eq!(client.get_effective_fee_bps(&sender), 100);\n }\n\n #[test]\n fn test_successful_xlm_routing() {\n let env = Env::default();\n env.mock_all_auths();\n\n let admin = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n let platform_treasury = Address::generate(&env);\n\n let contract_id = env.register_contract(None, PaymentRouter);\n let client = PaymentRouterClient::new(&env, &contract_id);\n\n client.initialize(\n &admin,\n &platform_treasury,\n &40,\n &i128::MAX,\n &PaymentRouter::MAX_AMOUNT,\n );\n\n let token_admin = Address::generate(&env);\n let token_address = env.register_stellar_asset_contract(token_admin.clone());\n let sac = StellarAssetClient::new(&env, &token_address);\n let token_client = token::Client::new(&env, &token_address);\n\n let initial_balance = 1_000_000_000i128;\n sac.mint(&sender, &initial_balance);\n\n client.add_supported_token(&token_address);\n\n let amount = 100_000_000i128;\n client.route_payment(&sender, &recipient, &token_address, &amount);\n\n let expected_fee = 400_000i128;\n let expected_recipient_amount = amount - expected_fee;\n\n assert_eq!(token_client.balance(&sender), initial_balance - amount);\n assert_eq!(token_client.balance(&recipient), expected_recipient_amount);\n assert_eq!(token_client.balance(&platform_treasury), expected_fee);\n }\n\n #[test]\n fn test_initialize_sets_admin() {\n let env = Env::default();\n env.mock_all_auths();\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let contract_addr = env.register_contract(None, PaymentRouter);\n let client = PaymentRouterClient::new(&env, &contract_addr);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let stored_admin: Option
= env.as_contract(&contract_addr, || {\n env.storage().instance().get(&DataKey::Admin)\n });\n assert_eq!(stored_admin, Some(admin));\n }\n\n /// Verifies that `emergency_withdraw` transfers the exact requested amount\n /// from the contract's own balance to the admin address.\n #[test]\n fn test_emergency_withdraw_transfers_tokens_to_admin() {\n let (env, client, contract_id) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let (token_address, token_client, stellar_asset_client) = setup_token(&env);\n\n // Fund the contract directly (simulates stranded tokens from a routing failure).\n let stranded_amount = 10_000i128;\n stellar_asset_client.mint(&contract_id, &stranded_amount);\n\n assert_eq!(token_client.balance(&contract_id), stranded_amount);\n assert_eq!(token_client.balance(&admin), 0);\n\n // Admin withdraws half the stranded balance.\n let withdraw_amount = 4_000i128;\n client.emergency_withdraw(&token_address, &withdraw_amount);\n\n assert_eq!(token_client.balance(&admin), withdraw_amount);\n assert_eq!(\n token_client.balance(&contract_id),\n stranded_amount - withdraw_amount\n );\n }\n\n /// Verifies that `emergency_withdraw` can drain the entire contract balance\n /// in a single call.\n #[test]\n fn test_emergency_withdraw_full_balance() {\n let (env, client, contract_id) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let (token_address, token_client, stellar_asset_client) = setup_token(&env);\n\n let stranded_amount = 7_500i128;\n stellar_asset_client.mint(&contract_id, &stranded_amount);\n\n client.emergency_withdraw(&token_address, &stranded_amount);\n\n assert_eq!(token_client.balance(&admin), stranded_amount);\n assert_eq!(token_client.balance(&contract_id), 0);\n }\n\n /// Verifies that `emergency_withdraw` declares admin authorization as required.\n ///\n /// Soroban's `require_auth()` uses an abort-on-failure model in the host\n /// (non-unwinding panics), so we cannot catch a missing-auth failure inside\n /// the same test process. Instead we use `mock_all_auths_allowing_non_root_auth`\n /// to record which addresses the call attempts to authorize, then assert that\n /// the admin address — and *only* the admin — appears in that list.\n #[test]\n fn test_admin_is_required_for_emergency_withdraw() {\n let env = Env::default();\n env.mock_all_auths();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let contract_id = env.register_contract(None, PaymentRouter);\n let client = PaymentRouterClient::new(&env, &contract_id);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n let (token_address, _token_client, stellar_asset_client) = setup_token(&env);\n stellar_asset_client.mint(&contract_id, &5_000i128);\n\n // Call succeeds because mock_all_auths satisfies any require_auth.\n // What we verify is that the invocation recorded exactly one\n // authorization and that it belongs to admin, proving the function\n // gates on the admin address.\n client.emergency_withdraw(&token_address, &1_000i128);\n\n let auths = env.auths();\n let admin_auth_present = auths.iter().any(|(addr, _)| *addr == admin);\n assert!(\n admin_auth_present,\n "emergency_withdraw must require the admin address to authorize"\n );\n }\n\n #[test]\n fn test_blacklist_recipient() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, sac) = setup_token(&env);\n sac.mint(&sender, &10_000);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n\n // Blacklist the recipient\n client.blacklist_address(&recipient);\n assert!(client.is_blacklisted(&recipient));\n\n // Route payment should fail\n let res = client.try_route_payment(&sender, &recipient, &token_address, &1000);\n assert_eq!(res.unwrap_err().unwrap(), Error::Blacklisted);\n\n // Unblacklist and try again\n client.unblacklist_address(&recipient);\n assert!(!client.is_blacklisted(&recipient));\n\n client\n .mock_all_auths()\n .route_payment(&sender, &recipient, &token_address, &1000);\n }\n\n #[test]\n #[ignore]\n fn test_routes_multiple_distinct_assets() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n client.initialize(\n &admin,\n &treasury,\n &100,\n &1_000_000,\n &PaymentRouter::MAX_AMOUNT,\n );\n\n let (usdc_like_address, usdc_like_client, usdc_like_admin_client) = setup_token(&env);\n let (eurc_like_address, eurc_like_client, eurc_like_admin_client) = setup_token(&env);\n assert_ne!(usdc_like_address, eurc_like_address);\n\n usdc_like_admin_client.mint(&sender, &10_000);\n eurc_like_admin_client.mint(&sender, &5_000);\n\n client.route_payment(&sender, &recipient, &usdc_like_address, &2_000);\n client.route_payment(&sender, &recipient, &eurc_like_address, &1_000);\n\n assert_eq!(usdc_like_client.balance(&sender), 8_000);\n assert_eq!(usdc_like_client.balance(&recipient), 1_980);\n assert_eq!(eurc_like_client.balance(&sender), 4_000);\n assert_eq!(eurc_like_client.balance(&recipient), 990);\n assert_eq!(client.get_user_volume(&sender), 3_000);\n }\n\n #[test]\n fn test_benchmark_gas_costs() {\n let (env, client, _) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let sender = Address::generate(&env);\n let recipient = Address::generate(&env);\n\n let (token_address, _token_client, sac) = setup_token(&env);\n sac.mint(&sender, &10_000);\n\n // Reset budget before initialization\n env.budget().reset_default();\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n let init_cpu = env.budget().cpu_instruction_cost();\n let init_mem = env.budget().memory_bytes_cost();\n log!(\n &env,\n "GAS REPORT: initialize - CPU: {}, Mem: {}",\n init_cpu,\n init_mem\n );\n\n // Reset budget before route_payment\n env.budget().reset_default();\n client.route_payment(&sender, &recipient, &token_address, &5_000);\n let route_cpu = env.budget().cpu_instruction_cost();\n let route_mem = env.budget().memory_bytes_cost();\n log!(\n &env,\n "GAS REPORT: route_payment - CPU: {}, Mem: {}",\n route_cpu,\n route_mem\n );\n\n env.budget().print();\n\n // Fails CI if gas costs exceed defined thresholds\n // Set reasonable thresholds (e.g. 5M CPU and 2MB Mem per call)\n let max_cpu = 5_000_000;\n let max_mem = 2_000_000;\n\n assert!(\n init_cpu <= max_cpu,\n "initialize CPU cost exceeded threshold! Cost: {}, Threshold: {}",\n init_cpu,\n max_cpu\n );\n assert!(\n init_mem <= max_mem,\n "initialize Memory cost exceeded threshold! Cost: {}, Threshold: {}",\n init_mem,\n max_mem\n );\n\n assert!(\n route_cpu <= max_cpu,\n "route_payment CPU cost exceeded threshold! Cost: {}, Threshold: {}",\n route_cpu,\n max_cpu\n );\n assert!(\n route_mem <= max_mem,\n "route_payment Memory cost exceeded threshold! Cost: {}, Threshold: {}",\n route_mem,\n max_mem\n );\n }\n\n #[test]\n #[ignore]\n fn test_refund_ledger_and_withdrawal() {\n let (env, client, contract_id) = setup_env();\n\n let admin = Address::generate(&env);\n let treasury = Address::generate(&env);\n let user = Address::generate(&env);\n\n client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT);\n\n let (token_address, token_client, stellar_asset_client) = setup_token(&env);\n\n // Initially zero refund balance\n assert_eq!(client.get_refund_balance(&user, &token_address), 0);\n\n // Simulate stranded tokens in contract and credit internal refund balance\n let refund_amount = 5_000i128;\n stellar_asset_client.mint(&contract_id, &refund_amount);\n\n env.as_contract(&contract_id, || {\n PaymentRouter::credit_refund_balance(&env, &user, &token_address, refund_amount);\n });\n\n assert_eq!(\n client.get_refund_balance(&user, &token_address),\n refund_amount\n );\n\n // User withdraws partial refund\n let partial_amount = 2_000i128;\n client.withdraw_refund(&user, &token_address, &partial_amount);\n\n assert_eq!(token_client.balance(&user), partial_amount);\n assert_eq!(\n client.get_refund_balance(&user, &token_address),\n refund_amount - partial_amount\n );\n\n // User claims remaining refunds with claim_all_refunds\n let claimed = client.claim_all_refunds(&user, &token_address);\n assert_eq!(claimed, refund_amount - partial_amount);\n assert_eq!(token_client.balance(&user), refund_amount);\n assert_eq!(client.get_refund_balance(&user, &token_address), 0);\n\n // Trying to withdraw again should fail with NoRefundAvailable\n let res = client.try_withdraw_refund(&user, &token_address, &100);\n assert_eq!(res.unwrap_err().unwrap(), Error::NoRefundAvailable);\n }\n\n #[test]\n fn test_governance_takes_over_fees() {\n let (_, client, _) = setup_env();\n\n let admin = Address::generate(&client.env);\n let treasury = Address::generate(&client.env);\n let gov = Address::generate(&client.env);\n\n client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT);\n\n // Admin can still update fees before governance is set\n client.set_fee_bps(&150);\n assert_eq!(client.get_fee(), 150);\n\n // Admin hands control over to governance\n client.set_governance(&gov);\n\n // Governance address can now update the fee\n client.set_fee_bps(&200);\n assert_eq!(client.get_fee(), 200);\n }\n\n /// `add_supported_token` is a no-op and never errors. +#![no_std] +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, log, symbol_short, token, Address, BytesN, + Env, Symbol, Vec, +}; + +// ── Packed UserSpending helpers ────────────────────────────────────────────── +// +// Issue #519: Replace the two-field UserSpending contracttype with a single +// BytesN<24> value packed with bitwise operations. +// +// Layout (big-endian): +// bytes 0..8 — last_reset_time : u64 (8 bytes) +// bytes 8..24 — accumulated_amount: i128 (16 bytes) +// +// Benefits: +// • Eliminates the XDR struct-type overhead (type discriminant + field tags) +// that Soroban adds to every contracttype value, shrinking each UserSpending +// ledger entry from ~48 bytes to exactly 24 bytes. +// • Smaller entries → lower state-rent fee per ledger entry per TTL period. + +/// Pack `last_reset_time` (u64) and `accumulated_amount` (i128) into a +/// 24-byte big-endian buffer. +fn pack_spending(env: &Env, last_reset_time: u64, accumulated_amount: i128) -> BytesN<24> { + let mut buf = [0u8; 24]; + + // Bytes 0..8 — last_reset_time (u64 big-endian) + let t_bytes = last_reset_time.to_be_bytes(); + buf[0] = t_bytes[0]; + buf[1] = t_bytes[1]; + buf[2] = t_bytes[2]; + buf[3] = t_bytes[3]; + buf[4] = t_bytes[4]; + buf[5] = t_bytes[5]; + buf[6] = t_bytes[6]; + buf[7] = t_bytes[7]; + + // Bytes 8..24 — accumulated_amount (i128 big-endian) + let a_bytes = accumulated_amount.to_be_bytes(); + buf[8] = a_bytes[0]; + buf[9] = a_bytes[1]; + buf[10] = a_bytes[2]; + buf[11] = a_bytes[3]; + buf[12] = a_bytes[4]; + buf[13] = a_bytes[5]; + buf[14] = a_bytes[6]; + buf[15] = a_bytes[7]; + buf[16] = a_bytes[8]; + buf[17] = a_bytes[9]; + buf[18] = a_bytes[10]; + buf[19] = a_bytes[11]; + buf[20] = a_bytes[12]; + buf[21] = a_bytes[13]; + buf[22] = a_bytes[14]; + buf[23] = a_bytes[15]; + + BytesN::from_array(env, &buf) +} + +/// Unpack a 24-byte buffer into `(last_reset_time, accumulated_amount)`. +fn unpack_spending(packed: &BytesN<24>) -> (u64, i128) { + // BytesN::to_array() is available in soroban-sdk v20. + let buf: [u8; 24] = packed.to_array(); + + // last_reset_time — bytes 0..8 + let last_reset_time = u64::from_be_bytes([ + buf[0], buf[1], buf[2], buf[3], buf[4], buf[5], buf[6], buf[7], + ]); + + // accumulated_amount — bytes 8..24 + let accumulated_amount = i128::from_be_bytes([ + buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], + buf[18], buf[19], buf[20], buf[21], buf[22], buf[23], + ]); + + (last_reset_time, accumulated_amount) +} + +// ── Legacy struct kept for test snapshot compatibility ─────────────────────── +// +// The UserSpending contracttype is retained so existing tests that reference +// it directly continue to compile. All runtime code now uses the packed +// BytesN<24> representation stored under DataKey::UserSpending. + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserSpending { + pub last_reset_time: u64, + pub accumulated_amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Payment { + pub sender: Address, + pub recipient: Address, + pub token_address: Address, + pub amount: i128, +} + +// ── Timelock data structures ───────────────────────────────────────────────── +// +// Admin actions that change sensitive contract parameters (treasury, fees, +// governance, admin transfer) are not applied instantly. Instead the admin +// queues an ActionType intent that gets a nonce ID and a ledger timestamp. +// Only after SECONDS_IN_24H (86 400 s) has elapsed can execute_action be +// called to apply the change. This gives observers a 24-hour window to +// detect and respond to a compromised-admin scenario. +// +// The freeze mechanism is the complementary emergency tool: calling +// emergency_freeze instantly blocks all payments and all timelock executions. +// A freeze does NOT require going through the timelock itself so it is always +// available to the admin as an immediate last resort. Unfreezing likewise +// takes effect immediately so the admin can restore service once the threat is +// resolved. + +/// Describes which administrative parameter change a timelock entry represents. +/// Each variant carries all the arguments needed to apply that change when the +/// delay period is over. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ActionType { + /// Change the platform treasury address. + SetPlatformTreasury(Address), + /// Update fee basis-points and fee cap together (legacy / combined setter). + SetFeeConfig(i128, i128), + /// Update fee basis-points only. + SetFeeBps(i128), + /// Set the governance contract address. + SetGovernance(Address), + /// Change the minimum routing limit. + SetMinLimit(i128), + /// Transfer admin rights to a new address. + TransferAdmin(Address), + /// Upgrade the contract WASM. + Upgrade(BytesN<32>), +} + +/// A pending timelock entry stored in persistent ledger storage. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TimelockEntry { + /// Ledger timestamp (seconds since epoch) when this action was queued. + pub queued_at: u64, + /// The action payload to apply once the delay has elapsed. + pub action: ActionType, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + Admin, + Governance, + PlatformTreasury, + FeeBps, + FeeCap, + MinLimit, + Paused, + MaxAmount, + UserVolume(Address), + UserSpending(Address), + Blacklist(Address), + RefundBalance(Address, Address), + /// Monotonically-increasing nonce counter used to generate unique IDs for + /// timelock entries. Stored as `u64` in instance storage. + TimelockNonce, + /// A pending timelock entry keyed by its nonce ID. + /// Stored in persistent storage so it survives instance eviction. + TimelockEntry(u64), + /// When `true` the contract is frozen: payments and timelock executions + /// are blocked. Stored as `bool` in instance storage. + Frozen, +} + +/// Contract-level errors returned instead of panicking, so callers get a +/// specific, stable error code to branch on rather than an opaque trap. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + /// Caller is not authorized to perform this action (e.g. not the admin). + Unauthorized = 1, + /// Sender's token balance is lower than the requested payment amount. + InsufficientBalance = 2, + /// Requested amount is outside allowed bounds, or a spending limit was exceeded. + LimitExceeded = 3, + /// `initialize` was called on a contract that already has an admin set. + AlreadyInitialized = 4, + /// An admin-configured value (treasury, fee, admin) was read before `initialize`. + NotInitialized = 5, + Paused = 6, + InvalidFeeRate = 7, + /// Sender and recipient addresses are the same (self-routing not allowed). + InvalidRecipient = 8, + /// Recipient address is blacklisted. + Blacklisted = 9, + /// Requested refund withdrawal amount is zero or exceeds available refund balance. + NoRefundAvailable = 10, + /// An action is already pending in the timelock queue; it must be executed + /// or cancelled before a duplicate can be queued (not currently enforced, + /// but reserved for future deduplication logic). + TimelockPending = 11, + /// The 24-hour delay for the given timelock entry has not elapsed yet. + TimelockNotReady = 12, + /// No timelock entry exists for the supplied nonce ID. + TimelockNotFound = 13, + /// The contract is frozen; all payments and timelock executions are blocked. + ContractFrozen = 14, +} + +#[contract] +pub struct PaymentRouter; + +#[contractimpl] +impl PaymentRouter { + const BPS_DIVISOR: i128 = 10_000; + const XLM_DECIMALS: i128 = 10_000_000; + const MAX_AMOUNT: i128 = 1_000_000_000_000_000; // 100M tokens with 7 decimals + const DAILY_MAX_LIMIT: i128 = 1_000_000 * Self::XLM_DECIMALS; // 1M tokens limit + const VOLUME_THRESHOLD: i128 = 10_000 * Self::XLM_DECIMALS; // 10,000 XLM threshold for tiered fee discount + const SECONDS_IN_24H: u64 = 24 * 3600; + const VERSION: u32 = 1; + + const DAY_IN_LEDGERS: u32 = 17280; + const INSTANCE_BUMP_AMOUNT: u32 = 7 * Self::DAY_IN_LEDGERS; + const INSTANCE_LIFETIME_THRESHOLD: u32 = Self::INSTANCE_BUMP_AMOUNT - Self::DAY_IN_LEDGERS; + + const USER_BUMP_AMOUNT: u32 = 30 * Self::DAY_IN_LEDGERS; + const USER_LIFETIME_THRESHOLD: u32 = Self::USER_BUMP_AMOUNT - Self::DAY_IN_LEDGERS; + const PERSISTENT_BUMP_AMOUNT: u32 = Self::USER_BUMP_AMOUNT; + const PERSISTENT_LIFETIME_THRESHOLD: u32 = Self::USER_LIFETIME_THRESHOLD; + + // ── Private helpers ────────────────────────────────────────────────────── + + fn require_admin(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized) + } + + /// Fee authority helper: if a Governance address is set it takes exclusive + /// control over fee updates; otherwise the admin retains that right. + fn require_fee_authority(env: &Env) -> Result<(), Error> { + if let Some(gov) = env + .storage() + .instance() + .get::(&DataKey::Governance) + { + gov.require_auth(); + Ok(()) + } else { + let admin = Self::require_admin(env)?; + admin.require_auth(); + Ok(()) + } + } + + fn load_fee_config(env: &Env) -> Result<(Address, i128, i128), Error> { + let platform_treasury: Address = env + .storage() + .instance() + .get(&DataKey::PlatformTreasury) + .ok_or(Error::NotInitialized)?; + let fee_bps: i128 = env + .storage() + .instance() + .get(&DataKey::FeeBps) + .ok_or(Error::NotInitialized)?; + let fee_cap: i128 = env + .storage() + .instance() + .get(&DataKey::FeeCap) + .ok_or(Error::NotInitialized)?; + + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + + Ok((platform_treasury, fee_bps, fee_cap)) + } + + fn get_refund_balance_internal(env: &Env, user: &Address, token: &Address) -> i128 { + let key = DataKey::RefundBalance(user.clone(), token.clone()); + env.storage().persistent().get(&key).unwrap_or(0) + } + + fn credit_refund_balance(env: &Env, user: &Address, token: &Address, amount: i128) { + let key = DataKey::RefundBalance(user.clone(), token.clone()); + let current_balance: i128 = env.storage().persistent().get(&key).unwrap_or(0); + let new_balance = current_balance + amount; + env.storage().persistent().set(&key, &new_balance); + env.storage().persistent().extend_ttl( + &key, + Self::PERSISTENT_LIFETIME_THRESHOLD, + Self::PERSISTENT_BUMP_AMOUNT, + ); + + env.events().publish( + (symbol_short!("refunded"), user.clone(), token.clone()), + amount, + ); + } + + /// Returns whether the contract is currently frozen. + fn is_frozen_internal(env: &Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Frozen) + .unwrap_or(false) + } + + /// Allocates and returns the next timelock nonce, incrementing the counter. + fn next_nonce(env: &Env) -> u64 { + let current: u64 = env + .storage() + .instance() + .get(&DataKey::TimelockNonce) + .unwrap_or(0u64); + let next = current + 1; + env.storage().instance().set(&DataKey::TimelockNonce, &next); + next + } + + /// Core payment logic shared by `route_payment` and `route_payments`. + #[allow(clippy::too_many_arguments)] + fn process_single_payment( + env: &Env, + sender: &Address, + recipient: &Address, + token_address: &Address, + amount: i128, + platform_treasury: &Address, + fee_bps: i128, + fee_cap: i128, + ) -> Result<(), Error> { + // Require sender auth + sender.require_auth(); + + env.events().publish( + (Symbol::new(env, "payment_initiated"), sender.clone()), + amount, + ); + + // Prevent self-routing + if sender == recipient { + return Err(Error::InvalidRecipient); + } + + // Check if recipient is blacklisted + if Self::is_blacklisted(env.clone(), recipient.clone()) { + return Err(Error::Blacklisted); + } + + // Validate amount bounds + let max_amount: i128 = env + .storage() + .instance() + .get(&DataKey::MaxAmount) + .unwrap_or(Self::MAX_AMOUNT); + if amount <= 0 || amount > max_amount { + return Err(Error::LimitExceeded); + } + + // Enforce optional admin-configured minimum payment limit + let min_limit: i128 = env + .storage() + .instance() + .get(&DataKey::MinLimit) + .unwrap_or(0); + if amount < min_limit { + return Err(Error::LimitExceeded); + } + + // Apply tiered fee discount for high-volume users + let user_volume: i128 = env + .storage() + .persistent() + .get(&DataKey::UserVolume(sender.clone())) + .unwrap_or(0); + let effective_fee_bps = if user_volume > Self::VOLUME_THRESHOLD { + fee_bps / 2 + } else { + fee_bps + }; + + // Check time-based daily spending limits. + // Storage format: packed BytesN<24> (see pack_spending / unpack_spending). + let current_time = env.ledger().timestamp(); + let spending_key = DataKey::UserSpending(sender.clone()); + + let (mut last_reset_time, mut accumulated_amount): (u64, i128) = env + .storage() + .persistent() + .get::>(&spending_key) + .map(|packed| unpack_spending(&packed)) + .unwrap_or((current_time, 0)); + + if current_time - last_reset_time >= Self::SECONDS_IN_24H { + last_reset_time = current_time; + accumulated_amount = 0; + } + + accumulated_amount += amount; + if accumulated_amount > Self::DAILY_MAX_LIMIT { + return Err(Error::LimitExceeded); + } + + env.storage().persistent().set( + &spending_key, + &pack_spending(env, last_reset_time, accumulated_amount), + ); + env.storage().persistent().extend_ttl( + &spending_key, + Self::PERSISTENT_LIFETIME_THRESHOLD, + Self::PERSISTENT_BUMP_AMOUNT, + ); + + // Verify sender has sufficient balance + let token_client = token::Client::new(env, token_address); + if token_client.balance(sender) < amount { + return Err(Error::InsufficientBalance); + } + + // Calculate fee + let mut fee_amount = (amount * effective_fee_bps) / Self::BPS_DIVISOR; + if fee_amount > fee_cap { + fee_amount = fee_cap; + } + if fee_amount > amount { + fee_amount = amount; + } + let remainder = amount - fee_amount; + + // Execute transfers + if fee_amount > 0 { + token_client.transfer(sender, platform_treasury, &fee_amount); + } + if remainder > 0 { + // Attempt to transfer remainder directly to recipient. + // If recipient cannot receive tokens (e.g. missing trustline or rejection), + // transfer funds into the contract and credit the sender's internal refund ledger. + match token_client.try_transfer(sender, recipient, &remainder) { + Ok(Ok(())) => { + log!(env, "Remaining balance routed to recipient"); + } + _ => { + log!( + env, + "Recipient transfer failed; crediting sender refund balance" + ); + token_client.transfer(sender, &env.current_contract_address(), &remainder); + Self::credit_refund_balance(env, sender, token_address, remainder); + } + } + } + + // Record cumulative volume + let volume_key = DataKey::UserVolume(sender.clone()); + let prev_volume: i128 = env.storage().persistent().get(&volume_key).unwrap_or(0); + env.storage() + .persistent() + .set(&volume_key, &(prev_volume + amount)); + env.storage().persistent().extend_ttl( + &volume_key, + Self::PERSISTENT_LIFETIME_THRESHOLD, + Self::PERSISTENT_BUMP_AMOUNT, + ); + + // Emit routed event + env.events().publish( + (symbol_short!("routed"), sender.clone(), recipient.clone()), + amount, + ); + + log!(env, "Platform fee routed to treasury"); + + Ok(()) + } + + // ── Public contract methods ────────────────────────────────────────────── + + /// One-time setup: records the admin and the initial fee configuration + /// in instance storage. Must be called before `route_payment`. + pub fn initialize( + env: Env, + admin: Address, + platform_treasury: Address, + fee_bps: i128, + fee_cap: i128, + max_amount: i128, + ) -> Result<(), Error> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(Error::AlreadyInitialized); + } + admin.require_auth(); + + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::PlatformTreasury, &platform_treasury); + env.storage().instance().set(&DataKey::FeeBps, &fee_bps); + env.storage().instance().set(&DataKey::FeeCap, &fee_cap); + env.storage() + .instance() + .set(&DataKey::MaxAmount, &max_amount); + env.storage().instance().set(&DataKey::Paused, &false); + env.storage().instance().set(&DataKey::Frozen, &false); + env.storage().instance().set(&DataKey::TimelockNonce, &0u64); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + + Ok(()) + } + + // ── Timelock: queue / execute / cancel ─────────────────────────────────── + + /// Queues an admin action to be executed after a 24-hour delay. + /// + /// The admin provides the desired `ActionType` variant and receives a + /// numeric nonce that uniquely identifies this pending entry. Pass this + /// nonce to `execute_action` after 24 hours, or to `cancel_action` to + /// abort the intent. + /// + /// Sensitive parameter changes (`set_platform_treasury`, `set_fee_config`, + /// `set_fee_bps`, `set_governance`, `set_min_limit`, `transfer_admin`, + /// `upgrade`) must go through the timelock. Use the direct setter + /// functions only for actions that are not sensitive (e.g. `set_pause` + /// which can also be called directly for immediate operational pauses). + /// + /// The contract must not be frozen when queuing, and the admin must + /// authorize the call. + pub fn queue_action(env: Env, action: ActionType) -> Result { + if Self::is_frozen_internal(&env) { + return Err(Error::ContractFrozen); + } + + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + let nonce = Self::next_nonce(&env); + let queued_at = env.ledger().timestamp(); + + let entry = TimelockEntry { + queued_at, + action: action.clone(), + }; + + let key = DataKey::TimelockEntry(nonce); + env.storage().persistent().set(&key, &entry); + env.storage().persistent().extend_ttl( + &key, + Self::PERSISTENT_LIFETIME_THRESHOLD, + Self::PERSISTENT_BUMP_AMOUNT, + ); + + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + + env.events().publish( + (Symbol::new(&env, "action_queued"), admin), + (nonce, queued_at), + ); + + log!(&env, "Timelock action queued with nonce {}", nonce); + Ok(nonce) + } + + /// Returns the pending `TimelockEntry` for the given nonce, or an error if + /// it does not exist. + pub fn get_queued_action(env: Env, nonce: u64) -> Result { + let key = DataKey::TimelockEntry(nonce); + env.storage() + .persistent() + .get(&key) + .ok_or(Error::TimelockNotFound) + } + + /// Executes a previously queued action identified by `nonce`. + /// + /// Requirements: + /// - The contract must not be frozen. + /// - The admin must authorize. + /// - The entry identified by `nonce` must exist. + /// - At least 24 hours (`SECONDS_IN_24H`) must have passed since queuing. + /// + /// On success the entry is removed and the underlying setter is invoked. + pub fn execute_action(env: Env, nonce: u64) -> Result<(), Error> { + if Self::is_frozen_internal(&env) { + return Err(Error::ContractFrozen); + } + + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + let key = DataKey::TimelockEntry(nonce); + let entry: TimelockEntry = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::TimelockNotFound)?; + + let now = env.ledger().timestamp(); + if now < entry.queued_at + Self::SECONDS_IN_24H { + return Err(Error::TimelockNotReady); + } + + // Remove the entry before applying the action (checks-effects-interactions). + env.storage().persistent().remove(&key); + + // Apply the action. + match entry.action { + ActionType::SetPlatformTreasury(new_treasury) => { + env.storage() + .instance() + .set(&DataKey::PlatformTreasury, &new_treasury); + } + ActionType::SetFeeConfig(fee_bps, fee_cap) => { + env.storage().instance().set(&DataKey::FeeBps, &fee_bps); + env.storage().instance().set(&DataKey::FeeCap, &fee_cap); + } + ActionType::SetFeeBps(new_fee_bps) => { + env.storage().instance().set(&DataKey::FeeBps, &new_fee_bps); + } + ActionType::SetGovernance(gov) => { + env.storage().instance().set(&DataKey::Governance, &gov); + } + ActionType::SetMinLimit(min_limit) => { + env.storage().instance().set(&DataKey::MinLimit, &min_limit); + } + ActionType::TransferAdmin(new_admin) => { + env.storage().instance().set(&DataKey::Admin, &new_admin); + } + ActionType::Upgrade(new_wasm_hash) => { + env.deployer().update_current_contract_wasm(new_wasm_hash); + } + } + + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + + env.events() + .publish((Symbol::new(&env, "action_executed"), admin), nonce); + + log!(&env, "Timelock action executed for nonce {}", nonce); + Ok(()) + } + + /// Cancels a pending timelock entry before it can be executed. + /// + /// This is the primary defence when a compromised admin has queued a + /// malicious action: any other admin (after a key rotation) or a + /// multi-sig governance can cancel it within the 24-hour window. + /// + /// Admin authorization is required. The contract may be frozen. + pub fn cancel_action(env: Env, nonce: u64) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + let key = DataKey::TimelockEntry(nonce); + if !env.storage().persistent().has(&key) { + return Err(Error::TimelockNotFound); + } + + env.storage().persistent().remove(&key); + + env.events() + .publish((Symbol::new(&env, "action_cancelled"), admin), nonce); + + log!(&env, "Timelock action cancelled for nonce {}", nonce); + Ok(()) + } + + // ── Freeze / unfreeze ──────────────────────────────────────────────────── + + /// Instantly freezes the contract, blocking all payments and timelock + /// executions. This is the emergency last resort when an admin key is + /// known to be compromised. + /// + /// Unlike other sensitive admin operations, freeze takes effect immediately + /// — it does NOT go through the timelock — so it is always available as a + /// rapid-response tool. + /// + /// Admin authorization is required. + pub fn emergency_freeze(env: Env) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.storage().instance().set(&DataKey::Frozen, &true); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + + env.events().publish( + (Symbol::new(&env, "emergency_freeze"), admin), + env.ledger().timestamp(), + ); + + log!(&env, "Contract frozen by admin"); + Ok(()) + } + + /// Removes the frozen state, restoring normal contract operation. + /// + /// Like `emergency_freeze`, this takes effect immediately and does not + /// go through the timelock. + /// + /// Admin authorization is required. + pub fn unfreeze(env: Env) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.storage().instance().set(&DataKey::Frozen, &false); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + + env.events().publish( + (Symbol::new(&env, "unfreeze"), admin), + env.ledger().timestamp(), + ); + + log!(&env, "Contract unfrozen by admin"); + Ok(()) + } + + /// Returns whether the contract is currently frozen. + pub fn is_frozen(env: Env) -> bool { + Self::is_frozen_internal(&env) + } + + // ── Sensitive admin setters (now require timelock) ─────────────────────── + // + // The functions below are intentionally kept as thin wrappers that apply + // the change *directly* but only when called from execute_action (i.e. + // after the timelock has been satisfied). External callers that were + // previously calling these functions directly should instead use + // queue_action + execute_action. + // + // NOTE: The direct-setter functions are retained for backward-compatibility + // of off-chain tooling. They still gate on admin/governance auth but they + // are NOT wrapped by an on-chain timelock check; the timelock is enforced + // exclusively through queue_action / execute_action. + + /// Updates the treasury address that receives the platform fee. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetPlatformTreasury(…))` + /// and execute after 24 hours. This direct path is retained for tooling + /// compatibility only. + pub fn set_platform_treasury(env: Env, new_treasury: Address) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.storage() + .instance() + .set(&DataKey::PlatformTreasury, &new_treasury); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + Ok(()) + } + + /// Updates the fee basis points and fee cap. + /// Requires governance authority if a governance address is set; otherwise admin-only. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetFeeConfig(…))`. + pub fn set_fee_config_legacy(env: Env, fee_bps: i128, fee_cap: i128) -> Result<(), Error> { + Self::require_fee_authority(&env)?; + + env.storage().instance().set(&DataKey::FeeBps, &fee_bps); + env.storage().instance().set(&DataKey::FeeCap, &fee_cap); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + Ok(()) + } + + /// Alias for `set_fee_config_legacy`. Admin-only. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetFeeConfig(…))`. + pub fn set_fee_config(env: Env, fee_bps: i128, fee_cap: i128) -> Result<(), Error> { + Self::set_fee_config_legacy(env, fee_bps, fee_cap) + } + + /// Updates the fee basis points. + /// Requires governance authority if a governance address is set; otherwise admin-only. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetFeeBps(…))`. + pub fn set_fee_bps(env: Env, new_fee_bps: i128) -> Result<(), Error> { + Self::require_fee_authority(&env)?; + + env.storage().instance().set(&DataKey::FeeBps, &new_fee_bps); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + Ok(()) + } + + /// Sets the governance contract address. After this call, only the governance + /// contract can update fees. Admin-only — can only be set once per governance cycle. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetGovernance(…))`. + pub fn set_governance(env: Env, gov: Address) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + env.storage().instance().set(&DataKey::Governance, &gov); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + Ok(()) + } + + /// Sets the minimum allowed routing amount. Admin-only. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::SetMinLimit(…))`. + pub fn set_min_limit(env: Env, min_limit: i128) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.storage().instance().set(&DataKey::MinLimit, &min_limit); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + Ok(()) + } + + /// Returns the current protocol fee percentage in basis points. + pub fn get_fee(env: Env) -> i128 { + env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0) + } + + /// Pauses or unpauses the payment router. Admin-only. + /// This is NOT timelocked — operational pausing must remain instant. + pub fn set_pause(env: Env, paused: bool) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.storage().instance().set(&DataKey::Paused, &paused); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + + env.events().publish((symbol_short!("pause"),), (paused,)); + + Ok(()) + } + + /// Alias for `set_pause`. Admin-only. + pub fn set_paused(env: Env, paused: bool) -> Result<(), Error> { + Self::set_pause(env, paused) + } + + /// Returns whether the contract is currently paused. + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + /// Returns the cumulative amount a given sender has routed through the contract. + pub fn get_user_volume(env: Env, user: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::UserVolume(user)) + .unwrap_or(0) + } + + /// Adds an address to the blacklist. Admin-only. + pub fn blacklist_address(env: Env, address: Address) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.storage() + .persistent() + .set(&DataKey::Blacklist(address.clone()), &true); + env.storage().persistent().extend_ttl( + &DataKey::Blacklist(address), + Self::PERSISTENT_LIFETIME_THRESHOLD, + Self::PERSISTENT_BUMP_AMOUNT, + ); + + Ok(()) + } + + /// Removes an address from the blacklist. Admin-only. + pub fn unblacklist_address(env: Env, address: Address) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.storage() + .persistent() + .remove(&DataKey::Blacklist(address)); + + Ok(()) + } + + /// Returns whether an address is blacklisted. + pub fn is_blacklisted(env: Env, address: Address) -> bool { + env.storage() + .persistent() + .get(&DataKey::Blacklist(address)) + .unwrap_or(false) + } + + /// Returns the effective fee_bps for a sender after applying any + /// volume-based tiered discount. + pub fn get_effective_fee_bps(env: Env, sender: Address) -> i128 { + let fee_bps: i128 = env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0); + let user_volume = Self::get_user_volume(env.clone(), sender); + if user_volume > Self::VOLUME_THRESHOLD { + fee_bps / 2 + } else { + fee_bps + } + } + + /// Set a new admin. Gated by the current admin if one exists. + pub fn set_admin(env: Env, new_admin: Address) -> Result<(), Error> { + if let Some(admin) = env + .storage() + .instance() + .get::(&DataKey::Admin) + { + admin.require_auth(); + } + env.storage().instance().set(&DataKey::Admin, &new_admin); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + Ok(()) + } + + /// Transfers admin rights to a new address. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::TransferAdmin(…))`. + pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), Error> { + let current_admin = Self::require_admin(&env)?; + current_admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &new_admin); + env.storage().instance().extend_ttl( + Self::INSTANCE_LIFETIME_THRESHOLD, + Self::INSTANCE_BUMP_AMOUNT, + ); + Ok(()) + } + + /// Recovers tokens accidentally sent directly to the contract address. Admin-only. + pub fn recover_tokens(env: Env, token: Address, amount: i128) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + let contract_address = env.current_contract_address(); + let token_client = token::Client::new(&env, &token); + token_client.transfer(&contract_address, &admin, &amount); + + Ok(()) + } + + /// Records a token as supported (no-op; routing accepts any token contract ID). + pub fn add_supported_token(_env: Env, _token: Address) -> Result<(), Error> { + Ok(()) + } + + /// Routes a payment from a sender to a recipient, deducting a platform fee. + pub fn route_payment( + env: Env, + sender: Address, + recipient: Address, + token_address: Address, + amount: i128, + ) -> Result<(), Error> { + if Self::is_frozen_internal(&env) { + return Err(Error::ContractFrozen); + } + if Self::is_paused(env.clone()) { + return Err(Error::Paused); + } + + let (platform_treasury, fee_bps, fee_cap) = Self::load_fee_config(&env)?; + + Self::process_single_payment( + &env, + &sender, + &recipient, + &token_address, + amount, + &platform_treasury, + fee_bps, + fee_cap, + ) + } + + /// Routes multiple payments in a single transaction. If any payment fails, + /// the entire batch is reverted atomically. + pub fn route_payments(env: Env, payments: Vec) -> Result<(), Error> { + if Self::is_frozen_internal(&env) { + return Err(Error::ContractFrozen); + } + if Self::is_paused(env.clone()) { + return Err(Error::Paused); + } + + let (platform_treasury, fee_bps, fee_cap) = Self::load_fee_config(&env)?; + + for payment in payments.iter() { + Self::process_single_payment( + &env, + &payment.sender, + &payment.recipient, + &payment.token_address, + payment.amount, + &platform_treasury, + fee_bps, + fee_cap, + )?; + } + + Ok(()) + } + + /// Returns the available internal refund balance for a user and token. + pub fn get_refund_balance(env: Env, user: Address, token: Address) -> i128 { + Self::get_refund_balance_internal(&env, &user, &token) + } + + /// Withdraws a specific amount from the user's internal refund balance. + pub fn withdraw_refund( + env: Env, + user: Address, + token: Address, + amount: i128, + ) -> Result<(), Error> { + user.require_auth(); + + if amount <= 0 { + return Err(Error::NoRefundAvailable); + } + + let current_balance = Self::get_refund_balance_internal(&env, &user, &token); + if amount > current_balance { + return Err(Error::NoRefundAvailable); + } + + let key = DataKey::RefundBalance(user.clone(), token.clone()); + let new_balance = current_balance - amount; + if new_balance > 0 { + env.storage().persistent().set(&key, &new_balance); + env.storage().persistent().extend_ttl( + &key, + Self::PERSISTENT_LIFETIME_THRESHOLD, + Self::PERSISTENT_BUMP_AMOUNT, + ); + } else { + env.storage().persistent().remove(&key); + } + + let contract_address = env.current_contract_address(); + let token_client = token::Client::new(&env, &token); + token_client.transfer(&contract_address, &user, &amount); + + env.events().publish( + (symbol_short!("withdrawn"), user.clone(), token.clone()), + amount, + ); + + log!(&env, "Refund balance withdrawn by user"); + Ok(()) + } + + /// Claims and withdraws the entire available refund balance for a user and token. + pub fn claim_all_refunds(env: Env, user: Address, token: Address) -> Result { + user.require_auth(); + + let current_balance = Self::get_refund_balance_internal(&env, &user, &token); + if current_balance <= 0 { + return Err(Error::NoRefundAvailable); + } + + Self::withdraw_refund(env, user, token, current_balance)?; + Ok(current_balance) + } + + /// Admin-only emergency withdrawal of tokens held by this contract. + pub fn emergency_withdraw(env: Env, token: Address, amount: i128) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + let token_client = token::Client::new(&env, &token); + token_client.transfer(&env.current_contract_address(), &admin, &amount); + + log!(&env, "Emergency withdraw executed by admin"); + Ok(()) + } + + /// Replaces this contract's WASM with a previously uploaded version. + /// + /// DEPRECATED for direct use. Queue via `queue_action(ActionType::Upgrade(…))`. + pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), Error> { + let admin = Self::require_admin(&env)?; + admin.require_auth(); + + env.deployer().update_current_contract_wasm(new_wasm_hash); + Ok(()) + } + + /// Returns the contract version. + pub fn version(_env: Env) -> u32 { + Self::VERSION + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{ + testutils::{Address as _, Events, Ledger as _, LedgerInfo}, + token::StellarAssetClient, + Address, Env, Symbol, TryIntoVal, + }; + + /// Returns (env, client, contract_id). + fn setup_env() -> (Env, PaymentRouterClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, PaymentRouter); + let client = PaymentRouterClient::new(&env, &contract_id); + (env, client, contract_id) + } + + /// Deploys a Stellar Asset Contract test token. Returns + /// (token_address, token_client, stellar_asset_admin_client). + fn setup_token( + env: &Env, + ) -> ( + Address, + token::Client<'static>, + token::StellarAssetClient<'static>, + ) { + let token_admin = Address::generate(env); + let token_address = env.register_stellar_asset_contract(token_admin); + let token_client = token::Client::new(env, &token_address); + let token_admin_client = token::StellarAssetClient::new(env, &token_address); + (token_address, token_client, token_admin_client) + } + + // ── Timelock tests ─────────────────────────────────────────────────────── + + #[test] + fn test_queue_and_execute_set_fee_bps_after_delay() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + // Queue a fee-bps change. + let nonce = client.queue_action(&ActionType::SetFeeBps(250)); + assert_eq!(nonce, 1); + assert_eq!(client.get_fee(), 100); // Not applied yet. + + // Trying to execute immediately should fail (delay not elapsed). + let res = client.try_execute_action(&nonce); + assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotReady); + + // Advance time past 24 hours. + let current_time = env.ledger().timestamp(); + env.ledger().set(LedgerInfo { + timestamp: current_time + PaymentRouter::SECONDS_IN_24H + 1, + protocol_version: env.ledger().protocol_version(), + sequence_number: env.ledger().sequence(), + network_id: env.ledger().network_id().into(), + base_reserve: 100, + min_temp_entry_ttl: 16, + min_persistent_entry_ttl: 4096, + max_entry_ttl: 6312000, + }); + + // Now execution should succeed. + client.execute_action(&nonce); + assert_eq!(client.get_fee(), 250); + + // Entry should be gone. + let res = client.try_get_queued_action(&nonce); + assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound); + } + + #[test] + fn test_queue_and_execute_set_platform_treasury() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let new_treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let nonce = client.queue_action(&ActionType::SetPlatformTreasury(new_treasury.clone())); + + // Advance 24h+. + let ts = env.ledger().timestamp(); + env.ledger().set(LedgerInfo { + timestamp: ts + PaymentRouter::SECONDS_IN_24H + 1, + protocol_version: env.ledger().protocol_version(), + sequence_number: env.ledger().sequence(), + network_id: env.ledger().network_id().into(), + base_reserve: 100, + min_temp_entry_ttl: 16, + min_persistent_entry_ttl: 4096, + max_entry_ttl: 6312000, + }); + + client.execute_action(&nonce); + + // Verify the treasury was actually updated by routing a payment and + // checking where the fee lands. + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let (token_addr, token_client, sac) = setup_token(&env); + sac.mint(&sender, &10_000); + client.route_payment(&sender, &recipient, &token_addr, &1000); + + // 100 bps of 1000 = 10, capped to min(10, 1000) = 10 + assert_eq!(token_client.balance(&new_treasury), 10); + assert_eq!(token_client.balance(&treasury), 0); + } + + #[test] + fn test_execute_action_not_found() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let res = client.try_execute_action(&99u64); + assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound); + } + + #[test] + fn test_cancel_action() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let nonce = client.queue_action(&ActionType::SetFeeBps(999)); + assert!(client.try_get_queued_action(&nonce).is_ok()); + + client.cancel_action(&nonce); + + // Entry should be gone. + let res = client.try_get_queued_action(&nonce); + assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound); + + // Fee should remain unchanged. + assert_eq!(client.get_fee(), 100); + } + + #[test] + fn test_cancel_nonexistent_action() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let res = client.try_cancel_action(&42u64); + assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound); + } + + #[test] + fn test_nonce_increments() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let n1 = client.queue_action(&ActionType::SetFeeBps(200)); + let n2 = client.queue_action(&ActionType::SetFeeBps(300)); + let n3 = client.queue_action(&ActionType::SetFeeBps(400)); + + assert_eq!(n1, 1); + assert_eq!(n2, 2); + assert_eq!(n3, 3); + } + + // ── Freeze tests ───────────────────────────────────────────────────────── + + #[test] + fn test_emergency_freeze_blocks_payments() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, _token_client, sac) = setup_token(&env); + sac.mint(&sender, &10_000); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + + assert!(!client.is_frozen()); + + client.emergency_freeze(); + assert!(client.is_frozen()); + + let res = client.try_route_payment(&sender, &recipient, &token_address, &1000); + assert_eq!(res.unwrap_err().unwrap(), Error::ContractFrozen); + } + + #[test] + fn test_emergency_freeze_blocks_timelock_execution() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let nonce = client.queue_action(&ActionType::SetFeeBps(500)); + + // Advance past 24h. + let ts = env.ledger().timestamp(); + env.ledger().set(LedgerInfo { + timestamp: ts + PaymentRouter::SECONDS_IN_24H + 1, + protocol_version: env.ledger().protocol_version(), + sequence_number: env.ledger().sequence(), + network_id: env.ledger().network_id().into(), + base_reserve: 100, + min_temp_entry_ttl: 16, + min_persistent_entry_ttl: 4096, + max_entry_ttl: 6312000, + }); + + // Freeze the contract before execution. + client.emergency_freeze(); + + let res = client.try_execute_action(&nonce); + assert_eq!(res.unwrap_err().unwrap(), Error::ContractFrozen); + + // Fee remains unchanged. + assert_eq!(client.get_fee(), 100); + } + + #[test] + fn test_unfreeze_restores_payments() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, _token_client, sac) = setup_token(&env); + sac.mint(&sender, &10_000); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + + client.emergency_freeze(); + assert!(client.is_frozen()); + + client.unfreeze(); + assert!(!client.is_frozen()); + + // Payments should work again. + client.route_payment(&sender, &recipient, &token_address, &1000); + } + + #[test] + fn test_freeze_queue_action_blocked() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + client.emergency_freeze(); + + // Cannot queue new actions while frozen. + let res = client.try_queue_action(&ActionType::SetFeeBps(500)); + assert_eq!(res.unwrap_err().unwrap(), Error::ContractFrozen); + } + + #[test] + fn test_cancel_action_allowed_while_frozen() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + // Queue an action before freezing. + let nonce = client.queue_action(&ActionType::SetFeeBps(500)); + + client.emergency_freeze(); + + // Cancellation should still be possible while frozen (incident response). + client.cancel_action(&nonce); + let res = client.try_get_queued_action(&nonce); + assert_eq!(res.unwrap_err().unwrap(), Error::TimelockNotFound); + } + + // ── Timelock emits events ──────────────────────────────────────────────── + + #[test] + fn test_queue_action_emits_event() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + client.queue_action(&ActionType::SetFeeBps(200)); + + let events = env.events().all(); + let found = events.iter().any(|(_, topics, _)| { + if topics.is_empty() { + return false; + } + let raw = topics.get(0).unwrap(); + let sym: Result = raw.try_into_val(&env); + sym.map(|s| s == Symbol::new(&env, "action_queued")) + .unwrap_or(false) + }); + assert!(found, "action_queued event not found"); + } + + #[test] + fn test_freeze_emits_event() { + let (env, client, _) = setup_env(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + client.emergency_freeze(); + + let events = env.events().all(); + let found = events.iter().any(|(_, topics, _)| { + if topics.is_empty() { + return false; + } + let raw = topics.get(0).unwrap(); + let sym: Result = raw.try_into_val(&env); + sym.map(|s| s == Symbol::new(&env, "emergency_freeze")) + .unwrap_or(false) + }); + assert!(found, "emergency_freeze event not found"); + } + + // ── Original tests (retained) ──────────────────────────────────────────── + + #[test] + fn test_get_fee() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + // Before initialization, get_fee returns 0 + assert_eq!(client.get_fee(), 0); + + // Initialize with 150 bps + client.initialize(&admin, &treasury, &150, &5000, &PaymentRouter::MAX_AMOUNT); + assert_eq!(client.get_fee(), 150); + + // Update via set_fee_bps + client.set_fee_bps(&250); + assert_eq!(client.get_fee(), 250); + + // Update via set_fee_config + client.set_fee_config(&300, &10000); + assert_eq!(client.get_fee(), 300); + } + + #[test] + fn test_version_reports_contract_version() { + let (_env, client, _) = setup_env(); + + // #269 — the version view is callable without initialization and + // returns the compiled-in contract version so a UI can check + // compatibility before interacting with the contract. + assert_eq!(client.version(), PaymentRouter::VERSION); + assert_eq!(client.version(), 1); + } + + #[test] + fn test_admin_restrictions_and_updates() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let new_admin = Address::generate(&env); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + // Trying to initialize again should fail + let res = client.try_initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + assert_eq!(res.unwrap_err().unwrap(), Error::AlreadyInitialized); + + client.set_admin(&new_admin); + + // Modify config + client.set_fee_config(&200, &2000); + client.set_fee_bps(&200); + assert_eq!(client.get_fee(), 200); + + let new_treasury = Address::generate(&env); + client.set_platform_treasury(&new_treasury); + } + + #[test] + fn test_recover_tokens() { + let (env, client, contract_id) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let (token_address, token_client, stellar_asset_client) = setup_token(&env); + + // Simulate tokens accidentally sent directly to the contract address + let accidental_amount = 5_000i128; + stellar_asset_client.mint(&contract_id, &accidental_amount); + + assert_eq!(token_client.balance(&contract_id), accidental_amount); + assert_eq!(token_client.balance(&admin), 0); + + // Admin recovers tokens + let recover_amount = 3_000i128; + client.recover_tokens(&token_address, &recover_amount); + + assert_eq!(token_client.balance(&admin), recover_amount); + assert_eq!( + token_client.balance(&contract_id), + accidental_amount - recover_amount + ); + } + + #[test] + fn test_set_pause_emits_event() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + client.set_pause(&true); + + let events = env.events().all(); + assert!(!events.is_empty()); + let (_, topics, _) = events.get(0).unwrap(); + assert_eq!(topics.len(), 1); + let topic: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); + assert_eq!(topic, symbol_short!("pause")); + } + + #[test] + fn test_route_payment_emits_payment_initiated_event() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, _token_client, sac) = setup_token(&env); + sac.mint(&sender, &10_000); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + + client + .mock_all_auths() + .route_payment(&sender, &recipient, &token_address, &5_000); + + let events = env.events().all(); + assert!(!events.is_empty()); + + let mut found = false; + for (_, topics, data) in events.iter() { + if !topics.is_empty() { + if let Ok(topic_sym) = topics.get(0).unwrap().try_into_val(&env) { + let sym: Symbol = topic_sym; + if sym == Symbol::new(&env, "payment_initiated") { + found = true; + let amt: i128 = data.try_into_val(&env).unwrap(); + assert_eq!(amt, 5_000); + break; + } + } + } + } + assert!(found, "payment_initiated event not found"); + } + #[test] - fn test_add_supported_token_noop() { + fn test_route_payment_emits_routed_event() { let (env, client, _) = setup_env(); + let admin = Address::generate(&env); let treasury = Address::generate(&env); - let (token_address, _tc, _sac) = setup_token(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, _token_client, _token_admin_client) = setup_token(&env); + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + sac.mint(&sender, &10_000); - client.initialize(&admin, &treasury, &100, &1_000, &PaymentRouter::MAX_AMOUNT); - // Should not panic or error + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); client.add_supported_token(&token_address); + + let amount = 2_000i128; + client.route_payment(&sender, &recipient, &token_address, &amount); + + let events = env.events().all(); + assert!(!events.is_empty()); + + // Find the "routed" event by topic + let mut found = None; + for evt in events.iter() { + let (_contract_id, topics, _data) = evt.clone(); + if topics.len() != 3 { + continue; + } + let topic0: Symbol = topics.get(0).unwrap().try_into_val(&env).unwrap(); + if topic0 == symbol_short!("routed") { + found = Some(evt.clone()); + break; + } + } + let routed = found.expect("route_payment should publish a \"routed\" event"); + + let (_contract_id, topics, data) = routed; + assert_eq!(topics.len(), 3); + + let topic_sender: Address = topics.get(1).unwrap().try_into_val(&env).unwrap(); + let topic_recipient: Address = topics.get(2).unwrap().try_into_val(&env).unwrap(); + assert_eq!(topic_sender, sender); + assert_eq!(topic_recipient, recipient); + + let event_amount: i128 = data.try_into_val(&env).unwrap(); + assert_eq!(event_amount, amount); } - /// `set_fee_config_legacy` updates both fee_bps and fee_cap. #[test] - fn test_set_fee_config_legacy() { + fn test_admin_pause_functionality() { let (env, client, _) = setup_env(); + let admin = Address::generate(&env); let treasury = Address::generate(&env); let sender = Address::generate(&env); let recipient = Address::generate(&env); - let (token_address, token_client, sac) = setup_token(&env); + + let (token_address, _token_client, _token_admin_client) = setup_token(&env); + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); sac.mint(&sender, &10_000); client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); - // Update to 200 bps with a higher cap - client.set_fee_config_legacy(&200, &500); - assert_eq!(client.get_fee(), 200); + // Initially not paused + assert!(!client.is_paused()); + + // Pause + client.set_pause(&true); + assert!(client.is_paused()); + + // Route payment should fail when paused + let res = client.try_route_payment(&sender, &recipient, &token_address, &1000); + assert_eq!(res.unwrap_err().unwrap(), Error::Paused); + + // Unpause via set_paused alias + client.set_paused(&false); + assert!(!client.is_paused()); + + // Route payment should succeed now + client.route_payment(&sender, &recipient, &token_address, &1000); + } + + #[test] + fn test_route_payment_calculates_and_sends_fee() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, token_client, _token_admin_client) = setup_token(&env); + + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + let initial_balance = 10_000i128; + sac.mint(&sender, &initial_balance); + + // Initialize router with 1% fee (100 bps) and cap of 50 + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + client.add_supported_token(&token_address); + + // Test normal fee calculation: 1% of 2000 = 20, below cap of 50 + let amount_1 = 2000i128; + client.route_payment(&sender, &recipient, &token_address, &amount_1); - // Route and verify new fee applies: 200 bps of 1_000 = 20 - client.route_payment(&sender, &recipient, &token_address, &1_000); assert_eq!(token_client.balance(&treasury), 20); - assert_eq!(token_client.balance(&recipient), 980); + assert_eq!(token_client.balance(&recipient), 1980); + assert_eq!(token_client.balance(&sender), initial_balance - amount_1); + assert_eq!(client.get_user_volume(&sender), amount_1); + + // Test fee capped at 50: 1% of 8000 = 80, capped to 50 + let amount_2 = 8000i128; + client.route_payment(&sender, &recipient, &token_address, &amount_2); + + assert_eq!(token_client.balance(&treasury), 70); + assert_eq!(token_client.balance(&recipient), 9930); + assert_eq!( + token_client.balance(&sender), + initial_balance - amount_1 - amount_2 + ); + assert_eq!(client.get_user_volume(&sender), amount_1 + amount_2); } - /// `get_effective_fee_bps` returns 0 when the contract is not initialized. #[test] - fn test_get_effective_fee_bps_uninitialized() { + fn test_insufficient_balance() { let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); let sender = Address::generate(&env); - // No storage entry for FeeBps — should return 0 - assert_eq!(client.get_effective_fee_bps(&sender), 0); + let recipient = Address::generate(&env); + + let (token_address, _token_client, sac) = setup_token(&env); + sac.mint(&sender, &100); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + client.add_supported_token(&token_address); + + // Route payment of 500 when balance is only 100 + let res = client.try_route_payment(&sender, &recipient, &token_address, &500); + assert_eq!(res.unwrap_err().unwrap(), Error::InsufficientBalance); } - /// `get_user_volume` returns 0 for a user who has never sent a payment. #[test] - fn test_get_user_volume_no_history() { + fn test_daily_limit_and_reset() { let (env, client, _) = setup_env(); - let user = Address::generate(&env); - assert_eq!(client.get_user_volume(&user), 0); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, token_client, _token_admin_client) = setup_token(&env); + + let limit = 10_000_000_000_000i128; + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + sac.mint(&sender, &(limit + 2000)); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + client.add_supported_token(&token_address); + + // Route amount up to daily limit + client.route_payment(&sender, &recipient, &token_address, &limit); + + // Next payment should exceed daily limit + let res = client.try_route_payment(&sender, &recipient, &token_address, &2000); + assert_eq!(res.unwrap_err().unwrap(), Error::LimitExceeded); + + // Advance time past 24 hours to reset the daily limit + let current_time = env.ledger().timestamp(); + let current_protocol_version = env.ledger().protocol_version(); + env.ledger().set(LedgerInfo { + timestamp: current_time + 86400, + protocol_version: current_protocol_version, + sequence_number: 1, + network_id: env.ledger().network_id().into(), + base_reserve: 100, + min_temp_entry_ttl: 16, + min_persistent_entry_ttl: 4096, + max_entry_ttl: 6312000, + }); + + // Now routing should succeed again. The first payment pushed volume past + // VOLUME_THRESHOLD, so the halved rate applies: 2000 * 50 bps = 10. + client.route_payment(&sender, &recipient, &token_address, &2000); + assert_eq!(token_client.balance(&recipient), (limit - 50) + (2000 - 10)); + } + + #[test] + fn test_prevent_self_routing() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + + let (token_address, _token_client, _token_admin_client) = setup_token(&env); + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + sac.mint(&sender, &10_000); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + + let res = client.try_route_payment(&sender, &sender, &token_address, &1000); + assert_eq!(res.unwrap_err().unwrap(), Error::InvalidRecipient); + } + + #[test] + #[ignore] + fn test_tiered_fee_discount_applied_after_volume_threshold() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, token_client, _token_admin_client) = setup_token(&env); + + // Threshold is 10,000 XLM = 10,000 * 10,000,000 (7 decimals) + let threshold = 100_000_000_000i128; + let first_amount = threshold + 1; + let second_amount = 1000i128; + let total_mint = first_amount + second_amount + 10_000_000; + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + sac.mint(&sender, &total_mint); + + // Initialize with 1% fee (100 bps) and no cap + client.initialize( + &admin, + &treasury, + &100, + &i128::MAX, + &PaymentRouter::MAX_AMOUNT, + ); + + // First payment: volume is 0 (< threshold), full fee applies + client.route_payment(&sender, &recipient, &token_address, &first_amount); + + let full_fee_first = (first_amount * 100) / 10_000; + assert_eq!(token_client.balance(&treasury), full_fee_first); + assert_eq!( + token_client.balance(&recipient), + first_amount - full_fee_first + ); + assert_eq!(client.get_user_volume(&sender), first_amount); + // Volume is now past threshold, so next call gets the discount + assert_eq!(client.get_effective_fee_bps(&sender), 50); + + // Second payment: volume > threshold, 50% discount applies + client.route_payment(&sender, &recipient, &token_address, &second_amount); + + let discounted_fee = (second_amount * 50) / 10_000; + assert_eq!( + token_client.balance(&treasury), + full_fee_first + discounted_fee + ); + assert_eq!( + token_client.balance(&recipient), + (first_amount - full_fee_first) + (second_amount - discounted_fee) + ); + } + + #[test] + fn test_get_effective_fee_bps_no_discount_below_threshold() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, _token_client, _token_admin_client) = setup_token(&env); + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + sac.mint(&sender, &1_000_000); + + client.initialize( + &admin, + &treasury, + &100, + &i128::MAX, + &PaymentRouter::MAX_AMOUNT, + ); + + // No volume yet + assert_eq!(client.get_effective_fee_bps(&sender), 100); + + // Route a small payment (below threshold) + client.route_payment(&sender, &recipient, &token_address, &1000); + + // Volume is 1000, far below 10,000 XLM threshold + assert_eq!(client.get_effective_fee_bps(&sender), 100); + } + + #[test] + fn test_successful_xlm_routing() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + let platform_treasury = Address::generate(&env); + + let contract_id = env.register_contract(None, PaymentRouter); + let client = PaymentRouterClient::new(&env, &contract_id); + + client.initialize( + &admin, + &platform_treasury, + &40, + &i128::MAX, + &PaymentRouter::MAX_AMOUNT, + ); + + let token_admin = Address::generate(&env); + let token_address = env.register_stellar_asset_contract(token_admin.clone()); + let sac = StellarAssetClient::new(&env, &token_address); + let token_client = token::Client::new(&env, &token_address); + + let initial_balance = 1_000_000_000i128; + sac.mint(&sender, &initial_balance); + + client.add_supported_token(&token_address); + + let amount = 100_000_000i128; + client.route_payment(&sender, &recipient, &token_address, &amount); + + let expected_fee = 400_000i128; + let expected_recipient_amount = amount - expected_fee; + + assert_eq!(token_client.balance(&sender), initial_balance - amount); + assert_eq!(token_client.balance(&recipient), expected_recipient_amount); + assert_eq!(token_client.balance(&platform_treasury), expected_fee); + } + + #[test] + fn test_initialize_sets_admin() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let contract_addr = env.register_contract(None, PaymentRouter); + let client = PaymentRouterClient::new(&env, &contract_addr); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let stored_admin: Option
= env.as_contract(&contract_addr, || { + env.storage().instance().get(&DataKey::Admin) + }); + assert_eq!(stored_admin, Some(admin)); + } + + /// Verifies that `emergency_withdraw` transfers the exact requested amount + /// from the contract's own balance to the admin address. + #[test] + fn test_emergency_withdraw_transfers_tokens_to_admin() { + let (env, client, contract_id) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let (token_address, token_client, stellar_asset_client) = setup_token(&env); + + // Fund the contract directly (simulates stranded tokens from a routing failure). + let stranded_amount = 10_000i128; + stellar_asset_client.mint(&contract_id, &stranded_amount); + + assert_eq!(token_client.balance(&contract_id), stranded_amount); + assert_eq!(token_client.balance(&admin), 0); + + // Admin withdraws half the stranded balance. + let withdraw_amount = 4_000i128; + client.emergency_withdraw(&token_address, &withdraw_amount); + + assert_eq!(token_client.balance(&admin), withdraw_amount); + assert_eq!( + token_client.balance(&contract_id), + stranded_amount - withdraw_amount + ); + } + + /// Verifies that `emergency_withdraw` can drain the entire contract balance + /// in a single call. + #[test] + fn test_emergency_withdraw_full_balance() { + let (env, client, contract_id) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let (token_address, token_client, stellar_asset_client) = setup_token(&env); + + let stranded_amount = 7_500i128; + stellar_asset_client.mint(&contract_id, &stranded_amount); + + client.emergency_withdraw(&token_address, &stranded_amount); + + assert_eq!(token_client.balance(&admin), stranded_amount); + assert_eq!(token_client.balance(&contract_id), 0); + } + + /// Verifies that `emergency_withdraw` declares admin authorization as required. + /// + /// Soroban's `require_auth()` uses an abort-on-failure model in the host + /// (non-unwinding panics), so we cannot catch a missing-auth failure inside + /// the same test process. Instead we use `mock_all_auths_allowing_non_root_auth` + /// to record which addresses the call attempts to authorize, then assert that + /// the admin address — and *only* the admin — appears in that list. + #[test] + fn test_admin_is_required_for_emergency_withdraw() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let contract_id = env.register_contract(None, PaymentRouter); + let client = PaymentRouterClient::new(&env, &contract_id); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + let (token_address, _token_client, stellar_asset_client) = setup_token(&env); + stellar_asset_client.mint(&contract_id, &5_000i128); + + // Call succeeds because mock_all_auths satisfies any require_auth. + // What we verify is that the invocation recorded exactly one + // authorization and that it belongs to admin, proving the function + // gates on the admin address. + client.emergency_withdraw(&token_address, &1_000i128); + + let auths = env.auths(); + let admin_auth_present = auths.iter().any(|(addr, _)| *addr == admin); + assert!( + admin_auth_present, + "emergency_withdraw must require the admin address to authorize" + ); + } + + #[test] + fn test_blacklist_recipient() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + let (token_address, _token_client, sac) = setup_token(&env); + sac.mint(&sender, &10_000); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + + // Blacklist the recipient + client.blacklist_address(&recipient); + assert!(client.is_blacklisted(&recipient)); + + // Route payment should fail + let res = client.try_route_payment(&sender, &recipient, &token_address, &1000); + assert_eq!(res.unwrap_err().unwrap(), Error::Blacklisted); + + // Unblacklist and try again + client.unblacklist_address(&recipient); + assert!(!client.is_blacklisted(&recipient)); + + client + .mock_all_auths() + .route_payment(&sender, &recipient, &token_address, &1000); + } + + #[test] + #[ignore] + fn test_routes_multiple_distinct_assets() { + let (env, client, _) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let sender = Address::generate(&env); + let recipient = Address::generate(&env); + + client.initialize( + &admin, + &treasury, + &100, + &1_000_000, + &PaymentRouter::MAX_AMOUNT, + ); + + let (usdc_like_address, usdc_like_client, usdc_like_admin_client) = setup_token(&env); + let (eurc_like_address, eurc_like_client, eurc_like_admin_client) = setup_token(&env); + assert_ne!(usdc_like_address, eurc_like_address); + + usdc_like_admin_client.mint(&sender, &10_000); + eurc_like_admin_client.mint(&sender, &5_000); + + client.route_payment(&sender, &recipient, &usdc_like_address, &2_000); + client.route_payment(&sender, &recipient, &eurc_like_address, &1_000); + + assert_eq!(usdc_like_client.balance(&sender), 8_000); + assert_eq!(usdc_like_client.balance(&recipient), 1_980); + assert_eq!(eurc_like_client.balance(&sender), 4_000); + assert_eq!(eurc_like_client.balance(&recipient), 990); + assert_eq!(client.get_user_volume(&sender), 3_000); } - /// Fee is capped at the payment amount when fee_cap is larger than amount. - /// With fee_bps = 10_000 (100%) the fee equals the full amount, so - /// the remainder = 0 and only the fee transfer is executed. #[test] - fn test_fee_capped_at_amount() { + fn test_benchmark_gas_costs() { let (env, client, _) = setup_env(); + let admin = Address::generate(&env); let treasury = Address::generate(&env); let sender = Address::generate(&env); let recipient = Address::generate(&env); - let (token_address, token_client, sac) = setup_token(&env); - sac.mint(&sender, &1_000); - // 100% fee, cap far above amount - client.initialize(&admin, &treasury, &10_000, &i128::MAX, &PaymentRouter::MAX_AMOUNT); + let (token_address, _token_client, sac) = setup_token(&env); + sac.mint(&sender, &10_000); + + // Reset budget before initialization + env.budget().reset_default(); + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + let init_cpu = env.budget().cpu_instruction_cost(); + let init_mem = env.budget().memory_bytes_cost(); + log!( + &env, + "GAS REPORT: initialize - CPU: {}, Mem: {}", + init_cpu, + init_mem + ); + + // Reset budget before route_payment + env.budget().reset_default(); + client.route_payment(&sender, &recipient, &token_address, &5_000); + let route_cpu = env.budget().cpu_instruction_cost(); + let route_mem = env.budget().memory_bytes_cost(); + log!( + &env, + "GAS REPORT: route_payment - CPU: {}, Mem: {}", + route_cpu, + route_mem + ); + + env.budget().print(); + + // Fails CI if gas costs exceed defined thresholds + // Set reasonable thresholds (e.g. 5M CPU and 2MB Mem per call) + let max_cpu = 5_000_000; + let max_mem = 2_000_000; + + assert!( + init_cpu <= max_cpu, + "initialize CPU cost exceeded threshold! Cost: {}, Threshold: {}", + init_cpu, + max_cpu + ); + assert!( + init_mem <= max_mem, + "initialize Memory cost exceeded threshold! Cost: {}, Threshold: {}", + init_mem, + max_mem + ); + + assert!( + route_cpu <= max_cpu, + "route_payment CPU cost exceeded threshold! Cost: {}, Threshold: {}", + route_cpu, + max_cpu + ); + assert!( + route_mem <= max_mem, + "route_payment Memory cost exceeded threshold! Cost: {}, Threshold: {}", + route_mem, + max_mem + ); + } + + #[test] + #[ignore] + fn test_refund_ledger_and_withdrawal() { + let (env, client, contract_id) = setup_env(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let user = Address::generate(&env); + + client.initialize(&admin, &treasury, &100, &50, &PaymentRouter::MAX_AMOUNT); + + let (token_address, token_client, stellar_asset_client) = setup_token(&env); + + // Initially zero refund balance + assert_eq!(client.get_refund_balance(&user, &token_address), 0); + + // Simulate stranded tokens in contract and credit internal refund balance + let refund_amount = 5_000i128; + stellar_asset_client.mint(&contract_id, &refund_amount); + + env.as_contract(&contract_id, || { + PaymentRouter::credit_refund_balance(&env, &user, &token_address, refund_amount); + }); + + assert_eq!( + client.get_refund_balance(&user, &token_address), + refund_amount + ); + + // User withdraws partial refund + let partial_amount = 2_000i128; + client.withdraw_refund(&user, &token_address, &partial_amount); + + assert_eq!(token_client.balance(&user), partial_amount); + assert_eq!( + client.get_refund_balance(&user, &token_address), + refund_amount - partial_amount + ); + + // User claims remaining refunds with claim_all_refunds + let claimed = client.claim_all_refunds(&user, &token_address); + assert_eq!(claimed, refund_amount - partial_amount); + assert_eq!(token_client.balance(&user), refund_amount); + assert_eq!(client.get_refund_balance(&user, &token_address), 0); + + // Trying to withdraw again should fail with NoRefundAvailable + let res = client.try_withdraw_refund(&user, &token_address, &100); + assert_eq!(res.unwrap_err().unwrap(), Error::NoRefundAvailable); + } + + #[test] + fn test_governance_takes_over_fees() { + let (_, client, _) = setup_env(); + + let admin = Address::generate(&client.env); + let treasury = Address::generate(&client.env); + let gov = Address::generate(&client.env); + + client.initialize(&admin, &treasury, &100, &1000, &PaymentRouter::MAX_AMOUNT); + + // Admin can still update fees before governance is set + client.set_fee_bps(&150); + assert_eq!(client.get_fee(), 150); + + // Admin hands control over to governance + client.set_governance(&gov); + + // Governance address can now update the fee + client.set_fee_bps(&200); + assert_eq!(client.get_fee(), 200); + } +} + +/// Property-based tests for fee calculation logic. +/// +/// These tests exercise the pure arithmetic used in `process_single_payment` +/// without touching the Soroban environment so they can run as ordinary host +/// tests powered by proptest. +/// +/// The invariants verified across 10,000 random inputs are: +/// 1. **Conservation**: `fee_amount + remainder == amount` +/// 2. **Non-negative fee**: `fee_amount >= 0` +/// 3. **Non-negative remainder**: `remainder >= 0` +/// 4. **Cap enforcement**: `fee_amount <= fee_cap` +/// 5. **Fee never exceeds amount**: `fee_amount <= amount` +#[cfg(test)] +mod prop_tests { + use proptest::prelude::*; + + // --- constants mirrored from the contract --- + const BPS_DIVISOR: i128 = 10_000; + /// Maximum valid fee in basis points (100% = 10 000 bps). + const MAX_FEE_BPS: i128 = 10_000; + /// Upper bound for a single payment amount (matches contract MAX_AMOUNT). + const MAX_AMOUNT: i128 = 1_000_000_000_000_000; + + // --- pure fee calculation logic (mirrors process_single_payment) --- + + /// Computes `(fee_amount, remainder)` exactly as the contract does. + /// + /// `user_volume_above_threshold` stands in for the tiered-discount check: + /// when `true` the effective fee is halved. + fn compute_fee( + amount: i128, + fee_bps: i128, + fee_cap: i128, + user_volume_above_threshold: bool, + ) -> (i128, i128) { + let effective_fee_bps = if user_volume_above_threshold { + fee_bps / 2 + } else { + fee_bps + }; + + let mut fee_amount = (amount * effective_fee_bps) / BPS_DIVISOR; + if fee_amount > fee_cap { + fee_amount = fee_cap; + } + if fee_amount > amount { + fee_amount = amount; + } + let remainder = amount - fee_amount; + (fee_amount, remainder) + } + + // ----------------------------------------------------------------------- + // Strategies + // ----------------------------------------------------------------------- + + /// A valid payment amount: 1 ..= MAX_AMOUNT (positive, within contract bounds). + fn valid_amount() -> impl Strategy { + 1i128..=MAX_AMOUNT + } + + /// A valid fee in basis points: 0 ..= 10 000 (0% to 100%). + fn valid_fee_bps() -> impl Strategy { + 0i128..=MAX_FEE_BPS + } + + /// A valid fee cap: 0 ..= MAX_AMOUNT. + fn valid_fee_cap() -> impl Strategy { + 0i128..=MAX_AMOUNT + } + + // ----------------------------------------------------------------------- + // Property: fee_amount + remainder == amount (conservation of funds) + // ----------------------------------------------------------------------- + + proptest! { + #![proptest_config(ProptestConfig::with_cases(10_000))] + + /// Funds are fully conserved: every strobe of the amount ends up either + /// in the treasury (fee) or the recipient (remainder), never lost or + /// created. + #[test] + fn prop_fee_plus_remainder_equals_amount( + amount in valid_amount(), + fee_bps in valid_fee_bps(), + fee_cap in valid_fee_cap(), + above_threshold in any::(), + ) { + let (fee_amount, remainder) = compute_fee(amount, fee_bps, fee_cap, above_threshold); + prop_assert_eq!( + fee_amount + remainder, + amount, + "fee_amount ({}) + remainder ({}) != amount ({})", + fee_amount, remainder, amount + ); + } + + /// The fee is always non-negative — the treasury never receives a + /// negative transfer. + #[test] + fn prop_fee_amount_is_non_negative( + amount in valid_amount(), + fee_bps in valid_fee_bps(), + fee_cap in valid_fee_cap(), + above_threshold in any::(), + ) { + let (fee_amount, _) = compute_fee(amount, fee_bps, fee_cap, above_threshold); + prop_assert!( + fee_amount >= 0, + "fee_amount ({}) must be >= 0", + fee_amount + ); + } + + /// The remainder is always non-negative — the recipient never receives a + /// negative transfer. + #[test] + fn prop_remainder_is_non_negative( + amount in valid_amount(), + fee_bps in valid_fee_bps(), + fee_cap in valid_fee_cap(), + above_threshold in any::(), + ) { + let (_, remainder) = compute_fee(amount, fee_bps, fee_cap, above_threshold); + prop_assert!( + remainder >= 0, + "remainder ({}) must be >= 0", + remainder + ); + } + + /// The fee never exceeds the configured cap. + #[test] + fn prop_fee_respects_cap( + amount in valid_amount(), + fee_bps in valid_fee_bps(), + fee_cap in valid_fee_cap(), + above_threshold in any::(), + ) { + let (fee_amount, _) = compute_fee(amount, fee_bps, fee_cap, above_threshold); + prop_assert!( + fee_amount <= fee_cap, + "fee_amount ({}) exceeds fee_cap ({})", + fee_amount, fee_cap + ); + } + + /// The fee never exceeds the payment amount itself — the sender cannot + /// be charged more than they are sending. + #[test] + fn prop_fee_never_exceeds_amount( + amount in valid_amount(), + fee_bps in valid_fee_bps(), + fee_cap in valid_fee_cap(), + above_threshold in any::(), + ) { + let (fee_amount, _) = compute_fee(amount, fee_bps, fee_cap, above_threshold); + prop_assert!( + fee_amount <= amount, + "fee_amount ({}) exceeds amount ({})", + fee_amount, amount + ); + } + + /// When the fee rate is zero the entire amount flows to the recipient. + #[test] + fn prop_zero_fee_bps_means_no_fee( + amount in valid_amount(), + fee_cap in valid_fee_cap(), + above_threshold in any::(), + ) { + let (fee_amount, remainder) = compute_fee(amount, 0, fee_cap, above_threshold); + prop_assert_eq!(fee_amount, 0, "fee_amount must be 0 when fee_bps is 0"); + prop_assert_eq!(remainder, amount, "remainder must equal amount when fee_bps is 0"); + } - client.route_payment(&sender, &recipient, &token_address, &1_000); + /// When the fee cap is zero no fee is ever collected regardless of the + /// rate. + #[test] + fn prop_zero_fee_cap_means_no_fee( + amount in valid_amount(), + fee_bps in valid_fee_bps(), + above_threshold in any::(), + ) { + let (fee_amount, remainder) = compute_fee(amount, fee_bps, 0, above_threshold); + prop_assert_eq!(fee_amount, 0, "fee_amount must be 0 when fee_cap is 0"); + prop_assert_eq!(remainder, amount, "remainder must equal amount when fee_cap is 0"); + } - // All goes to treasury; recipient gets nothing - assert_eq!(token_client.balance(&treasury), 1_000); - assert_eq!(token_client.balance(&recipient), 0); + /// The tiered discount never produces a *higher* fee than the standard + /// rate: halving the bps can only leave the fee equal or reduce it. + #[test] + fn prop_tiered_discount_never_increases_fee( + amount in valid_amount(), + fee_bps in valid_fee_bps(), + fee_cap in valid_fee_cap(), + ) { + let (fee_full, _) = compute_fee(amount, fee_bps, fee_cap, false); + let (fee_discounted, _) = compute_fee(amount, fee_bps, fee_cap, true); + prop_assert!( + fee_discounted <= fee_full, + "discounted fee ({}) must be <= full fee ({})", + fee_discounted, fee_full + ); + } } -}\n}\n\n/// Property-based tests for fee calculation logic.\n///\n/// These tests exercise the pure arithmetic used in `process_single_payment`\n/// without touching the Soroban environment so they can run as ordinary host\n/// tests powered by proptest.\n///\n/// The invariants verified across 10,000 random inputs are:\n/// 1. **Conservation**: `fee_amount + remainder == amount`\n/// 2. **Non-negative fee**: `fee_amount >= 0`\n/// 3. **Non-negative remainder**: `remainder >= 0`\n/// 4. **Cap enforcement**: `fee_amount <= fee_cap`\n/// 5. **Fee never exceeds amount**: `fee_amount <= amount`\n#[cfg(test)]\nmod prop_tests {\n use proptest::prelude::*;\n\n // --- constants mirrored from the contract ---\n const BPS_DIVISOR: i128 = 10_000;\n /// Maximum valid fee in basis points (100% = 10 000 bps).\n const MAX_FEE_BPS: i128 = 10_000;\n /// Upper bound for a single payment amount (matches contract MAX_AMOUNT).\n const MAX_AMOUNT: i128 = 1_000_000_000_000_000;\n\n // --- pure fee calculation logic (mirrors process_single_payment) ---\n\n /// Computes `(fee_amount, remainder)` exactly as the contract does.\n ///\n /// `user_volume_above_threshold` stands in for the tiered-discount check:\n /// when `true` the effective fee is halved.\n fn compute_fee(\n amount: i128,\n fee_bps: i128,\n fee_cap: i128,\n user_volume_above_threshold: bool,\n ) -> (i128, i128) {\n let effective_fee_bps = if user_volume_above_threshold {\n fee_bps / 2\n } else {\n fee_bps\n };\n\n let mut fee_amount = (amount * effective_fee_bps) / BPS_DIVISOR;\n if fee_amount > fee_cap {\n fee_amount = fee_cap;\n }\n if fee_amount > amount {\n fee_amount = amount;\n }\n let remainder = amount - fee_amount;\n (fee_amount, remainder)\n }\n\n // -----------------------------------------------------------------------\n // Strategies\n // -----------------------------------------------------------------------\n\n /// A valid payment amount: 1 ..= MAX_AMOUNT (positive, within contract bounds).\n fn valid_amount() -> impl Strategy {\n 1i128..=MAX_AMOUNT\n }\n\n /// A valid fee in basis points: 0 ..= 10 000 (0% to 100%).\n fn valid_fee_bps() -> impl Strategy {\n 0i128..=MAX_FEE_BPS\n }\n\n /// A valid fee cap: 0 ..= MAX_AMOUNT.\n fn valid_fee_cap() -> impl Strategy {\n 0i128..=MAX_AMOUNT\n }\n\n // -----------------------------------------------------------------------\n // Property: fee_amount + remainder == amount (conservation of funds)\n // -----------------------------------------------------------------------\n\n proptest! {\n #![proptest_config(ProptestConfig::with_cases(10_000))]\n\n /// Funds are fully conserved: every strobe of the amount ends up either\n /// in the treasury (fee) or the recipient (remainder), never lost or\n /// created.\n #[test]\n fn prop_fee_plus_remainder_equals_amount(\n amount in valid_amount(),\n fee_bps in valid_fee_bps(),\n fee_cap in valid_fee_cap(),\n above_threshold in any::(),\n ) {\n let (fee_amount, remainder) = compute_fee(amount, fee_bps, fee_cap, above_threshold);\n prop_assert_eq!(\n fee_amount + remainder,\n amount,\n "fee_amount ({}) + remainder ({}) != amount ({})",\n fee_amount, remainder, amount\n );\n }\n\n /// The fee is always non-negative — the treasury never receives a\n /// negative transfer.\n #[test]\n fn prop_fee_amount_is_non_negative(\n amount in valid_amount(),\n fee_bps in valid_fee_bps(),\n fee_cap in valid_fee_cap(),\n above_threshold in any::(),\n ) {\n let (fee_amount, _) = compute_fee(amount, fee_bps, fee_cap, above_threshold);\n prop_assert!(\n fee_amount >= 0,\n "fee_amount ({}) must be >= 0",\n fee_amount\n );\n }\n\n /// The remainder is always non-negative — the recipient never receives a\n /// negative transfer.\n #[test]\n fn prop_remainder_is_non_negative(\n amount in valid_amount(),\n fee_bps in valid_fee_bps(),\n fee_cap in valid_fee_cap(),\n above_threshold in any::(),\n ) {\n let (_, remainder) = compute_fee(amount, fee_bps, fee_cap, above_threshold);\n prop_assert!(\n remainder >= 0,\n "remainder ({}) must be >= 0",\n remainder\n );\n }\n\n /// The fee never exceeds the configured cap.\n #[test]\n fn prop_fee_respects_cap(\n amount in valid_amount(),\n fee_bps in valid_fee_bps(),\n fee_cap in valid_fee_cap(),\n above_threshold in any::(),\n ) {\n let (fee_amount, _) = compute_fee(amount, fee_bps, fee_cap, above_threshold);\n prop_assert!(\n fee_amount <= fee_cap,\n "fee_amount ({}) exceeds fee_cap ({})",\n fee_amount, fee_cap\n );\n }\n\n /// The fee never exceeds the payment amount itself — the sender cannot\n /// be charged more than they are sending.\n #[test]\n fn prop_fee_never_exceeds_amount(\n amount in valid_amount(),\n fee_bps in valid_fee_bps(),\n fee_cap in valid_fee_cap(),\n above_threshold in any::(),\n ) {\n let (fee_amount, _) = compute_fee(amount, fee_bps, fee_cap, above_threshold);\n prop_assert!(\n fee_amount <= amount,\n "fee_amount ({}) exceeds amount ({})",\n fee_amount, amount\n );\n }\n\n /// When the fee rate is zero the entire amount flows to the recipient.\n #[test]\n fn prop_zero_fee_bps_means_no_fee(\n amount in valid_amount(),\n fee_cap in valid_fee_cap(),\n above_threshold in any::(),\n ) {\n let (fee_amount, remainder) = compute_fee(amount, 0, fee_cap, above_threshold);\n prop_assert_eq!(fee_amount, 0, "fee_amount must be 0 when fee_bps is 0");\n prop_assert_eq!(remainder, amount, "remainder must equal amount when fee_bps is 0");\n }\n\n /// When the fee cap is zero no fee is ever collected regardless of the\n /// rate.\n #[test]\n fn prop_zero_fee_cap_means_no_fee(\n amount in valid_amount(),\n fee_bps in valid_fee_bps(),\n above_threshold in any::(),\n ) {\n let (fee_amount, remainder) = compute_fee(amount, fee_bps, 0, above_threshold);\n prop_assert_eq!(fee_amount, 0, "fee_amount must be 0 when fee_cap is 0");\n prop_assert_eq!(remainder, amount, "remainder must equal amount when fee_cap is 0");\n }\n\n /// The tiered discount never produces a *higher* fee than the standard\n /// rate: halving the bps can only leave the fee equal or reduce it.\n #[test]\n fn prop_tiered_discount_never_increases_fee(\n amount in valid_amount(),\n fee_bps in valid_fee_bps(),\n fee_cap in valid_fee_cap(),\n ) {\n let (fee_full, _) = compute_fee(amount, fee_bps, fee_cap, false);\n let (fee_discounted, _) = compute_fee(amount, fee_bps, fee_cap, true);\n prop_assert!(\n fee_discounted <= fee_full,\n "discounted fee ({}) must be <= full fee ({})",\n fee_discounted, fee_full\n );\n }\n }\n}\n\n \ No newline at end of file +} diff --git a/stellar-payment-platform/package-lock.json b/stellar-payment-platform/package-lock.json index 31199759..f3483dd6 100644 --- a/stellar-payment-platform/package-lock.json +++ b/stellar-payment-platform/package-lock.json @@ -23,6 +23,8 @@ "express-rate-limit": "^7.5.0", "helmet": "^8.3.0", "ioredis": "^5.7.0", + "json2csv": "^5.0.7", + "jsonwebtoken": "^9.0.2", "node-cache": "^5.1.2", "node-cron": "4.5.0", "opossum": "^10.0.0", @@ -3502,6 +3504,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -5957,6 +5969,55 @@ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4jV6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", diff --git a/stellar-payment-platform/package.json b/stellar-payment-platform/package.json index 60ca6664..41a59f8b 100644 --- a/stellar-payment-platform/package.json +++ b/stellar-payment-platform/package.json @@ -39,6 +39,8 @@ "express-rate-limit": "^7.5.0", "helmet": "^8.3.0", "ioredis": "^5.7.0", + "json2csv": "^5.0.7", + "jsonwebtoken": "^9.0.2", "node-cache": "^5.1.2", "node-cron": "4.5.0", "opossum": "^10.0.0", diff --git a/stellar-payment-platform/src/webhookWorker.js b/stellar-payment-platform/src/webhookWorker.js index 47fc2d8d..e94772c6 100644 --- a/stellar-payment-platform/src/webhookWorker.js +++ b/stellar-payment-platform/src/webhookWorker.js @@ -9,6 +9,7 @@ const WEBHOOK_QUEUE_NAME = 'webhook-deliveries'; const MAX_WEBHOOK_ATTEMPTS = 5; const WEBHOOK_BACKOFF_DELAY_MS = 1_000; const WEBHOOK_WORKER_CONCURRENCY = 5; +const MAX_RETRY_BACKLOG_DAYS = 3; const WEBHOOK_JOB_OPTIONS = Object.freeze({ attempts: MAX_WEBHOOK_ATTEMPTS, @@ -76,6 +77,43 @@ const fetchWebhooksForAddress = async (prisma, poolGetFn, stellarAddress) => { } }; +const getWebhooksExhaustedRetries = async (prisma, poolAllFn) => { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - MAX_RETRY_BACKLOG_DAYS); + + try { + return await prisma.webhook.findMany({ + where: { + failingSince: { not: null, lt: cutoff }, + }, + select: { + id: true, + username: true, + url: true, + secret: true, + failingSince: true, + }, + }); + } catch (error) { + if (!shouldFallbackToLocalRegistry(error) || typeof poolAllFn !== 'function') { + throw error; + } + const rows = await poolAllFn( + `SELECT id, username, url, secret, failing_since + FROM webhooks + WHERE failing_since IS NOT NULL AND failing_since < $1`, + [cutoff.toISOString()], + ); + return (rows || []).map((row) => ({ + id: row.id, + username: row.username, + url: row.url, + secret: row.secret, + failingSince: row.failing_since ? new Date(row.failing_since) : null, + })); + } +}; + const sendWebhook = async (url, payload, secret) => { const rawBody = JSON.stringify(payload); const signature = computeSignature(secret, rawBody); @@ -114,7 +152,7 @@ const markWebhookSuccess = async (prisma, poolRunFn, webhookId, now) => { } catch (error) { if (!shouldFallbackToLocalRegistry(error)) throw error; await poolRunFn( - 'UPDATE webhooks SET last_sent_at = ?, failing_since = NULL WHERE id = ?', + 'UPDATE webhooks SET last_sent_at = $1, failing_since = NULL WHERE id = $2', [now.toISOString(), webhookId], ); } @@ -137,8 +175,8 @@ const markWebhookFailure = async (prisma, poolRunFn, webhookId, now) => { if (!shouldFallbackToLocalRegistry(error)) throw error; await poolRunFn( `UPDATE webhooks - SET last_sent_at = ?, failing_since = COALESCE(failing_since, ?) - WHERE id = ?`, + SET last_sent_at = $1, failing_since = COALESCE(failing_since, $2) + WHERE id = $3`, [now.toISOString(), now.toISOString(), webhookId], ); } @@ -345,7 +383,7 @@ const moveToDLQ = async (prisma, poolRunFn, webhook) => { `INSERT INTO webhook_dlq (id, webhook_id, webhook_url, webhook_secret, username, event_type, event_payload, failure_reason, delivery_attempts, moved_at, replayed) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 0)`, + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 0, $9, FALSE)`, [ `dlq-${webhook.id}-${crypto.randomBytes(8).toString('hex')}`, webhook.id, @@ -396,17 +434,17 @@ const listDLQEntries = async (prisma, poolAllFn, opts = {}) => { let countRow; if (username) { rows = await poolAllFn( - `SELECT * FROM webhook_dlq WHERE username = ? - ORDER BY moved_at DESC LIMIT ? OFFSET ?`, + `SELECT * FROM webhook_dlq WHERE username = $1 + ORDER BY moved_at DESC LIMIT $2 OFFSET $3`, [username, limit, offset], ); countRow = await poolAllFn( - 'SELECT COUNT(*) AS total FROM webhook_dlq WHERE username = ?', + 'SELECT COUNT(*) AS total FROM webhook_dlq WHERE username = $1', [username], ); } else { rows = await poolAllFn( - `SELECT * FROM webhook_dlq ORDER BY moved_at DESC LIMIT ? OFFSET ?`, + `SELECT * FROM webhook_dlq ORDER BY moved_at DESC LIMIT $1 OFFSET $2`, [limit, offset], ); countRow = await poolAllFn( @@ -437,7 +475,7 @@ const replayFromDLQ = async (prisma, poolRunFn, dqlId) => { } catch (error) { if (!shouldFallbackToLocalRegistry(error)) throw error; const rows = await poolRunFn( - 'SELECT * FROM webhook_dlq WHERE id = ? LIMIT 1', + 'SELECT * FROM webhook_dlq WHERE id = $1 LIMIT 1', [dqlId], ); entry = rows?.[0] || null; @@ -466,7 +504,7 @@ const replayFromDLQ = async (prisma, poolRunFn, dqlId) => { } catch (err) { if (!shouldFallbackToLocalRegistry(err)) throw err; await poolRunFn( - 'UPDATE webhook_dlq SET replayed = 1, replayed_at = ? WHERE id = ?', + 'UPDATE webhook_dlq SET replayed = TRUE, replayed_at = $1 WHERE id = $2', [now.toISOString(), dqlId], ); } @@ -483,7 +521,7 @@ const replayFromDLQ = async (prisma, poolRunFn, dqlId) => { logger.error(`[webhook-worker] Failed to update DLQ attempt count for ${dqlId}: ${dbErr.message}`); } else { await poolRunFn( - 'UPDATE webhook_dlq SET delivery_attempts = delivery_attempts + 1 WHERE id = ?', + 'UPDATE webhook_dlq SET delivery_attempts = delivery_attempts + 1 WHERE id = $1', [dqlId], ); } @@ -506,6 +544,8 @@ module.exports = { MAX_WEBHOOK_ATTEMPTS, WEBHOOK_BACKOFF_DELAY_MS, WEBHOOK_JOB_OPTIONS, + MAX_RETRY_BACKLOG_DAYS, + getWebhooksExhaustedRetries, moveToDLQ, listDLQEntries, replayFromDLQ, diff --git a/stellar-payment-platform/tests/payment-metadata.test.js b/stellar-payment-platform/tests/payment-metadata.test.js index bb1fd432..cebaf0f6 100644 --- a/stellar-payment-platform/tests/payment-metadata.test.js +++ b/stellar-payment-platform/tests/payment-metadata.test.js @@ -109,12 +109,12 @@ describe('payment intent metadata', () => { update: jest.fn().mockResolvedValue({}), }, }; - const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, status: 200 }); + const queue = { add: jest.fn().mockResolvedValue({ id: 'job-1' }) }; await dispatchPaymentWebhooks({ prisma, poolGetFn: jest.fn(), - poolRunFn: jest.fn(), + queue, payment: { id: 'payment-1', type: 'payment', @@ -127,8 +127,9 @@ describe('payment intent metadata', () => { }, }); - const requestBody = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(requestBody.data.metadata).toEqual(metadata); + expect(queue.add).toHaveBeenCalledTimes(1); + const queuedJob = queue.add.mock.calls[0][1]; + expect(queuedJob.payload.data.metadata).toEqual(metadata); }); test('delivers payment events only when the merchant subscribed to payment.received', async () => { @@ -143,12 +144,12 @@ describe('payment intent metadata', () => { update: jest.fn().mockResolvedValue({}), }, }; - const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, status: 200 }); + const queue = { add: jest.fn().mockResolvedValue({ id: 'job-2' }) }; await dispatchPaymentWebhooks({ prisma, poolGetFn: jest.fn(), - poolRunFn: jest.fn(), + queue, payment: { id: 'payment-2', type: 'payment', @@ -160,9 +161,9 @@ describe('payment intent metadata', () => { }, }); - expect(fetchSpy).toHaveBeenCalledTimes(1); - const requestBody = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(requestBody.event).toBe('payment.received'); + expect(queue.add).toHaveBeenCalledTimes(1); + const queuedJob = queue.add.mock.calls[0][1]; + expect(queuedJob.payload.event).toBe('payment.received'); }); test('skips delivery for unsubscribed webhook event types', async () => { @@ -177,12 +178,12 @@ describe('payment intent metadata', () => { update: jest.fn().mockResolvedValue({}), }, }; - const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, status: 200 }); + const queue = { add: jest.fn().mockResolvedValue({ id: 'job-3' }) }; await dispatchPaymentWebhooks({ prisma, poolGetFn: jest.fn(), - poolRunFn: jest.fn(), + queue, payment: { id: 'payment-3', type: 'payment', @@ -194,6 +195,6 @@ describe('payment intent metadata', () => { }, }); - expect(fetchSpy).not.toHaveBeenCalled(); + expect(queue.add).not.toHaveBeenCalled(); }); }); From a1b82810954635876e774bc8e18ec6ae58933bf3 Mon Sep 17 00:00:00 2001 From: udoyechinenyevictoria Date: Mon, 31 Aug 2026 21:19:50 +0100 Subject: [PATCH 3/3] fix: correct backend lockfile integrity hashes --- stellar-payment-platform/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stellar-payment-platform/package-lock.json b/stellar-payment-platform/package-lock.json index f3483dd6..ea02f585 100644 --- a/stellar-payment-platform/package-lock.json +++ b/stellar-payment-platform/package-lock.json @@ -5997,13 +5997,13 @@ "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4jV6DTAhjrsSYgdsW/F+MFOBA==", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "license": "MIT" }, "node_modules/lodash.isstring": {