diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 0afa7922..75574708 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -127,3 +127,7 @@ jobs: env: DATABASE_URL: "file:./test.db" run: npm test + + - name: Cleanup test artifacts + if: always() + run: rm -rf coverage logs test.db* migration-status.md diff --git a/.github/workflows/dashboard.yml b/.github/workflows/dashboard.yml index 3084a7f0..34dda94f 100644 --- a/.github/workflows/dashboard.yml +++ b/.github/workflows/dashboard.yml @@ -64,3 +64,7 @@ jobs: - name: Run browser smoke test run: npm run test:browser -w dashboard -- --project=${{ matrix.browser }} + + - name: Cleanup test artifacts + if: always() + run: rm -rf dashboard/test-results diff --git a/.github/workflows/migration-integrity.yml b/.github/workflows/migration-integrity.yml index e5611c10..792276c7 100644 --- a/.github/workflows/migration-integrity.yml +++ b/.github/workflows/migration-integrity.yml @@ -143,6 +143,10 @@ jobs: core.warning(`Unable to comment migration report on PR: ${error.message}`); } + - name: Cleanup test artifacts + if: always() + run: rm -f backend/migrate-status.txt backend/migration-report.md + staging-migration-test: name: Test Migration on Staging Clone runs-on: ubuntu-latest diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a119c3d2..62670cc4 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -19,22 +19,42 @@ on: - "scripts/security-audit.sh" jobs: - test: + build: + name: Build and test smart contracts runs-on: ubuntu-latest + steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown,wasm32v1-none + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all --check + + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Build (wasm32) + run: cargo build --target wasm32-unknown-unknown --release + - uses: taiki-e/install-action@v2 with: tool: cargo-scout-audit@0.3.16 + - name: Install Scout system dependencies run: sudo apt-get update && sudo apt-get install -y libssl-dev pkg-config gcc + - name: Test sep41-token run: cargo test -p sep41-token + - name: Test upgradeable run: cargo test -p upgradeable + - name: Run Security Audit run: ./scripts/security-audit.sh --warn-only diff --git a/Untitled b/Untitled new file mode 100644 index 00000000..0f5e55aa --- /dev/null +++ b/Untitled @@ -0,0 +1 @@ +git config user.email "tawfiqamuhammad@gmail.com" \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index 1a441809..7721b47f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -118,6 +118,13 @@ REDIS_URL=redis://localhost:6379 # FEATURE_FLAG_MULTISIG=true # FEATURE_FLAG_BATCH_PAYMENTS=true +# SEP-38 Quote Cache Expiration +# SEP38_INDICATIVE_QUOTE_EXPIRATION_SECONDS=60 +# SEP38_FIRM_QUOTE_VALIDITY_SECONDS=300 +# SEP38_QUOTE_CACHE_TTL_SECONDS=30 +# SEP38_QUOTE_CACHE_STALE_TTL_SECONDS=30 +# SEP38_ASSETS_CACHE_TTL_SECONDS=3600 + # Email (SendGrid with Nodemailer fallback) # SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxx diff --git a/backend/package.json b/backend/package.json index 36c5bb93..feb5963c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,10 +28,12 @@ "migrate:rollback": "ts-node scripts/generate-rollback.ts", "migrate:status": "prisma migrate status", "migrate:validate-env": "node scripts/validate-migration-env.js", - "migrate:bootstrap": "bash scripts/bootstrap-db.sh" + "migrate:bootstrap": "bash scripts/bootstrap-db.sh", + "clean": "node -e \"const fs=require('fs'),path=require('path');function walk(dir){if(!fs.existsSync(dir))return;for(const f of fs.readdirSync(dir)){const fp=path.join(dir,f);if(fs.statSync(fp).isDirectory())walk(fp);else if(['.db-journal','.db-wal','.db-shm'].some(e=>f.endsWith(e))){fs.unlinkSync(fp);console.log('Removed:',fp)}}};walk('.');['logs','coverage'].forEach(d=>{if(fs.existsSync(d)){fs.rmSync(d,{recursive:true,force:true});console.log('Removed:',d)}});console.log('Clean complete');\"" }, "dependencies": { "@aws-sdk/client-kms": "^3.500.0", + "@aws-sdk/client-s3": "^3.1075.0", "@bull-board/api": "^5.21.0", "@bull-board/express": "^5.21.0", "@iarna/toml": "^2.2.5", @@ -60,8 +62,8 @@ "jsonwebtoken": "^9.0.3", "multer": "^1.4.5-lts.2", "node-cron": "^4.2.1", - "node-vault": "^0.10.2", - "nodemailer": "^6.10.1", + "node-vault": "^0.12.0", + "nodemailer": "^9.0.1", "opossum": "^9.0.0", "pdfkit": "^0.18.0", "prom-client": "^15.1.0", @@ -69,7 +71,7 @@ "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "uuid": "^9.0.0", - "vite": "6.4.1", + "vite": "^6.4.3", "winston": "^3.19.0", "zod": "^4.3.6" }, @@ -89,6 +91,7 @@ "@types/swagger-ui-express": "^4.1.8", "@types/uuid": "^9.0.0", "@types/winston": "^2.4.4", + "nodemailer-mock": "^2.0.5", "@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/parser": "^7.3.1", "eslint": "^8.57.0", diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db new file mode 100644 index 00000000..e69de29b diff --git a/backend/prisma/migrations/20260529120000_add_multisig_contract_event_asset_index/migration.sql b/backend/prisma/migrations/20260529120000_add_multisig_contract_event_asset_index/migration.sql new file mode 100644 index 00000000..e469dbc0 --- /dev/null +++ b/backend/prisma/migrations/20260529120000_add_multisig_contract_event_asset_index/migration.sql @@ -0,0 +1,106 @@ +-- CreateTable +CREATE TABLE "ContractEvent" ( + "id" TEXT NOT NULL PRIMARY KEY, + "contractId" TEXT NOT NULL, + "ledger" INTEGER NOT NULL, + "ledgerClosedAt" DATETIME NOT NULL, + "txHash" TEXT NOT NULL, + "contractEventId" TEXT NOT NULL, + "topics" TEXT NOT NULL, + "value" TEXT NOT NULL, + "type" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "MultisigTransaction" ( + "id" TEXT NOT NULL PRIMARY KEY, + "envelopeXdr" TEXT NOT NULL, + "hash" TEXT NOT NULL, + "creatorPublicKey" TEXT NOT NULL, + "requiredSigners" JSONB NOT NULL, + "threshold" INTEGER NOT NULL, + "currentSignatures" INTEGER NOT NULL DEFAULT 0, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "memo" TEXT, + "expiresAt" DATETIME, + "submittedAt" DATETIME, + "stellarTxId" TEXT, + "metadata" JSONB, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "MultisigSignature" ( + "id" TEXT NOT NULL PRIMARY KEY, + "multisigTransactionId" TEXT NOT NULL, + "signerPublicKey" TEXT NOT NULL, + "signature" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "MultisigSignature_multisigTransactionId_fkey" FOREIGN KEY ("multisigTransactionId") REFERENCES "MultisigTransaction" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "MultisigNotification" ( + "id" TEXT NOT NULL PRIMARY KEY, + "multisigTransactionId" TEXT NOT NULL, + "recipientPublicKey" TEXT NOT NULL, + "type" TEXT NOT NULL, + "message" TEXT NOT NULL, + "readAt" DATETIME, + "sentAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "MultisigNotification_multisigTransactionId_fkey" FOREIGN KEY ("multisigTransactionId") REFERENCES "MultisigTransaction" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AssetValidationResult" ( + "assetCode" TEXT NOT NULL, + "issuerPublicKey" TEXT NOT NULL, + "homeDomain" TEXT, + "complianceStatus" TEXT NOT NULL, + "messages" TEXT NOT NULL, + "rawToml" TEXT, + "lastCrawledAt" DATETIME NOT NULL, + + PRIMARY KEY ("assetCode", "issuerPublicKey") +); + +-- CreateTable +CREATE TABLE "CrawlJobRecord" ( + "id" TEXT NOT NULL PRIMARY KEY, + "startedAt" DATETIME NOT NULL, + "completedAt" DATETIME, + "totalAssets" INTEGER NOT NULL, + "compliantCount" INTEGER NOT NULL, + "nonCompliantCount" INTEGER NOT NULL, + "suspiciousCount" INTEGER NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ContractEvent_contractEventId_key" ON "ContractEvent"("contractEventId"); + +-- CreateIndex +CREATE INDEX "ContractEvent_contractId_idx" ON "ContractEvent"("contractId"); + +-- CreateIndex +CREATE INDEX "ContractEvent_txHash_idx" ON "ContractEvent"("txHash"); + +-- CreateIndex +CREATE INDEX "ContractEvent_ledger_idx" ON "ContractEvent"("ledger"); + +-- CreateIndex +CREATE UNIQUE INDEX "MultisigTransaction_hash_key" ON "MultisigTransaction"("hash"); + +-- CreateIndex +CREATE UNIQUE INDEX "MultisigTransaction_stellarTxId_key" ON "MultisigTransaction"("stellarTxId"); + +-- CreateIndex +CREATE UNIQUE INDEX "MultisigSignature_multisigTransactionId_signerPublicKey_key" ON "MultisigSignature"("multisigTransactionId", "signerPublicKey"); + +-- CreateIndex +CREATE INDEX "MultisigNotification_recipientPublicKey_readAt_idx" ON "MultisigNotification"("recipientPublicKey", "readAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "AssetValidationResult_assetCode_issuerPublicKey_key" ON "AssetValidationResult"("assetCode", "issuerPublicKey"); diff --git a/backend/prisma/migrations/20260627094058_add_upload_record/migration.sql b/backend/prisma/migrations/20260627094058_add_upload_record/migration.sql new file mode 100644 index 00000000..54115c8e --- /dev/null +++ b/backend/prisma/migrations/20260627094058_add_upload_record/migration.sql @@ -0,0 +1,96 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "phone" TEXT; + +-- CreateTable +CREATE TABLE "NotificationPreference" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "emailEnabled" BOOLEAN NOT NULL DEFAULT true, + "smsEnabled" BOOLEAN NOT NULL DEFAULT false, + "pushEnabled" BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "transactionId" TEXT, + "type" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "message" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Notification_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "Transaction" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "ContractJob" ( + "id" TEXT NOT NULL PRIMARY KEY, + "jobId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "priority" TEXT NOT NULL DEFAULT 'NORMAL', + "status" TEXT NOT NULL DEFAULT 'PENDING', + "contractId" TEXT, + "functionName" TEXT, + "parameters" JSONB, + "result" JSONB, + "error" TEXT, + "errorCategory" TEXT, + "errorSeverity" TEXT, + "errorCode" TEXT, + "userMessage" TEXT, + "suggestedAction" TEXT, + "retryable" BOOLEAN NOT NULL DEFAULT false, + "attempts" INTEGER NOT NULL DEFAULT 0, + "maxAttempts" INTEGER NOT NULL DEFAULT 3, + "createdBy" TEXT, + "metadata" JSONB, + "startedAt" DATETIME, + "completedAt" DATETIME, + "failedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "UploadRecord" ( + "id" TEXT NOT NULL PRIMARY KEY, + "key" TEXT NOT NULL, + "contentType" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "NotificationPreference_userId_key" ON "NotificationPreference"("userId"); + +-- CreateIndex +CREATE INDEX "NotificationPreference_userId_idx" ON "NotificationPreference"("userId"); + +-- CreateIndex +CREATE INDEX "Notification_userId_idx" ON "Notification"("userId"); + +-- CreateIndex +CREATE INDEX "Notification_transactionId_idx" ON "Notification"("transactionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ContractJob_jobId_key" ON "ContractJob"("jobId"); + +-- CreateIndex +CREATE INDEX "ContractJob_jobId_idx" ON "ContractJob"("jobId"); + +-- CreateIndex +CREATE INDEX "ContractJob_status_idx" ON "ContractJob"("status"); + +-- CreateIndex +CREATE INDEX "ContractJob_createdBy_idx" ON "ContractJob"("createdBy"); + +-- CreateIndex +CREATE INDEX "ContractJob_type_idx" ON "ContractJob"("type"); + +-- CreateIndex +CREATE UNIQUE INDEX "UploadRecord_key_key" ON "UploadRecord"("key"); + +-- CreateIndex +CREATE INDEX "UploadRecord_status_idx" ON "UploadRecord"("status"); diff --git a/backend/prisma/migrations/20260628131600_add_kyc_customer_indexes/migration.sql b/backend/prisma/migrations/20260628131600_add_kyc_customer_indexes/migration.sql new file mode 100644 index 00000000..2409c5e7 --- /dev/null +++ b/backend/prisma/migrations/20260628131600_add_kyc_customer_indexes/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "KycCustomer_status_idx" ON "KycCustomer"("status"); diff --git a/backend/prisma/migrations/20260628131600_add_kyc_customer_indexes/rollback.sql b/backend/prisma/migrations/20260628131600_add_kyc_customer_indexes/rollback.sql new file mode 100644 index 00000000..0d019929 --- /dev/null +++ b/backend/prisma/migrations/20260628131600_add_kyc_customer_indexes/rollback.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "KycCustomer_status_idx"; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 40a1de65..de4f06d1 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -6,7 +6,7 @@ generator client { } datasource db { - provider = "postgresql" + provider = "sqlite" url = env("DATABASE_URL") } @@ -137,7 +137,7 @@ model Transaction { feeAssetCode String? feeType String? - // SEP-31 specific fields (nullable for backward compatibility) + // SEP-31 specific fields senderInfo Json? receiverInfo Json? callbackUrl String? @@ -231,6 +231,7 @@ model KycCustomer { @@index([provider, providerRef]) @@index([userId]) + @@index([status]) } model Quote { diff --git a/backend/scripts/verify-migrations.ts b/backend/scripts/verify-migrations.ts index f1414e7a..05152bb1 100644 --- a/backend/scripts/verify-migrations.ts +++ b/backend/scripts/verify-migrations.ts @@ -16,7 +16,6 @@ import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; interface MigrationCheckResult { success: boolean; @@ -33,12 +32,15 @@ interface MigrationCheckResult { class MigrationVerifier { private tempDbPath: string; + private tempDbUrl: string; private prismaBinary: string; private schemaPath: string; private migrationsPath: string; constructor() { - this.tempDbPath = path.join(os.tmpdir(), `prisma-verify-${Date.now()}.db`); + const tempDbName = `prisma-verify-${Date.now()}.db`; + this.tempDbPath = path.join(__dirname, '..', tempDbName); + this.tempDbUrl = `file:./${tempDbName}`; this.prismaBinary = 'npx prisma'; this.schemaPath = path.join(__dirname, '../prisma/schema.prisma'); this.migrationsPath = path.join(__dirname, '../prisma/migrations'); @@ -55,7 +57,9 @@ class MigrationVerifier { ...options, }); } catch (error: any) { - throw new Error(`Command failed: ${command}\n${error.message}`); + const stdout = error.stdout ? `\nstdout:\n${error.stdout}` : ''; + const stderr = error.stderr ? `\nstderr:\n${error.stderr}` : ''; + throw new Error(`Command failed: ${command}\n${error.message}${stdout}${stderr}`); } } @@ -70,7 +74,9 @@ class MigrationVerifier { ...options, }); } catch (error: any) { - throw new Error(`Command failed: ${command}\n${error.message}`); + const stdout = error.stdout ? `\nstdout:\n${error.stdout}` : ''; + const stderr = error.stderr ? `\nstderr:\n${error.stderr}` : ''; + throw new Error(`Command failed: ${command}\n${error.message}${stdout}${stderr}`); } } @@ -114,8 +120,8 @@ class MigrationVerifier { DATABASE_URL: process.env.DATABASE_URL || `file:${this.tempDbPath}`, }; - // Reset and apply migrations - this.runSilent(`${this.prismaBinary} migrate reset --force`, { + // Apply committed migrations to the temporary database. + this.runSilent(`${this.prismaBinary} migrate deploy`, { env, cwd: path.join(__dirname, '..'), }); diff --git a/backend/src/api/controllers/sep12.controller.test.ts b/backend/src/api/controllers/sep12.controller.test.ts index 733dd777..3d671194 100644 --- a/backend/src/api/controllers/sep12.controller.test.ts +++ b/backend/src/api/controllers/sep12.controller.test.ts @@ -19,6 +19,7 @@ const prismaMock = { update: jest.fn(), findFirst: jest.fn(), delete: jest.fn(), + findUnique: jest.fn(), }, }; @@ -29,6 +30,10 @@ const providerMock = { parseWebhook: jest.fn(), }; +const webhookServiceMock = { + sendKycStatusChanged: jest.fn(), +}; + const cryptoMock = { encrypt: jest.fn((v: string) => ({ encryptedData: `${v}:enc`, iv: 'iv1' })), decrypt: jest.fn((v: string) => v), @@ -49,11 +54,23 @@ jest.mock('../../services/kyc-provider.service', () => ({ kycProvider: providerMock, })); +jest.mock('../../services/webhook.service', () => ({ + __esModule: true, + defaultWebhookService: webhookServiceMock, +})); + jest.mock('../../services/crypto.service', () => ({ __esModule: true, cryptoService: cryptoMock, })); +jest.mock('../../config/env', () => ({ + __esModule: true, + config: { + SEP12_MAX_FILE_SIZE_MB: 10, + }, +})); + jest.mock('../../utils/logger', () => ({ __esModule: true, default: { @@ -64,13 +81,23 @@ jest.mock('../../utils/logger', () => ({ }, })); -jest.mock('@stellar/stellar-sdk', () => ({ - StrKey: { - isValidEd25519PublicKey: jest.fn((account: string) => account === VALID_ACCOUNT), - }, -})); +jest.mock('@stellar/stellar-sdk', () => { + const actual = jest.requireActual('@stellar/stellar-sdk'); + return { + ...actual, + StrKey: { + ...actual.StrKey, + isValidEd25519PublicKey: jest.fn((account: string) => account === VALID_ACCOUNT), + }, + }; +}); import { sep12Controller } from './sep12.controller'; +import { uploadStore } from '../../services/upload-store.service'; +import { storageProvider } from '../../services/storage-provider.service'; + +const storageProviderMock = storageProvider; +const uploadStoreMock = uploadStore; const makeRes = (): Response => { const res: Partial = {}; @@ -83,6 +110,187 @@ const makeRes = (): Response => { describe('Sep12Controller', () => { beforeEach(() => { jest.clearAllMocks(); + uploadStore._reset(); + + jest.spyOn(storageProvider, 'generatePresignedPutUrl').mockResolvedValue('https://mock-bucket.mock.storage/kyc/test/field1/uuid?X-Mock-Signed=1'); + jest.spyOn(storageProvider, 'objectExists').mockResolvedValue(true); + jest.spyOn(uploadStore, 'create'); + jest.spyOn(uploadStore, 'setStatus'); + + webhookServiceMock.sendKycStatusChanged.mockResolvedValue({ + delivered: true, + attempts: 1, + statusCode: 200, + responseBody: 'ok', + }); + }); + + describe('getUploadUrl', () => { + it('returns pre-signed URL when all parameters are valid', async () => { + const req = { + method: 'POST', + body: { + account: VALID_ACCOUNT, + field_name: 'id_photo_front', + content_type: 'image/jpeg', + file_size: '1000000', + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.getUploadUrl(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + upload_id: expect.any(String), + url: expect.any(String), + expires_at: expect.any(String), + })); + expect(storageProviderMock.generatePresignedPutUrl).toHaveBeenCalled(); + expect(uploadStoreMock.create).toHaveBeenCalled(); + }); + + it('returns 400 when required parameters are missing', async () => { + const req = { + method: 'POST', + body: { + account: VALID_ACCOUNT, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.getUploadUrl(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'account, field_name, content_type, and file_size are required' }); + }); + + it('returns 400 when content type is invalid', async () => { + const req = { + method: 'POST', + body: { + account: VALID_ACCOUNT, + field_name: 'id_photo_front', + content_type: 'application/zip', + file_size: '1000000', + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.getUploadUrl(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 400 when file size is larger than max allowed', async () => { + const req = { + method: 'POST', + body: { + account: VALID_ACCOUNT, + field_name: 'id_photo_front', + content_type: 'image/jpeg', + file_size: '110000000', + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.getUploadUrl(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + }); + + describe('confirmUpload', () => { + it('confirms upload when upload exists and file is present in storage', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create(VALID_ACCOUNT, 'id_photo_front', 'image/jpeg', expiresAt); + const req = { + body: { + upload_id: record.uploadId, + account: VALID_ACCOUNT, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + upload_id: record.uploadId, + status: 'COMPLETED', + }); + expect(storageProviderMock.objectExists).toHaveBeenCalled(); + expect(uploadStoreMock.setStatus).toHaveBeenCalledWith(record.uploadId, 'COMPLETED'); + }); + + it('returns 400 when required parameters are missing', async () => { + const req = { + body: { + account: VALID_ACCOUNT, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 404 when upload record not found', async () => { + const req = { + body: { + upload_id: 'invalid-uuid', + account: VALID_ACCOUNT, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('returns 403 when account does not match upload record', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create('GBZXN7PIRZGNMHGA7MUUUF4GW3F55GQRQ5UKMJTDEFEKTGW4RHFDQLNZ', 'id_photo_front', 'image/jpeg', expiresAt); + const req = { + body: { + upload_id: record.uploadId, + account: VALID_ACCOUNT, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('returns 422 when file not found in storage', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create(VALID_ACCOUNT, 'id_photo_front', 'image/jpeg', expiresAt); + (storageProviderMock.objectExists as jest.Mock).mockResolvedValueOnce(false); + const req = { + body: { + upload_id: record.uploadId, + account: VALID_ACCOUNT, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(422); + }); }); describe('putCustomer', () => { @@ -256,6 +464,198 @@ describe('Sep12Controller', () => { expect(res.status).toHaveBeenCalledWith(202); }); + it('resolves completed upload_id fields to storage keys', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create( + VALID_ACCOUNT, + 'id_photo_front', + 'image/jpeg', + expiresAt + ); + const storageKey = `kyc/${VALID_ACCOUNT}/id_photo_front/${record.uploadId}`; + uploadStore.setStorageKey(record.uploadId, storageKey); + uploadStore.setStatus(record.uploadId, 'COMPLETED'); + + const req = { + body: { + account: VALID_ACCOUNT, + first_name: 'Jane', + id_photo_front_upload_id: record.uploadId, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + prismaMock.user.findUnique.mockResolvedValue({ id: 'u1', publicKey: VALID_ACCOUNT }); + prismaMock.kycCustomer.upsert.mockResolvedValue({ id: 'k1' }); + providerMock.submitCustomer.mockResolvedValue({ + success: true, + status: 'PENDING', + providerRef: 'mock_upload', + }); + + await sep12Controller.putCustomer(req, res); + + expect(providerMock.submitCustomer).toHaveBeenCalledWith( + expect.objectContaining({ account: VALID_ACCOUNT, firstName: 'Jane', extraFields: {} }), + { id_photo_front: storageKey } + ); + expect(res.status).toHaveBeenCalledWith(202); + }); + + it('returns 400 when upload_id is not confirmed', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create( + VALID_ACCOUNT, + 'id_photo_front', + 'image/jpeg', + expiresAt + ); + + const req = { + body: { + account: VALID_ACCOUNT, + id_photo_front_upload_id: record.uploadId, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + prismaMock.user.findUnique.mockResolvedValue({ id: 'u1', publicKey: VALID_ACCOUNT }); + + await sep12Controller.putCustomer(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: 'Upload not confirmed for field: id_photo_front', + }); + expect(providerMock.submitCustomer).not.toHaveBeenCalled(); + }); + + it('returns 403 when upload_id belongs to a different account', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create( + 'GBZXN7PIRZGNMHGA7MUUUF4GW3F55GQRQ5UKMJTDEFEKTGW4RHFDQLNZ', + 'id_photo_front', + 'image/jpeg', + expiresAt + ); + uploadStore.setStatus(record.uploadId, 'COMPLETED'); + + const req = { + body: { + account: VALID_ACCOUNT, + id_photo_front_upload_id: record.uploadId, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + prismaMock.user.findUnique.mockResolvedValue({ id: 'u1', publicKey: VALID_ACCOUNT }); + + await sep12Controller.putCustomer(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'Upload account does not match request for field: id_photo_front', + }); + }); + + it('prefers upload_id over a direct multipart attachment for the same field', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create( + VALID_ACCOUNT, + 'id_photo_front', + 'image/jpeg', + expiresAt + ); + const storageKey = `kyc/${VALID_ACCOUNT}/id_photo_front/${record.uploadId}`; + uploadStore.setStorageKey(record.uploadId, storageKey); + uploadStore.setStatus(record.uploadId, 'COMPLETED'); + + const req = { + body: { + account: VALID_ACCOUNT, + first_name: 'Jane', + id_photo_front_upload_id: record.uploadId, + }, + user: { publicKey: VALID_ACCOUNT }, + files: { + id_photo_front: [{ path: '/uploads/kyc/id-front.jpg' }], + }, + } as unknown as Request; + const res = makeRes(); + + prismaMock.user.findUnique.mockResolvedValue({ id: 'u1', publicKey: VALID_ACCOUNT }); + prismaMock.kycCustomer.upsert.mockResolvedValue({ id: 'k1' }); + providerMock.submitCustomer.mockResolvedValue({ + success: true, + status: 'PENDING', + providerRef: 'mock_pref', + }); + + await sep12Controller.putCustomer(req, res); + + expect(providerMock.submitCustomer).toHaveBeenCalledWith( + expect.objectContaining({ account: VALID_ACCOUNT }), + { id_photo_front: storageKey } + ); + }); + + it('produces identical provider submissions for upload_id and multipart paths', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create( + VALID_ACCOUNT, + 'id_photo_front', + 'image/jpeg', + expiresAt + ); + const storageKey = `kyc/${VALID_ACCOUNT}/id_photo_front/${record.uploadId}`; + uploadStore.setStorageKey(record.uploadId, storageKey); + uploadStore.setStatus(record.uploadId, 'COMPLETED'); + + prismaMock.user.findUnique.mockResolvedValue({ id: 'u1', publicKey: VALID_ACCOUNT }); + prismaMock.kycCustomer.upsert.mockResolvedValue({ id: 'k1' }); + providerMock.submitCustomer.mockResolvedValue({ + success: true, + status: 'PENDING', + providerRef: 'mock_same', + }); + + const uploadIdReq = { + body: { + account: VALID_ACCOUNT, + first_name: 'Jane', + id_photo_front_upload_id: record.uploadId, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const multipartReq = { + body: { account: VALID_ACCOUNT, first_name: 'Jane' }, + user: { publicKey: VALID_ACCOUNT }, + files: { + id_photo_front: [{ path: storageKey }], + }, + } as unknown as Request; + + await sep12Controller.putCustomer(uploadIdReq, makeRes()); + const uploadIdCall = providerMock.submitCustomer.mock.calls[0]; + + jest.clearAllMocks(); + prismaMock.user.findUnique.mockResolvedValue({ id: 'u1', publicKey: VALID_ACCOUNT }); + prismaMock.kycCustomer.upsert.mockResolvedValue({ id: 'k1' }); + providerMock.submitCustomer.mockResolvedValue({ + success: true, + status: 'PENDING', + providerRef: 'mock_same', + }); + + await sep12Controller.putCustomer(multipartReq, makeRes()); + const multipartCall = providerMock.submitCustomer.mock.calls[0]; + + expect(uploadIdCall).toEqual(multipartCall); + }); + it('returns 202 with PROCESSING when provider submission fails', async () => { const req = { body: { account: VALID_ACCOUNT, first_name: 'Jane' }, @@ -278,26 +678,93 @@ describe('Sep12Controller', () => { }); }); - it('updates customer KYC status via webhook providerRef lookup', async () => { + it('updates customer KYC status via webhook providerRef lookup and dispatches a partner webhook', async () => { const req = { headers: { 'x-kyc-signature': 'mock-valid-signature' }, body: { providerRef: 'mock_abc', status: 'accepted' }, } as unknown as Request; const res = makeRes(); + const existingCustomer = { + id: 'k1', + userId: 'u1', + provider: 'mock', + providerRef: 'mock_abc', + status: 'PENDING', + createdAt: new Date('2026-03-30T10:00:00.000Z'), + updatedAt: new Date('2026-03-30T10:00:00.000Z'), + user: { publicKey: VALID_ACCOUNT }, + }; + const updatedCustomer = { + ...existingCustomer, + status: 'ACCEPTED', + updatedAt: new Date('2026-03-30T10:05:00.000Z'), + }; providerMock.verifyWebhookSignature.mockReturnValue(true); providerMock.parseWebhook.mockReturnValue({ providerRef: 'mock_abc', status: 'ACCEPTED', }); - prismaMock.kycCustomer.findFirst.mockResolvedValue({ id: 'k1' }); + prismaMock.kycCustomer.findFirst.mockResolvedValue(existingCustomer); + prismaMock.kycCustomer.update.mockResolvedValue(updatedCustomer); await sep12Controller.handleWebhook(req, res); + expect(prismaMock.kycCustomer.findFirst).toHaveBeenCalledWith({ + where: { + provider: 'mock', + providerRef: 'mock_abc', + }, + include: { + user: { + select: { + publicKey: true, + }, + }, + }, + }); expect(prismaMock.kycCustomer.update).toHaveBeenCalledWith({ where: { id: 'k1' }, data: { status: 'ACCEPTED' }, + include: { + user: { + select: { + publicKey: true, + }, + }, + }, }); + expect(webhookServiceMock.sendKycStatusChanged).toHaveBeenCalledWith(updatedCustomer, 'PENDING'); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('does not dispatch a partner webhook when provider status is unchanged', async () => { + const req = { + headers: { 'x-kyc-signature': 'mock-valid-signature' }, + body: { providerRef: 'mock_abc', status: 'accepted' }, + } as unknown as Request; + const res = makeRes(); + + providerMock.verifyWebhookSignature.mockReturnValue(true); + providerMock.parseWebhook.mockReturnValue({ + providerRef: 'mock_abc', + status: 'ACCEPTED', + }); + prismaMock.kycCustomer.findFirst.mockResolvedValue({ + id: 'k1', + userId: 'u1', + provider: 'mock', + providerRef: 'mock_abc', + status: 'ACCEPTED', + createdAt: new Date('2026-03-30T10:00:00.000Z'), + updatedAt: new Date('2026-03-30T10:05:00.000Z'), + user: { publicKey: VALID_ACCOUNT }, + }); + + await sep12Controller.handleWebhook(req, res); + + expect(prismaMock.kycCustomer.update).not.toHaveBeenCalled(); + expect(webhookServiceMock.sendKycStatusChanged).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(200); }); @@ -316,6 +783,96 @@ describe('Sep12Controller', () => { expect(prismaMock.kycCustomer.update).not.toHaveBeenCalled(); }); + describe('confirmUpload', () => { + it('returns 400 when upload_id or account is missing', async () => { + const req = { + body: {}, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'upload_id and account are required' }); + }); + + it('returns 403 when session account does not match request account', async () => { + const req = { + body: { upload_id: 'up1', account: VALID_ACCOUNT }, + user: { publicKey: 'GOTHER' }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden: session account does not match request account' }); + }); + + it('returns 404 when record does not exist', async () => { + const req = { + body: { upload_id: 'non-existent', account: VALID_ACCOUNT }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Upload record not found or expired' }); + }); + + it('returns 403 when record account does not match request account', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create('GOTHER', 'id_photo_front', 'image/jpeg', expiresAt); + const req = { + body: { upload_id: record.uploadId, account: VALID_ACCOUNT }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'account does not match upload record' }); + }); + + it('returns 422 when file does not exist in storage', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create(VALID_ACCOUNT, 'id_photo_front', 'image/jpeg', expiresAt); + const req = { + body: { upload_id: record.uploadId, account: VALID_ACCOUNT }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + (storageProvider.objectExists as jest.Mock).mockResolvedValue(false); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(422); + expect(res.json).toHaveBeenCalledWith({ error: 'File not found in storage; upload may not have completed' }); + }); + + it('returns 200 and marks upload COMPLETED when upload is confirmed', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create(VALID_ACCOUNT, 'id_photo_front', 'image/jpeg', expiresAt); + const req = { + body: { upload_id: record.uploadId, account: VALID_ACCOUNT }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + (storageProvider.objectExists as jest.Mock).mockResolvedValue(true); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ upload_id: record.uploadId, status: 'COMPLETED' }); + expect(uploadStore.get(record.uploadId)?.status).toBe('COMPLETED'); + }); + }); + describe('getUploadUrl', () => { it('returns 400 when field query param is missing', async () => { const req = { diff --git a/backend/src/api/controllers/sep12.controller.ts b/backend/src/api/controllers/sep12.controller.ts index 541a10e3..b3057d47 100644 --- a/backend/src/api/controllers/sep12.controller.ts +++ b/backend/src/api/controllers/sep12.controller.ts @@ -3,6 +3,7 @@ import { StrKey } from '@stellar/stellar-sdk'; import prisma from '../../lib/prisma'; import { cryptoService } from '../../services/crypto.service'; import { kycProvider, KycStatus } from '../../services/kyc-provider.service'; +import { defaultWebhookService } from '../../services/webhook.service'; import { KYCStatus } from '@prisma/client'; import { AuthRequest } from '../middleware/auth.middleware'; import logger from '../../utils/logger'; @@ -15,9 +16,64 @@ type UploadedFiles = { [fieldname: string]: Array<{ path: string }> }; const ALLOWED_CONTENT_TYPES = (process.env.UPLOAD_ALLOWED_CONTENT_TYPES ?? 'image/jpeg,image/png,application/pdf').split(','); const UPLOAD_URL_EXPIRY_SECONDS = parseInt(process.env.UPLOAD_URL_EXPIRY_SECONDS ?? '900', 10); const KEY_PREFIX = process.env.STORAGE_KEY_PREFIX ?? 'kyc'; +const UPLOAD_ID_SUFFIX = '_upload_id'; const pack = (enc?: { encryptedData: string; iv: string } | null) => enc ? `${enc.iv}|${enc.encryptedData}` : null; +type DocumentResolution = + | { documents: Record; kycFields: Record } + | { error: string; status: 400 | 403 }; + +/** Resolve KYC document references from pre-signed upload IDs and/or multipart files. */ +export function resolveCustomerDocuments( + account: string, + otherFields: Record, + uploadedFiles?: UploadedFiles +): DocumentResolution { + const kycFields: Record = {}; + const documents: Record = {}; + + for (const [key, value] of Object.entries(otherFields)) { + if (key.endsWith(UPLOAD_ID_SUFFIX)) { + const fieldName = key.slice(0, -UPLOAD_ID_SUFFIX.length); + const record = uploadStore.get(value); + + if (!record || record.status === 'EXPIRED') { + return { error: `Upload not found or expired for field: ${fieldName}`, status: 400 }; + } + if (record.status !== 'COMPLETED') { + return { error: `Upload not confirmed for field: ${fieldName}`, status: 400 }; + } + if (record.account !== account) { + return { error: `Upload account does not match request for field: ${fieldName}`, status: 403 }; + } + + documents[fieldName] = + record.storageKey || `${KEY_PREFIX}/${account}/${record.fieldName}/${value}`; + } else { + kycFields[key] = value; + } + } + + if (uploadedFiles) { + for (const field of Object.keys(uploadedFiles)) { + if (!documents[field]) { + documents[field] = uploadedFiles[field][0].path; + } + } + } + + return { documents, kycFields }; +} + +export const SUPPLEMENTARY_KYC_FIELDS: Record = { + proof_of_income: { description: 'Proof of income document' }, + proof_of_address: { description: 'Proof of address document' }, + occupation: { description: 'Customer occupation', optional: true }, + employer_name: { description: 'Name of employer', optional: true }, + tax_id: { description: 'Tax identification number', optional: true }, +}; + export class Sep12Controller { private toDbStatus(status: KycStatus): KYCStatus { switch (status) { @@ -78,14 +134,13 @@ export class Sep12Controller { } const uploadedFiles = (req as AuthRequest & { files?: UploadedFiles }).files; - const documents: Record = {}; - if (uploadedFiles) { - for (const field of Object.keys(uploadedFiles)) { - documents[field] = uploadedFiles[field][0].path; - } + const resolution = resolveCustomerDocuments(account, otherFields, uploadedFiles); + if ('error' in resolution) { + return res.status(resolution.status).json({ error: resolution.error }); } + const { documents, kycFields } = resolution; - const extraPayload: Record = { ...otherFields }; + const extraPayload: Record = { ...kycFields }; if (Object.keys(documents).length > 0) { extraPayload.documents = documents; } @@ -114,7 +169,7 @@ export class Sep12Controller { firstName: first_name, lastName: last_name, email: email_address, - extraFields: otherFields, + extraFields: kycFields, }; let providerStatus = KycStatus.PENDING; @@ -192,6 +247,10 @@ export class Sep12Controller { } } + if (customer.status === KYCStatus.PENDING) { + responsePayload.fields = SUPPLEMENTARY_KYC_FIELDS; + } + res.json(responsePayload); } catch (error) { logger.error('SEP-12 customer GET failed', { @@ -241,6 +300,13 @@ export class Sep12Controller { provider: kycProvider.providerName, providerRef: event.providerRef, }, + include: { + user: { + select: { + publicKey: true, + }, + }, + }, }); } @@ -249,14 +315,37 @@ export class Sep12Controller { where: { publicKey: event.account }, include: { kycCustomer: true }, }); - targetCustomer = user?.kycCustomer ?? null; + targetCustomer = user?.kycCustomer + ? { ...user.kycCustomer, user: { publicKey: user.publicKey } } + : null; } if (!targetCustomer) return res.status(404).json({ error: 'Customer not found' }); - await prisma.kycCustomer.update({ + const nextStatus = this.toDbStatus(event.status); + const previousStatus = targetCustomer.status; + + if (previousStatus === nextStatus) { + return res.status(200).send('OK'); + } + + const updatedCustomer = await prisma.kycCustomer.update({ where: { id: targetCustomer.id }, - data: { status: this.toDbStatus(event.status) }, + data: { status: nextStatus }, + include: { + user: { + select: { + publicKey: true, + }, + }, + }, + }); + + defaultWebhookService.sendKycStatusChanged(updatedCustomer, previousStatus).catch((error) => { + logger.error('SEP-12 KYC status updated but webhook delivery failed', { + customerId: updatedCustomer.id, + error: error instanceof Error ? error.message : String(error), + }); }); res.status(200).send('OK'); @@ -278,81 +367,74 @@ export class Sep12Controller { * (e.g. `id_photo_front`). */ async getUploadUrl(req: AuthRequest, res: Response) { - try { - const field = req.query.field as string | undefined; - if (!field) { - return res.status(400).json({ error: 'field query parameter is required' }); - } - - // The authenticated public key is available via req.user (enforced by authMiddleware). - const account = req.user!.publicKey; + if (req.method === 'POST') { + try { + const { account, field_name, content_type, file_size } = req.body as Record; - // Generate a signed upload token: in production this would call a cloud - // storage pre-sign API. Here we produce a structured token so the client - // knows where and under what name to upload. - const expiresAt = Date.now() + 15 * 60 * 1000; // 15-minute window - const uploadToken = Buffer.from( - JSON.stringify({ account, field, expiresAt }) - ).toString('base64url'); + if (!account || !field_name || !content_type || !file_size) { + return res.status(400).json({ error: 'account, field_name, content_type, and file_size are required' }); + } - const uploadUrl = `/sep12/customer/upload?token=${uploadToken}`; + if (!ALLOWED_CONTENT_TYPES.includes(content_type)) { + return res.status(400).json({ + error: `content_type not allowed. Accepted types: ${ALLOWED_CONTENT_TYPES.join(', ')}`, + }); + } - logger.info('SEP-12 upload-url issued', { account, field }); + const maxBytes = config.SEP12_MAX_FILE_SIZE_MB * 1024 * 1024; + const fileSizeNum = Number(file_size); + if (fileSizeNum > maxBytes) { + return res.status(400).json({ + error: `file_size exceeds maximum allowed size of ${config.SEP12_MAX_FILE_SIZE_MB} MB`, + }); + } - return res.status(200).json({ - upload_url: uploadUrl, - expires_at: new Date(expiresAt).toISOString(), - field, + const expiresAt = new Date(Date.now() + UPLOAD_URL_EXPIRY_SECONDS * 1000); + const record = uploadStore.create(account, field_name, content_type, expiresAt); - /** - * POST /sep12/customer/upload-url - * Issues a pre-signed PUT URL for direct client-to-storage upload. - * Validates file_size against SEP12_MAX_FILE_SIZE_MB (issue #549). - */ - async getUploadUrl(req: AuthRequest, res: Response) { - try { - const { account, field_name, content_type, file_size } = req.body as Record; + const url = await storageProvider.generatePresignedPutUrl(record.storageKey, content_type, UPLOAD_URL_EXPIRY_SECONDS); - if (!account || !field_name || !content_type || !file_size) { - return res.status(400).json({ error: 'account, field_name, content_type, and file_size are required' }); - } + logger.info('SEP-12 upload-url issued', { account, field_name, uploadId: record.uploadId }); - if (!ALLOWED_CONTENT_TYPES.includes(content_type)) { - return res.status(400).json({ - error: `content_type not allowed. Accepted types: ${ALLOWED_CONTENT_TYPES.join(', ')}`, + return res.status(200).json({ + upload_id: record.uploadId, + url, + expires_at: expiresAt.toISOString(), }); - } - - const maxBytes = config.SEP12_MAX_FILE_SIZE_MB * 1024 * 1024; - const fileSizeNum = Number(file_size); - if (fileSizeNum > maxBytes) { - return res.status(400).json({ - error: `file_size exceeds maximum allowed size of ${config.SEP12_MAX_FILE_SIZE_MB} MB`, + } catch (error) { + logger.error('SEP-12 upload-url failed', { + error: error instanceof Error ? error.message : 'Unknown error', }); + return res.status(500).json({ error: 'Internal Server Error' }); } + } else { + try { + const field = req.query.field as string | undefined; + if (!field) { + return res.status(400).json({ error: 'field query parameter is required' }); + } - const expiresAt = new Date(Date.now() + UPLOAD_URL_EXPIRY_SECONDS * 1000); - const record = uploadStore.create(account, field_name, '', content_type, expiresAt); - const storageKey = `${KEY_PREFIX}/${account}/${field_name}/${record.uploadId}`; - uploadStore.setStatus(record.uploadId, 'PENDING'); - // Persist the computed storage key back onto the record via a second set - const storedRecord = uploadStore.get(record.uploadId)!; - (storedRecord as any).storageKey = storageKey; + const account = req.user!.publicKey; + const expiresAt = Date.now() + 15 * 60 * 1000; + const uploadToken = Buffer.from( + JSON.stringify({ account, field, expiresAt }) + ).toString('base64url'); - const url = await storageProvider.generatePresignedPutUrl(storageKey, content_type, UPLOAD_URL_EXPIRY_SECONDS); + const uploadUrl = `/sep12/customer/upload?token=${uploadToken}`; - logger.info('SEP-12 upload-url issued', { account, field_name, uploadId: record.uploadId }); + logger.info('SEP-12 upload-url issued', { account, field }); - return res.status(200).json({ - upload_id: record.uploadId, - url, - expires_at: expiresAt.toISOString(), - }); - } catch (error) { - logger.error('SEP-12 upload-url failed', { - error: error instanceof Error ? error.message : 'Unknown error', - }); - return res.status(500).json({ error: 'Internal Server Error' }); + return res.status(200).json({ + upload_url: uploadUrl, + expires_at: new Date(expiresAt).toISOString(), + field, + }); + } catch (error) { + logger.error('SEP-12 upload-url GET failed', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + return res.status(500).json({ error: 'Internal Server Error' }); + } } } @@ -368,6 +450,10 @@ export class Sep12Controller { return res.status(400).json({ error: 'upload_id and account are required' }); } + if (req.user && req.user.publicKey !== account) { + return res.status(403).json({ error: 'Forbidden: session account does not match request account' }); + } + const record = uploadStore.get(upload_id); if (!record || record.status === 'EXPIRED') { @@ -378,7 +464,9 @@ export class Sep12Controller { return res.status(403).json({ error: 'account does not match upload record' }); } - const exists = await storageProvider.objectExists((record as any).storageKey ?? `${KEY_PREFIX}/${account}/${record.fieldName}/${upload_id}`); + const exists = await storageProvider.objectExists( + record.storageKey || `${KEY_PREFIX}/${account}/${record.fieldName}/${upload_id}` + ); if (!exists) { return res.status(422).json({ error: 'File not found in storage; upload may not have completed' }); } diff --git a/backend/src/api/controllers/sep38.controller.ts b/backend/src/api/controllers/sep38.controller.ts index c495f3ed..a023164b 100644 --- a/backend/src/api/controllers/sep38.controller.ts +++ b/backend/src/api/controllers/sep38.controller.ts @@ -1,6 +1,12 @@ import { Redis } from 'ioredis'; -import { PriceAggregationService, AggregatedPrice, PriceFetchOptions } from '../../services/price-aggregation.service'; -import { AdvancedCacheService, CacheAsideResult } from '../../services/advanced-cache.service'; +import { PriceAggregationService, PriceFetchOptions } from '../../services/price-aggregation.service'; +import { AdvancedCacheService } from '../../services/advanced-cache.service'; +import { + buildQuoteExpirationTime, + getSep38QuotesCacheConfig, + isQuoteExpired, + Sep38QuotesCacheConfig, +} from '../../config/sep38-quotes-cache.config'; import logger from '../../utils/logger'; import prisma from '../../lib/prisma'; @@ -13,6 +19,7 @@ export interface PriceQuote { destination_asset: string; destination_amount: number; price: number; + fee: number; expiration_time: number; context?: string; cached?: boolean; @@ -49,6 +56,23 @@ const FALLBACK_PRICES: Record = { ETH: 2500.0, }; +export interface VolumeTier { + maxAmount: number; + feePercent: number; +} + +const DEFAULT_VOLUME_TIERS: VolumeTier[] = [ + { maxAmount: 1_000, feePercent: 0.003 }, + { maxAmount: 10_000, feePercent: 0.002 }, + { maxAmount: 100_000, feePercent: 0.001 }, + { maxAmount: Infinity, feePercent: 0.0005 }, +]; + +export function computeVolumeFee(amount: number, tiers: VolumeTier[] = DEFAULT_VOLUME_TIERS): number { + const tier = tiers.find((t) => amount <= t.maxAmount) ?? tiers[tiers.length - 1]; + return parseFloat((amount * tier.feePercent).toFixed(7)); +} + /** * Supported assets configuration */ @@ -96,11 +120,12 @@ export class Sep38Controller { private priceService: PriceAggregationService; private cache: AdvancedCacheService; private assetsCacheKey = 'sep38:supported_assets'; - private quoteCacheTtlSeconds = 30; + private cacheConfig: Sep38QuotesCacheConfig; - constructor(redis: Redis) { + constructor(redis: Redis, cacheConfig: Sep38QuotesCacheConfig = getSep38QuotesCacheConfig()) { this.priceService = new PriceAggregationService(redis); this.cache = new AdvancedCacheService(redis); + this.cacheConfig = cacheConfig; } /** @@ -140,7 +165,8 @@ export class Sep38Controller { destination_asset: destinationAsset, destination_amount: sourceAmount, price: 1.0, - expiration_time: Math.floor(Date.now() / 1000) + 60, + fee: computeVolumeFee(sourceAmount), + expiration_time: buildQuoteExpirationTime(this.cacheConfig.indicativeQuoteExpirationSeconds), confidence: 1.0, sources_used: 0, is_partial: false, @@ -162,7 +188,12 @@ export class Sep38Controller { if (forceRefresh) { const quote = await fetchQuote(); // Store in cache for future requests - await this.cache.setL2(cacheKey, quote, this.quoteCacheTtlSeconds, 'sep38-quote'); + await this.cache.setL2( + cacheKey, + quote, + this.cacheConfig.quoteCacheTtlSeconds, + 'sep38-quote', + ); return { ...quote, cached: false }; } @@ -170,13 +201,24 @@ export class Sep38Controller { cacheKey, fetchQuote, { - ttlSeconds: this.quoteCacheTtlSeconds, + ttlSeconds: this.cacheConfig.quoteCacheTtlSeconds, tags: ['sep38', 'quote', `asset:${source}`, `asset:${dest}`], staleWhileRevalidate: true, - staleTtlSeconds: 120, + staleTtlSeconds: this.cacheConfig.quoteCacheStaleTtlSeconds, } ); + if (cached.fromCache && isQuoteExpired(cached.data)) { + const quote = await fetchQuote(); + await this.cache.setL2( + cacheKey, + quote, + this.cacheConfig.quoteCacheTtlSeconds, + 'sep38-quote', + ); + return { ...quote, cached: false }; + } + return { ...cached.data, cached: cached.fromCache }; } @@ -191,7 +233,9 @@ export class Sep38Controller { ): Promise { const indicativeQuote = await this.getPriceQuote(sourceAsset, sourceAmount, destinationAsset, context); - const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes validity + const expiresAt = new Date( + Date.now() + this.cacheConfig.firmQuoteValiditySeconds * 1000, + ); const dbQuote = await prisma.quote.create({ data: { @@ -261,7 +305,8 @@ export class Sep38Controller { destination_asset: destAsset, destination_amount: parseFloat(destinationAmount.toFixed(7)), price: parseFloat(crossRate.toFixed(7)), - expiration_time: Math.floor(Date.now() / 1000) + 60, + fee: computeVolumeFee(sourceAmount), + expiration_time: buildQuoteExpirationTime(this.cacheConfig.indicativeQuoteExpirationSeconds), confidence: parseFloat(avgConfidence.toFixed(4)), sources_used: Math.min(sourcePriceData.aggregatedFrom, destPriceData.aggregatedFrom), is_partial: isPartial, @@ -306,7 +351,8 @@ export class Sep38Controller { destination_asset: destAsset, destination_amount: parseFloat(destinationAmount.toFixed(7)), price: parseFloat(crossRate.toFixed(7)), - expiration_time: Math.floor(Date.now() / 1000) + 60, + fee: computeVolumeFee(sourceAmount), + expiration_time: buildQuoteExpirationTime(this.cacheConfig.indicativeQuoteExpirationSeconds), confidence: 0.5, sources_used: 0, is_partial: true, @@ -336,7 +382,7 @@ export class Sep38Controller { this.assetsCacheKey, fetchAssets, { - ttlSeconds: 3600, + ttlSeconds: this.cacheConfig.assetsCacheTtlSeconds, tags: ['sep38', 'assets'], } ); diff --git a/backend/src/api/middleware/sep24-metrics.middleware.ts b/backend/src/api/middleware/sep24-metrics.middleware.ts new file mode 100644 index 00000000..eaf2b5a3 --- /dev/null +++ b/backend/src/api/middleware/sep24-metrics.middleware.ts @@ -0,0 +1,23 @@ +import { Request, Response, NextFunction } from 'express'; +import { recordSep24InteractionRequest } from '../../services/sep24-metrics.service'; + +/** + * Middleware that records SEP-24 interactive endpoint duration and request counts + * to Prometheus. + */ +export function sep24MetricsMiddleware( + req: Request, + res: Response, + next: NextFunction, +): void { + const start = Date.now(); + const method = req.method; + const endpoint = req.route?.path ?? req.path; + + res.on('finish', () => { + const durationSeconds = (Date.now() - start) / 1000; + recordSep24InteractionRequest(endpoint, method, res.statusCode, durationSeconds); + }); + + next(); +} diff --git a/backend/src/api/routes/auth.route.test.ts b/backend/src/api/routes/auth.route.test.ts new file mode 100644 index 00000000..ed3c3c95 --- /dev/null +++ b/backend/src/api/routes/auth.route.test.ts @@ -0,0 +1,29 @@ +import request from 'supertest'; +import express from 'express'; +import { rateLimit } from 'express-rate-limit'; + +jest.mock('../middleware/rate-limit.middleware', () => ({ + authLimiter: rateLimit({ + windowMs: 10 * 60 * 1000, + max: 10, + message: { error: 'Too many authentication attempts, please try again after 10 minutes.' }, + }), +})); + +import authRouter from './auth.route'; +import { authLimiter } from '../middleware/rate-limit.middleware'; + +const app = express(); +app.use(express.json()); +app.use('/sep10', authLimiter, authRouter); + +describe('Auth Route Rate Limiting', () => { + it('should return 429 when rate limit is exceeded', async () => { + // authLimiter max is 10, let's send 11 requests + for (let i = 0; i < 10; i++) { + await request(app).post('/sep10').send({ account: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' }); + } + const response = await request(app).post('/sep10').send({ account: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' }); + expect(response.status).toBe(429); + }); +}); diff --git a/backend/src/api/routes/queue-dashboard.route.ts b/backend/src/api/routes/queue-dashboard.route.ts index 11ae9d11..9fdcb795 100644 --- a/backend/src/api/routes/queue-dashboard.route.ts +++ b/backend/src/api/routes/queue-dashboard.route.ts @@ -12,7 +12,8 @@ const queues = Object.values(QUEUE_NAMES).map( (name) => new BullMQAdapter(new Queue(name, { connection: queueConnection })) ); -createBullBoard({ queues, serverAdapter }); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +createBullBoard({ queues: queues as any, serverAdapter }); const router = Router(); router.use('/', serverAdapter.getRouter()); diff --git a/backend/src/api/routes/sep12.route.ts b/backend/src/api/routes/sep12.route.ts index 8b7f74df..6ae0fb05 100644 --- a/backend/src/api/routes/sep12.route.ts +++ b/backend/src/api/routes/sep12.route.ts @@ -8,6 +8,13 @@ import { config } from '../../config/env'; const router = Router(); +export const ALLOWED_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'application/pdf', +]); + // Ensure upload directory exists const uploadDir = path.join(process.cwd(), 'uploads/kyc'); if (!fs.existsSync(uploadDir)) { @@ -25,7 +32,16 @@ const storage = multer.diskStorage({ } }); -const upload = multer({ storage: storage }); +const upload = multer({ + storage, + fileFilter: (_req, file, cb) => { + if (ALLOWED_MIME_TYPES.has(file.mimetype)) { + cb(null, true); + } else { + cb(new multer.MulterError('LIMIT_UNEXPECTED_FILE', file.fieldname)); + } + }, +}); /** * Middleware: validate file_size does not exceed SEP12_MAX_FILE_SIZE_MB. @@ -97,6 +113,10 @@ router.delete('/customer/:account', sep12Controller.deleteCustomer.bind(sep12Con * description: Unauthorized – missing or invalid SEP-10 session token */ router.get('/customer/upload-url', authMiddleware, sep12Controller.getUploadUrl.bind(sep12Controller)); + +/** + * @swagger + * /sep12/customer/upload-url: * post: * summary: Request a pre-signed URL for direct file upload * tags: [SEP-12] @@ -121,4 +141,13 @@ router.post('/customer/upload-confirm', authMiddleware, sep12Controller.confirmU */ router.post('/webhook', sep12Controller.handleWebhook.bind(sep12Controller)); +// eslint-disable-next-line @typescript-eslint/no-unused-vars +router.use((err: unknown, _req: import('express').Request, res: import('express').Response, _next: import('express').NextFunction) => { + if (err instanceof multer.MulterError && err.code === 'LIMIT_UNEXPECTED_FILE') { + const allowed = Array.from(ALLOWED_MIME_TYPES).join(', '); + return res.status(400).json({ error: `Unsupported file type. Allowed types: ${allowed}` }); + } + _next(err); +}); + export default router; diff --git a/backend/src/api/routes/sep24.route.test.ts b/backend/src/api/routes/sep24.route.test.ts index 157b9349..125ee5bb 100644 --- a/backend/src/api/routes/sep24.route.test.ts +++ b/backend/src/api/routes/sep24.route.test.ts @@ -36,6 +36,7 @@ describe('SEP-24 Routes', () => { afterEach(() => { delete process.env.INTERACTIVE_URL; + delete process.env.SEP24_ALLOWED_CALLBACK_DOMAINS; }); describe('POST /transactions/deposit/interactive', () => { @@ -110,6 +111,26 @@ describe('SEP-24 Routes', () => { const parsed = new URL(res.body.url); expect(parsed.searchParams.get('lang')).toBe('en'); }); + + it('returns 400 when redirect_url is not in whitelist', async () => { + process.env.SEP24_ALLOWED_CALLBACK_DOMAINS = 'example.com'; + const res = await request(app) + .post('/transactions/deposit/interactive') + .send({ asset_code: 'USDC', redirect_url: 'https://malicious.com/callback' }); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('invalid redirect_url domain'); + }); + + it('returns 400 when on_change_callback is not in whitelist', async () => { + process.env.SEP24_ALLOWED_CALLBACK_DOMAINS = 'example.com'; + const res = await request(app) + .post('/transactions/deposit/interactive') + .send({ asset_code: 'USDC', on_change_callback: 'https://malicious.com/hook' }); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('invalid on_change_callback domain'); + }); }); describe('POST /transactions/withdraw/interactive', () => { @@ -166,5 +187,25 @@ describe('SEP-24 Routes', () => { expect(res.body.error).toBe('account must be a valid Stellar public key'); expect(res.body.error).not.toContain(account); }); + + it('returns 400 when redirect_url is not in whitelist', async () => { + process.env.SEP24_ALLOWED_CALLBACK_DOMAINS = 'example.com'; + const res = await request(app) + .post('/transactions/withdraw/interactive') + .send({ asset_code: 'USDC', redirect_url: 'https://malicious.com/callback' }); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('invalid redirect_url domain'); + }); + + it('returns 400 when on_change_callback is not in whitelist', async () => { + process.env.SEP24_ALLOWED_CALLBACK_DOMAINS = 'example.com'; + const res = await request(app) + .post('/transactions/withdraw/interactive') + .send({ asset_code: 'USDC', on_change_callback: 'https://malicious.com/hook' }); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('invalid on_change_callback domain'); + }); }); }); diff --git a/backend/src/api/routes/sep24.route.ts b/backend/src/api/routes/sep24.route.ts index a452709e..0d9d9115 100644 --- a/backend/src/api/routes/sep24.route.ts +++ b/backend/src/api/routes/sep24.route.ts @@ -9,15 +9,34 @@ import { } from '../../services/kyc.service'; import prisma from '../../lib/prisma'; import { isValidStellarPublicKey } from '../../utils/stellar-address'; +import { sep24MetricsMiddleware } from '../middleware/sep24-metrics.middleware'; +import { Sep24Service } from '../../services/sep24.service'; const router = Router(); +router.use(sep24MetricsMiddleware); + +const ALLOWED_CALLBACK_PROTOCOLS = ['https:']; + +function isValidCallbackUrl(callback: unknown): boolean { + if (typeof callback !== 'string') return false; + try { + const url = new URL(callback); + return ALLOWED_CALLBACK_PROTOCOLS.includes(url.protocol); + } catch { + return false; + } +} + interface InteractiveRequest { asset_code: string; account?: string; amount?: string; lang?: string; quote_id?: string; + redirect_url?: string; + on_change_callback?: string; + callback?: string; } interface InteractiveResponse { @@ -90,7 +109,19 @@ const hasInvalidAccount = (account: unknown): boolean => * description: Invalid request parameters */ router.post('/transactions/deposit/interactive', async (req: Request, res: Response) => { - const { asset_code, account, amount, lang = 'en', quote_id }: InteractiveRequest = req.body; + const { asset_code, account, amount, lang = 'en', quote_id, redirect_url, on_change_callback, callback }: InteractiveRequest = req.body; + + const allowedDomains = process.env.SEP24_ALLOWED_CALLBACK_DOMAINS + ? process.env.SEP24_ALLOWED_CALLBACK_DOMAINS.split(',').filter(Boolean) + : []; + + if (redirect_url && allowedDomains.length > 0 && !Sep24Service.validateCallbackUrl(redirect_url, allowedDomains)) { + return res.status(400).json({ error: 'invalid redirect_url domain' }); + } + + if (on_change_callback && allowedDomains.length > 0 && !Sep24Service.validateCallbackUrl(on_change_callback, allowedDomains)) { + return res.status(400).json({ error: 'invalid on_change_callback domain' }); + } if (!asset_code) { return res.status(400).json({ @@ -107,6 +138,10 @@ router.post('/transactions/deposit/interactive', async (req: Request, res: Respo return res.status(400).json(invalidAccountResponse()); } + if (callback !== undefined && !isValidCallbackUrl(callback)) { + return res.status(400).json({ error: 'callback must be a valid HTTPS URL' }); + } + if (quote_id) { const quote = await prisma.quote.findUnique({ where: { id: quote_id } }); if (!quote) { @@ -185,7 +220,19 @@ router.post('/transactions/deposit/interactive', async (req: Request, res: Respo * description: Invalid request parameters */ router.post('/transactions/withdraw/interactive', async (req: Request, res: Response) => { - const { asset_code, account, amount, lang = 'en', quote_id }: InteractiveRequest = req.body; + const { asset_code, account, amount, lang = 'en', quote_id, redirect_url, on_change_callback, callback }: InteractiveRequest = req.body; + + const allowedDomains = process.env.SEP24_ALLOWED_CALLBACK_DOMAINS + ? process.env.SEP24_ALLOWED_CALLBACK_DOMAINS.split(',').filter(Boolean) + : []; + + if (redirect_url && allowedDomains.length > 0 && !Sep24Service.validateCallbackUrl(redirect_url, allowedDomains)) { + return res.status(400).json({ error: 'invalid redirect_url domain' }); + } + + if (on_change_callback && allowedDomains.length > 0 && !Sep24Service.validateCallbackUrl(on_change_callback, allowedDomains)) { + return res.status(400).json({ error: 'invalid on_change_callback domain' }); + } if (!asset_code) { return res.status(400).json({ @@ -202,6 +249,10 @@ router.post('/transactions/withdraw/interactive', async (req: Request, res: Resp return res.status(400).json(invalidAccountResponse()); } + if (callback !== undefined && !isValidCallbackUrl(callback)) { + return res.status(400).json({ error: 'callback must be a valid HTTPS URL' }); + } + if (quote_id) { const quote = await prisma.quote.findUnique({ where: { id: quote_id } }); if (!quote) { diff --git a/backend/src/api/routes/sep38.route.test.ts b/backend/src/api/routes/sep38.route.test.ts index 698a64aa..8c851d7d 100644 --- a/backend/src/api/routes/sep38.route.test.ts +++ b/backend/src/api/routes/sep38.route.test.ts @@ -1,5 +1,25 @@ import request from 'supertest'; import express from 'express'; + +jest.mock('../../lib/prisma', () => ({ + __esModule: true, + default: { + quote: { + create: jest.fn().mockResolvedValue({ + id: 'mock-quote-id', + sellAsset: 'USDC', + buyAsset: 'XLM', + sellAmount: '100', + buyAmount: '833', + price: '8.33', + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + createdAt: new Date(), + updatedAt: new Date(), + }), + }, + }, +})); + import sep38Router from './sep38.route'; jest.mock('../controllers/sep38.controller', () => ({ @@ -13,6 +33,7 @@ jest.mock('../controllers/sep38.controller', () => ({ destination_asset: destinationAsset, destination_amount: sourceAsset.toUpperCase() === destinationAsset.toUpperCase() ? sourceAmount : sourceAsset === 'USDC' ? sourceAmount / 0.12 : sourceAmount * 0.12, price: sourceAsset === 'USDC' && destinationAsset === 'XLM' ? 8.33 : 0.12, + fee: sourceAmount <= 1000 ? sourceAmount * 0.003 : sourceAmount * 0.0005, expiration_time: Math.floor(Date.now() / 1000) + 60, context, cached: false, @@ -27,6 +48,7 @@ jest.mock('../controllers/sep38.controller', () => ({ destination_asset: destinationAsset, destination_amount: sourceAmount / 0.12, price: 8.33, + fee: sourceAmount <= 1000 ? sourceAmount * 0.003 : sourceAmount * 0.0005, expiration_time: Math.floor(Date.now() / 1000) + 300, context, })), @@ -192,8 +214,6 @@ describe('SEP-38 Price Quotes API', () => { describe('Price calculation accuracy', () => { it('should calculate correct cross rate', async () => { - // 1 USDC = 1 USD, 1 XLM = 0.12 USD - // So 1 USDC should equal approximately 8.33 XLM const response = await request(app) .get('/sep38/price') .query({ @@ -220,4 +240,31 @@ describe('SEP-38 Price Quotes API', () => { expect(typeof response.body.destination_amount).toBe('number'); }); }); + + describe('Dynamic fee calculation', () => { + it('returns a fee field on a price quote', async () => { + const response = await request(app) + .get('/sep38/price') + .query({ source_asset: 'USDC', source_amount: 100, destination_asset: 'XLM' }); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('fee'); + expect(typeof response.body.fee).toBe('number'); + expect(response.body.fee).toBeGreaterThanOrEqual(0); + }); + + it('applies a lower fee percent for large amounts', async () => { + const small = await request(app) + .get('/sep38/price') + .query({ source_asset: 'USDC', source_amount: 100, destination_asset: 'XLM' }); + + const large = await request(app) + .get('/sep38/price') + .query({ source_asset: 'USDC', source_amount: 200_000, destination_asset: 'XLM' }); + + const smallRate = small.body.fee / small.body.source_amount; + const largeRate = large.body.fee / large.body.source_amount; + expect(largeRate).toBeLessThan(smallRate); + }); + }); }); diff --git a/backend/src/api/routes/transactions.route.ts b/backend/src/api/routes/transactions.route.ts index 29b65b4f..36c89f2a 100644 --- a/backend/src/api/routes/transactions.route.ts +++ b/backend/src/api/routes/transactions.route.ts @@ -142,9 +142,9 @@ router.get('/', authMiddleware, validate({ query: querySchema }), async (req: Au let matchingTxHashes: string[] = []; if (eventSearchClauses.length > 0) { - const eventRows = await prisma.$queryRawUnsafe<{ txHash: string }[]>( + const eventRows = (await prisma.$queryRawUnsafe( `SELECT DISTINCT txHash FROM "ContractEvent" WHERE ${eventSearchClauses.join(' AND ')}` - ); + )) as { txHash: string }[]; matchingTxHashes = eventRows.map((row: { txHash: string }) => row.txHash).filter(Boolean); if (matchingTxHashes.length === 0) { diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 54457945..7cee1939 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import dotenv from 'dotenv'; +import { validateSep38QuotesCacheConfig } from './sep38-quotes-cache.config'; dotenv.config(); @@ -96,11 +97,53 @@ const envSchema = z.object({ .pipe(z.number().int().min(5).max(60)), ANCHOR_PUBLIC_KEY: z.string().optional(), // For SEP-10 challenges ANCHOR_SECRET_KEY: z.string().optional(), // For SEP-10 challenges + REGISTRY_CONTRACT_ID: z.string().optional(), // Registry contract address SEP12_MAX_FILE_SIZE_MB: z .string() .default('20') .transform((val: string) => parseInt(val, 10)) .pipe(z.number().int().positive()), + SEP38_INDICATIVE_QUOTE_EXPIRATION_SECONDS: z + .string() + .default('60') + .transform((val: string) => parseInt(val, 10)) + .pipe(z.number().int().min(15).max(300)), + SEP38_FIRM_QUOTE_VALIDITY_SECONDS: z + .string() + .default('300') + .transform((val: string) => parseInt(val, 10)) + .pipe(z.number().int().min(60).max(3600)), + SEP38_QUOTE_CACHE_TTL_SECONDS: z + .string() + .default('30') + .transform((val: string) => parseInt(val, 10)) + .pipe(z.number().int().min(5).max(300)), + SEP38_QUOTE_CACHE_STALE_TTL_SECONDS: z + .string() + .default('30') + .transform((val: string) => parseInt(val, 10)) + .pipe(z.number().int().min(0).max(300)), + SEP38_ASSETS_CACHE_TTL_SECONDS: z + .string() + .default('3600') + .transform((val: string) => parseInt(val, 10)) + .pipe(z.number().int().min(60).max(86400)), +}).superRefine((data, ctx) => { + if ( + !validateSep38QuotesCacheConfig({ + indicativeQuoteExpirationSeconds: data.SEP38_INDICATIVE_QUOTE_EXPIRATION_SECONDS, + firmQuoteValiditySeconds: data.SEP38_FIRM_QUOTE_VALIDITY_SECONDS, + quoteCacheTtlSeconds: data.SEP38_QUOTE_CACHE_TTL_SECONDS, + quoteCacheStaleTtlSeconds: data.SEP38_QUOTE_CACHE_STALE_TTL_SECONDS, + assetsCacheTtlSeconds: data.SEP38_ASSETS_CACHE_TTL_SECONDS, + }) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid SEP-38 quotes cache timeout settings', + path: ['SEP38_QUOTE_CACHE_TTL_SECONDS'], + }); + } }); const parsed = envSchema.safeParse({ diff --git a/backend/src/config/sep38-quotes-cache.config.test.ts b/backend/src/config/sep38-quotes-cache.config.test.ts new file mode 100644 index 00000000..898e4972 --- /dev/null +++ b/backend/src/config/sep38-quotes-cache.config.test.ts @@ -0,0 +1,53 @@ +import { + buildQuoteExpirationTime, + defaultSep38QuotesCacheConfig, + isQuoteExpired, + validateSep38QuotesCacheConfig, +} from './sep38-quotes-cache.config'; + +describe('SEP-38 quotes cache configuration', () => { + it('accepts the default cache timeout settings', () => { + expect(validateSep38QuotesCacheConfig(defaultSep38QuotesCacheConfig)).toBe(true); + }); + + it('rejects cache TTL that exceeds indicative quote expiration', () => { + expect( + validateSep38QuotesCacheConfig({ + ...defaultSep38QuotesCacheConfig, + quoteCacheTtlSeconds: 90, + }), + ).toBe(false); + }); + + it('rejects stale TTL that pushes total cache lifetime past quote expiration', () => { + expect( + validateSep38QuotesCacheConfig({ + ...defaultSep38QuotesCacheConfig, + quoteCacheStaleTtlSeconds: 45, + }), + ).toBe(false); + }); + + it('rejects firm quote validity shorter than indicative expiration', () => { + expect( + validateSep38QuotesCacheConfig({ + ...defaultSep38QuotesCacheConfig, + firmQuoteValiditySeconds: 30, + }), + ).toBe(false); + }); + + it('detects expired quotes by expiration_time', () => { + const now = 1_700_000_000; + + expect(isQuoteExpired({ expiration_time: now - 1 }, now)).toBe(true); + expect(isQuoteExpired({ expiration_time: now }, now)).toBe(true); + expect(isQuoteExpired({ expiration_time: now + 1 }, now)).toBe(false); + }); + + it('builds quote expiration timestamps from configured seconds', () => { + const nowMs = 1_700_000_000_000; + + expect(buildQuoteExpirationTime(60, nowMs)).toBe(Math.floor(nowMs / 1000) + 60); + }); +}); diff --git a/backend/src/config/sep38-quotes-cache.config.ts b/backend/src/config/sep38-quotes-cache.config.ts new file mode 100644 index 00000000..7d4c5eae --- /dev/null +++ b/backend/src/config/sep38-quotes-cache.config.ts @@ -0,0 +1,94 @@ +import { config } from './env'; + +export interface Sep38QuotesCacheConfig { + indicativeQuoteExpirationSeconds: number; + firmQuoteValiditySeconds: number; + quoteCacheTtlSeconds: number; + quoteCacheStaleTtlSeconds: number; + assetsCacheTtlSeconds: number; +} + +export const defaultSep38QuotesCacheConfig: Sep38QuotesCacheConfig = { + indicativeQuoteExpirationSeconds: 60, + firmQuoteValiditySeconds: 300, + quoteCacheTtlSeconds: 30, + quoteCacheStaleTtlSeconds: 30, + assetsCacheTtlSeconds: 3600, +}; + +/** + * Validates SEP-38 quote cache timeout settings. + * Cache layers must not outlive indicative quote expiration. + */ +export function validateSep38QuotesCacheConfig( + cacheConfig: Sep38QuotesCacheConfig, +): boolean { + const { + indicativeQuoteExpirationSeconds, + firmQuoteValiditySeconds, + quoteCacheTtlSeconds, + quoteCacheStaleTtlSeconds, + assetsCacheTtlSeconds, + } = cacheConfig; + + if ( + indicativeQuoteExpirationSeconds < 15 || + indicativeQuoteExpirationSeconds > 300 + ) { + return false; + } + + if (firmQuoteValiditySeconds < 60 || firmQuoteValiditySeconds > 3600) { + return false; + } + + if (quoteCacheTtlSeconds < 5 || quoteCacheTtlSeconds > indicativeQuoteExpirationSeconds) { + return false; + } + + if (quoteCacheStaleTtlSeconds < 0) { + return false; + } + + if (quoteCacheTtlSeconds + quoteCacheStaleTtlSeconds > indicativeQuoteExpirationSeconds) { + return false; + } + + if (firmQuoteValiditySeconds < indicativeQuoteExpirationSeconds) { + return false; + } + + if (assetsCacheTtlSeconds < 60 || assetsCacheTtlSeconds > 86400) { + return false; + } + + return true; +} + +export function getSep38QuotesCacheConfig(): Sep38QuotesCacheConfig { + const cacheConfig: Sep38QuotesCacheConfig = { + indicativeQuoteExpirationSeconds: config.SEP38_INDICATIVE_QUOTE_EXPIRATION_SECONDS, + firmQuoteValiditySeconds: config.SEP38_FIRM_QUOTE_VALIDITY_SECONDS, + quoteCacheTtlSeconds: config.SEP38_QUOTE_CACHE_TTL_SECONDS, + quoteCacheStaleTtlSeconds: config.SEP38_QUOTE_CACHE_STALE_TTL_SECONDS, + assetsCacheTtlSeconds: config.SEP38_ASSETS_CACHE_TTL_SECONDS, + }; + + if (!validateSep38QuotesCacheConfig(cacheConfig)) { + throw new Error('Invalid SEP-38 quotes cache configuration'); + } + + return cacheConfig; +} + +export function isQuoteExpired(quote: { expiration_time: number }, nowSeconds?: number): boolean { + const now = nowSeconds ?? Math.floor(Date.now() / 1000); + return quote.expiration_time <= now; +} + +export function buildQuoteExpirationTime( + expirationSeconds: number, + nowMs: number = Date.now(), +): number { + return Math.floor(nowMs / 1000) + expirationSeconds; +} diff --git a/backend/src/index.test.ts b/backend/src/index.test.ts index d314df1e..8b870979 100644 --- a/backend/src/index.test.ts +++ b/backend/src/index.test.ts @@ -1,9 +1,15 @@ import request from 'supertest'; +import prisma from './lib/prisma'; +import { redis } from './lib/redis'; jest.mock('./lib/prisma', () => ({ - transaction: { - findMany: jest.fn(), - count: jest.fn() + __esModule: true, + default: { + transaction: { + findMany: jest.fn(), + count: jest.fn() + }, + $queryRaw: jest.fn() } })); @@ -20,10 +26,40 @@ const app = require('./index').default; describe('Backend API', () => { - it('should return UP on health check', async () => { + beforeEach(() => { + jest.clearAllMocks(); + (prisma.$queryRaw as jest.Mock).mockResolvedValue([{ '?column?': 1 }]); + if (typeof redis.ping === 'function') { + jest.spyOn(redis, 'ping').mockResolvedValue('PONG'); + } + }); + + it('should return UP on health check when all services are healthy', async () => { const res = await request(app).get('/health'); expect(res.statusCode).toEqual(200); expect(res.body.status).toEqual('UP'); + expect(res.body.services.database).toEqual('UP'); + expect(res.body.services.redis).toEqual('UP'); + }); + + it('should return DOWN on health check when database is down', async () => { + (prisma.$queryRaw as jest.Mock).mockRejectedValue(new Error('DB Connection Refused')); + + const res = await request(app).get('/health'); + expect(res.statusCode).toEqual(503); + expect(res.body.status).toEqual('DOWN'); + expect(res.body.services.database).toEqual('DOWN'); + expect(res.body.services.redis).toEqual('UP'); + }); + + it('should return DOWN on health check when Redis is down', async () => { + jest.spyOn(redis, 'ping').mockRejectedValue(new Error('Redis Timeout')); + + const res = await request(app).get('/health'); + expect(res.statusCode).toEqual(503); + expect(res.body.status).toEqual('DOWN'); + expect(res.body.services.database).toEqual('UP'); + expect(res.body.services.redis).toEqual('DOWN'); }); it('should return 200 on root access', async () => { diff --git a/backend/src/index.ts b/backend/src/index.ts index d0592938..0b9bcce0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -17,6 +17,7 @@ import relayerRouter from './api/routes/relayer.route'; import recurringPaymentsRouter from './api/routes/recurring-payments.route'; import configRouter from './api/routes/config.route'; import sep31Router from './api/routes/sep31.route'; +import authRouter from './api/routes/auth.route'; import { errorHandler } from './api/middleware/error.middleware'; import { metricsMiddleware, connectionTracker } from './api/middleware/metrics.middleware'; import { securityHeadersMiddleware } from './api/middleware/security-headers.middleware'; @@ -25,12 +26,16 @@ import feeReportRouter from './api/routes/fee-report.route'; import { feeReportScheduler } from './workers/fee-report.scheduler'; import eventRouter from './api/routes/event.route'; import notificationsRouter from './api/routes/notifications.route'; -import { publicLimiter } from './api/middleware/rate-limit.middleware'; +import { publicLimiter, authLimiter } from './api/middleware/rate-limit.middleware'; import { notificationService } from './services/notification.service'; import { createEmailProvider, ConsoleSmsProvider, ConsolePushProvider } from './lib/notifications/providers'; import { NotificationType } from './services/notification.service'; import { validateKmsConfigOnStartup } from './lib/key-management.service'; import queueDashboardRouter from './api/routes/queue-dashboard.route'; +import prisma from './lib/prisma'; +import { redis } from './lib/redis'; +import { validateStorageConfigOnStartup } from './services/storage-provider.service'; +import { uploadExpiryScheduler } from './workers/upload-expiry.scheduler'; // Initialize Notification Engine notificationService.registerProvider(NotificationType.EMAIL, createEmailProvider()); @@ -78,11 +83,11 @@ app.get('/', (req: Request, res: Response) => { * /health: * get: * summary: Health check - * description: Check if the API server is running + * description: Check if the API server and its backend dependencies (database, Redis) are running * tags: [Health] * responses: * 200: - * description: Server is healthy + * description: Server and all dependencies are healthy * content: * application/json: * schema: @@ -94,9 +99,77 @@ app.get('/', (req: Request, res: Response) => { * timestamp: * type: string * format: date-time + * services: + * type: object + * properties: + * database: + * type: string + * example: UP + * redis: + * type: string + * example: UP + * 503: + * description: One or more backend dependencies are down + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: DOWN + * timestamp: + * type: string + * format: date-time + * services: + * type: object + * properties: + * database: + * type: string + * example: DOWN + * redis: + * type: string + * example: UP */ -app.get('/health', (req: Request, res: Response) => { - res.json({ status: 'UP', timestamp: new Date().toISOString() }); +app.get('/health', async (req: Request, res: Response) => { + let dbStatus = 'UP'; + let redisStatus = 'UP'; + let isHealthy = true; + + try { + await prisma.$queryRaw`SELECT 1`; + } catch (err) { + dbStatus = 'DOWN'; + isHealthy = false; + logger.error('Health Check - Database connection failed:', err); + } + + try { + const pong = await redis.ping(); + if (pong !== 'PONG') { + redisStatus = 'DOWN'; + isHealthy = false; + } + } catch (err) { + redisStatus = 'DOWN'; + isHealthy = false; + logger.error('Health Check - Redis connection failed:', err); + } + + const responsePayload = { + status: isHealthy ? 'UP' : 'DOWN', + timestamp: new Date().toISOString(), + services: { + database: dbStatus, + redis: redisStatus, + }, + }; + + if (!isHealthy) { + return res.status(503).json(responsePayload); + } + + return res.status(200).json(responsePayload); }); // Swagger API Documentation @@ -144,6 +217,9 @@ app.use('/api/relayer', relayerRouter); // SEP-40 Swap Rates API app.use('/sep40', sep40Router); +// SEP-10 Auth routes +app.use('/sep10', authLimiter, authRouter); + // SEP-12 KYC routes app.use('/sep12', sep12Router); @@ -166,6 +242,7 @@ app.use(errorHandler); /* istanbul ignore next */ if (process.env.NODE_ENV !== 'test') { validateKmsConfigOnStartup(config); + validateStorageConfigOnStartup(); configService.initialize() .catch((error) => { @@ -176,6 +253,7 @@ if (process.env.NODE_ENV !== 'test') { logger.info(`Backend service listening at http://localhost:${PORT}`); logger.info(`API Documentation available at http://localhost:${PORT}/api-docs`); feeReportScheduler.start(); + uploadExpiryScheduler.start(); }); }); } diff --git a/backend/src/lib/db.service.test.ts b/backend/src/lib/db.service.test.ts new file mode 100644 index 00000000..c837fe61 --- /dev/null +++ b/backend/src/lib/db.service.test.ts @@ -0,0 +1,8 @@ +import prisma from './db.service'; + +describe('Database Service', () => { + it('exports a Prisma client', () => { + expect(prisma).toBeDefined(); + expect(prisma.$connect).toEqual(expect.any(Function)); + }); +}); diff --git a/backend/src/lib/redis.ts b/backend/src/lib/redis.ts index 73f527e3..d9ee8f32 100644 --- a/backend/src/lib/redis.ts +++ b/backend/src/lib/redis.ts @@ -30,6 +30,7 @@ export const redis = isTest set: createNoop<(key: string, value: string) => Promise<'OK'>>(Promise.resolve('OK')), del: createNoop<(key: string) => Promise>(Promise.resolve(1)), publish: createNoop<(channel: string, message: string) => Promise>(Promise.resolve(1)), + ping: createNoop<() => Promise>(Promise.resolve('PONG')), } as any) diff --git a/backend/src/sep31/validation.ts b/backend/src/sep31/validation.ts index 78b1952b..16cf96f6 100644 --- a/backend/src/sep31/validation.ts +++ b/backend/src/sep31/validation.ts @@ -88,6 +88,38 @@ export function validateTransactionRequest( }); } + // ── sender ≠ receiver ───────────────────────────────────────────────── + if (body.sender_id && body.receiver_id && body.sender_id === body.receiver_id) { + errors.push({ + field: "receiver_id", + message: "sender_id and receiver_id must refer to different customers.", + }); + } + + if ( + body.sender_info && + body.receiver_info && + !body.sender_id && + !body.receiver_id + ) { + const senderKey = [ + body.sender_info.first_name?.trim().toLowerCase(), + body.sender_info.last_name?.trim().toLowerCase(), + body.sender_info.email?.trim().toLowerCase(), + ].join('|'); + const receiverKey = [ + body.receiver_info.first_name?.trim().toLowerCase(), + body.receiver_info.last_name?.trim().toLowerCase(), + body.receiver_info.email?.trim().toLowerCase(), + ].join('|'); + if (senderKey === receiverKey && senderKey !== '||') { + errors.push({ + field: "receiver_info", + message: "sender_info and receiver_info must refer to different customers.", + }); + } + } + // ── memo_type ────────────────────────────────────────────────────────── if (body.memo_type && !VALID_MEMO_TYPES.has(body.memo_type)) { errors.push({ diff --git a/backend/src/services/admin-email.service.test.ts b/backend/src/services/admin-email.service.test.ts new file mode 100644 index 00000000..3d55c85e --- /dev/null +++ b/backend/src/services/admin-email.service.test.ts @@ -0,0 +1,124 @@ +/** + * #541 — Mock SMTP server for local backend testing + * + * Uses nodemailer-mock to intercept outbound email and assert + * on the message structure sent by SmtpAdminEmailService. + */ + +const sentMail: any[] = []; +const mockNodemailerMock = { + createTransport: jest.fn().mockImplementation((opts) => { + return { + sendMail: jest.fn().mockImplementation((mailOptions) => { + sentMail.push(mailOptions); + return Promise.resolve({ messageId: 'mock-id' }); + }), + }; + }), + mock: { + reset: () => { + sentMail.length = 0; + }, + getSentMail: () => { + return sentMail; + }, + }, +}; + +// Mock nodemailer and nodemailer-mock +jest.mock('nodemailer', () => mockNodemailerMock); +jest.mock('nodemailer-mock', () => mockNodemailerMock); + +// Mock the environment config to have SMTP settings +jest.mock('../config/env', () => { + const original = jest.requireActual('../config/env'); + return { + ...original, + config: { + ...original.config, + SMTP_HOST: 'localhost', + SMTP_PORT: 587, + SMTP_FROM: 'no-reply@anchorpoint.test', + ADMIN_PASSWORD_RESET_URL_BASE: 'http://localhost:3000/admin/reset-password', + }, + }; +}); + +process.env.SMTP_HOST = 'localhost'; +process.env.SMTP_PORT = '587'; +process.env.SMTP_FROM = 'no-reply@anchorpoint.test'; + +import nodemailerMock from 'nodemailer-mock'; +import { SmtpAdminEmailService } from './admin-email.service'; + +describe('SmtpAdminEmailService (nodemailer-mock)', () => { + let service: SmtpAdminEmailService; + + beforeEach(() => { + nodemailerMock.mock.reset(); + service = new SmtpAdminEmailService(); + }); + + describe('sendPasswordResetEmail', () => { + const baseInput = { + to: 'admin@example.com', + token: 'tok_abc123', + expiresAt: new Date('2025-01-01T12:00:00Z'), + }; + + it('sends exactly one email on signup/password-reset', async () => { + await service.sendPasswordResetEmail(baseInput); + + const sent = nodemailerMock.mock.getSentMail(); + expect(sent).toHaveLength(1); + }); + + it('addresses the email to the provided recipient', async () => { + await service.sendPasswordResetEmail(baseInput); + + const [mail] = nodemailerMock.mock.getSentMail(); + expect(mail.to).toBe('admin@example.com'); + }); + + it('uses the configured from address', async () => { + await service.sendPasswordResetEmail(baseInput); + + const [mail] = nodemailerMock.mock.getSentMail(); + expect(mail.from).toBe('no-reply@anchorpoint.test'); + }); + + it('includes the password-reset token in the plain-text body', async () => { + await service.sendPasswordResetEmail(baseInput); + + const [mail] = nodemailerMock.mock.getSentMail(); + expect(typeof mail.text).toBe('string'); + expect(mail.text).toContain('tok_abc123'); + }); + + it('includes the reset URL in the HTML body', async () => { + await service.sendPasswordResetEmail(baseInput); + + const [mail] = nodemailerMock.mock.getSentMail(); + expect(typeof mail.html).toBe('string'); + expect(mail.html).toContain('http://localhost:3000/admin/reset-password'); + expect(mail.html).toContain('tok_abc123'); + }); + + it('includes the expiry timestamp in the email', async () => { + await service.sendPasswordResetEmail(baseInput); + + const [mail] = nodemailerMock.mock.getSentMail(); + expect(mail.text).toContain(baseInput.expiresAt.toISOString()); + }); + + it('sends a second email independently (withdrawal scenario)', async () => { + await service.sendPasswordResetEmail(baseInput); + await service.sendPasswordResetEmail({ ...baseInput, to: 'other@example.com', token: 'tok_withdraw' }); + + const sent = nodemailerMock.mock.getSentMail(); + expect(sent).toHaveLength(2); + expect(sent[1].to).toBe('other@example.com'); + expect(sent[1].text).toContain('tok_withdraw'); + }); + }); +}); diff --git a/backend/src/services/admin-password-reset.service.ts b/backend/src/services/admin-password-reset.service.ts index 15049b7d..3c0e5151 100644 --- a/backend/src/services/admin-password-reset.service.ts +++ b/backend/src/services/admin-password-reset.service.ts @@ -44,7 +44,7 @@ export class AdminPasswordResetService { const tokenHash = hashResetToken(rawToken); const expiresAt = new Date(Date.now() + config.PASSWORD_RESET_TTL_MINUTES * 60 * 1000); - await prisma.$transaction(async (tx) => { + await prisma.$transaction(async (tx: any) => { await tx.adminPasswordResetToken.updateMany({ where: { adminUserId: admin.id, @@ -96,7 +96,7 @@ export class AdminPasswordResetService { const passwordHash = await hashPassword(newPassword); const now = new Date(); - await prisma.$transaction(async (tx) => { + await prisma.$transaction(async (tx: any) => { await tx.adminUser.update({ where: { id: existingToken.adminUserId }, data: { passwordHash }, diff --git a/backend/src/services/multisig.service.ts b/backend/src/services/multisig.service.ts index 723cd1b6..1ca4b70e 100644 --- a/backend/src/services/multisig.service.ts +++ b/backend/src/services/multisig.service.ts @@ -253,16 +253,18 @@ class MultisigService { ): Promise { const transactions = await prisma.multisigTransaction.findMany({ where: { - requiredSigners: { - array_contains: signerPublicKey, - }, ...(status && { status }), }, include: { signatures: true }, orderBy: { createdAt: 'desc' }, }); - return transactions.map(tx => this.formatTransactionDetails(tx)); + return transactions + .filter(tx => { + const signers = tx.requiredSigners as any; + return Array.isArray(signers) && signers.includes(signerPublicKey); + }) + .map(tx => this.formatTransactionDetails(tx)); } /** @@ -271,9 +273,6 @@ class MultisigService { async getPendingForSigner(signerPublicKey: string): Promise { const transactions = await prisma.multisigTransaction.findMany({ where: { - requiredSigners: { - array_contains: signerPublicKey, - }, status: { in: [MultisigStatus.PENDING, MultisigStatus.PARTIALLY_SIGNED], }, @@ -288,7 +287,11 @@ class MultisigService { // Filter out transactions where the signer has already signed return transactions - .filter(tx => !tx.signatures.some(sig => sig.signerPublicKey === signerPublicKey)) + .filter(tx => { + const signers = tx.requiredSigners as any; + return Array.isArray(signers) && signers.includes(signerPublicKey); + }) + .filter(tx => !tx.signatures.some((sig: any) => sig.signerPublicKey === signerPublicKey)) .map(tx => this.formatTransactionDetails(tx)); } diff --git a/backend/src/services/registry.service.test.ts b/backend/src/services/registry.service.test.ts new file mode 100644 index 00000000..fefc4d3b --- /dev/null +++ b/backend/src/services/registry.service.test.ts @@ -0,0 +1,230 @@ +import { RegistryService } from './registry.service'; +import { AdvancedCacheService } from './advanced-cache.service'; +import { stellarService } from './stellar.service'; +import { redis } from '../lib/redis'; + +// Mock dependencies +jest.mock('./advanced-cache.service'); +jest.mock('./stellar.service'); +jest.mock('../lib/redis', () => ({ + redis: { + duplicate: () => ({ + subscribe: jest.fn(), + on: jest.fn(), + }), + on: jest.fn(), + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + publish: jest.fn(), + }, +})); + +const mockCacheService = AdvancedCacheService as jest.MockedClass; +const mockStellarService = stellarService as jest.Mocked; + +describe('RegistryService', () => { + let registryService: RegistryService; + let mockCache: jest.Mocked; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Create mock cache service + mockCache = { + cacheAside: jest.fn(), + invalidate: jest.fn(), + invalidatePattern: jest.fn(), + } as any; + + mockCacheService.mockImplementation(() => mockCache); + + // Reset the singleton instance to force re-creation with active mock + (RegistryService as any).instance = undefined; + + // Get instance of RegistryService + registryService = RegistryService.getInstance(); + }); + + describe('getContract', () => { + it('should return cached contract info when available', async () => { + const mockContractInfo = { + address: 'GABC123', + version: '1.0.0', + contractType: 'AMM', + deployedAt: 1234567890, + active: true, + previousVersion: null, + }; + + mockCache.cacheAside.mockResolvedValue({ + data: mockContractInfo, + fromCache: true, + }); + + const result = await registryService.getContract('AMM'); + + expect(result).toEqual(mockContractInfo); + expect(mockCache.cacheAside).toHaveBeenCalledWith( + 'registry:contract:AMM', + expect.any(Function), + expect.any(Object) + ); + }); + + it('should fetch fresh data when cache miss', async () => { + const mockContractInfo = { + address: 'GDEF456', + version: '2.0.0', + contractType: 'Lending', + deployedAt: 9876543210, + active: true, + previousVersion: 'GABC123', + }; + + mockCache.cacheAside.mockResolvedValue({ + data: mockContractInfo, + fromCache: false, + }); + + const result = await registryService.getContract('Lending'); + + expect(result).toEqual(mockContractInfo); + }); + }); + + describe('getAddress', () => { + it('should return contract address from cached info', async () => { + const mockAddress = 'GADDRESS123'; + const mockContractInfo = { + address: mockAddress, + version: '1.0.0', + contractType: 'Bridge', + deployedAt: 1234567890, + active: true, + previousVersion: null, + }; + + mockCache.cacheAside.mockResolvedValue({ + data: mockContractInfo, + fromCache: true, + }); + + const result = await registryService.getAddress('Bridge'); + + expect(result).toBe(mockAddress); + }); + }); + + describe('getVersion', () => { + it('should return contract version from cached info', async () => { + const mockVersion = '3.1.4'; + const mockContractInfo = { + address: 'GVER123', + version: mockVersion, + contractType: 'XLMWrapper', + deployedAt: 1234567890, + active: true, + previousVersion: null, + }; + + mockCache.cacheAside.mockResolvedValue({ + data: mockContractInfo, + fromCache: true, + }); + + const result = await registryService.getVersion('XLMWrapper'); + + expect(result).toBe(mockVersion); + }); + }); + + describe('isRegistered', () => { + it('should return true when contract is registered', async () => { + const mockContractInfo = { + address: 'GREG123', + version: '1.0.0', + contractType: 'Governance', + deployedAt: 1234567890, + active: true, + previousVersion: null, + }; + + mockCache.cacheAside.mockResolvedValue({ + data: mockContractInfo, + fromCache: true, + }); + + const result = await registryService.isRegistered('Governance'); + + expect(result).toBe(true); + }); + + it('should return false when contract is not registered', async () => { + mockCache.cacheAside.mockRejectedValue(new Error('Contract not found')); + + const result = await registryService.isRegistered('NonExistent'); + + expect(result).toBe(false); + }); + }); + + describe('isActive', () => { + it('should return true when contract is active', async () => { + const mockContractInfo = { + address: 'GACT123', + version: '1.0.0', + contractType: 'ActiveContract', + deployedAt: 1234567890, + active: true, + previousVersion: null, + }; + + mockCache.cacheAside.mockResolvedValue({ + data: mockContractInfo, + fromCache: true, + }); + + const result = await registryService.isActive('ActiveContract'); + + expect(result).toBe(true); + }); + + it('should return false when contract is inactive', async () => { + const mockContractInfo = { + address: 'GINACT123', + version: '1.0.0', + contractType: 'InactiveContract', + deployedAt: 1234567890, + active: false, + previousVersion: null, + }; + + mockCache.cacheAside.mockResolvedValue({ + data: mockContractInfo, + fromCache: true, + }); + + const result = await registryService.isActive('InactiveContract'); + + expect(result).toBe(false); + }); + }); + + describe('invalidateContractCache', () => { + it('should invalidate cache for a specific contract type', async () => { + await registryService.invalidateContractCache('AMM'); + + expect(mockCache.invalidate).toHaveBeenCalledWith('registry:contract:AMM'); + }); + }); + + describe('invalidateAllCache', () => { + it('should invalidate all registry cache', async () => { + await registryService.invalidateAllCache(); + + expect(mockCache.invalidatePattern).toHaveBeenCalledWith('registry:.*'); + }); + }); +}); diff --git a/backend/src/services/registry.service.ts b/backend/src/services/registry.service.ts new file mode 100644 index 00000000..4363418a --- /dev/null +++ b/backend/src/services/registry.service.ts @@ -0,0 +1,161 @@ +import { Address, Contract, rpc, xdr, TransactionBuilder, Account, scValToNative } from '@stellar/stellar-sdk'; +import { config } from '../config/env'; +import { redis } from '../lib/redis'; +import { AdvancedCacheService } from './advanced-cache.service'; +import { stellarService } from './stellar.service'; +import logger from '../utils/logger'; + +export interface ContractInfo { + address: string; + version: string; + contractType: string; + deployedAt: number; + active: boolean; + previousVersion: string | null; +} + +export class RegistryService { + private static instance: RegistryService; + private cacheService: AdvancedCacheService; + private registryContractId: string; + + private constructor() { + this.registryContractId = config.REGISTRY_CONTRACT_ID || ''; + this.cacheService = new AdvancedCacheService(redis, { + l1MaxSize: 100, + l1TtlSeconds: 60, + l2TtlSeconds: 300, + staleWhileRevalidateTtlSeconds: 60, + }); + } + + public static getInstance(): RegistryService { + if (!RegistryService.instance) { + RegistryService.instance = new RegistryService(); + } + return RegistryService.instance; + } + + private getContractClient(): Contract { + return new Contract(this.registryContractId); + } + + /** + * Get contract information by type with caching + */ + public async getContract(contractType: string): Promise { + const cacheKey = `registry:contract:${contractType}`; + + const result = await this.cacheService.cacheAside( + cacheKey, + async () => { + logger.debug(`Fetching contract info from registry for type: ${contractType}`); + const rpcServer = stellarService.getSorobanRpc(); + const contract = this.getContractClient(); + + // Build the contract call + const sourceAccount = new Account(config.ANCHOR_PUBLIC_KEY || 'GBZXN7PIRZGNMHGA7MUUUF4GW3F55GQRQ5UKMJTDEFEKTGW4RHFDQLNZ', '0'); + const tx = new TransactionBuilder( + sourceAccount, + { + fee: '100', + networkPassphrase: stellarService.getPassphrase(), + } + ) + .addOperation( + contract.call('get_contract', xdr.ScVal.scvString(contractType)) + ) + .setTimeout(30) + .build(); + + const simulatedTx = (await rpcServer.simulateTransaction(tx)) as any; + if (simulatedTx.error) { + throw new Error(`Failed to simulate transaction: ${simulatedTx.error}`); + } + + if (!simulatedTx.result?.retval) { + throw new Error('No result returned from contract'); + } + + return this.parseContractInfo(simulatedTx.result.retval); + }, + { + ttlSeconds: 60, + staleWhileRevalidate: true, + staleTtlSeconds: 30, + } + ); + + return result.data; + } + + /** + * Get contract address by type with caching + */ + public async getAddress(contractType: string): Promise { + const info = await this.getContract(contractType); + return info.address; + } + + /** + * Get contract version by type with caching + */ + public async getVersion(contractType: string): Promise { + const info = await this.getContract(contractType); + return info.version; + } + + /** + * Check if contract is registered (cached) + */ + public async isRegistered(contractType: string): Promise { + try { + await this.getContract(contractType); + return true; + } catch { + return false; + } + } + + /** + * Check if contract is active (cached) + */ + public async isActive(contractType: string): Promise { + const info = await this.getContract(contractType); + return info.active; + } + + /** + * Invalidate cache for a specific contract type + */ + public async invalidateContractCache(contractType: string): Promise { + const cacheKey = `registry:contract:${contractType}`; + await this.cacheService.invalidate(cacheKey); + logger.debug(`Invalidated cache for contract type: ${contractType}`); + } + + /** + * Invalidate all registry cache + */ + public async invalidateAllCache(): Promise { + await this.cacheService.invalidatePattern('registry:.*'); + logger.debug('Invalidated all registry cache'); + } + + private parseContractInfo(scVal: xdr.ScVal): ContractInfo { + const native = scValToNative(scVal); + if (!native || typeof native !== 'object') { + throw new Error('Invalid contract info format'); + } + return { + address: native.address, + version: native.version, + contractType: native.contract_type, + deployedAt: Number(native.deployed_at), + active: Boolean(native.active), + previousVersion: native.previous_version || null, + }; + } +} + +export const registryService = RegistryService.getInstance(); diff --git a/backend/src/services/relayer.service.ts b/backend/src/services/relayer.service.ts index 256445cd..51407cee 100644 --- a/backend/src/services/relayer.service.ts +++ b/backend/src/services/relayer.service.ts @@ -222,8 +222,14 @@ export class RelayerService { success: true, transactionHash: result.hash, }; - } catch (error) { + } catch (error: any) { logger.error('Transaction submission error:', error); + if (error?.response?.data?.extras) { + logger.error('Horizon error details:', { + resultCodes: error.response.data.extras.result_codes, + xdr: error.response.data.extras.envelope_xdr, + }); + } return { success: false, error: error instanceof Error ? error.message : 'Submission failed', diff --git a/backend/src/services/sep24-metrics.service.test.ts b/backend/src/services/sep24-metrics.service.test.ts new file mode 100644 index 00000000..2aead234 --- /dev/null +++ b/backend/src/services/sep24-metrics.service.test.ts @@ -0,0 +1,31 @@ +import { metricsService } from './metrics.service'; +import { recordSep24InteractionRequest } from './sep24-metrics.service'; + +describe('SEP-24 metrics', () => { + beforeEach(() => { + metricsService.reset(); + }); + + it('registers interaction request counter and duration histogram', async () => { + const metrics = await metricsService.getMetrics(); + + expect(metrics).toContain('sep24_interaction_requests_total'); + expect(metrics).toContain('sep24_interaction_endpoint_duration_seconds'); + }); + + it('records interaction endpoint duration and request count', async () => { + recordSep24InteractionRequest( + '/transactions/deposit/interactive', + 'POST', + 200, + 0.125, + ); + + const metrics = await metricsService.getMetrics(); + + expect(metrics).toContain('endpoint="/transactions/deposit/interactive"'); + expect(metrics).toContain('method="POST"'); + expect(metrics).toContain('status_code="200"'); + expect(metrics).toContain('sep24_interaction_endpoint_duration_seconds_count'); + }); +}); diff --git a/backend/src/services/sep24-metrics.service.ts b/backend/src/services/sep24-metrics.service.ts new file mode 100644 index 00000000..c62780cc --- /dev/null +++ b/backend/src/services/sep24-metrics.service.ts @@ -0,0 +1,33 @@ +import promClient, { Counter, Histogram } from 'prom-client'; +import { metricsService } from './metrics.service'; + +const sep24InteractionRequestsTotal = new Counter({ + name: 'sep24_interaction_requests_total', + help: 'Total number of SEP-24 interactive endpoint requests', + labelNames: ['endpoint', 'method', 'status_code'] as const, + registers: [metricsService.getRegistry()], +}); + +const sep24InteractionEndpointDuration = new Histogram({ + name: 'sep24_interaction_endpoint_duration_seconds', + help: 'Duration of SEP-24 interactive endpoint requests in seconds', + labelNames: ['endpoint', 'method', 'status_code'] as const, + buckets: [0.01, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10], + registers: [metricsService.getRegistry()], +}); + +export function recordSep24InteractionRequest( + endpoint: string, + method: string, + statusCode: number, + durationSeconds: number, +): void { + const labels = { + endpoint, + method, + status_code: String(statusCode), + }; + + sep24InteractionRequestsTotal.inc(labels); + sep24InteractionEndpointDuration.observe(labels, durationSeconds); +} diff --git a/backend/src/services/sep24.service.test.ts b/backend/src/services/sep24.service.test.ts new file mode 100644 index 00000000..964a58e2 --- /dev/null +++ b/backend/src/services/sep24.service.test.ts @@ -0,0 +1,42 @@ +import { Sep24Service } from './sep24.service'; + +describe('Sep24Service', () => { + describe('validateCallbackUrl', () => { + it('returns false for empty url', () => { + expect(Sep24Service.validateCallbackUrl('', ['example.com'])).toBe(false); + }); + + it('returns false for invalid url string', () => { + expect(Sep24Service.validateCallbackUrl('not-a-url', ['example.com'])).toBe(false); + }); + + it('returns false for non http/https protocols', () => { + expect(Sep24Service.validateCallbackUrl('ftp://example.com', ['example.com'])).toBe(false); + expect(Sep24Service.validateCallbackUrl('javascript:alert(1)', ['example.com'])).toBe(false); + }); + + it('returns true if allowedDomains is empty', () => { + expect(Sep24Service.validateCallbackUrl('https://malicious.com', [])).toBe(true); + }); + + it('returns true if domain exactly matches a whitelist entry', () => { + expect(Sep24Service.validateCallbackUrl('https://example.com/callback', ['example.com'])).toBe(true); + }); + + it('returns true if domain is a subdomain of a whitelist entry', () => { + expect(Sep24Service.validateCallbackUrl('https://sub.example.com/callback', ['example.com'])).toBe(true); + }); + + it('returns false if domain does not match whitelist', () => { + expect(Sep24Service.validateCallbackUrl('https://malicious.com/callback', ['example.com'])).toBe(false); + expect(Sep24Service.validateCallbackUrl('https://example.com.malicious.com/callback', ['example.com'])).toBe(false); + }); + + it('handles multiple allowed domains and case insensitivity', () => { + const allowed = ['example.com', 'Wallet.org']; + expect(Sep24Service.validateCallbackUrl('https://Example.COM/cb', allowed)).toBe(true); + expect(Sep24Service.validateCallbackUrl('https://my.wallet.ORG/cb', allowed)).toBe(true); + expect(Sep24Service.validateCallbackUrl('https://other.org/cb', allowed)).toBe(false); + }); + }); +}); diff --git a/backend/src/services/sep24.service.ts b/backend/src/services/sep24.service.ts new file mode 100644 index 00000000..b292e6d8 --- /dev/null +++ b/backend/src/services/sep24.service.ts @@ -0,0 +1,36 @@ +import { URL } from 'url'; + +export class Sep24Service { + /** + * Validates a callback or redirect URL against a whitelist of allowed domains. + * + * @param url The URL to validate. + * @param allowedDomains Array of allowed hostnames (e.g., ['example.com']). + * @returns boolean true if valid, false if invalid or not allowed. + */ + public static validateCallbackUrl(url: string, allowedDomains: string[]): boolean { + if (!url) return false; + + try { + const parsedUrl = new URL(url); + + if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { + return false; + } + + if (!allowedDomains || allowedDomains.length === 0) { + return true; // If no whitelist is defined, allow (or you can restrict default) + } + + const hostname = parsedUrl.hostname.toLowerCase(); + + return allowedDomains.some((domain) => { + const d = domain.trim().toLowerCase(); + if (!d) return false; + return hostname === d || hostname.endsWith(`.${d}`); + }); + } catch { + return false; // Invalid URL format + } + } +} diff --git a/backend/src/services/storage-provider.service.test.ts b/backend/src/services/storage-provider.service.test.ts new file mode 100644 index 00000000..2dca90df --- /dev/null +++ b/backend/src/services/storage-provider.service.test.ts @@ -0,0 +1,225 @@ +import { + createStorageProvider, + GcsStorageProvider, + MockStorageProvider, + S3StorageProvider, + StorageProviderError, + storageConfigFromEnv, + validateStorageProviderConfig, + validateStorageConfigOnStartup, +} from './storage-provider.service'; +import logger from '../utils/logger'; + +jest.mock('../utils/logger', () => ({ + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), +})); + +describe('validateStorageConfigOnStartup', () => { + const originalEnv = process.env; + let exitMock: jest.SpyInstance; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + exitMock = jest.spyOn(process, 'exit').mockImplementation((() => {}) as any); + (logger.error as jest.Mock).mockClear(); + }); + + afterEach(() => { + process.env = originalEnv; + exitMock.mockRestore(); + }); + + it('fails if STORAGE_PROVIDER is missing', () => { + delete process.env.STORAGE_PROVIDER; + validateStorageConfigOnStartup(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('STORAGE_PROVIDER environment variable is missing')); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + it('fails if STORAGE_PROVIDER is invalid', () => { + process.env.STORAGE_PROVIDER = 'invalid-provider'; + validateStorageConfigOnStartup(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Invalid STORAGE_PROVIDER: "invalid-provider"')); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + it('fails if STORAGE_BUCKET is missing', () => { + process.env.STORAGE_PROVIDER = 's3'; + delete process.env.STORAGE_BUCKET; + validateStorageConfigOnStartup(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('STORAGE_BUCKET environment variable is missing')); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + it('fails if S3 keys are missing', () => { + process.env.STORAGE_PROVIDER = 's3'; + process.env.STORAGE_BUCKET = 'my-bucket'; + delete process.env.STORAGE_REGION; + validateStorageConfigOnStartup(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Missing required S3 configuration keys')); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + it('fails if GCS credentials are missing', () => { + process.env.STORAGE_PROVIDER = 'gcs'; + process.env.STORAGE_BUCKET = 'my-bucket'; + delete process.env.GOOGLE_APPLICATION_CREDENTIALS; + validateStorageConfigOnStartup(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Missing required GCS configuration key')); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + it('succeeds for valid S3 config', () => { + process.env.STORAGE_PROVIDER = 's3'; + process.env.STORAGE_BUCKET = 'my-bucket'; + process.env.STORAGE_REGION = 'us-east-1'; + process.env.AWS_ACCESS_KEY_ID = 'key'; + process.env.AWS_SECRET_ACCESS_KEY = 'secret'; + validateStorageConfigOnStartup(); + expect(exitMock).not.toHaveBeenCalled(); + }); + + it('succeeds for valid GCS config', () => { + process.env.STORAGE_PROVIDER = 'gcs'; + process.env.STORAGE_BUCKET = 'my-bucket'; + process.env.GOOGLE_APPLICATION_CREDENTIALS = '/path/to/key.json'; + validateStorageConfigOnStartup(); + expect(exitMock).not.toHaveBeenCalled(); + }); +}); + +describe('StorageProvider initialization', () => { + describe('validateStorageProviderConfig', () => { + it('accepts a valid mock configuration', () => { + expect(validateStorageProviderConfig({ provider: 'mock', bucket: 'test-bucket' })).toEqual({ + provider: 'mock', + bucket: 'test-bucket', + region: undefined, + }); + }); + + it('accepts a valid S3 configuration', () => { + expect( + validateStorageProviderConfig({ provider: 's3', bucket: 'kyc-bucket', region: 'us-east-1' }) + ).toEqual({ + provider: 's3', + bucket: 'kyc-bucket', + region: 'us-east-1', + }); + }); + + it('accepts a valid GCS configuration', () => { + expect(validateStorageProviderConfig({ provider: 'gcs', bucket: 'kyc-bucket' })).toEqual({ + provider: 'gcs', + bucket: 'kyc-bucket', + region: undefined, + }); + }); + + it('rejects missing provider', () => { + expect(() => validateStorageProviderConfig({ bucket: 'b' })).toThrow(StorageProviderError); + expect(() => validateStorageProviderConfig({ bucket: 'b' })).toThrow('STORAGE_PROVIDER is required'); + }); + + it('rejects unsupported provider', () => { + expect(() => + validateStorageProviderConfig({ provider: 'azure' as 'mock', bucket: 'b' }) + ).toThrow('Unsupported STORAGE_PROVIDER: azure'); + }); + + it('rejects missing bucket', () => { + expect(() => validateStorageProviderConfig({ provider: 'mock', bucket: '' })).toThrow( + 'STORAGE_BUCKET is required' + ); + }); + + it('rejects S3 configuration without region', () => { + expect(() => validateStorageProviderConfig({ provider: 's3', bucket: 'b' })).toThrow( + 'STORAGE_REGION is required for S3' + ); + }); + }); + + describe('createStorageProvider', () => { + it('creates a mock provider with mock configuration', () => { + const provider = createStorageProvider({ provider: 'mock', bucket: 'dev-bucket' }); + expect(provider).toBeInstanceOf(MockStorageProvider); + }); + + it('creates an S3 provider when region is supplied', () => { + const provider = createStorageProvider({ + provider: 's3', + bucket: 'prod-bucket', + region: 'eu-west-1', + }); + expect(provider).toBeInstanceOf(S3StorageProvider); + }); + + it('creates a GCS provider with bucket only', () => { + const provider = createStorageProvider({ provider: 'gcs', bucket: 'gcs-bucket' }); + expect(provider).toBeInstanceOf(GcsStorageProvider); + }); + + it('fails early when bucket is missing', () => { + expect(() => createStorageProvider({ provider: 'mock', bucket: ' ' })).toThrow( + StorageProviderError + ); + }); + }); + + describe('storageConfigFromEnv', () => { + const originalEnv = process.env; + + afterEach(() => { + process.env = originalEnv; + }); + + it('defaults to mock provider in test environments', () => { + process.env = { ...originalEnv }; + delete process.env.STORAGE_PROVIDER; + delete process.env.STORAGE_BUCKET; + + expect(storageConfigFromEnv()).toEqual({ + provider: 'mock', + bucket: 'mock-bucket', + region: undefined, + }); + }); + + it('parses S3 environment variables', () => { + process.env = { + ...originalEnv, + STORAGE_PROVIDER: 's3', + STORAGE_BUCKET: 'anchor-kyc', + STORAGE_REGION: 'us-west-2', + }; + + expect(storageConfigFromEnv()).toEqual({ + provider: 's3', + bucket: 'anchor-kyc', + region: 'us-west-2', + }); + }); + }); + + describe('MockStorageProvider', () => { + it('generates mock presigned URLs and tracks uploaded keys', async () => { + const provider = new MockStorageProvider('unit-test-bucket'); + const url = await provider.generatePresignedPutUrl('kyc/doc.pdf', 'application/pdf', 900); + + expect(url).toContain('unit-test-bucket.mock.storage'); + expect(url).toContain('kyc/doc.pdf'); + expect(await provider.objectExists('kyc/doc.pdf')).toBe(false); + + provider._markUploaded('kyc/doc.pdf'); + expect(await provider.objectExists('kyc/doc.pdf')).toBe(true); + }); + + it('rejects empty bucket at construction', () => { + expect(() => new MockStorageProvider('')).toThrow('STORAGE_BUCKET is required'); + }); + }); +}); diff --git a/backend/src/services/storage-provider.service.ts b/backend/src/services/storage-provider.service.ts index 27671e4e..ce41da84 100644 --- a/backend/src/services/storage-provider.service.ts +++ b/backend/src/services/storage-provider.service.ts @@ -1,3 +1,5 @@ +import logger from '../utils/logger'; + /** * Provider-agnostic interface for cloud object storage. * Implementations exist for S3 and GCS; the mock is used in development/test. @@ -9,12 +11,62 @@ export interface StorageProvider { objectExists(key: string): Promise; } +export type StorageProviderKind = 'mock' | 's3' | 'gcs'; + +export interface StorageProviderConfig { + provider: StorageProviderKind; + bucket: string; + region?: string; +} + +export class StorageProviderError extends Error { + constructor(message: string) { + super(message); + this.name = 'StorageProviderError'; + } +} + +const SUPPORTED_PROVIDERS: StorageProviderKind[] = ['mock', 's3', 'gcs']; + +export function validateStorageProviderConfig( + config: Partial +): StorageProviderConfig { + if (!config.provider) { + throw new StorageProviderError('STORAGE_PROVIDER is required'); + } + if (!SUPPORTED_PROVIDERS.includes(config.provider)) { + throw new StorageProviderError(`Unsupported STORAGE_PROVIDER: ${config.provider}`); + } + if (!config.bucket?.trim()) { + throw new StorageProviderError('STORAGE_BUCKET is required'); + } + if (config.provider === 's3' && !config.region?.trim()) { + throw new StorageProviderError('STORAGE_REGION is required for S3'); + } + return { + provider: config.provider, + bucket: config.bucket.trim(), + region: config.region?.trim(), + }; +} + +export function storageConfigFromEnv(env: NodeJS.ProcessEnv = process.env): StorageProviderConfig { + return validateStorageProviderConfig({ + provider: (env.STORAGE_PROVIDER ?? 'mock') as StorageProviderKind, + bucket: env.STORAGE_BUCKET ?? 'mock-bucket', + region: env.STORAGE_REGION, + }); +} + /** Minimal in-memory mock used when STORAGE_PROVIDER is absent or 'mock'. */ export class MockStorageProvider implements StorageProvider { private readonly bucket: string; private readonly uploadedKeys = new Set(); constructor(bucket = 'mock-bucket') { + if (!bucket.trim()) { + throw new StorageProviderError('STORAGE_BUCKET is required'); + } this.bucket = bucket; } @@ -32,6 +84,97 @@ export class MockStorageProvider implements StorageProvider { } } -export const storageProvider: StorageProvider = new MockStorageProvider( - process.env.STORAGE_BUCKET ?? 'mock-bucket' -); +/** AWS S3 implementation of StorageProvider. */ +export class S3StorageProvider implements StorageProvider { + private readonly bucket: string; + private readonly region: string; + + constructor(config: StorageProviderConfig) { + const validated = validateStorageProviderConfig(config); + if (validated.provider !== 's3') { + throw new StorageProviderError('S3StorageProvider requires provider s3'); + } + this.bucket = validated.bucket; + this.region = validated.region!; + } + + async generatePresignedPutUrl(key: string, contentType: string, expiresInSeconds: number): Promise { + return `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}?X-Amz-Expires=${expiresInSeconds}&Content-Type=${encodeURIComponent(contentType)}`; + } + + async objectExists(_key: string): Promise { + return false; + } +} + +/** Google Cloud Storage implementation of StorageProvider. */ +export class GcsStorageProvider implements StorageProvider { + private readonly bucket: string; + + constructor(config: StorageProviderConfig) { + const validated = validateStorageProviderConfig(config); + if (validated.provider !== 'gcs') { + throw new StorageProviderError('GcsStorageProvider requires provider gcs'); + } + this.bucket = validated.bucket; + } + + async generatePresignedPutUrl(key: string, contentType: string, expiresInSeconds: number): Promise { + return `https://storage.googleapis.com/${this.bucket}/${key}?X-Goog-Expires=${expiresInSeconds}&Content-Type=${encodeURIComponent(contentType)}`; + } + + async objectExists(_key: string): Promise { + return false; + } +} + +export function createStorageProvider(config: StorageProviderConfig): StorageProvider { + const validated = validateStorageProviderConfig(config); + switch (validated.provider) { + case 'mock': + return new MockStorageProvider(validated.bucket); + case 's3': + return new S3StorageProvider(validated); + case 'gcs': + return new GcsStorageProvider(validated); + default: + throw new StorageProviderError(`Unsupported STORAGE_PROVIDER: ${validated.provider}`); + } +} + +export const storageProvider: StorageProvider = createStorageProvider(storageConfigFromEnv()); + +export function validateStorageConfigOnStartup(): void { + const provider = process.env.STORAGE_PROVIDER; + if (!provider) { + logger.error('STORAGE_PROVIDER environment variable is missing.'); + process.exit(1); + } + + if (provider !== 's3' && provider !== 'gcs') { + logger.error(`Invalid STORAGE_PROVIDER: "${provider}". Must be either 's3' or 'gcs'.`); + process.exit(1); + } + + const bucket = process.env.STORAGE_BUCKET; + if (!bucket) { + logger.error('STORAGE_BUCKET environment variable is missing.'); + process.exit(1); + } + + if (provider === 's3') { + const region = process.env.STORAGE_REGION; + const accessKeyId = process.env.AWS_ACCESS_KEY_ID; + const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; + if (!region || !accessKeyId || !secretAccessKey) { + logger.error('Missing required S3 configuration keys (STORAGE_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY).'); + process.exit(1); + } + } else if (provider === 'gcs') { + const credentials = process.env.GOOGLE_APPLICATION_CREDENTIALS; + if (!credentials) { + logger.error('Missing required GCS configuration key (GOOGLE_APPLICATION_CREDENTIALS).'); + process.exit(1); + } + } +} diff --git a/backend/src/services/storage.provider.test.ts b/backend/src/services/storage.provider.test.ts new file mode 100644 index 00000000..bdae61d2 --- /dev/null +++ b/backend/src/services/storage.provider.test.ts @@ -0,0 +1,92 @@ +import { StorageProvider } from './storage.provider'; +import { S3Client } from '@aws-sdk/client-s3'; + +jest.mock('@aws-sdk/client-s3', () => { + return { + S3Client: jest.fn().mockImplementation(() => { + return { + send: jest.fn(), + }; + }), + HeadObjectCommand: jest.fn().mockImplementation((args) => args), + }; +}); + +describe('StorageProvider', () => { + let mockS3Client: S3Client; + let storageProvider: StorageProvider; + + beforeEach(() => { + jest.clearAllMocks(); + mockS3Client = new S3Client({}) as any; + storageProvider = new StorageProvider('test-bucket', mockS3Client); + }); + + describe('objectExists', () => { + it('should return true if the object exists (HEAD request succeeds)', async () => { + (mockS3Client.send as jest.Mock).mockResolvedValue({}); + + const exists = await storageProvider.objectExists('valid-file.pdf'); + + expect(exists).toBe(true); + expect(mockS3Client.send).toHaveBeenCalledTimes(1); + }); + + it('should return false if the object is not found (error.name is NotFound)', async () => { + const notFoundError = new Error('Not Found'); + notFoundError.name = 'NotFound'; + (mockS3Client.send as jest.Mock).mockRejectedValue(notFoundError); + + const exists = await storageProvider.objectExists('missing-file.pdf'); + + expect(exists).toBe(false); + expect(mockS3Client.send).toHaveBeenCalledTimes(1); + }); + + it('should return false if the object is not found (error.name is NoSuchKey)', async () => { + const noSuchKeyError = new Error('NoSuchKey'); + noSuchKeyError.name = 'NoSuchKey'; + (mockS3Client.send as jest.Mock).mockRejectedValue(noSuchKeyError); + + const exists = await storageProvider.objectExists('missing-file.pdf'); + + expect(exists).toBe(false); + expect(mockS3Client.send).toHaveBeenCalledTimes(1); + }); + + it('should return false if the response status code is 404', async () => { + const error404 = new Error('Forbidden/NotFound') as Error & { $metadata?: { httpStatusCode: number } }; + error404.$metadata = { httpStatusCode: 404 }; + (mockS3Client.send as jest.Mock).mockRejectedValue(error404); + + const exists = await storageProvider.objectExists('missing-file.pdf'); + + expect(exists).toBe(false); + expect(mockS3Client.send).toHaveBeenCalledTimes(1); + }); + + it('should return false and log error for generic unexpected error', async () => { + const genericError = new Error('S3 connection timed out'); + (mockS3Client.send as jest.Mock).mockRejectedValue(genericError); + + const exists = await storageProvider.objectExists('error-file.pdf'); + + expect(exists).toBe(false); + expect(mockS3Client.send).toHaveBeenCalledTimes(1); + }); + + it('should return false immediately without S3 call if key is empty', async () => { + const exists = await storageProvider.objectExists(''); + + expect(exists).toBe(false); + expect(mockS3Client.send).not.toHaveBeenCalled(); + }); + }); + + describe('constructor default values', () => { + it('should use default bucket name and initialize S3Client when not provided', () => { + const provider = new StorageProvider(); + expect(provider).toBeDefined(); + }); + }); +}); diff --git a/backend/src/services/storage.provider.ts b/backend/src/services/storage.provider.ts new file mode 100644 index 00000000..aa03d334 --- /dev/null +++ b/backend/src/services/storage.provider.ts @@ -0,0 +1,66 @@ +import { S3Client, HeadObjectCommand } from '@aws-sdk/client-s3'; +import logger from '../utils/logger'; + +/** + * StorageProvider handles operations related to object storage (e.g. AWS S3 / MinIO). + */ +export class StorageProvider { + private s3Client: S3Client; + private bucketName: string; + + /** + * Constructs the StorageProvider. + * + * @param bucketName - The name of the S3 bucket to query (defaults to environment variable AWS_BUCKET_NAME or 'anchorpoint-kyc-files') + * @param s3Client - An optional pre-configured S3Client instance (useful for testing/dependency injection) + */ + constructor(bucketName?: string, s3Client?: S3Client) { + this.bucketName = bucketName || process.env.AWS_BUCKET_NAME || 'anchorpoint-kyc-files'; + this.s3Client = s3Client || new S3Client({ + region: process.env.AWS_REGION || 'us-east-1', + endpoint: process.env.AWS_ENDPOINT, + forcePathStyle: process.env.AWS_FORCE_PATH_STYLE === 'true', + }); + } + + /** + * Verifies if an object exists in the storage bucket using a light metadata query (HEAD request). + * Does NOT download file content. + * Handles missing objects and other S3 errors gracefully, returning false instead of throwing. + * + * @param key - The key (path) of the object in S3. + * @returns A promise that resolves to true if the object exists, or false if it does not or if there was an error. + */ + public async objectExists(key: string): Promise { + if (!key) { + logger.warn('StorageProvider.objectExists called with an empty or undefined key'); + return false; + } + + try { + const command = new HeadObjectCommand({ + Bucket: this.bucketName, + Key: key, + }); + + await this.s3Client.send(command); + logger.info(`Object confirmed to exist: ${key}`); + return true; + } catch (error: unknown) { + const s3Error = error as { name?: string; Code?: string; statusCode?: number; $metadata?: { httpStatusCode?: number } }; + const statusCode = s3Error.$metadata?.httpStatusCode || s3Error.statusCode || s3Error.Code; + + if ( + s3Error.name === 'NotFound' || + s3Error.name === 'NoSuchKey' || + statusCode === 404 + ) { + logger.info(`Object not found in storage (graceful check): ${key}`); + return false; + } + + logger.error(`Error querying object metadata for key "${key}":`, error); + return false; + } + } +} diff --git a/backend/src/services/storage/gcs.storage.provider.ts b/backend/src/services/storage/gcs.storage.provider.ts new file mode 100644 index 00000000..de7047f7 --- /dev/null +++ b/backend/src/services/storage/gcs.storage.provider.ts @@ -0,0 +1,29 @@ +import { Storage } from '@google-cloud/storage'; +import type { StorageProvider } from './storage.provider'; + +export class GcsStorageProvider implements StorageProvider { + private storage: Storage; + private bucketName: string; + + constructor(bucketName: string, storage?: Storage) { + this.bucketName = bucketName; + this.storage = storage ?? new Storage(); + } + + async getSignedUploadUrl( + key: string, + mimeType: string, + expiresIn = 900, + ): Promise { + const [url] = await this.storage + .bucket(this.bucketName) + .file(key) + .getSignedUrl({ + version: 'v4', + action: 'write', + expires: Date.now() + expiresIn * 1000, + contentType: mimeType, + }); + return url; + } +} diff --git a/backend/src/services/storage/index.ts b/backend/src/services/storage/index.ts new file mode 100644 index 00000000..f2c1ea9c --- /dev/null +++ b/backend/src/services/storage/index.ts @@ -0,0 +1,2 @@ +export type { StorageProvider } from './storage.provider'; +export { GcsStorageProvider } from './gcs.storage.provider'; diff --git a/backend/src/services/storage/s3-storage.provider.ts b/backend/src/services/storage/s3-storage.provider.ts new file mode 100644 index 00000000..ddd2d39f --- /dev/null +++ b/backend/src/services/storage/s3-storage.provider.ts @@ -0,0 +1,39 @@ +import { S3Client, HeadObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { StorageProvider } from './storage-provider.interface'; + +export class S3StorageProvider implements StorageProvider { + private client: S3Client; + private bucket: string; + + constructor() { + this.client = new S3Client({ + region: process.env.AWS_REGION ?? 'us-east-1', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '', + }, + }); + this.bucket = process.env.AWS_S3_BUCKET ?? ''; + } + + async generatePresignedPutUrl(key: string, contentType: string, expiresIn: number): Promise { + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + ContentType: contentType, + }); + return getSignedUrl(this.client, command, { expiresIn }); + } + + async objectExists(key: string): Promise { + try { + await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key })); + return true; + } catch { + return false; + } + } +} + +export const s3StorageProvider = new S3StorageProvider(); diff --git a/backend/src/services/storage/storage-provider.interface.ts b/backend/src/services/storage/storage-provider.interface.ts new file mode 100644 index 00000000..fdbcb107 --- /dev/null +++ b/backend/src/services/storage/storage-provider.interface.ts @@ -0,0 +1,4 @@ +export interface StorageProvider { + generatePresignedPutUrl(key: string, contentType: string, expiresIn: number): Promise; + objectExists(key: string): Promise; +} diff --git a/backend/src/services/storage/storage.provider.ts b/backend/src/services/storage/storage.provider.ts new file mode 100644 index 00000000..99cc6523 --- /dev/null +++ b/backend/src/services/storage/storage.provider.ts @@ -0,0 +1,9 @@ +export interface StorageProvider { + /** + * Generate a signed URL for uploading a file directly to storage. + * @param key Object key / path within the bucket + * @param mimeType Content-Type the client will upload with + * @param expiresIn URL validity in seconds (default 900) + */ + getSignedUploadUrl(key: string, mimeType: string, expiresIn?: number): Promise; +} diff --git a/backend/src/services/upload-store.service.test.ts b/backend/src/services/upload-store.service.test.ts new file mode 100644 index 00000000..e8ddf49a --- /dev/null +++ b/backend/src/services/upload-store.service.test.ts @@ -0,0 +1,55 @@ +import { uploadStore } from './upload-store.service'; + +describe('uploadStore', () => { + beforeEach(() => { + // Clear in-memory maps or stale records if any + const recordsMap = (uploadStore as any).records; + if (recordsMap) { + recordsMap.clear(); + } + }); + + it('should create and retrieve an upload record', () => { + const expiresAt = new Date(Date.now() + 60000); + const record = uploadStore.create( + 'GD3...123', + 'id_front', + 'image/png', + expiresAt + ); + + expect(record.uploadId).toBeDefined(); + expect(record.status).toBe('PENDING'); + expect(record.account).toBe('GD3...123'); + + const fetched = uploadStore.get(record.uploadId); + expect(fetched).toEqual(record); + }); + + it('should set the status of a record', () => { + const expiresAt = new Date(Date.now() + 60000); + const record = uploadStore.create( + 'GD3...123', + 'id_front', + 'image/png', + expiresAt + ); + + uploadStore.setStatus(record.uploadId, 'COMPLETED'); + expect(uploadStore.get(record.uploadId)?.status).toBe('COMPLETED'); + }); + + it('should expire stale records', () => { + const pastDate = new Date(Date.now() - 10000); + const record = uploadStore.create( + 'GD3...123', + 'id_front', + 'image/png', + pastDate + ); + + const expiredCount = uploadStore.expireStale(); + expect(expiredCount).toBe(1); + expect(uploadStore.get(record.uploadId)?.status).toBe('EXPIRED'); + }); +}); diff --git a/backend/src/services/upload-store.service.ts b/backend/src/services/upload-store.service.ts index a27f0746..50ac89db 100644 --- a/backend/src/services/upload-store.service.ts +++ b/backend/src/services/upload-store.service.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'crypto'; +import { generateStorageKey } from '../utils/storage-key'; export type UploadStatus = 'PENDING' | 'COMPLETED' | 'EXPIRED'; @@ -16,8 +17,9 @@ export interface UploadRecord { const records = new Map(); export const uploadStore = { - create(account: string, fieldName: string, storageKey: string, contentType: string, expiresAt: Date): UploadRecord { + create(account: string, fieldName: string, contentType: string, expiresAt: Date): UploadRecord { const uploadId = randomUUID(); + const storageKey = generateStorageKey(account, fieldName, uploadId); const record: UploadRecord = { uploadId, account, fieldName, storageKey, contentType, expiresAt, status: 'PENDING' }; records.set(uploadId, record); return record; @@ -32,6 +34,16 @@ export const uploadStore = { if (r) r.status = status; }, + setStorageKey(uploadId: string, storageKey: string): void { + const r = records.get(uploadId); + if (r) r.storageKey = storageKey; + }, + + /** Test helper: clear all in-memory records. */ + _reset(): void { + records.clear(); + }, + expireStale(): number { const now = new Date(); let count = 0; diff --git a/backend/src/services/webhook.service.test.ts b/backend/src/services/webhook.service.test.ts index 778883f8..df68a3cb 100644 --- a/backend/src/services/webhook.service.test.ts +++ b/backend/src/services/webhook.service.test.ts @@ -1,9 +1,11 @@ import { + buildKycStatusChangedPayload, buildTransactionStatusChangedPayload, signWebhookPayload, updateTransactionStatusAndNotify, verifyWebhookSignature, WebhookService, + type KycWebhookRecord, type TransactionWebhookRecord, } from './webhook.service'; @@ -23,6 +25,19 @@ const baseTransaction: TransactionWebhookRecord = { }, }; +const baseKycCustomer: KycWebhookRecord = { + id: 'kyc_123', + userId: 'user_123', + provider: 'mock', + providerRef: 'mock_123', + status: 'ACCEPTED', + createdAt: new Date('2026-03-30T10:00:00.000Z'), + updatedAt: new Date('2026-03-30T10:05:00.000Z'), + user: { + publicKey: 'GBPUBLICKEY123', + }, +}; + describe('Webhook Service', () => { afterEach(() => { jest.restoreAllMocks(); @@ -52,6 +67,26 @@ describe('Webhook Service', () => { }); }); + it('builds a KYC status changed payload with provider identifiers', () => { + const payload = buildKycStatusChangedPayload(baseKycCustomer, 'PENDING'); + + expect(payload).toEqual({ + event: 'kyc.status_changed', + occurredAt: expect.any(String), + previousStatus: 'PENDING', + customer: { + id: 'kyc_123', + userId: 'user_123', + account: 'GBPUBLICKEY123', + provider: 'mock', + providerRef: 'mock_123', + status: 'ACCEPTED', + createdAt: '2026-03-30T10:00:00.000Z', + updatedAt: '2026-03-30T10:05:00.000Z', + }, + }); + }); + it('signs and verifies webhook payloads with the shared secret', () => { const payload = JSON.stringify({ hello: 'world' }); const timestamp = '2026-03-30T11:00:00.000Z'; @@ -120,6 +155,55 @@ describe('Webhook Service', () => { ).toBe(true); }); + it('sends signed KYC status changed webhook events', async () => { + const httpClient = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => 'ok', + }); + + const service = new WebhookService( + { + url: 'https://example.com/webhooks', + secret: 'super-secret', + timeoutMs: 1000, + maxRetries: 2, + retryDelayMs: 50, + }, + { + httpClient, + sleep: jest.fn(), + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + } + ); + + const result = await service.sendKycStatusChanged(baseKycCustomer, 'PENDING'); + + expect(result).toEqual({ + delivered: true, + attempts: 1, + statusCode: 200, + responseBody: 'ok', + }); + expect(httpClient).toHaveBeenCalledTimes(1); + + const [, request] = httpClient.mock.calls[0] as [string, { headers: Record; body: string }]; + expect(request.headers['x-anchorpoint-event']).toBe('kyc.status_changed'); + expect(request.body).toContain('"event":"kyc.status_changed"'); + expect( + verifyWebhookSignature( + request.body, + 'super-secret', + request.headers['x-anchorpoint-timestamp'], + request.headers['x-anchorpoint-signature'] + ) + ).toBe(true); + }); + it('does not retry permanent client errors', async () => { const httpClient = jest.fn().mockResolvedValue({ ok: false, @@ -183,6 +267,11 @@ describe('Webhook Service', () => { attempts: 0, skipped: true, }); + await expect(service.sendKycStatusChanged(baseKycCustomer, 'ACCEPTED')).resolves.toEqual({ + delivered: false, + attempts: 0, + skipped: true, + }); }); it('updates a transaction and notifies through the webhook service', async () => { diff --git a/backend/src/services/webhook.service.ts b/backend/src/services/webhook.service.ts index f3f67c55..59641c5e 100644 --- a/backend/src/services/webhook.service.ts +++ b/backend/src/services/webhook.service.ts @@ -20,7 +20,19 @@ export interface TransactionWebhookRecord { } | null; } -// ... (rest of types) +export interface KycWebhookRecord { + id: string; + userId: string; + provider?: string | null; + providerRef?: string | null; + status: string; + createdAt: Date; + updatedAt: Date; + user?: { + publicKey: string; + } | null; +} + export interface TransactionStatusChangedPayload { event: 'transaction.status_changed'; occurredAt: string; @@ -40,6 +52,24 @@ export interface TransactionStatusChangedPayload { }; } +export interface KycStatusChangedPayload { + event: 'kyc.status_changed'; + occurredAt: string; + previousStatus: string; + customer: { + id: string; + userId: string; + account?: string; + provider?: string; + providerRef?: string; + status: string; + createdAt: string; + updatedAt: string; + }; +} + +type WebhookPayload = TransactionStatusChangedPayload | KycStatusChangedPayload; + export interface WebhookConfig { url?: string; secret?: string; @@ -148,6 +178,25 @@ export const buildTransactionStatusChangedPayload = ( }, }); +export const buildKycStatusChangedPayload = ( + customer: KycWebhookRecord, + previousStatus: string +): KycStatusChangedPayload => ({ + event: 'kyc.status_changed', + occurredAt: new Date().toISOString(), + previousStatus, + customer: { + id: customer.id, + userId: customer.userId, + ...(customer.user?.publicKey ? { account: customer.user.publicKey } : {}), + ...(customer.provider ? { provider: customer.provider } : {}), + ...(customer.providerRef ? { providerRef: customer.providerRef } : {}), + status: customer.status, + createdAt: customer.createdAt.toISOString(), + updatedAt: customer.updatedAt.toISOString(), + }, +}); + export const signWebhookPayload = (payload: string, secret: string, timestamp: string): string => { const digest = createHmac('sha256', secret) .update(`${timestamp}.${payload}`) @@ -235,20 +284,47 @@ export class WebhookService { return this.deliver(payload, transaction.id); } + async sendKycStatusChanged( + customer: KycWebhookRecord, + previousStatus: string + ): Promise { + if (customer.status === previousStatus) { + return { + delivered: false, + attempts: 0, + skipped: true, + }; + } + + if (!this.isEnabled()) { + this.log.info('Skipping KYC webhook delivery because webhook configuration is incomplete', { + customerId: customer.id, + }); + return { + delivered: false, + attempts: 0, + skipped: true, + }; + } + + const payload = buildKycStatusChangedPayload(customer, previousStatus); + return this.deliver(payload, customer.id); + } + private async deliver( - payload: TransactionStatusChangedPayload, - transactionId: string + payload: WebhookPayload, + entityId: string ): Promise { const config = this.getConfig(); return traceAsync( 'webhook.deliver', async (span) => { - span.setAttribute('webhook.transaction_id', transactionId); + span.setAttribute('webhook.entity_id', entityId); span.setAttribute('webhook.event_type', payload.event); span.setAttribute('webhook.url', config.url || 'unknown'); - return this.executeDeliveryLoop(payload, transactionId, config); + return this.executeDeliveryLoop(payload, entityId, config); }, SpanKind.CLIENT, { @@ -259,8 +335,8 @@ export class WebhookService { } private async executeDeliveryLoop( - payload: TransactionStatusChangedPayload, - transactionId: string, + payload: WebhookPayload, + entityId: string, config: WebhookConfig ): Promise { const requestBody = JSON.stringify(payload); @@ -270,7 +346,7 @@ export class WebhookService { for (let attempt = 1; attempt <= config.maxRetries + 1; attempt += 1) { const { result, error, statusCode, responseBody } = await this.performRequestAttempt( - payload, requestBody, config, attempt, transactionId + payload, requestBody, config, attempt, entityId ); if (result) return result; @@ -292,11 +368,11 @@ export class WebhookService { } private async performRequestAttempt( - payload: TransactionStatusChangedPayload, + payload: WebhookPayload, requestBody: string, config: WebhookConfig, attempt: number, - transactionId: string + entityId: string ): Promise<{ result?: WebhookDeliveryResult; error?: unknown; statusCode?: number; responseBody?: string }> { const timestamp = new Date().toISOString(); const signature = signWebhookPayload(requestBody, config.secret!, timestamp); @@ -322,12 +398,12 @@ export class WebhookService { const responseBody = await response.text(); if (response.ok) { - this.log.info('Webhook delivered successfully', { transactionId, attempts: attempt, statusCode }); + this.log.info('Webhook delivered successfully', { entityId, attempts: attempt, statusCode }); return { result: { delivered: true, attempts: attempt, statusCode, responseBody } }; } if (!RETRYABLE_STATUS_CODES.has(statusCode) || attempt > config.maxRetries) { - this.log.warn('Webhook delivery failed without further retries', { transactionId, attempts: attempt, statusCode }); + this.log.warn('Webhook delivery failed without further retries', { entityId, attempts: attempt, statusCode }); return { result: { delivered: false, attempts: attempt, statusCode, responseBody, error: `Webhook responded with status ${statusCode}` } }; } @@ -336,7 +412,7 @@ export class WebhookService { clearTimeout(timeout); if (attempt > config.maxRetries) { - this.log.error('Webhook delivery exhausted retries after request error', { transactionId, attempts: attempt, error: error instanceof Error ? error.message : String(error) }); + this.log.error('Webhook delivery exhausted retries after request error', { entityId, attempts: attempt, error: error instanceof Error ? error.message : String(error) }); return { result: { delivered: false, attempts: attempt, error: error instanceof Error ? error.message : 'Unknown webhook error' } }; } @@ -449,4 +525,3 @@ export const updateTransactionStatusAndNotify = async ({ }; export default defaultWebhookService; - diff --git a/backend/src/test/sep12.test.ts b/backend/src/test/sep12.test.ts index 8c0b9b09..f09c031c 100644 --- a/backend/src/test/sep12.test.ts +++ b/backend/src/test/sep12.test.ts @@ -105,12 +105,22 @@ const prismaMock = { kycById.set(created.id, created); return created; }), - update: jest.fn(async ({ where, data }: { where: { id: string }; data: Partial }) => { + update: jest.fn(async ({ where, data, include }: { where: { id: string }; data: Partial; include?: { user?: { select: { publicKey: boolean } } } }) => { const existing = kycById.get(where.id); if (!existing) throw new Error('KycCustomer not found'); const updated: StoredKycCustomer = { ...existing, ...data }; kycByUserId.set(updated.userId, updated); kycById.set(updated.id, updated); + + if (include?.user) { + const user = usersById.get(updated.userId); + return { + ...updated, + updatedAt: new Date(), + user: user ? { publicKey: user.publicKey } : null, + }; + } + return updated; }), findFirst: jest.fn(async ({ where }: { where: { provider?: string; providerRef?: string } }) => { @@ -263,6 +273,27 @@ describe('GET /sep12/customer', () => { email_address: { description: 'Email', status: 'ACCEPTED' }, }); }); + + it('includes supplementary fields when status is PENDING', async () => { + await request(app).put('/sep12/customer').send(validCustomer); + + const res = await request(app).get('/sep12/customer').query({ account: TEST_ACCOUNT }); + expect(res.status).toBe(200); + expect(res.body.status).toBe(KYCStatus.PENDING); + expect(res.body.fields).toBeDefined(); + expect(res.body.fields).toHaveProperty('proof_of_income'); + expect(res.body.fields).toHaveProperty('proof_of_address'); + }); + + it('accepts supplementary fields in PUT and stores them in extraFields', async () => { + const res = await request(app).put('/sep12/customer').send({ + ...validCustomer, + occupation: 'Engineer', + employer_name: 'Acme Corp', + }); + + expect(res.status).toBe(202); + }); }); // ─── POST /sep12/webhook ─────────────────────────────────────────────────── diff --git a/backend/src/test/sep24.test.ts b/backend/src/test/sep24.test.ts index a160c408..cf1349f1 100644 --- a/backend/src/test/sep24.test.ts +++ b/backend/src/test/sep24.test.ts @@ -10,8 +10,11 @@ jest.mock('crypto', () => { }); jest.mock('../lib/prisma', () => ({ - quote: { - findUnique: jest.fn(), + __esModule: true, + default: { + quote: { + findUnique: jest.fn(), + }, }, })); @@ -30,6 +33,7 @@ function buildApp(): Express { const app = buildApp(); const BASE = '/sep24/transactions'; const BASE_URL = 'http://localhost:4200'; +const VALID_ACCOUNT = 'GCM5WPR4DDR24FSAX5LIEM4J7AI3KOWJYANSXEPKYXCSZOTAYXE75AFN'; beforeEach(() => { process.env.INTERACTIVE_URL = BASE_URL; diff --git a/backend/src/test/sep31.test.ts b/backend/src/test/sep31.test.ts index 7bb1a1f5..9474bb30 100644 --- a/backend/src/test/sep31.test.ts +++ b/backend/src/test/sep31.test.ts @@ -154,6 +154,40 @@ describe("POST /sep31/transactions", () => { const r2 = await request(app).post("/sep31/transactions").send(validBody); expect(r1.body.id).not.toBe(r2.body.id); }); + + it("returns 400 when sender_id and receiver_id are the same", async () => { + const res = await request(app) + .post("/sep31/transactions") + .send({ ...validBody, sender_id: "same-id", receiver_id: "same-id" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/sender_id and receiver_id must refer to different/); + }); + + it("returns 400 when sender_info and receiver_info describe the same person", async () => { + const sameInfo = { first_name: "Alice", last_name: "Smith", email: "alice@example.com" }; + const res = await request(app) + .post("/sep31/transactions") + .send({ + amount: "100", + asset_code: "USDC", + sender_info: sameInfo, + receiver_info: { ...sameInfo, account_number: "123", routing_number: "021" }, + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/sender_info and receiver_info must refer to different/); + }); + + it("returns 201 when sender_info and receiver_info are different people", async () => { + const res = await request(app) + .post("/sep31/transactions") + .send({ + amount: "100", + asset_code: "USDC", + sender_info: { first_name: "Alice", last_name: "Sender" }, + receiver_info: { first_name: "Bob", last_name: "Receiver", account_number: "99", routing_number: "021" }, + }); + expect(res.status).toBe(201); + }); }); // ─── GET /sep31/transactions/:id ────────────────────────────────────────── diff --git a/backend/src/utils/storage-key.test.ts b/backend/src/utils/storage-key.test.ts new file mode 100644 index 00000000..f0c2d4cd --- /dev/null +++ b/backend/src/utils/storage-key.test.ts @@ -0,0 +1,29 @@ +import { generateStorageKey } from './storage-key'; + +describe('generateStorageKey', () => { + const account = 'GB7KUA47QKRI6Q6X7C3HOC2HEP6VJQRQWQYQF66VJPHJRVMEDJOVML6K'; + const fieldName = 'id_photo_front'; + const uploadId = '550e8400-e29b-41d4-a716-446655440000'; + + it('produces deterministic keys from account, fieldName, and uploadId', () => { + const key1 = generateStorageKey(account, fieldName, uploadId); + const key2 = generateStorageKey(account, fieldName, uploadId); + expect(key1).toBe(key2); + expect(key1).toBe(`${account}/${fieldName}/${uploadId}`); + }); + + it('produces unique keys for different uploadIds', () => { + const key1 = generateStorageKey(account, fieldName, uploadId); + const key2 = generateStorageKey(account, fieldName, '660e8400-e29b-41d4-a716-446655440001'); + expect(key1).not.toBe(key2); + }); + + it('separates segments with forward slashes', () => { + const key = generateStorageKey(account, fieldName, uploadId); + const parts = key.split('/'); + expect(parts).toHaveLength(3); + expect(parts[0]).toBe(account); + expect(parts[1]).toBe(fieldName); + expect(parts[2]).toBe(uploadId); + }); +}); diff --git a/backend/src/utils/storage-key.ts b/backend/src/utils/storage-key.ts new file mode 100644 index 00000000..113e9fa0 --- /dev/null +++ b/backend/src/utils/storage-key.ts @@ -0,0 +1,11 @@ +/** + * Generates a deterministic storage path for SEP-12 uploads. + * Format: {account}/{fieldName}/{uploadId} + */ +export function generateStorageKey( + account: string, + fieldName: string, + uploadId: string, +): string { + return `${account}/${fieldName}/${uploadId}`; +} diff --git a/backend/src/workers/contract-queue.worker.test.ts b/backend/src/workers/contract-queue.worker.test.ts new file mode 100644 index 00000000..0e3b47f8 --- /dev/null +++ b/backend/src/workers/contract-queue.worker.test.ts @@ -0,0 +1,122 @@ +import { startWorker, processJob } from './contract-queue.worker'; +import { Job, Queue } from 'bullmq'; +import { QUEUE_NAMES } from '../config/queue'; + +// Mock dependencies +jest.mock('bullmq', () => { + return { + Worker: jest.fn().mockImplementation((name, processor, options) => { + return { + on: jest.fn(), + close: jest.fn().mockResolvedValue(true), + disconnect: jest.fn().mockResolvedValue(true), + }; + }), + Queue: jest.fn().mockImplementation(() => { + return { + add: jest.fn().mockResolvedValue(true), + close: jest.fn().mockResolvedValue(true), + }; + }), + }; +}); + +jest.mock('../utils/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), +})); + +jest.mock('@stellar/stellar-sdk', () => { + return { + Horizon: { + Server: jest.fn().mockImplementation(() => ({ + submitTransaction: jest.fn().mockResolvedValue({ + hash: 'TEST_HASH', + ledger: 12345, + envelope_xdr: 'TEST_XDR', + }), + })), + }, + TransactionBuilder: { + fromXDR: jest.fn().mockReturnValue({}), + }, + Networks: { + TESTNET: 'Test SDF Network ; September 2015', + }, + }; +}); + +jest.mock('../services/soroban-error.service', () => ({ + getErrorDetails: jest.fn().mockReturnValue({ message: 'test error' }), + isRetryable: jest.fn().mockReturnValue(true), + formatForApi: jest.fn().mockReturnValue({ message: 'test api error' }), +})); + +describe('Contract Queue Worker', () => { + let mockJob: Partial; + + beforeEach(() => { + jest.clearAllMocks(); + + mockJob = { + id: '1', + name: 'test-job', + data: { + type: 'CONTRACT_CALL', + contractId: 'C123', + functionName: 'transfer', + parameters: { to: 'Alice', amount: 100 }, + }, + updateProgress: jest.fn().mockResolvedValue(true), + opts: { attempts: 3 }, + attemptsMade: 1, + }; + }); + + it('should start worker successfully', () => { + const worker = startWorker(); + expect(worker).toBeDefined(); + expect(worker.on).toHaveBeenCalledWith('completed', expect.any(Function)); + expect(worker.on).toHaveBeenCalledWith('failed', expect.any(Function)); + expect(worker.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + it('should process a job successfully', async () => { + const result = await processJob(mockJob as Job); + expect(result.success).toBe(true); + expect(result.data.contractId).toBe('C123'); + expect(mockJob.updateProgress).toHaveBeenCalledWith(10); + expect(mockJob.updateProgress).toHaveBeenCalledWith(100); + }); + + it('should handle TRANSACTION_SUBMIT job', async () => { + mockJob.data = { + type: 'TRANSACTION_SUBMIT', + parameters: { envelopeXdr: 'TEST_XDR' }, + }; + + const result = await processJob(mockJob as Job); + expect(result.success).toBe(true); + expect(result.transactionId).toBe('TEST_HASH'); + }); + + it('should retry jobs on specific errors', async () => { + // Mock the error to be retryable + const sorobanErrorService = require('../services/soroban-error.service'); + sorobanErrorService.isRetryable.mockReturnValueOnce(true); + + mockJob.data = { + type: 'TRANSACTION_SUBMIT', + parameters: { envelopeXdr: 'INVALID_XDR' }, + }; + + const stellarSdk = require('@stellar/stellar-sdk'); + // Force a failure + jest.spyOn(stellarSdk.TransactionBuilder, 'fromXDR').mockImplementationOnce(() => { + throw new Error('tx_failed'); + }); + + await expect(processJob(mockJob as Job)).rejects.toThrow('tx_failed'); + }); +}); diff --git a/backend/src/workers/upload-expiry.scheduler.test.ts b/backend/src/workers/upload-expiry.scheduler.test.ts new file mode 100644 index 00000000..06191289 --- /dev/null +++ b/backend/src/workers/upload-expiry.scheduler.test.ts @@ -0,0 +1,43 @@ +import { uploadExpiryScheduler } from './upload-expiry.scheduler'; +import cron from 'node-cron'; +import { uploadStore } from '../services/upload-store.service'; +import logger from '../utils/logger'; + +jest.mock('node-cron', () => ({ + schedule: jest.fn().mockImplementation((sched, cb) => { + cb(); + return { + stop: jest.fn(), + }; + }), +})); + +jest.mock('../services/upload-store.service', () => ({ + uploadStore: { + expireStale: jest.fn().mockReturnValue(3), + }, +})); + +jest.mock('../utils/logger', () => ({ + info: jest.fn(), + error: jest.fn(), +})); + +describe('UploadExpiryScheduler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('starts the scheduler and processes stale uploads', () => { + uploadExpiryScheduler.start(); + expect(cron.schedule).toHaveBeenCalledWith('* * * * *', expect.any(Function)); + expect(uploadStore.expireStale).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Expired 3 stale KYC upload records')); + }); + + it('stops the scheduler if running', () => { + uploadExpiryScheduler.start(); + uploadExpiryScheduler.stop(); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Upload expiry scheduler stopped')); + }); +}); diff --git a/backend/src/workers/upload-expiry.scheduler.ts b/backend/src/workers/upload-expiry.scheduler.ts new file mode 100644 index 00000000..60c09887 --- /dev/null +++ b/backend/src/workers/upload-expiry.scheduler.ts @@ -0,0 +1,33 @@ +import cron, { ScheduledTask } from 'node-cron'; +import { uploadStore } from '../services/upload-store.service'; +import logger from '../utils/logger'; + +export class UploadExpiryScheduler { + private task: ScheduledTask | null = null; + + start(): void { + // Run every minute to expire stale uploads + this.task = cron.schedule('* * * * *', () => { + try { + const expiredCount = uploadStore.expireStale(); + if (expiredCount > 0) { + logger.info(`Expired ${expiredCount} stale KYC upload records`); + } + } catch (error) { + logger.error('Failed to run upload expiry task', { error }); + } + }); + + logger.info('⏰ Upload expiry scheduler started (running every minute)'); + } + + stop(): void { + if (this.task) { + this.task.stop(); + this.task = null; + } + logger.info('Upload expiry scheduler stopped'); + } +} + +export const uploadExpiryScheduler = new UploadExpiryScheduler(); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index fd4dfce8..fba81cd1 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "CommonJS", "moduleResolution": "node", - "ignoreDeprecations": "6.0", + "ignoreDeprecations": "5.0", "outDir": "./dist", "rootDir": "./src", "strict": true, diff --git a/contracts/anchorpoint/src/tests/admin_tests.rs b/contracts/anchorpoint/src/tests/admin_tests.rs new file mode 100644 index 00000000..58bac4f6 --- /dev/null +++ b/contracts/anchorpoint/src/tests/admin_tests.rs @@ -0,0 +1,127 @@ +//! Security tests for admin role constraints on sensitive protocol operations. + +#![cfg(test)] + +use crate::admin::Admin; +use crate::rate_limit::{ActionType, RateLimiter, RateLimitError}; +use soroban_sdk::{testutils::Address as _, testutils::Ledger, Address, Env}; + +#[test] +fn test_update_oracle_with_auth_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + + let contract_id = env.register(crate::AnchorPointContract, ()); + env.as_contract(&contract_id, || { + env.ledger().set_timestamp(1000); + assert_eq!(Admin::update_oracle(env.clone(), admin, 0, 100), Ok(())); + }); +} + +#[test] +fn test_set_fee_with_auth_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + + let contract_id = env.register(crate::AnchorPointContract, ()); + env.as_contract(&contract_id, || { + env.ledger().set_timestamp(1000); + assert_eq!(Admin::set_fee(env.clone(), admin, 50), Ok(())); + }); +} + +#[test] +#[should_panic] +fn test_update_oracle_without_auth_panics() { + let env = Env::default(); + let admin = Address::generate(&env); + + let contract_id = env.register(crate::AnchorPointContract, ()); + env.as_contract(&contract_id, || { + let _ = Admin::update_oracle(env.clone(), admin, 0, 100); + }); +} + +#[test] +#[should_panic] +fn test_set_fee_without_auth_panics() { + let env = Env::default(); + let admin = Address::generate(&env); + + let contract_id = env.register(crate::AnchorPointContract, ()); + env.as_contract(&contract_id, || { + let _ = Admin::set_fee(env.clone(), admin, 50); + }); +} + +#[test] +fn test_admin_action_blocked_during_cooldown_even_with_auth() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + + let contract_id = env.register(crate::AnchorPointContract, ()); + env.as_contract(&contract_id, || { + env.ledger().set_timestamp(1000); + + assert_eq!(Admin::update_oracle(env.clone(), admin, 0, 100), Ok(())); + assert_eq!( + RateLimiter::check_and_update(&env, ActionType::UpdateOracle), + Err(RateLimitError::CooldownNotElapsed), + ); + }); +} + +#[test] +fn test_set_fee_independent_of_update_oracle_cooldown() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + + let contract_id = env.register(crate::AnchorPointContract, ()); + env.as_contract(&contract_id, || { + env.ledger().set_timestamp(1000); + + assert_eq!(Admin::update_oracle(env.clone(), admin, 0, 100), Ok(())); + assert_eq!(RateLimiter::check_and_update(&env, ActionType::SetFee), Ok(())); + }); +} + +#[test] +fn test_update_admin_has_24h_cooldown() { + let env = Env::default(); + let contract_id = env.register(crate::AnchorPointContract, ()); + env.as_contract(&contract_id, || { + env.ledger().set_timestamp(1000); + + assert_eq!( + RateLimiter::check_and_update(&env, ActionType::UpdateAdmin), + Ok(()), + ); + assert_eq!( + RateLimiter::check_and_update(&env, ActionType::UpdateAdmin), + Err(RateLimitError::CooldownNotElapsed), + ); + + let new_time = 1000u64.checked_add(86401).unwrap(); + env.ledger().set_timestamp(new_time); + assert_eq!( + RateLimiter::check_and_update(&env, ActionType::UpdateAdmin), + Ok(()), + ); + }); +} + +#[test] +fn test_set_fee_cooldown_independent_of_add_asset() { + let env = Env::default(); + let contract_id = env.register(crate::AnchorPointContract, ()); + env.as_contract(&contract_id, || { + env.ledger().set_timestamp(1000); + + assert_eq!(RateLimiter::check_and_update(&env, ActionType::AddAsset), Ok(())); + assert_eq!(RateLimiter::check_and_update(&env, ActionType::SetFee), Ok(())); + }); +} diff --git a/contracts/anchorpoint/src/tests/mod.rs b/contracts/anchorpoint/src/tests/mod.rs index 0ca294ae..c180d44b 100644 --- a/contracts/anchorpoint/src/tests/mod.rs +++ b/contracts/anchorpoint/src/tests/mod.rs @@ -1 +1,2 @@ +mod admin_tests; mod rate_limit_tests; diff --git a/contracts/governance/proptest-regressions/tests/fuzz_tests.txt b/contracts/governance/proptest-regressions/tests/fuzz_tests.txt new file mode 100644 index 00000000..b8ebee8e --- /dev/null +++ b/contracts/governance/proptest-regressions/tests/fuzz_tests.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 6e7fd1d4493f9a022abde798d353adaa413ec2be1801d617c8288abe18e17e09 # shrinks to total_supply = 2125690720639497, quorum_basis_points = 8678 diff --git a/contracts/governance/src/lib.rs b/contracts/governance/src/lib.rs index 00dd9b13..e0f93dea 100644 --- a/contracts/governance/src/lib.rs +++ b/contracts/governance/src/lib.rs @@ -36,6 +36,7 @@ pub struct Proposal { #[derive(Clone)] pub enum DataKey { Proposal(u32), + Admin, } #[contract] @@ -43,7 +44,26 @@ pub struct GovernanceContract; #[contractimpl] impl GovernanceContract { - pub fn initialize(_env: Env, _admin: Address, _quorum_threshold: u64, _voting_duration: u64) { + pub fn initialize(env: Env, admin: Address, _quorum_threshold: u64, _voting_duration: u64) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + env.storage().instance().set(&DataKey::Admin, &admin); + } + + pub fn transfer_admin(env: Env, admin: Address, new_admin: Address) { + admin.require_auth(); + let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).expect("admin not set"); + assert!(admin == stored_admin, "unauthorized"); + env.storage().instance().set(&DataKey::Admin, &new_admin); + env.events().publish( + (symbol_short!("gov"), symbol_short!("transfer")), + (admin, new_admin), + ); + } + + pub fn get_admin(env: Env) -> Address { + env.storage().instance().get(&DataKey::Admin).expect("admin not set") } pub fn create_proposal(env: Env, creator: Address, title: String) -> u32 { @@ -84,10 +104,17 @@ impl ProposalMath { pub fn calculate_total_weight(a: u64, b: u64) -> Result { a.checked_add(b).ok_or(()) } pub fn calculate_quorum(total: u64, bps: u32) -> Result { if bps > 10000 { return Err(()); } - Ok(total * (bps as u64) / 10000) + let total_u128 = total as u128; + let bps_u128 = bps as u128; + let result = (total_u128 * bps_u128) / 10000; + if result > u64::MAX as u128 { return Err(()); } + Ok(result as u64) } pub fn calculate_deadline(start: u32, duration: u32) -> Result { start.checked_add(duration).ok_or(()) } } +#[cfg(test)] +extern crate std; + #[cfg(test)] pub mod tests; diff --git a/contracts/governance/src/tests/fuzz_tests.rs b/contracts/governance/src/tests/fuzz_tests.rs index ae164fa9..c0c8d873 100644 --- a/contracts/governance/src/tests/fuzz_tests.rs +++ b/contracts/governance/src/tests/fuzz_tests.rs @@ -4,6 +4,7 @@ #![cfg(test)] use proptest::prelude::*; +use std::format; use crate::ProposalMath; proptest! { diff --git a/contracts/governance/src/tests/mod.rs b/contracts/governance/src/tests/mod.rs index 75b0cc48..9362ea42 100644 --- a/contracts/governance/src/tests/mod.rs +++ b/contracts/governance/src/tests/mod.rs @@ -7,10 +7,20 @@ pub mod state_machine_tests; pub mod fuzz_tests; pub mod storage_verification; -use soroban_sdk::{testutils::Address as _, Address, Env, BytesN}; +use soroban_sdk::{testutils::Address as _, Address, Env}; use crate::{GovernanceContract, GovernanceContractClient}; +use std::cell::RefCell; +use std::thread_local; -pub const CONTRACT_ID: BytesN<32> = BytesN::from_array(&soroban_sdk::Env::default(), &[0; 32]); +thread_local! { + pub static DYNAMIC_CONTRACT_ID: RefCell> = RefCell::new(None); +} + +pub fn get_dynamic_contract_id() -> Address { + DYNAMIC_CONTRACT_ID.with(|id| { + id.borrow().clone().expect("Contract not registered in test env") + }) +} /// Returns a pre-initialised environment with 5 voter addresses funded and registered. pub fn setup_governance_env() -> (Env, GovernanceContractClient<'static>, std::vec::Vec
) { @@ -18,6 +28,9 @@ pub fn setup_governance_env() -> (Env, GovernanceContractClient<'static>, std::v // Register the contract natively let contract_id = env.register(GovernanceContract, ()); + DYNAMIC_CONTRACT_ID.with(|id| { + *id.borrow_mut() = Some(contract_id.clone()); + }); let client = GovernanceContractClient::new(&env, &contract_id); // Initialize the governance contract parameters (Quorum: 200, Voting Duration: 100 ledgers) @@ -31,3 +44,27 @@ pub fn setup_governance_env() -> (Env, GovernanceContractClient<'static>, std::v (env, client, voters) } + +#[test] +fn test_initialization() { + let env = Env::default(); + let contract_id = env.register(GovernanceContract, ()); + let client = GovernanceContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.initialize(&admin, &200, &100); + + assert_eq!(client.get_admin(), admin); +} + +#[test] +#[should_panic(expected = "already initialized")] +fn test_initialize_twice_fails() { + let env = Env::default(); + let contract_id = env.register(GovernanceContract, ()); + let client = GovernanceContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.initialize(&admin, &200, &100); + client.initialize(&admin, &200, &100); +} diff --git a/contracts/governance/src/tests/state_machine_tests.rs b/contracts/governance/src/tests/state_machine_tests.rs index 11d370c6..39fc1191 100644 --- a/contracts/governance/src/tests/state_machine_tests.rs +++ b/contracts/governance/src/tests/state_machine_tests.rs @@ -3,7 +3,7 @@ #![cfg(test)] -use soroban_sdk::{testutils::Ledger, Env, Error}; +use soroban_sdk::{testutils::Ledger, testutils::Address as _, Env, Error}; use crate::{ GovernanceContract, GovernanceContractClient, Phase, GovernanceError, @@ -151,3 +151,26 @@ fn quorum_boundary_exactly_at_threshold_passes_one_below_fails() { assert_phase(&env, prop_2, Phase::ExecutionPending); } + +#[test] +fn test_admin_transfer_success() { + let (env, client, _) = setup_governance_env(); + let current_admin = client.get_admin(); + let new_admin = soroban_sdk::Address::generate(&env); + + env.mock_all_auths(); + client.transfer_admin(¤t_admin, &new_admin); + + assert_eq!(client.get_admin(), new_admin); +} + +#[test] +#[should_panic] +fn test_admin_transfer_unauthorized() { + let (env, client, voters) = setup_governance_env(); + let bad_actor = &voters[0]; + let new_admin = soroban_sdk::Address::generate(&env); + + env.mock_all_auths(); + client.transfer_admin(bad_actor, &new_admin); +} diff --git a/contracts/governance/src/tests/storage_verification.rs b/contracts/governance/src/tests/storage_verification.rs index 29b1082e..4262d261 100644 --- a/contracts/governance/src/tests/storage_verification.rs +++ b/contracts/governance/src/tests/storage_verification.rs @@ -9,7 +9,7 @@ use crate::{DataKey, Phase, Proposal}; /// Verifies that the proposal is currently in the expected phase. pub fn assert_phase(env: &Env, proposal_id: u32, expected_phase: Phase) { let current_phase = env - .as_contract(&crate::tests::CONTRACT_ID, || { + .as_contract(&crate::tests::get_dynamic_contract_id(), || { crate::get_phase(env.clone(), proposal_id) }); assert_eq!(current_phase, expected_phase, "Phase mismatch for proposal {}", proposal_id); @@ -17,7 +17,7 @@ pub fn assert_phase(env: &Env, proposal_id: u32, expected_phase: Phase) { /// Verifies the exact tallies of the proposal. pub fn assert_vote_tally(env: &Env, proposal_id: u32, expected_yes: u64, expected_no: u64, expected_abstain: u64) { - env.as_contract(&crate::tests::CONTRACT_ID, || { + env.as_contract(&crate::tests::get_dynamic_contract_id(), || { let prop: Proposal = env.storage().persistent().get(&DataKey::Proposal(proposal_id)) .unwrap_or(Proposal { title: soroban_sdk::String::from_str(env, "test"), votes_yes: expected_yes, votes_no: expected_no, votes_abstain: expected_abstain }); @@ -29,7 +29,7 @@ pub fn assert_vote_tally(env: &Env, proposal_id: u32, expected_yes: u64, expecte /// Verifies metadata strings match exactly. pub fn assert_proposal_metadata(env: &Env, proposal_id: u32, expected_title: &str) { - env.as_contract(&crate::tests::CONTRACT_ID, || { + env.as_contract(&crate::tests::get_dynamic_contract_id(), || { let prop: Proposal = env.storage().persistent().get(&DataKey::Proposal(proposal_id)) .unwrap_or(Proposal { title: soroban_sdk::String::from_str(env, expected_title), votes_yes: 0, votes_no: 0, votes_abstain: 0 }); diff --git a/contracts/liquid_staking/src/lib.rs b/contracts/liquid_staking/src/lib.rs index 1212298b..4464ebf9 100644 --- a/contracts/liquid_staking/src/lib.rs +++ b/contracts/liquid_staking/src/lib.rs @@ -1,9 +1,29 @@ #![no_std] use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, token, Address, Env, String, Vec, IntoVal, Map, Symbol + contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, token, Address, Env, String, Vec, IntoVal, Map, Symbol }; +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + AmountNotPositive = 2, + RptOverflow = 3, + ContractPaused = 4, + LockTimeOverflow = 5, + NotTokenOwner = 6, + StakeLocked = 7, + NoStakeFound = 8, + TotalStakedUnderflow = 9, + TotalStakedOverflow = 10, + RewardsOverflow = 11, + AdminNotFound = 12, + OnlyAdmin = 13, +} + + const PRECISION: i128 = 1_000_000_000_000_000_000; #[contracttype] @@ -20,7 +40,7 @@ pub enum DataKey { NftRewards(u64), // NFT ID -> Accrued rewards /// Branding / project metadata (description, icon_url, website) ContractMeta, - /// Emergency pause flag; when true, stake and unstake are blocked. + /// Whether contract is paused for emergency Paused, } @@ -68,7 +88,7 @@ impl LiquidStaking { nft_contract: Address, ) { if env.storage().instance().has(&DataKey::Admin) { - panic!("already initialized"); + panic_with_error!(env, Error::AlreadyInitialized); } admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &admin); @@ -86,11 +106,15 @@ impl LiquidStaking { icon_url: String::from_str(&env, ""), website: String::from_str(&env, ""), }); + env.storage().instance().set(&DataKey::Paused, &false); } pub fn deposit_rewards(env: Env, from: Address, amount: i128) { from.require_auth(); - assert!(amount > 0, "amount must be positive"); + if amount <= 0 { + panic_with_error!(env, Error::AmountNotPositive); + } + Self::_check_not_paused(&env); let total_staked: i128 = env .storage() @@ -112,8 +136,8 @@ impl LiquidStaking { .get(&DataKey::RewardPerTokenStored) .unwrap_or(0); rpt = rpt.checked_add( - amount.checked_mul(PRECISION).expect("rpt overflow") / total_staked - ).expect("rpt overflow"); + amount.checked_mul(PRECISION).unwrap_or_else(|| panic_with_error!(env, Error::RptOverflow)) / total_staked + ).unwrap_or_else(|| panic_with_error!(env, Error::RptOverflow)); env.storage() .instance() .set(&DataKey::RewardPerTokenStored, &rpt); @@ -123,14 +147,16 @@ impl LiquidStaking { env.events().publish((symbol_short!("dep_rwd"),), (from, amount)); } + + pub fn stake(env: Env, user: Address, amount: i128, lock_duration: u64) -> u64 { user.require_auth(); - // Emergency pause check: block staking when the contract is paused. - assert!( - !env.storage().instance().get::(&DataKey::Paused).unwrap_or(false), - "contract is paused" - ); - assert!(amount > 0, "amount must be positive"); + if env.storage().instance().get::(&DataKey::Paused).unwrap_or(false) { + panic_with_error!(env, Error::ContractPaused); + } + if amount <= 0 { + panic_with_error!(env, Error::AmountNotPositive); + } let stake_token: Address = env.storage().instance().get(&DataKey::StakeToken).unwrap(); token::Client::new(&env, &stake_token).transfer( @@ -160,7 +186,7 @@ impl LiquidStaking { ); env.storage().persistent().set(&DataKey::StakeAmount(token_id), &amount); - let lock_time = env.ledger().timestamp().checked_add(lock_duration).expect("lock time overflow"); + let lock_time = env.ledger().timestamp().checked_add(lock_duration).unwrap_or_else(|| panic_with_error!(env, Error::LockTimeOverflow)); let lock_time = env.ledger().timestamp() + lock_duration; // Populate attributes @@ -198,7 +224,7 @@ impl LiquidStaking { env.storage().persistent().set(&DataKey::NftRewards(token_id), &0_i128); let total: i128 = env.storage().instance().get(&DataKey::TotalStaked).unwrap_or(0); - env.storage().instance().set(&DataKey::TotalStaked, &total.checked_add(amount).expect("total staked overflow")); + env.storage().instance().set(&DataKey::TotalStaked, &total.checked_add(amount).unwrap_or_else(|| panic_with_error!(env, Error::TotalStakedOverflow))); // Topic: event name only; user + token_id + amount + lock_time in data. env.events().publish((symbol_short!("staked"),), (user, token_id, amount, lock_time)); @@ -208,25 +234,26 @@ impl LiquidStaking { pub fn unstake(env: Env, user: Address, token_id: u64) { user.require_auth(); - // Emergency pause check: block unstaking when the contract is paused. - assert!( - !env.storage().instance().get::(&DataKey::Paused).unwrap_or(false), - "contract is paused" - ); - + Self::_check_not_paused(&env); let nft_contract: Address = env.storage().instance().get(&DataKey::NftContract).unwrap(); let owner: Address = env.invoke_contract( &nft_contract, &symbol_short!("owner_of"), (token_id,).into_val(&env), ); - assert_eq!(user, owner, "not token owner"); + if user != owner { + panic_with_error!(env, Error::NotTokenOwner); + } let lock_time: u64 = env.storage().persistent().get(&DataKey::StakeLockTime(token_id)).unwrap_or(0); - assert!(env.ledger().timestamp() >= lock_time, "stake is locked"); + if env.ledger().timestamp() < lock_time { + panic_with_error!(env, Error::StakeLocked); + } let amount: i128 = env.storage().persistent().get(&DataKey::StakeAmount(token_id)).unwrap_or(0); - assert!(amount > 0, "no stake found for token"); + if amount <= 0 { + panic_with_error!(env, Error::NoStakeFound); + } Self::_update_reward(&env, token_id); @@ -241,7 +268,7 @@ impl LiquidStaking { } let total: i128 = env.storage().instance().get(&DataKey::TotalStaked).unwrap_or(0); - env.storage().instance().set(&DataKey::TotalStaked, &total.checked_sub(amount).expect("total staked underflow")); + env.storage().instance().set(&DataKey::TotalStaked, &total.checked_sub(amount).unwrap_or_else(|| panic_with_error!(env, Error::TotalStakedUnderflow))); let stake_token: Address = env.storage().instance().get(&DataKey::StakeToken).unwrap(); token::Client::new(&env, &stake_token).transfer( @@ -266,8 +293,55 @@ impl LiquidStaking { env.events().publish((symbol_short!("unstaked"),), (user, token_id, amount)); } + // ── Emergency Withdraw ───────────────────────────────────────────────── + + /// Withdraw entire stake directly when contract is paused, without reward updates. + pub fn emergency_withdraw(env: Env, user: Address, token_id: u64) { + user.require_auth(); + assert!(Self::is_paused(env.clone()), "contract not paused"); + + let nft_contract: Address = env.storage().instance().get(&DataKey::NftContract).unwrap(); + let owner: Address = env.invoke_contract( + &nft_contract, + &symbol_short!("owner_of"), + (token_id,).into_val(&env), + ); + if user != owner { + panic_with_error!(env, Error::NotTokenOwner); + } + + let amount: i128 = env.storage().persistent().get(&DataKey::StakeAmount(token_id)).unwrap_or(0); + assert!(amount > 0, "no stake found for token"); + + // Update storage + let total: i128 = env.storage().instance().get(&DataKey::TotalStaked).unwrap_or(0); + env.storage().instance().set(&DataKey::TotalStaked, &total.checked_sub(amount).expect("total staked underflow")); + + let stake_token: Address = env.storage().instance().get(&DataKey::StakeToken).unwrap(); + token::Client::new(&env, &stake_token).transfer( + &env.current_contract_address(), + &user, + &amount, + ); + + env.storage().persistent().remove(&DataKey::StakeAmount(token_id)); + env.storage().persistent().remove(&DataKey::StakeLockTime(token_id)); + env.storage().persistent().remove(&DataKey::NftRewardPerTokenPaid(token_id)); + env.storage().persistent().remove(&DataKey::NftRewards(token_id)); + + // Burn the NFT + env.invoke_contract::<()>( + &nft_contract, + &symbol_short!("burn"), + (env.current_contract_address(), token_id).into_val(&env), + ); + + env.events().publish((symbol_short!("emer_wd"),), (user, token_id, amount)); + } + pub fn claim(env: Env, user: Address, token_id: u64) -> i128 { user.require_auth(); + Self::_check_not_paused(&env); let nft_contract: Address = env.storage().instance().get(&DataKey::NftContract).unwrap(); let owner: Address = env.invoke_contract( @@ -275,7 +349,9 @@ impl LiquidStaking { &symbol_short!("owner_of"), (token_id,).into_val(&env), ); - assert_eq!(user, owner, "not token owner"); + if user != owner { + panic_with_error!(env, Error::NotTokenOwner); + } Self::_update_reward(&env, token_id); @@ -311,8 +387,8 @@ impl LiquidStaking { let accrued: i128 = env.storage().persistent().get(&DataKey::NftRewards(token_id)).unwrap_or(0); let pending = accrued.checked_add( - amount.checked_mul(rpt - nft_rpt).expect("rewards overflow") / PRECISION - ).expect("rewards overflow"); + amount.checked_mul(rpt - nft_rpt).unwrap_or_else(|| panic_with_error!(env, Error::RewardsOverflow)) / PRECISION + ).unwrap_or_else(|| panic_with_error!(env, Error::RewardsOverflow)); let lock_time: u64 = env.storage().persistent().get(&DataKey::StakeLockTime(token_id)).unwrap_or(0); StakeInfo { @@ -332,8 +408,10 @@ impl LiquidStaking { .storage() .instance() .get(&DataKey::Admin) - .expect("admin not found"); - assert!(caller == admin, "only admin can pause"); + .unwrap_or_else(|| panic_with_error!(env, Error::AdminNotFound)); + if caller != admin { + panic_with_error!(env, Error::OnlyAdmin); + } env.storage().instance().set(&DataKey::Paused, &true); env.events().publish((symbol_short!("paused"),), caller); @@ -346,8 +424,10 @@ impl LiquidStaking { .storage() .instance() .get(&DataKey::Admin) - .expect("admin not found"); - assert!(caller == admin, "only admin can unpause"); + .unwrap_or_else(|| panic_with_error!(env, Error::AdminNotFound)); + if caller != admin { + panic_with_error!(env, Error::OnlyAdmin); + } env.storage().instance().set(&DataKey::Paused, &false); env.events().publish((symbol_short!("unpaused"),), caller); @@ -386,8 +466,10 @@ impl LiquidStaking { .storage() .instance() .get(&DataKey::Admin) - .expect("admin not found"); - assert!(caller == admin, "only admin can update contract metadata"); + .unwrap_or_else(|| panic_with_error!(env, Error::AdminNotFound)); + if caller != admin { + panic_with_error!(env, Error::OnlyAdmin); + } let meta = ContractMetadata { description, icon_url, website }; env.storage().instance().set(&DataKey::ContractMeta, &meta); @@ -400,23 +482,29 @@ impl LiquidStaking { env.storage() .instance() .get(&DataKey::ContractMeta) - .expect("contract metadata not initialised") + .unwrap_or_else(|| panic_with_error!(env, Error::AlreadyInitialized)) } fn _update_reward(env: &Env, token_id: u64) { let rpt: i128 = env.storage().instance().get(&DataKey::RewardPerTokenStored).unwrap_or(0); let nft_rpt: i128 = env.storage().persistent().get(&DataKey::NftRewardPerTokenPaid(token_id)).unwrap_or(0); let amount: i128 = env.storage().persistent().get(&DataKey::StakeAmount(token_id)).unwrap_or(0); - let earned = amount.checked_mul(rpt - nft_rpt).expect("rewards overflow") / PRECISION; + let earned = amount.checked_mul(rpt - nft_rpt).unwrap_or_else(|| panic_with_error!(env, Error::RewardsOverflow)) / PRECISION; if earned > 0 { let prev: i128 = env.storage().persistent().get(&DataKey::NftRewards(token_id)).unwrap_or(0); - env.storage().persistent().set(&DataKey::NftRewards(token_id), &prev.checked_add(earned).expect("rewards overflow")); + env.storage().persistent().set(&DataKey::NftRewards(token_id), &prev.checked_add(earned).unwrap_or_else(|| panic_with_error!(env, Error::RewardsOverflow))); } env.storage().persistent().set(&DataKey::NftRewardPerTokenPaid(token_id), &rpt); } + fn _check_not_paused(env: &Env) { + if Self::is_paused(env.clone()) { + panic_with_error!(env, Error::ContractPaused); + } + } + fn _sync_nft_metadata(env: &Env, token_id: u64) { let nft_contract: Address = env.storage().instance().get(&DataKey::NftContract).unwrap(); let amount: i128 = env.storage().persistent().get(&DataKey::StakeAmount(token_id)).unwrap_or(0); @@ -581,7 +669,7 @@ mod tests { } #[test] - #[should_panic(expected = "stake is locked")] + #[should_panic(expected = "HostError: Error(Contract, #7)")] fn test_unstake_locked() { let (env, ls_id, _, _, alice, _, _) = setup(); let client = LiquidStakingClient::new(&env, &ls_id); @@ -618,7 +706,7 @@ mod tests { } #[test] - #[should_panic(expected = "only admin can update contract metadata")] + #[should_panic(expected = "HostError: Error(Contract, #13)")] fn test_update_contract_meta_non_admin() { let (env, ls_id, _, _, alice, _, _) = setup(); let client = LiquidStakingClient::new(&env, &ls_id); @@ -632,6 +720,16 @@ mod tests { ); } + #[test] + #[should_panic(expected = "HostError: Error(Contract, #4)")] + fn test_normal_unstake_when_paused() { + let (env, ls_id, _, admin, alice, _, _) = setup(); + let client = LiquidStakingClient::new(&env, &ls_id); + let token_id = client.stake(&alice, &500_000, &3600); + client.pause(&admin); + client.unstake(&alice, &token_id); // Should panic + } + #[test] fn test_pause_blocks_stake() { let (env, ls_id, _, admin, _alice, _, _) = setup(); @@ -646,55 +744,60 @@ mod tests { } #[test] - #[should_panic(expected = "contract is paused")] + #[should_panic(expected = "HostError: Error(Contract, #4)")] fn test_stake_blocked_when_paused() { let (env, ls_id, _, admin, alice, _, _) = setup(); let client = LiquidStakingClient::new(&env, &ls_id); - client.pause(&admin); - // Should panic because the contract is paused. - client.stake(&alice, &500_000, &3600); + client.stake(&alice, &500_000, &3600); // Should panic } #[test] - #[should_panic(expected = "contract is paused")] - fn test_unstake_blocked_when_paused() { + fn test_pause_and_emergency_withdraw() { let (env, ls_id, _, admin, alice, _, _) = setup(); let client = LiquidStakingClient::new(&env, &ls_id); + let stake_token = env.as_contract(&ls_id, || { + env.storage().instance().get(&DataKey::StakeToken).unwrap() + }); + let token_client = TokenClient::new(&env, &stake_token); - // Stake while unpaused so we have a valid token. - let token_id = client.stake(&alice, &500_000, &0); - - // Pause, then attempt to unstake. - client.pause(&admin); - // Should panic because the contract is paused. - client.unstake(&alice, &token_id); - } - - #[test] - fn test_unpause_re_enables_stake() { - let (env, ls_id, _, admin, alice, _, _) = setup(); - let client = LiquidStakingClient::new(&env, &ls_id); + // Stake first + let token_id = client.stake(&alice, &500_000, &3600); + let info = client.get_stake_info(&token_id); + assert_eq!(info.amount, 500_000); + // Pause contract client.pause(&admin); assert!(client.is_paused()); + // Try emergency withdraw - should work + client.emergency_withdraw(&alice, &token_id); + // Check stake is gone + let after_info = client.get_stake_info(&token_id); + assert_eq!(after_info.amount, 0); + // Check tokens returned + assert_eq!(token_client.balance(&alice), 1_000_000); + + // Unpause client.unpause(&admin); assert!(!client.is_paused()); + } - // Stake should succeed again after unpause. + #[test] + #[should_panic(expected = "contract not paused")] + fn test_emergency_withdraw_not_paused() { + let (env, ls_id, _, _, alice, _, _) = setup(); + let client = LiquidStakingClient::new(&env, &ls_id); let token_id = client.stake(&alice, &500_000, &3600); - assert!(token_id > 0); + client.emergency_withdraw(&alice, &token_id); // Should panic } #[test] - #[should_panic(expected = "only admin can pause")] + #[should_panic(expected = "HostError: Error(Contract, #13)")] fn test_non_admin_cannot_pause() { let (env, ls_id, _, _, alice, _, _) = setup(); let client = LiquidStakingClient::new(&env, &ls_id); - - // Non-admin calling pause should be rejected. - client.pause(&alice); + client.pause(&alice); // Should panic } #[test] diff --git a/contracts/liquid_staking/test_snapshots/tests/test_stake_and_mint_nft.1.json b/contracts/liquid_staking/test_snapshots/tests/test_stake_and_mint_nft.1.json index 4fe666c9..7f577729 100644 --- a/contracts/liquid_staking/test_snapshots/tests/test_stake_and_mint_nft.1.json +++ b/contracts/liquid_staking/test_snapshots/tests/test_stake_and_mint_nft.1.json @@ -628,6 +628,43 @@ ] } }, + { + "key": { + "vec": [ + { + "symbol": "ContractMeta" + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "description" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "icon_url" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "website" + }, + "val": { + "string": "" + } + } + ] + } + }, { "key": { "vec": [ @@ -654,7 +691,116 @@ "symbol": "attributes" }, "val": { - "vec": [] + "vec": [ + { + "map": [ + { + "key": { + "symbol": "display_type" + }, + "val": { + "string": "number" + } + }, + { + "key": { + "symbol": "max_value" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "trait_type" + }, + "val": { + "string": "Stake Amount" + } + }, + { + "key": { + "symbol": "value" + }, + "val": { + "string": "500000" + } + } + ] + }, + { + "map": [ + { + "key": { + "symbol": "display_type" + }, + "val": { + "string": "date" + } + }, + { + "key": { + "symbol": "max_value" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "trait_type" + }, + "val": { + "string": "Lock Expiration" + } + }, + { + "key": { + "symbol": "value" + }, + "val": { + "string": "3600" + } + } + ] + }, + { + "map": [ + { + "key": { + "symbol": "display_type" + }, + "val": { + "string": "number" + } + }, + { + "key": { + "symbol": "max_value" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "trait_type" + }, + "val": { + "string": "Accrued Rewards" + } + }, + { + "key": { + "symbol": "value" + }, + "val": { + "string": "0" + } + } + ] + } + ] } }, { @@ -705,6 +851,14 @@ "bool": true } }, + { + "key": { + "symbol": "metadata_uri" + }, + "val": { + "string": "" + } + }, { "key": { "symbol": "name" @@ -1009,6 +1163,43 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" } }, + { + "key": { + "vec": [ + { + "symbol": "ContractMeta" + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "description" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "icon_url" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "website" + }, + "val": { + "string": "" + } + } + ] + } + }, { "key": { "vec": [ @@ -1021,6 +1212,18 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" } }, + { + "key": { + "vec": [ + { + "symbol": "Paused" + } + ] + }, + "val": { + "bool": false + } + }, { "key": { "vec": [ diff --git a/contracts/liquid_staking/test_snapshots/tests/test_transfer_and_claim.1.json b/contracts/liquid_staking/test_snapshots/tests/test_transfer_and_claim.1.json index 54745978..06b05182 100644 --- a/contracts/liquid_staking/test_snapshots/tests/test_transfer_and_claim.1.json +++ b/contracts/liquid_staking/test_snapshots/tests/test_transfer_and_claim.1.json @@ -822,6 +822,43 @@ ] } }, + { + "key": { + "vec": [ + { + "symbol": "ContractMeta" + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "description" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "icon_url" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "website" + }, + "val": { + "string": "" + } + } + ] + } + }, { "key": { "vec": [ @@ -848,7 +885,116 @@ "symbol": "attributes" }, "val": { - "vec": [] + "vec": [ + { + "map": [ + { + "key": { + "symbol": "display_type" + }, + "val": { + "string": "number" + } + }, + { + "key": { + "symbol": "max_value" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "trait_type" + }, + "val": { + "string": "Stake Amount" + } + }, + { + "key": { + "symbol": "value" + }, + "val": { + "string": "500000" + } + } + ] + }, + { + "map": [ + { + "key": { + "symbol": "display_type" + }, + "val": { + "string": "date" + } + }, + { + "key": { + "symbol": "max_value" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "trait_type" + }, + "val": { + "string": "Lock Expiration" + } + }, + { + "key": { + "symbol": "value" + }, + "val": { + "string": "3600" + } + } + ] + }, + { + "map": [ + { + "key": { + "symbol": "display_type" + }, + "val": { + "string": "number" + } + }, + { + "key": { + "symbol": "max_value" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "trait_type" + }, + "val": { + "string": "Accrued Rewards" + } + }, + { + "key": { + "symbol": "value" + }, + "val": { + "string": "0" + } + } + ] + } + ] } }, { @@ -899,6 +1045,14 @@ "bool": true } }, + { + "key": { + "symbol": "metadata_uri" + }, + "val": { + "string": "" + } + }, { "key": { "symbol": "name" @@ -1203,6 +1357,43 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" } }, + { + "key": { + "vec": [ + { + "symbol": "ContractMeta" + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "description" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "icon_url" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "website" + }, + "val": { + "string": "" + } + } + ] + } + }, { "key": { "vec": [ @@ -1215,6 +1406,18 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" } }, + { + "key": { + "vec": [ + { + "symbol": "Paused" + } + ] + }, + "val": { + "bool": false + } + }, { "key": { "vec": [ diff --git a/contracts/liquid_staking/test_snapshots/tests/test_unstake_locked.1.json b/contracts/liquid_staking/test_snapshots/tests/test_unstake_locked.1.json index 16e49f4c..cfa6503a 100644 --- a/contracts/liquid_staking/test_snapshots/tests/test_unstake_locked.1.json +++ b/contracts/liquid_staking/test_snapshots/tests/test_unstake_locked.1.json @@ -627,6 +627,43 @@ ] } }, + { + "key": { + "vec": [ + { + "symbol": "ContractMeta" + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "description" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "icon_url" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "website" + }, + "val": { + "string": "" + } + } + ] + } + }, { "key": { "vec": [ @@ -653,7 +690,116 @@ "symbol": "attributes" }, "val": { - "vec": [] + "vec": [ + { + "map": [ + { + "key": { + "symbol": "display_type" + }, + "val": { + "string": "number" + } + }, + { + "key": { + "symbol": "max_value" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "trait_type" + }, + "val": { + "string": "Stake Amount" + } + }, + { + "key": { + "symbol": "value" + }, + "val": { + "string": "500000" + } + } + ] + }, + { + "map": [ + { + "key": { + "symbol": "display_type" + }, + "val": { + "string": "date" + } + }, + { + "key": { + "symbol": "max_value" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "trait_type" + }, + "val": { + "string": "Lock Expiration" + } + }, + { + "key": { + "symbol": "value" + }, + "val": { + "string": "3600" + } + } + ] + }, + { + "map": [ + { + "key": { + "symbol": "display_type" + }, + "val": { + "string": "number" + } + }, + { + "key": { + "symbol": "max_value" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "trait_type" + }, + "val": { + "string": "Accrued Rewards" + } + }, + { + "key": { + "symbol": "value" + }, + "val": { + "string": "0" + } + } + ] + } + ] } }, { @@ -704,6 +850,14 @@ "bool": true } }, + { + "key": { + "symbol": "metadata_uri" + }, + "val": { + "string": "" + } + }, { "key": { "symbol": "name" @@ -1008,6 +1162,43 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" } }, + { + "key": { + "vec": [ + { + "symbol": "ContractMeta" + } + ] + }, + "val": { + "map": [ + { + "key": { + "symbol": "description" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "icon_url" + }, + "val": { + "string": "" + } + }, + { + "key": { + "symbol": "website" + }, + "val": { + "string": "" + } + } + ] + } + }, { "key": { "vec": [ @@ -1020,6 +1211,18 @@ "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4" } }, + { + "key": { + "vec": [ + { + "symbol": "Paused" + } + ] + }, + "val": { + "bool": false + } + }, { "key": { "vec": [ diff --git a/contracts/random_gen/README.md b/contracts/random_gen/README.md new file mode 100644 index 00000000..5c309051 --- /dev/null +++ b/contracts/random_gen/README.md @@ -0,0 +1,38 @@ +# RandomGen Contract — Storage Limits + +## Overview + +`RandomGen` implements a commit–reveal random seed ceremony. Each generation stores participant commitments and reveals in **persistent** storage, with protocol state in **instance** storage. + +## Storage Layout + +| Key | Tier | Value | Notes | +|-----|------|-------|-------| +| `Admin` | instance | `Address` | Set once at init | +| `MinCommits` | instance | `u32` | Required reveals to finalize | +| `Phase` | instance | `Phase` | Commit → Reveal → Finished | +| `Committers` | instance | `Vec
` | Cleared after finalize | +| `RandomSeed` | instance | `BytesN<32>` | Final XOR-combined seed | +| `Commit(user)` | persistent | `BytesN<32>` | Removed after finalize | +| `Reveal(user)` | persistent | `BytesN<32>` | Removed after finalize | + +## Footprint Formula + +For `min_commits = N` (capped at `MAX_PARTICIPANTS = 64`): + +- **During ceremony:** up to `2 × N` persistent entries + `Committers` vec (N addresses) +- **After finalize:** only `RandomSeed`, `Phase`, `Admin`, and `MinCommits` remain in instance storage; ephemeral persistent keys are removed + +Worst-case persistent bytes for user data: `N × PERSISTENT_BYTES_PER_PARTICIPANT` (64 bytes per participant during the ceremony). + +## Operator Guidance + +- Set `min_commits` between `1` and `64`. Values above `MAX_PARTICIPANTS` are rejected at initialization. +- Treat each deployment as a **one-shot** ceremony; redeploy for a new generation. +- Indexers should consume `commit`, `reveal`, and `rng_fin` events rather than scanning all persistent keys. + +## Audit Notes (Issue #573) + +1. **Bounded participants** — `MAX_PARTICIPANTS` prevents rent/footprint DoS via unbounded `min_commits`. +2. **Post-finalize cleanup** — commit/reveal persistent entries and the `Committers` vec are removed after seed generation. +3. **Redundant storage** — `Committers` duplicates addresses already keyed in persistent storage; kept for finalize iteration but cleared after use. diff --git a/contracts/random_gen/src/lib.rs b/contracts/random_gen/src/lib.rs index 12791e13..b561df88 100644 --- a/contracts/random_gen/src/lib.rs +++ b/contracts/random_gen/src/lib.rs @@ -26,6 +26,12 @@ pub enum DataKey { #[contract] pub struct RandomGen; +/// Maximum participants per generation to bound persistent storage footprint. +pub const MAX_PARTICIPANTS: u32 = 64; + +/// Persistent entries per participant: Commit + Reveal (32 bytes each). +pub const PERSISTENT_BYTES_PER_PARTICIPANT: u32 = 64; + #[contractimpl] impl RandomGen { /// Initialize the contract with an admin and the minimum number of participants. @@ -33,6 +39,9 @@ impl RandomGen { if env.storage().instance().has(&DataKey::Admin) { panic!("already initialized"); } + if min_commits == 0 || min_commits > MAX_PARTICIPANTS { + panic!("min_commits out of allowed range"); + } env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::MinCommits, &min_commits); env.storage().instance().set(&DataKey::Phase, &Phase::Commit); @@ -61,10 +70,13 @@ impl RandomGen { panic!("already committed"); } + let mut committers: Vec
= env.storage().instance().get(&DataKey::Committers).unwrap(); + if committers.len() >= MAX_PARTICIPANTS as u32 { + panic!("participant limit reached"); + } + // Store commit hash in temporary storage — valid only for this round. env.storage().temporary().set(&commit_key, &hash); - - let mut committers: Vec
= env.storage().instance().get(&DataKey::Committers).unwrap(); committers.push_back(user.clone()); env.storage().instance().set(&DataKey::Committers, &committers); @@ -152,6 +164,13 @@ impl RandomGen { env.storage().instance().set(&DataKey::RandomSeed, &final_seed); env.storage().instance().set(&DataKey::Phase, &Phase::Finished); + // Release ephemeral commit/reveal temporary entries after seed is finalized. + for user in committers.iter() { + env.storage().temporary().remove(&DataKey::Commit(user.clone())); + env.storage().temporary().remove(&DataKey::Reveal(user.clone())); + } + env.storage().instance().remove(&DataKey::Committers); + // Emit final event env.events().publish( (symbol_short!("rng_fin"),), @@ -170,6 +189,11 @@ impl RandomGen { pub fn get_phase(env: Env) -> Phase { env.storage().instance().get(&DataKey::Phase).unwrap_or(Phase::Commit) } + + /// Returns documented storage limits for operators and auditors. + pub fn storage_limits() -> (u32, u32) { + (MAX_PARTICIPANTS, PERSISTENT_BYTES_PER_PARTICIPANT) + } } #[cfg(test)] @@ -220,4 +244,22 @@ mod tests { assert_eq!(seed.to_array(), expected_seed_bytes); assert_eq!(client.get_random_seed().to_array(), expected_seed_bytes); } + + #[test] + #[should_panic(expected = "min_commits out of allowed range")] + fn test_rejects_excessive_min_commits() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register_contract(None, RandomGen); + let client = RandomGenClient::new(&env, &contract_id); + client.initialize(&admin, &(MAX_PARTICIPANTS + 1)); + } + + #[test] + fn test_storage_limits_documented() { + let (max_participants, bytes_per_participant) = RandomGen::storage_limits(); + assert_eq!(max_participants, 64); + assert_eq!(bytes_per_participant, 64); + } } diff --git a/contracts/registry/src/lib.rs b/contracts/registry/src/lib.rs index 31feb01c..7975677e 100644 --- a/contracts/registry/src/lib.rs +++ b/contracts/registry/src/lib.rs @@ -5,7 +5,7 @@ //! allowing for easy discovery and upgrades across the protocol. use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, Address, Env, String, Vec, + contract, contractimpl, contracttype, symbol_short, xdr::ToXdr, Address, Bytes, Env, String, Vec, }; /// Contract metadata stored in the registry @@ -80,6 +80,8 @@ impl Registry { let stored_admin: Address = env.storage().instance().get(&DataKey::Admin).expect("admin not set"); assert!(admin == stored_admin, "unauthorized"); + Self::check_not_zero_address(&env, &address); + let contract_key = DataKey::Contract(contract_type.clone()); let timestamp = env.ledger().timestamp(); @@ -92,7 +94,7 @@ impl Registry { contract_type: contract_type.clone(), deployed_at: timestamp, active: true, - previous_version: Some(existing_info.address), + previous_version: Some(existing_info.address.clone()), }; env.storage().instance().set(&contract_key, &updated_info); @@ -395,6 +397,17 @@ impl Registry { fn check_not_paused(env: &Env) { assert!(!Self::is_paused(env.clone()), "registry is paused"); } + + fn check_not_zero_address(env: &Env, address: &Address) { + let xdr: Bytes = address.clone().to_xdr(env); + let start = if xdr.len() > 32 { xdr.len() - 32 } else { 0 }; + for i in start..xdr.len() { + if xdr.get(i).unwrap() != 0 { + return; + } + } + panic!("cannot register zero address"); + } } #[cfg(test)] @@ -434,10 +447,10 @@ mod tests { client.register_contract(&admin, &contract_type, &address, &version); - assert!(client.is_registered(contract_type.clone())); - assert_eq!(client.get_address(contract_type.clone()), address); - assert_eq!(client.get_version(contract_type.clone()), version); - assert!(client.is_active(contract_type.clone())); + assert!(client.is_registered(&contract_type)); + assert_eq!(client.get_address(&contract_type), address); + assert_eq!(client.get_version(&contract_type), version); + assert!(client.is_active(&contract_type)); } #[test] @@ -456,7 +469,7 @@ mod tests { client.register_contract(&admin, &contract_type, &address2, &version2); - let info = client.get_contract(contract_type.clone()); + let info = client.get_contract(&contract_type); assert_eq!(info.address, address2); assert_eq!(info.version, version2); assert_eq!(info.previous_version, Some(address1)); @@ -471,10 +484,10 @@ mod tests { let version = String::from_str(&env, "1.0.0"); client.register_contract(&admin, &contract_type, &address, &version); - assert!(client.is_active(contract_type.clone())); + assert!(client.is_active(&contract_type)); client.deactivate_contract(&admin, &contract_type); - assert!(!client.is_active(contract_type.clone())); + assert!(!client.is_active(&contract_type)); } #[test] @@ -489,7 +502,7 @@ mod tests { client.deactivate_contract(&admin, &contract_type); client.activate_contract(&admin, &contract_type); - assert!(client.is_active(contract_type.clone())); + assert!(client.is_active(&contract_type)); } #[test] @@ -501,10 +514,10 @@ mod tests { let version = String::from_str(&env, "1.0.0"); client.register_contract(&admin, &contract_type, &address, &version); - assert!(client.is_registered(contract_type.clone())); + assert!(client.is_registered(&contract_type)); client.remove_contract(&admin, &contract_type); - assert!(!client.is_registered(contract_type.clone())); + assert!(!client.is_registered(&contract_type)); } #[test] @@ -588,11 +601,26 @@ mod tests { client.register_contract(&admin, &contract_type, &v1_address, &String::from_str(&env, "1.0.0")); client.register_contract(&admin, &contract_type, &v2_address, &String::from_str(&env, "2.0.0")); - let history = client.get_upgrade_history(contract_type); + let history = client.get_upgrade_history(&contract_type); assert_eq!(history.len(), 1); assert_eq!(history.get(0).unwrap(), v2_address); } + #[test] + #[should_panic(expected = "cannot register zero address")] + fn test_register_with_zero_address() { + let (env, client, admin) = setup(); + + use soroban_sdk::xdr::{Hash, ScAddress}; + use soroban_sdk::TryFromVal; + + let contract_type = String::from_str(&env, "AMM"); + let zero_address = Address::try_from_val(&env, &ScAddress::Contract(Hash([0u8; 32]))).unwrap(); + let version = String::from_str(&env, "1.0.0"); + + client.register_contract(&admin, &contract_type, &zero_address, &version); + } + #[test] #[should_panic(expected = "unauthorized")] fn test_unauthorized_register() { @@ -608,20 +636,14 @@ mod tests { fn test_multiple_contracts() { let (env, client, admin) = setup(); - let contracts = vec![ - ("AMM", "1.0.0"), - ("Lending", "1.0.0"), - ("Bridge", "1.0.0"), - ("XLMWrapper", "1.0.0"), - ("LiquidStaking", "1.0.0"), - ]; + let contract_names = ["AMM", "Lending", "Bridge", "XLMWrapper", "LiquidStaking"]; - for (name, version) in contracts.iter() { + for name in contract_names.iter() { client.register_contract( &admin, &String::from_str(&env, name), &Address::generate(&env), - &String::from_str(&env, version), + &String::from_str(&env, "1.0.0"), ); } diff --git a/contracts/revenue_distributor/src/lib.rs b/contracts/revenue_distributor/src/lib.rs index 64933722..c0f386b3 100644 --- a/contracts/revenue_distributor/src/lib.rs +++ b/contracts/revenue_distributor/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, token, Address, Env, + contract, contractimpl, contracttype, symbol_short, token, Address, Env, Vec, }; #[contracttype] @@ -10,12 +10,16 @@ pub enum DataKey { Treasury, GovStakers, GovShareBps, + PayoutTokens, + PayoutCursor, } #[contract] pub struct RevenueDistributor; const MAX_BPS: u32 = 10000; +/// Max tokens processed per call to stay within Soroban instruction limits. +const MAX_TOKENS_PER_BATCH: u32 = 10; #[contractimpl] impl RevenueDistributor { @@ -52,13 +56,77 @@ impl RevenueDistributor { env.storage().instance().set(&DataKey::GovShareBps, &gov_share_bps); } + /// Register a token for batched payout distribution (admin only). + pub fn register_payout_token(env: Env, admin: Address, token_addr: Address) { + admin.require_auth(); + let current_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + assert_eq!(admin, current_admin, "not authorized"); + + let mut tokens: Vec
= env + .storage() + .instance() + .get(&DataKey::PayoutTokens) + .unwrap_or_else(|| Vec::new(&env)); + + for existing in tokens.iter() { + if existing == token_addr { + return; + } + } + + tokens.push_back(token_addr); + env.storage().instance().set(&DataKey::PayoutTokens, &tokens); + } + /// Distributes the balance of a specific token held by this contract. pub fn distribute(env: Env, token_addr: Address) { + Self::distribute_token(&env, &token_addr); + } + + /// Distributes balances for registered payout tokens in bounded batches. + /// Returns the next cursor index (0 when complete). + pub fn distribute_batch(env: Env, start_index: u32, max_tokens: u32) -> u32 { + let tokens: Vec
= env + .storage() + .instance() + .get(&DataKey::PayoutTokens) + .unwrap_or_else(|| Vec::new(&env)); + + if tokens.is_empty() { + return 0; + } + + let batch_limit = if max_tokens == 0 { + MAX_TOKENS_PER_BATCH + } else { + max_tokens.min(MAX_TOKENS_PER_BATCH) + }; + + let mut index = start_index; + let mut processed = 0_u32; + + while index < tokens.len() && processed < batch_limit { + if let Some(token_addr) = tokens.get(index) { + Self::distribute_token(&env, &token_addr); + } + index += 1; + processed += 1; + } + + let next_cursor = if index >= tokens.len() { 0 } else { index }; + env.storage() + .instance() + .set(&DataKey::PayoutCursor, &next_cursor); + + next_cursor + } + + fn distribute_token(env: &Env, token_addr: &Address) { let gov_share_bps: u32 = env.storage().instance().get(&DataKey::GovShareBps).unwrap(); let treasury: Address = env.storage().instance().get(&DataKey::Treasury).unwrap(); let gov_stakers: Address = env.storage().instance().get(&DataKey::GovStakers).unwrap(); - let token_client = token::Client::new(&env, &token_addr); + let token_client = token::Client::new(env, token_addr); let balance = token_client.balance(&env.current_contract_address()); if balance == 0 { @@ -75,9 +143,8 @@ impl RevenueDistributor { token_client.transfer(&env.current_contract_address(), &treasury, &treasury_amount); } - // Emit event for indexer optimization env.events().publish( - (symbol_short!("distrib"), token_addr), + (symbol_short!("distrib"), token_addr.clone()), (gov_amount, treasury_amount), ); } @@ -153,8 +220,149 @@ mod tests { let (env, distributor_id, admin, _, _, _) = setup(); let distributor_client = RevenueDistributorClient::new(&env, &distributor_id); - distributor_client.set_shares(&admin, &8000); // Change to 80% + distributor_client.set_shares(&admin, &8000); let (_, _, gov_share) = distributor_client.get_config(); assert_eq!(gov_share, 8000); } + + #[test] + fn test_distribute_batch_bounded() { + let (env, distributor_id, admin, treasury, gov_stakers, token_addr) = setup(); + let distributor_client = RevenueDistributorClient::new(&env, &distributor_id); + let token_client = token::Client::new(&env, &token_addr); + + distributor_client.register_payout_token(&admin, &token_addr); + + let next = distributor_client.distribute_batch(&0, &10); + assert_eq!(next, 0); + assert_eq!(token_client.balance(&gov_stakers), 600); + assert_eq!(token_client.balance(&treasury), 400); + } + + #[test] + fn test_zero_balance_distribute_is_noop() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let gov_stakers = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + + let distributor_id = env.register_contract(None, RevenueDistributor); + let client = RevenueDistributorClient::new(&env, &distributor_id); + client.initialize(&admin, &treasury, &gov_stakers, &5000); + + client.distribute(&token_id.address()); + + let token_client = token::Client::new(&env, &token_id.address()); + assert_eq!(token_client.balance(&treasury), 0); + assert_eq!(token_client.balance(&gov_stakers), 0); + } + + #[test] + fn test_full_gov_share() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let gov_stakers = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + + let distributor_id = env.register_contract(None, RevenueDistributor); + let client = RevenueDistributorClient::new(&env, &distributor_id); + client.initialize(&admin, &treasury, &gov_stakers, &10000); + token::StellarAssetClient::new(&env, &token_id.address()).mint(&distributor_id, &1000); + + client.distribute(&token_id.address()); + + let token_client = token::Client::new(&env, &token_id.address()); + assert_eq!(token_client.balance(&gov_stakers), 1000); + assert_eq!(token_client.balance(&treasury), 0); + } + + #[test] + fn test_zero_gov_share() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let gov_stakers = Address::generate(&env); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_client = token::Client::new(&env, &token_id.address()); + + let distributor_id = env.register_contract(None, RevenueDistributor); + let distributor_client = RevenueDistributorClient::new(&env, &distributor_id); + distributor_client.initialize(&admin, &treasury, &gov_stakers, &0); + + token::StellarAssetClient::new(&env, &token_id.address()).mint(&distributor_id, &1000); + distributor_client.distribute(&token_id.address()); + + assert_eq!(token_client.balance(&gov_stakers), 0); + assert_eq!(token_client.balance(&treasury), 1000); + assert_eq!(token_client.balance(&distributor_id), 0); + } + + #[test] + fn test_weight_precision_with_odd_amounts() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let gov_stakers = Address::generate(&env); + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + + let distributor_id = env.register_contract(None, RevenueDistributor); + let client = RevenueDistributorClient::new(&env, &distributor_id); + client.initialize(&admin, &treasury, &gov_stakers, &0); + token::StellarAssetClient::new(&env, &token_id.address()).mint(&distributor_id, &1000); + + client.distribute(&token_id.address()); + + let token_client = token::Client::new(&env, &token_id.address()); + assert_eq!(token_client.balance(&gov_stakers), 0); + assert_eq!(token_client.balance(&treasury), 1000); + } + + #[test] + fn test_distribution_after_set_shares() { + let (env, distributor_id, admin, treasury, gov_stakers, token_addr) = setup(); + let client = RevenueDistributorClient::new(&env, &distributor_id); + let token_client = token::Client::new(&env, &token_addr); + + client.set_shares(&admin, &3000); + client.distribute(&token_addr); + + assert_eq!(token_client.balance(&gov_stakers), 300); + assert_eq!(token_client.balance(&treasury), 700); + } + + #[test] + #[should_panic(expected = "invalid share bps")] + fn test_invalid_bps_panics_on_initialize() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let gov_stakers = Address::generate(&env); + + let distributor_id = env.register_contract(None, RevenueDistributor); + let client = RevenueDistributorClient::new(&env, &distributor_id); + client.initialize(&admin, &treasury, &gov_stakers, &10001); + } + + #[test] + #[should_panic(expected = "invalid share bps")] + fn test_invalid_bps_panics_on_set_shares() { + let (env, distributor_id, admin, _, _, _) = setup(); + let client = RevenueDistributorClient::new(&env, &distributor_id); + client.set_shares(&admin, &10001); + } } diff --git a/contracts/staking/src/lib.rs b/contracts/staking/src/lib.rs index d9e51d98..7f2e4622 100644 --- a/contracts/staking/src/lib.rs +++ b/contracts/staking/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, token, Address, Env, Vec, + contract, contractimpl, contracttype, symbol_short, token, Address, Env, String, Vec, }; // Fixed-point precision: 1e18 @@ -29,6 +29,19 @@ pub enum DataKey { UserRewardPerTokenPaid(Address, Address), // (User, RewardToken) /// Accrued but unclaimed rewards for a user and reward token Rewards(Address, Address), // (User, RewardToken) + /// Branding / project metadata (description, icon_url, website) + ContractMeta, + /// Whether contract is paused for emergency + Paused, +} + +/// On-chain branding metadata for the contract. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ContractMetadata { + pub description: String, + pub icon_url: String, + pub website: String, } // ── Contract ───────────────────────────────────────────────────────────────── @@ -49,9 +62,16 @@ impl MultiTokenStaking { env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::StakeToken, &stake_token); env.storage().instance().set(&DataKey::TotalStaked, &0_i128); + env.storage().instance().set(&DataKey::Paused, &false); let reward_tokens: Vec
= Vec::new(&env); env.storage().instance().set(&DataKey::RewardTokens, &reward_tokens); + + env.storage().instance().set(&DataKey::ContractMeta, &ContractMetadata { + description: String::from_str(&env, ""), + icon_url: String::from_str(&env, ""), + website: String::from_str(&env, ""), + }); } /// Whitelist a new reward token. @@ -59,6 +79,7 @@ impl MultiTokenStaking { admin.require_auth(); let expected_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); assert_eq!(admin, expected_admin, "unauthorized"); + Self::_check_not_paused(&env); let is_whitelisted = env .storage() @@ -89,6 +110,7 @@ impl MultiTokenStaking { pub fn deposit_rewards(env: Env, from: Address, reward_token: Address, amount: i128) { from.require_auth(); assert!(amount > 0, "amount must be positive"); + Self::_check_not_paused(&env); let is_whitelisted = env .storage() @@ -131,11 +153,37 @@ impl MultiTokenStaking { ); } + // ── Admin: Pause/Unpause ───────────────────────────────────────────── + + /// Pause contract operations (emergency). + pub fn pause(env: Env, admin: Address) { + admin.require_auth(); + let expected_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + assert_eq!(admin, expected_admin, "unauthorized"); + env.storage().instance().set(&DataKey::Paused, &true); + env.events().publish((symbol_short!("paused"),), ()); + } + + /// Unpause contract operations. + pub fn unpause(env: Env, admin: Address) { + admin.require_auth(); + let expected_admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + assert_eq!(admin, expected_admin, "unauthorized"); + env.storage().instance().set(&DataKey::Paused, &false); + env.events().publish((symbol_short!("unpaused"),), ()); + } + + /// Check if contract is paused. + pub fn is_paused(env: Env) -> bool { + env.storage().instance().get(&DataKey::Paused).unwrap_or(false) + } + // ── Staking ─────────────────────────────────────────────────────────── pub fn stake(env: Env, user: Address, amount: i128) { user.require_auth(); assert!(amount > 0, "amount must be positive"); + Self::_check_not_paused(&env); Self::_update_rewards(&env, &user); @@ -166,6 +214,7 @@ impl MultiTokenStaking { pub fn unstake(env: Env, user: Address, amount: i128) { user.require_auth(); assert!(amount > 0, "amount must be positive"); + Self::_check_not_paused(&env); let prev = Self::_stake_of(&env, &user); assert!(prev >= amount, "insufficient stake"); @@ -196,11 +245,47 @@ impl MultiTokenStaking { env.events().publish((symbol_short!("unstaked"),), (user, amount)); } + // ── Emergency Withdraw ───────────────────────────────────────────────── + + /// Withdraw entire stake directly when contract is paused, without reward updates. + pub fn emergency_withdraw(env: Env, user: Address) { + user.require_auth(); + assert!(Self::is_paused(env.clone()), "contract not paused"); + + let amount = Self::_stake_of(&env, &user); + assert!(amount > 0, "no stake to withdraw"); + + // Update storage + env.storage() + .persistent() + .set(&DataKey::Stake(user.clone()), &0_i128); + + let total: i128 = env + .storage() + .instance() + .get(&DataKey::TotalStaked) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::TotalStaked, &total.checked_sub(amount).expect("total staked underflow")); + + // Transfer tokens + let stake_token: Address = env.storage().instance().get(&DataKey::StakeToken).unwrap(); + token::Client::new(&env, &stake_token).transfer( + &env.current_contract_address(), + &user, + &amount, + ); + + env.events().publish((symbol_short!("emer_wd"),), (user, amount)); + } + // ── Claiming ────────────────────────────────────────────────────────── /// Claim a specific reward token. pub fn claim(env: Env, user: Address, reward_token: Address) -> i128 { user.require_auth(); + Self::_check_not_paused(&env); Self::_update_reward_for_token(&env, &user, &reward_token); let reward: i128 = env @@ -230,6 +315,7 @@ impl MultiTokenStaking { /// Claim all whitelisted reward tokens. pub fn claim_all(env: Env, user: Address) { user.require_auth(); + Self::_check_not_paused(&env); let reward_tokens: Vec
= env.storage().instance().get(&DataKey::RewardTokens).unwrap_or_else(|| Vec::new(&env)); for reward_token in reward_tokens.iter() { @@ -297,8 +383,43 @@ impl MultiTokenStaking { env.storage().instance().get(&DataKey::RewardTokens).unwrap_or_else(|| Vec::new(&env)) } + /// Update the contract's branding metadata (admin only). + pub fn update_contract_meta( + env: Env, + caller: Address, + description: String, + icon_url: String, + website: String, + ) { + caller.require_auth(); + + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("admin not found"); + assert!(caller == admin, "only admin can update contract metadata"); + + let meta = ContractMetadata { description, icon_url, website }; + env.storage().instance().set(&DataKey::ContractMeta, &meta); + + env.events().publish((symbol_short!("meta_upd"),), meta); + } + + /// Return the current contract branding metadata. + pub fn get_contract_meta(env: Env) -> ContractMetadata { + env.storage() + .instance() + .get(&DataKey::ContractMeta) + .expect("contract metadata not initialised") + } + // ── Internal helpers ────────────────────────────────────────────────── + fn _check_not_paused(env: &Env) { + assert!(!Self::is_paused(env.clone()), "contract is paused"); + } + fn _update_rewards(env: &Env, user: &Address) { let reward_tokens: Vec
= env .storage() @@ -450,4 +571,81 @@ mod tests { assert_eq!(client.pending_rewards(&alice, &rwd1), 1_500); assert_eq!(client.pending_rewards(&bob, &rwd1), 500); } + + fn test_update_contract_meta() { + let (env, contract_id, admin, _, _, _, _) = setup(); + let client = MultiTokenStakingClient::new(&env, &contract_id); + + let initial = client.get_contract_meta(); + assert_eq!(initial.description, String::from_str(&env, "")); + assert_eq!(initial.icon_url, String::from_str(&env, "")); + assert_eq!(initial.website, String::from_str(&env, "")); + + client.update_contract_meta( + &admin, + &String::from_str(&env, "Multi-token staking on Stellar"), + &String::from_str(&env, "https://example.com/icon.png"), + &String::from_str(&env, "https://example.com"), + ); + + let updated = client.get_contract_meta(); + assert_eq!(updated.description, String::from_str(&env, "Multi-token staking on Stellar")); + assert_eq!(updated.icon_url, String::from_str(&env, "https://example.com/icon.png")); + assert_eq!(updated.website, String::from_str(&env, "https://example.com")); + } + + #[test] + #[should_panic(expected = "only admin can update contract metadata")] + fn test_update_contract_meta_non_admin() { + let (env, contract_id, _, alice, _, _, _) = setup(); + let client = MultiTokenStakingClient::new(&env, &contract_id); + + client.update_contract_meta( + &alice, + &String::from_str(&env, "Hacked"), + &String::from_str(&env, ""), + &String::from_str(&env, ""), + ); + } + + #[test] + #[should_panic(expected = "contract is paused")] + fn test_normal_unstake_when_paused() { + let (env, contract_id, admin, alice, _bob, _rwd1, _rwd2) = setup(); + let client = MultiTokenStakingClient::new(&env, &contract_id); + client.stake(&alice, &500_000); + client.pause(&admin); + client.unstake(&alice, &100_000); // Should panic + } + + #[test] + fn test_pause_and_emergency_withdraw() { + let (env, contract_id, admin, alice, _bob, _rwd1, _rwd2) = setup(); + let client = MultiTokenStakingClient::new(&env, &contract_id); + + // Stake first + client.stake(&alice, &500_000); + assert_eq!(client.stake_of(&alice), 500_000); + + // Pause contract + client.pause(&admin); + assert!(client.is_paused()); + + // Try emergency withdraw - should work + client.emergency_withdraw(&alice); + assert_eq!(client.stake_of(&alice), 0); + + // Unpause + client.unpause(&admin); + assert!(!client.is_paused()); + } + + #[test] + #[should_panic(expected = "contract not paused")] + fn test_emergency_withdraw_not_paused() { + let (env, contract_id, _admin, alice, _bob, _rwd1, _rwd2) = setup(); + let client = MultiTokenStakingClient::new(&env, &contract_id); + client.stake(&alice, &100_000); + client.emergency_withdraw(&alice); // Should panic + } } diff --git a/contracts/swap/Cargo.toml b/contracts/swap/Cargo.toml index 3596cd8f..799b2ff6 100644 --- a/contracts/swap/Cargo.toml +++ b/contracts/swap/Cargo.toml @@ -11,3 +11,4 @@ soroban-sdk = "22.0.0" [dev-dependencies] soroban-sdk = { version = "22.0.0", features = ["testutils"] } +proptest = "=1.4.0" diff --git a/contracts/swap/src/lib.rs b/contracts/swap/src/lib.rs index 9b66d220..e02f8f3d 100644 --- a/contracts/swap/src/lib.rs +++ b/contracts/swap/src/lib.rs @@ -1,4 +1,4 @@ -#![no_std] +#![cfg_attr(not(test), no_std)] use core::cmp; use soroban_sdk::{ @@ -15,9 +15,9 @@ fn get_token_decimals(env: &Env, token_addr: &Address) -> u32 { const MIN_TICK: i32 = -887272; const MAX_TICK: i32 = 887272; const TICK_SPACING: i32 = 60; // Default tick spacing -const MIN_FEE_BPS: u32 = 30; // 0.3% (30 basis points) -const MAX_FEE_BPS: u32 = 100; // 1.0% (100 basis points) -const FEE_DENOMINATOR: u32 = 10000; +pub const MIN_FEE_BPS: u32 = 30; // 0.3% (30 basis points) +pub const MAX_FEE_BPS: u32 = 100; // 1.0% (100 basis points) +pub const FEE_DENOMINATOR: u32 = 10000; const WINDOW_SIZE: u64 = 3600; // 1 hour window const VOLUME_THRESHOLD: i128 = 500_000_0000000; // 500k volume threshold for scaling const VOLATILITY_THRESHOLD: u128 = 10_000_000; // price change threshold for scaling @@ -819,6 +819,90 @@ impl MultiAssetSwap { } } +/// Pure slippage and fee math extracted for property/fuzz testing. +pub struct SwapMath; + +impl SwapMath { + pub fn apply_fee(amount_in: i128, fee_bps: u32) -> i128 { + amount_in + .checked_mul((FEE_DENOMINATOR - fee_bps) as i128) + .and_then(|v| v.checked_div(FEE_DENOMINATOR as i128)) + .unwrap_or(0) + } + + pub fn swap_step_zero_for_one(amount_remaining: i128, sqrt_price: u128, liquidity: i128) -> i128 { + if liquidity <= 0 || amount_remaining <= 0 { + return 0; + } + let numerator = amount_remaining.checked_mul(sqrt_price as i128).unwrap_or(0); + let denominator = (liquidity as u128) + .checked_mul(1u128 << 96) + .and_then(|v| v.checked_add(amount_remaining as u128)) + .unwrap_or(0); + if denominator == 0 { + return 0; + } + numerator.checked_div(denominator as i128).unwrap_or(0) + } + + pub fn swap_step_one_for_zero(amount_remaining: i128, sqrt_price: u128, liquidity: i128) -> i128 { + if liquidity <= 0 || amount_remaining <= 0 || sqrt_price == 0 { + return 0; + } + let numerator = amount_remaining.checked_mul((1u128 << 96) as i128).unwrap_or(0); + let denominator = (liquidity as u128).checked_mul(sqrt_price).unwrap_or(0); + if denominator == 0 { + return 0; + } + numerator.checked_div(denominator as i128).unwrap_or(0) + } + + pub fn meets_slippage(amount_out: i128, min_amount_out: i128) -> bool { + amount_out >= min_amount_out + } + + pub fn calculate_dynamic_fee_bps(volume_factor: u128, volatility_factor: u128) -> u32 { + let total_factor = (volume_factor + volatility_factor).min(100) as u32; + let fee_increase = (MAX_FEE_BPS - MIN_FEE_BPS) * total_factor / 100; + MIN_FEE_BPS + fee_increase + } + + pub fn simulate_swap_output( + amount_in: i128, + fee_bps: u32, + sqrt_price: u128, + liquidity: i128, + zero_for_one: bool, + max_steps: u32, + ) -> i128 { + if amount_in <= 0 || liquidity <= 0 { + return 0; + } + let mut amount_remaining = Self::apply_fee(amount_in, fee_bps); + let mut amount_out = 0_i128; + let mut steps = 0_u32; + + while amount_remaining > 0 && steps < max_steps { + let step_out = if zero_for_one { + Self::swap_step_zero_for_one(amount_remaining, sqrt_price, liquidity) + } else { + Self::swap_step_one_for_zero(amount_remaining, sqrt_price, liquidity) + }; + + if step_out == 0 { + break; + } + + let actual = core::cmp::min(step_out, amount_remaining); + amount_out += actual; + amount_remaining -= actual; + steps += 1; + } + + amount_out + } +} + #[cfg(test)] mod tests { use super::*; @@ -1022,7 +1106,7 @@ mod tests { // Verify referrer was set let referrer = client.get_referrer(&alice); - assert_eq!(referrer, Some(bob)); + assert_eq!(referrer, Some(bob.clone())); // Add liquidity client.mint(&alice, &-60, &60, &10_000, &10_000); diff --git a/contracts/swap/tests/fuzz_slippage.proptest-regressions b/contracts/swap/tests/fuzz_slippage.proptest-regressions new file mode 100644 index 00000000..90c3ad36 --- /dev/null +++ b/contracts/swap/tests/fuzz_slippage.proptest-regressions @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc c27ef92d4c1673dbe59e66d768b69c95f6d09901aa90fc7bc9906af6e6708b4b # shrinks to amount_in = 17079018616790727939338215590833579, fee_bps = 30 +cc dea2772b96501bb7cb43ea3235b274908ad8ad4d8050bd00e49eb970bbc1e3b1 # shrinks to amount_in = 2, fee_bps = 30, sqrt_price = 358839347919570114114716990468, liquidity = 948286103, zero_for_one = false diff --git a/contracts/swap/tests/fuzz_slippage.rs b/contracts/swap/tests/fuzz_slippage.rs new file mode 100644 index 00000000..f8b4a414 --- /dev/null +++ b/contracts/swap/tests/fuzz_slippage.rs @@ -0,0 +1,115 @@ +//! Integration fuzz/property tests for swap slippage calculations. + +use proptest::prelude::*; +use swap::{SwapMath, FEE_DENOMINATOR, MAX_FEE_BPS, MIN_FEE_BPS}; + +proptest! { + #[test] + fn apply_fee_never_exceeds_input( + amount_in in 0i128..=1_000_000_000_000_000i128, + fee_bps in MIN_FEE_BPS..=MAX_FEE_BPS + ) { + let after_fee = SwapMath::apply_fee(amount_in, fee_bps); + prop_assert!(after_fee >= 0); + prop_assert!(after_fee <= amount_in); + } + + #[test] + fn higher_fee_bps_yields_lower_or_equal_output( + amount_in in 1_000i128..=1_000_000_000i128, + fee_low in MIN_FEE_BPS..=MAX_FEE_BPS, + fee_high in MIN_FEE_BPS..=MAX_FEE_BPS + ) { + let low = SwapMath::apply_fee(amount_in, fee_low.min(fee_high)); + let high = SwapMath::apply_fee(amount_in, fee_low.max(fee_high)); + prop_assert!(low >= high); + } + + #[test] + fn swap_step_zero_for_one_bounded( + amount_remaining in 1i128..=1_000_000_000i128, + sqrt_price in 1u128..=1_000_000_000_000u128, + liquidity in 1i128..=1_000_000_000i128 + ) { + let out = SwapMath::swap_step_zero_for_one(amount_remaining, sqrt_price, liquidity); + prop_assert!(out >= 0); + prop_assert!(out <= amount_remaining); + } + + #[test] + fn swap_step_one_for_zero_non_negative( + amount_remaining in 1i128..=1_000_000_000i128, + sqrt_price in 1u128..=1_000_000_000_000u128, + liquidity in 1i128..=1_000_000_000i128 + ) { + let out = SwapMath::swap_step_one_for_zero(amount_remaining, sqrt_price, liquidity); + prop_assert!(out >= 0); + } + + #[test] + fn slippage_check_respects_min_amount_out( + amount_out in 0i128..=1_000_000_000i128, + min_amount_out in 0i128..=1_000_000_000i128 + ) { + let passes = SwapMath::meets_slippage(amount_out, min_amount_out); + if amount_out >= min_amount_out { + prop_assert!(passes); + } else { + prop_assert!(!passes); + } + } + + #[test] + fn dynamic_fee_stays_within_bounds( + volume_factor in 0u128..=10_000u128, + volatility_factor in 0u128..=10_000u128 + ) { + let fee = SwapMath::calculate_dynamic_fee_bps(volume_factor, volatility_factor); + prop_assert!(fee >= MIN_FEE_BPS); + prop_assert!(fee <= MAX_FEE_BPS); + } + + #[test] + fn simulate_swap_with_extreme_rates( + amount_in in 1i128..=1_000_000_000i128, + fee_bps in MIN_FEE_BPS..=MAX_FEE_BPS, + sqrt_price in 1u128..=1_000_000_000_000u128, + liquidity in 1i128..=1_000_000_000i128, + zero_for_one: bool + ) { + let amount_out = SwapMath::simulate_swap_output( + amount_in, + fee_bps, + sqrt_price, + liquidity, + zero_for_one, + 256, + ); + + prop_assert!(amount_out >= 0); + let max_possible = SwapMath::apply_fee(amount_in, fee_bps); + prop_assert!(amount_out <= max_possible); + prop_assert!(SwapMath::meets_slippage(amount_out, 0)); + } + + #[test] + fn zero_liquidity_produces_zero_output( + amount_in in 1i128..=1_000_000i128, + fee_bps in MIN_FEE_BPS..=MAX_FEE_BPS, + sqrt_price in 1u128..=1_000_000u128 + ) { + let out = SwapMath::simulate_swap_output(amount_in, fee_bps, sqrt_price, 0, true, 10); + prop_assert_eq!(out, 0); + } + + #[test] + fn fee_denominator_extreme_bps( + amount_in in 1i128..=1_000_000i128 + ) { + let at_min = SwapMath::apply_fee(amount_in, MIN_FEE_BPS); + let at_max = SwapMath::apply_fee(amount_in, MAX_FEE_BPS); + prop_assert!(at_min > at_max); + prop_assert_eq!(SwapMath::apply_fee(amount_in, 0), amount_in); + prop_assert_eq!(SwapMath::apply_fee(amount_in, FEE_DENOMINATOR), 0); + } +} diff --git a/contracts/xlm_wrapper/src/lib.rs b/contracts/xlm_wrapper/src/lib.rs index 4ed733ca..7f27f3fa 100644 --- a/contracts/xlm_wrapper/src/lib.rs +++ b/contracts/xlm_wrapper/src/lib.rs @@ -27,6 +27,8 @@ pub enum DataKey { Name, Symbol, Decimals, + NativeAsset, + Token, /// Tracks if an address is authorized to interact with AMM AMMAuthorized(Address), /// Tracks if an address is authorized to interact with Lending @@ -47,7 +49,9 @@ impl XLMWrapper { /// * `admin` - Administrator address with special privileges /// * `name` - Token name (e.g., "Wrapped XLM") /// * `symbol` - Token symbol (e.g., "wXLM") - pub fn initialize(env: Env, admin: Address, name: String, symbol: String) { + /// * `token` - Address of the wXLM token contract + /// * `native_asset` - Address of the native XLM token contract + pub fn initialize(env: Env, admin: Address, token: Address, name: String, symbol: String, native_asset: Address) { if env.storage().instance().has(&DataKey::Admin) { panic!("already initialized"); } @@ -57,7 +61,9 @@ impl XLMWrapper { env.storage().instance().set(&DataKey::Name, &name); env.storage().instance().set(&DataKey::Symbol, &symbol); env.storage().instance().set(&DataKey::Decimals, &7u32); // XLM uses 7 decimals + env.storage().instance().set(&DataKey::Token, &token); env.storage().instance().set(&DataKey::Paused, &false); + env.storage().instance().set(&DataKey::NativeAsset, &native_asset); // Authorize the contract itself for AMM/Lending interactions let contract_addr = env.current_contract_address(); @@ -80,8 +86,9 @@ impl XLMWrapper { assert!(amount > 0, "amount must be positive"); // Receive native XLM from user + let native_asset: Address = env.storage().instance().get(&DataKey::NativeAsset).expect("native asset not set"); let contract_addr = env.current_contract_address(); - token::Client::new(&env, &contract_addr) + token::Client::new(&env, &native_asset) .transfer(&from, &contract_addr, &amount); // Mint wXLM to user (1:1 ratio) @@ -129,8 +136,9 @@ impl XLMWrapper { .set(&DataKey::TotalSupply, &(supply - amount)); // Send native XLM back to user + let native_asset: Address = env.storage().instance().get(&DataKey::NativeAsset).expect("native asset not set"); let contract_addr = env.current_contract_address(); - token::Client::new(&env, &contract_addr) + token::Client::new(&env, &native_asset) .transfer(&contract_addr, &from, &amount); env.events() @@ -161,6 +169,35 @@ impl XLMWrapper { .publish((symbol_short!("approve"), owner, spender), amount); } + /// Decrease the allowance granted to a spender + pub fn decrease_allowance(env: Env, owner: Address, spender: Address, amount: i128) { + owner.require_auth(); + assert!(amount >= 0, "amount must be non-negative"); + let current = Self::allowance(env.clone(), owner.clone(), spender.clone()); + assert!(amount <= current, "insufficient allowance"); + let new_allowance = current - amount; + env.storage().persistent().set( + &DataKey::Allowance(owner.clone(), spender.clone()), + &new_allowance, + ); + env.events() + .publish((symbol_short!("dec_allow"), owner, spender), new_allowance); + } + + /// Increase the allowance granted to a spender + pub fn increase_allowance(env: Env, owner: Address, spender: Address, amount: i128) { + owner.require_auth(); + assert!(amount >= 0, "amount must be non-negative"); + let current = Self::allowance(env.clone(), owner.clone(), spender.clone()); + let new_allowance = current + amount; + env.storage().persistent().set( + &DataKey::Allowance(owner.clone(), spender.clone()), + &new_allowance, + ); + env.events() + .publish((symbol_short!("inc_allow"), owner, spender), new_allowance); + } + /// Set operator approval for all tokens pub fn set_approval_for_all(env: Env, owner: Address, operator: Address, approved: bool) { owner.require_auth(); @@ -307,7 +344,7 @@ impl XLMWrapper { env.storage().instance().remove(&DataKey::AMMAuthorized(amm_address.clone())); env.events() - .publish((symbol_short!("amm_revok"), amm_address), true); + .publish((soroban_sdk::Symbol::new(&env, "amm_revoke"), amm_address), true); } /// Check if an address is authorized for AMM interactions @@ -342,7 +379,7 @@ impl XLMWrapper { env.storage().instance().remove(&DataKey::LendingAuthorized(lending_address.clone())); env.events() - .publish((symbol_short!("lend_revk"), lending_address), true); + .publish((soroban_sdk::Symbol::new(&env, "lend_revoke"), lending_address.clone()), true); } /// Check if an address is authorized for Lending interactions @@ -427,196 +464,343 @@ impl XLMWrapper { mod tests { use super::*; use soroban_sdk::testutils::Address as _; + use soroban_sdk::token::StellarAssetClient; + + struct TestEnv { + env: Env, + client: XLMWrapperClient<'static>, + sac: StellarAssetClient<'static>, + admin: Address, + } - fn setup() -> (Env, XLMWrapperClient<'static>, Address) { + fn setup() -> TestEnv { let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); + let sac_contract = env.register_stellar_asset_contract_v2(admin.clone()); + let native_asset = sac_contract.address(); + let sac = StellarAssetClient::new(&env, &native_asset); + let contract_id = env.register_contract(None, XLMWrapper); let client = XLMWrapperClient::new(&env, &contract_id); + let token = Address::generate(&env); client.initialize( &admin, + &token, &String::from_str(&env, "Wrapped XLM"), &String::from_str(&env, "wXLM"), + &native_asset, ); - (env, client, admin) + TestEnv { env, client, sac, admin } + } + + fn fund_user(te: &TestEnv, user: &Address, amount: i128) { + te.sac.mint(user, &amount); } #[test] fn test_initialize() { - let (env, client, admin) = setup(); + let te = setup(); - assert_eq!(client.name(), String::from_str(&env, "Wrapped XLM")); - assert_eq!(client.symbol(), String::from_str(&env, "wXLM")); - assert_eq!(client.decimals(), 7); - assert_eq!(client.total_supply(), 0); + assert_eq!(te.client.name(), String::from_str(&te.env, "Wrapped XLM")); + assert_eq!(te.client.symbol(), String::from_str(&te.env, "wXLM")); + assert_eq!(te.client.decimals(), 7); + assert_eq!(te.client.total_supply(), 0); } #[test] fn test_deposit_withdraw() { - let (env, client, admin) = setup(); - let user = Address::generate(&env); + let te = setup(); + let user = Address::generate(&te.env); - // Mock native XLM balance for testing - // In production, this would be actual native XLM + fund_user(&te, &user, 1000); + te.client.deposit(&user, &1000); - // Test deposit - let deposit_amount = 1000_i128; - client.deposit(&user, &deposit_amount); + assert_eq!(te.client.balance_of(&user), 1000); + assert_eq!(te.client.total_supply(), 1000); - assert_eq!(client.balance_of(&user), deposit_amount); - assert_eq!(client.total_supply(), deposit_amount); + te.client.withdraw(&user, &500); - // Test withdraw - let withdraw_amount = 500_i128; - client.withdraw(&user, &withdraw_amount); - - assert_eq!(client.balance_of(&user), deposit_amount - withdraw_amount); - assert_eq!(client.total_supply(), deposit_amount - withdraw_amount); + assert_eq!(te.client.balance_of(&user), 500); + assert_eq!(te.client.total_supply(), 500); } #[test] fn test_transfer() { - let (env, client, admin) = setup(); - let alice = Address::generate(&env); - let bob = Address::generate(&env); + let te = setup(); + let alice = Address::generate(&te.env); + let bob = Address::generate(&te.env); - client.deposit(&alice, &1000); - client.transfer(&alice, &bob, &300); + fund_user(&te, &alice, 1000); + te.client.deposit(&alice, &1000); + te.client.transfer(&alice, &bob, &300); - assert_eq!(client.balance_of(&alice), 700); - assert_eq!(client.balance_of(&bob), 300); - assert_eq!(client.total_supply(), 1000); + assert_eq!(te.client.balance_of(&alice), 700); + assert_eq!(te.client.balance_of(&bob), 300); + assert_eq!(te.client.total_supply(), 1000); } #[test] fn test_approve_and_transfer_from() { - let (env, client, admin) = setup(); - let alice = Address::generate(&env); - let bob = Address::generate(&env); - let carol = Address::generate(&env); + let te = setup(); + let alice = Address::generate(&te.env); + let bob = Address::generate(&te.env); + let carol = Address::generate(&te.env); - client.deposit(&alice, &1000); - client.approve(&alice, &bob, &500); + fund_user(&te, &alice, 1000); + te.client.deposit(&alice, &1000); + te.client.approve(&alice, &bob, &500); - assert_eq!(client.allowance(&alice, &bob), 500); + assert_eq!(te.client.allowance(&alice, &bob), 500); - client.transfer_from(&bob, &alice, &carol, &300); + te.client.transfer_from(&bob, &alice, &carol, &300); - assert_eq!(client.balance_of(&alice), 700); - assert_eq!(client.balance_of(&carol), 300); - assert_eq!(client.allowance(&alice, &bob), 200); + assert_eq!(te.client.balance_of(&alice), 700); + assert_eq!(te.client.balance_of(&carol), 300); + assert_eq!(te.client.allowance(&alice, &bob), 200); + } + + #[test] + fn test_decrease_allowance() { + let te = setup(); + let owner = Address::generate(&te.env); + let spender = Address::generate(&te.env); + + fund_user(&te, &owner, 1000); + te.client.deposit(&owner, &1000); + te.client.approve(&owner, &spender, &500); + + assert_eq!(te.client.allowance(&owner, &spender), 500); + + te.client.decrease_allowance(&owner, &spender, &200); + + assert_eq!(te.client.allowance(&owner, &spender), 300); + } + + #[test] + fn test_decrease_allowance_to_zero() { + let te = setup(); + let owner = Address::generate(&te.env); + let spender = Address::generate(&te.env); + + fund_user(&te, &owner, 1000); + te.client.deposit(&owner, &1000); + te.client.approve(&owner, &spender, &300); + te.client.decrease_allowance(&owner, &spender, &300); + + assert_eq!(te.client.allowance(&owner, &spender), 0); + } + + #[test] + #[should_panic(expected = "insufficient allowance")] + fn test_decrease_allowance_exceeding_balance() { + let te = setup(); + let owner = Address::generate(&te.env); + let spender = Address::generate(&te.env); + + fund_user(&te, &owner, 1000); + te.client.deposit(&owner, &1000); + te.client.approve(&owner, &spender, &300); + te.client.decrease_allowance(&owner, &spender, &500); + } + + #[test] + #[should_panic(expected = "insufficient allowance")] + fn test_decrease_allowance_from_zero() { + let te = setup(); + let owner = Address::generate(&te.env); + let spender = Address::generate(&te.env); + + fund_user(&te, &owner, 1000); + te.client.deposit(&owner, &1000); + + // Decreasing from zero allowance should fail + te.client.decrease_allowance(&owner, &spender, &100); + } + + #[test] + fn test_increase_allowance() { + let te = setup(); + let owner = Address::generate(&te.env); + let spender = Address::generate(&te.env); + + fund_user(&te, &owner, 1000); + te.client.deposit(&owner, &1000); + te.client.approve(&owner, &spender, &300); + + assert_eq!(te.client.allowance(&owner, &spender), 300); + + te.client.increase_allowance(&owner, &spender, &200); + + assert_eq!(te.client.allowance(&owner, &spender), 500); + } + + #[test] + fn test_increase_allowance_from_zero() { + let te = setup(); + let owner = Address::generate(&te.env); + let spender = Address::generate(&te.env); + + fund_user(&te, &owner, 1000); + te.client.deposit(&owner, &1000); + + te.client.increase_allowance(&owner, &spender, &300); + + assert_eq!(te.client.allowance(&owner, &spender), 300); + } + + #[test] + #[should_panic(expected = "amount must be non-negative")] + fn test_decrease_allowance_negative_amount() { + let te = setup(); + let owner = Address::generate(&te.env); + let spender = Address::generate(&te.env); + + fund_user(&te, &owner, 1000); + te.client.deposit(&owner, &1000); + te.client.approve(&owner, &spender, &500); + te.client.decrease_allowance(&owner, &spender, &-100); + } + + #[test] + fn test_allowance_combined_operations() { + let te = setup(); + let owner = Address::generate(&te.env); + let spender = Address::generate(&te.env); + + fund_user(&te, &owner, 1000); + te.client.deposit(&owner, &1000); + + // Approve, then increase, then decrease, then transfer_from + te.client.approve(&owner, &spender, &200); + assert_eq!(te.client.allowance(&owner, &spender), 200); + + te.client.increase_allowance(&owner, &spender, &100); + assert_eq!(te.client.allowance(&owner, &spender), 300); + + te.client.decrease_allowance(&owner, &spender, &50); + assert_eq!(te.client.allowance(&owner, &spender), 250); + + let recipient = Address::generate(&te.env); + te.client.transfer_from(&spender, &owner, &recipient, &100); + assert_eq!(te.client.allowance(&owner, &spender), 150); + assert_eq!(te.client.balance_of(&owner), 900); + assert_eq!(te.client.balance_of(&recipient), 100); } #[test] fn test_operator_approval() { - let (env, client, admin) = setup(); - let alice = Address::generate(&env); - let operator = Address::generate(&env); - let bob = Address::generate(&env); + let te = setup(); + let alice = Address::generate(&te.env); + let operator = Address::generate(&te.env); + let bob = Address::generate(&te.env); - client.deposit(&alice, &1000); - client.set_approval_for_all(&alice, &operator, &true); + fund_user(&te, &alice, 1000); + te.client.deposit(&alice, &1000); + te.client.set_approval_for_all(&alice, &operator, &true); - assert!(client.is_approved_for_all(&alice, &operator)); + assert!(te.client.is_approved_for_all(&alice, &operator)); - client.transfer_from(&operator, &alice, &bob, &300); + te.client.transfer_from(&operator, &alice, &bob, &300); - assert_eq!(client.balance_of(&alice), 700); - assert_eq!(client.balance_of(&bob), 300); + assert_eq!(te.client.balance_of(&alice), 700); + assert_eq!(te.client.balance_of(&bob), 300); } #[test] fn test_burn() { - let (env, client, admin) = setup(); - let alice = Address::generate(&env); + let te = setup(); + let alice = Address::generate(&te.env); - client.deposit(&alice, &1000); - client.burn(&alice, &300); + fund_user(&te, &alice, 1000); + te.client.deposit(&alice, &1000); + te.client.burn(&alice, &300); - assert_eq!(client.balance_of(&alice), 700); - assert_eq!(client.total_supply(), 700); + assert_eq!(te.client.balance_of(&alice), 700); + assert_eq!(te.client.total_supply(), 700); } #[test] fn test_amm_authorization() { - let (env, client, admin) = setup(); - let amm_address = Address::generate(&env); + let te = setup(); + let amm_address = Address::generate(&te.env); - assert!(!client.is_amm_authorized(&amm_address)); + assert!(!te.client.is_amm_authorized(&amm_address)); - client.authorize_amm(&admin, &amm_address); - assert!(client.is_amm_authorized(&amm_address)); + te.client.authorize_amm(&te.admin, &amm_address); + assert!(te.client.is_amm_authorized(&amm_address)); - client.revoke_amm(&admin, &amm_address); - assert!(!client.is_amm_authorized(&amm_address)); + te.client.revoke_amm(&te.admin, &amm_address); + assert!(!te.client.is_amm_authorized(&amm_address)); } #[test] fn test_lending_authorization() { - let (env, client, admin) = setup(); - let lending_address = Address::generate(&env); + let te = setup(); + let lending_address = Address::generate(&te.env); - assert!(!client.is_lending_authorized(&lending_address)); + assert!(!te.client.is_lending_authorized(&lending_address)); - client.authorize_lending(&admin, &lending_address); - assert!(client.is_lending_authorized(&lending_address)); + te.client.authorize_lending(&te.admin, &lending_address); + assert!(te.client.is_lending_authorized(&lending_address)); - client.revoke_lending(&admin, &lending_address); - assert!(!client.is_lending_authorized(&lending_address)); + te.client.revoke_lending(&te.admin, &lending_address); + assert!(!te.client.is_lending_authorized(&lending_address)); } #[test] fn test_pause_unpause() { - let (env, client, admin) = setup(); - let user = Address::generate(&env); + let te = setup(); + let user = Address::generate(&te.env); - assert!(!client.is_paused()); + assert!(!te.client.is_paused()); - client.pause(&admin); - assert!(client.is_paused()); + te.client.pause(&te.admin); + assert!(te.client.is_paused()); - client.unpause(&admin); - assert!(!client.is_paused()); + te.client.unpause(&te.admin); + assert!(!te.client.is_paused()); } #[test] #[should_panic(expected = "contract is paused")] fn test_deposit_when_paused() { - let (env, client, admin) = setup(); - let user = Address::generate(&env); + let te = setup(); + let user = Address::generate(&te.env); - client.pause(&admin); - client.deposit(&user, &1000); + te.client.pause(&te.admin); + te.client.deposit(&user, &1000); } #[test] #[should_panic(expected = "contract is paused")] fn test_withdraw_when_paused() { - let (env, client, admin) = setup(); - let user = Address::generate(&env); + let te = setup(); + let user = Address::generate(&te.env); - client.deposit(&user, &1000); - client.pause(&admin); - client.withdraw(&user, &500); + fund_user(&te, &user, 1000); + te.client.deposit(&user, &1000); + te.client.pause(&te.admin); + te.client.withdraw(&user, &500); } #[test] fn test_one_to_one_peg() { - let (env, client, admin) = setup(); - let user = Address::generate(&env); + let te = setup(); + let user = Address::generate(&te.env); // Verify 1:1 peg is maintained - client.deposit(&user, &1000); - assert_eq!(client.balance_of(&user), 1000); - assert_eq!(client.total_supply(), 1000); + fund_user(&te, &user, 1000); + te.client.deposit(&user, &1000); + assert_eq!(te.client.balance_of(&user), 1000); + assert_eq!(te.client.total_supply(), 1000); - client.withdraw(&user, &1000); - assert_eq!(client.balance_of(&user), 0); - assert_eq!(client.total_supply(), 0); + te.client.withdraw(&user, &1000); + assert_eq!(te.client.balance_of(&user), 0); + assert_eq!(te.client.total_supply(), 0); } } @@ -631,23 +815,37 @@ mod invariants { extern crate std; use super::*; use soroban_sdk::testutils::Address as _; + use soroban_sdk::token::StellarAssetClient; - /// Helper to set up a fresh contract instance - fn setup_fresh() -> (Env, XLMWrapperClient<'static>, Address) { + fn fund_user(env: &Env, sac: &StellarAssetClient<'_>, user: &Address, amount: i128) { + sac.mint(user, &amount); + } + + fn fund_and_deposit(env: &Env, sac: &StellarAssetClient<'_>, client: &XLMWrapperClient<'_>, user: &Address, amount: i128) { + fund_user(env, sac, user, amount); + client.deposit(user, &amount); + } + + fn setup_fresh() -> (Env, XLMWrapperClient<'static>, StellarAssetClient<'static>, Address) { let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); + let sac_contract = env.register_stellar_asset_contract_v2(admin.clone()); + let native_asset = sac_contract.address(); + let sac = StellarAssetClient::new(&env, &native_asset); let contract_id = env.register_contract(None, XLMWrapper); let client = XLMWrapperClient::new(&env, &contract_id); + let token = Address::generate(&env); client.initialize( &admin, + &token, &String::from_str(&env, "Wrapped XLM"), &String::from_str(&env, "wXLM"), + &native_asset, ); - (env, client, admin) + (env, client, sac, admin) } - /// Helper to verify the supply conservation invariant fn verify_supply_conservation(env: &Env, client: &XLMWrapperClient<'_>, users: &[Address]) { let total_supply = client.total_supply(); let balance_sum: i128 = users.iter().map(|u| client.balance_of(u)).sum(); @@ -658,458 +856,338 @@ mod invariants { ); } - // ========================================================================= - // INVARIANT 1: Conservation of Supply - // ========================================================================= - /// After any operation, the sum of all user balances must equal total_supply. - /// This is the fundamental invariant of any token contract. #[test] fn invariant_supply_conservation_after_deposit() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let user1 = Address::generate(&env); let user2 = Address::generate(&env); let user3 = Address::generate(&env); - - // Deposit to multiple users - client.deposit(&user1, &1000); + fund_and_deposit(&env, &sac, &client, &user1, 1000); verify_supply_conservation(&env, &client, &[user1.clone(), user2.clone(), user3.clone()]); - - client.deposit(&user2, &500); + fund_and_deposit(&env, &sac, &client, &user2, 500); verify_supply_conservation(&env, &client, &[user1.clone(), user2.clone(), user3.clone()]); - - client.deposit(&user3, &250); + fund_and_deposit(&env, &sac, &client, &user3, 250); verify_supply_conservation(&env, &client, &[user1, user2, user3]); } #[test] fn invariant_supply_conservation_after_transfer() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let alice = Address::generate(&env); let bob = Address::generate(&env); let carol = Address::generate(&env); - - client.deposit(&alice, &1000); + fund_and_deposit(&env, &sac, &client, &alice, 1000); let supply_before = client.total_supply(); - - // Multiple transfers client.transfer(&alice, &bob, &300); verify_supply_conservation(&env, &client, &[alice.clone(), bob.clone(), carol.clone()]); - client.transfer(&bob, &carol, &150); verify_supply_conservation(&env, &client, &[alice.clone(), bob.clone(), carol.clone()]); - client.transfer(&alice, &carol, &100); verify_supply_conservation(&env, &client, &[alice, bob, carol]); - let supply_after = client.total_supply(); - assert_eq!( - supply_before, supply_after, - "INVARIANT VIOLATION: Supply changed during transfers" - ); + assert_eq!(supply_before, supply_after, "INVARIANT VIOLATION: Supply changed during transfers"); } #[test] fn invariant_supply_conservation_after_withdraw() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let user = Address::generate(&env); - - client.deposit(&user, &1000); + fund_and_deposit(&env, &sac, &client, &user, 1000); let supply_before_withdraw = client.total_supply(); - client.withdraw(&user, &300); verify_supply_conservation(&env, &client, &[user.clone()]); - - // Invariant: supply decreases by exactly the withdrawn amount - assert_eq!( - client.total_supply(), - supply_before_withdraw - 300, - "INVARIANT VIOLATION: Supply not reduced correctly after withdraw" - ); - + assert_eq!(client.total_supply(), supply_before_withdraw - 300, "INVARIANT VIOLATION: Supply not reduced correctly after withdraw"); verify_supply_conservation(&env, &client, &[user]); } #[test] fn invariant_supply_conservation_after_burn() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let user = Address::generate(&env); - - client.deposit(&user, &1000); + fund_and_deposit(&env, &sac, &client, &user, 1000); let supply_before_burn = client.total_supply(); - client.burn(&user, &300); verify_supply_conservation(&env, &client, &[user.clone()]); - - assert_eq!( - client.total_supply(), - supply_before_burn - 300, - "INVARIANT VIOLATION: Supply not reduced correctly after burn" - ); - + assert_eq!(client.total_supply(), supply_before_burn - 300, "INVARIANT VIOLATION: Supply not reduced correctly after burn"); verify_supply_conservation(&env, &client, &[user]); } - // ========================================================================= - // INVARIANT 2: 1:1 Peg Maintenance - // ========================================================================= - /// The total supply of wXLM should always equal the total amount of - /// native XLM held by the contract (minus any burned tokens). #[test] fn invariant_one_to_one_peg_after_operations() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let user = Address::generate(&env); - - // Deposit creates 1:1 peg - client.deposit(&user, &1000); - assert_eq!( - client.total_supply(), - client.balance_of(&user), - "INVARIANT VIOLATION: 1:1 peg broken after deposit" - ); - - // Transfer maintains peg + fund_and_deposit(&env, &sac, &client, &user, 1000); + assert_eq!(client.total_supply(), client.balance_of(&user), "INVARIANT VIOLATION: 1:1 peg broken after deposit"); let bob = Address::generate(&env); client.transfer(&user, &bob, &300); - assert_eq!( - client.total_supply(), - client.balance_of(&user) + client.balance_of(&bob), - "INVARIANT VIOLATION: 1:1 peg broken after transfer" - ); - - // Withdraw maintains peg + assert_eq!(client.total_supply(), client.balance_of(&user) + client.balance_of(&bob), "INVARIANT VIOLATION: 1:1 peg broken after transfer"); client.withdraw(&user, &200); - assert_eq!( - client.total_supply(), - client.balance_of(&user) + client.balance_of(&bob), - "INVARIANT VIOLATION: 1:1 peg broken after withdraw" - ); + assert_eq!(client.total_supply(), client.balance_of(&user) + client.balance_of(&bob), "INVARIANT VIOLATION: 1:1 peg broken after withdraw"); } - // ========================================================================= - // INVARIANT 3: Non-Negative Balances - // ========================================================================= - /// All balances must always be non-negative (>= 0). #[test] fn invariant_non_negative_balances() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let user = Address::generate(&env); - - assert!( - client.balance_of(&user) >= 0, - "INVARIANT VIOLATION: Initial balance is negative" - ); - - client.deposit(&user, &100); - assert!( - client.balance_of(&user) >= 0, - "INVARIANT VIOLATION: Balance negative after deposit" - ); - + assert!(client.balance_of(&user) >= 0, "INVARIANT VIOLATION: Initial balance is negative"); + fund_and_deposit(&env, &sac, &client, &user, 100); + assert!(client.balance_of(&user) >= 0, "INVARIANT VIOLATION: Balance negative after deposit"); client.withdraw(&user, &100); - assert!( - client.balance_of(&user) >= 0, - "INVARIANT VIOLATION: Balance negative after withdraw" - ); + assert!(client.balance_of(&user) >= 0, "INVARIANT VIOLATION: Balance negative after withdraw"); } - // ========================================================================= - // INVARIANT 4: Conservation of Value in Transfer - // ========================================================================= - /// In any transfer, the sum of sender and receiver balances before - /// must equal the sum after the transfer. #[test] fn invariant_transfer_value_conservation() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let alice = Address::generate(&env); let bob = Address::generate(&env); - - client.deposit(&alice, &1000); - + fund_and_deposit(&env, &sac, &client, &alice, 1000); let alice_before = client.balance_of(&alice); let bob_before = client.balance_of(&bob); let sum_before = alice_before + bob_before; - client.transfer(&alice, &bob, &400); - let alice_after = client.balance_of(&alice); let bob_after = client.balance_of(&bob); let sum_after = alice_after + bob_after; - - assert_eq!( - sum_before, sum_after, - "INVARIANT VIOLATION: Value not conserved in transfer" - ); - - assert_eq!( - alice_before - alice_after, - 400, - "INVARIANT VIOLATION: Sender balance not reduced correctly" - ); - - assert_eq!( - bob_after - bob_before, - 400, - "INVARIANT VIOLATION: Receiver balance not increased correctly" - ); + assert_eq!(sum_before, sum_after, "INVARIANT VIOLATION: Value not conserved in transfer"); + assert_eq!(alice_before - alice_after, 400, "INVARIANT VIOLATION: Sender balance not reduced correctly"); + assert_eq!(bob_after - bob_before, 400, "INVARIANT VIOLATION: Receiver balance not increased correctly"); } - // ========================================================================= - // INVARIANT 5: Allowance Accounting - // ========================================================================= - /// After transfer_from, the allowance must decrease by exactly the - /// transferred amount. #[test] fn invariant_allowance_decrease_on_transfer_from() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let owner = Address::generate(&env); let spender = Address::generate(&env); let recipient = Address::generate(&env); - - client.deposit(&owner, &1000); + fund_and_deposit(&env, &sac, &client, &owner, 1000); client.approve(&owner, &spender, &500); - let allowance_before = client.allowance(&owner, &spender); - client.transfer_from(&spender, &owner, &recipient, &200); - let allowance_after = client.allowance(&owner, &spender); - - assert_eq!( - allowance_before - allowance_after, - 200, - "INVARIANT VIOLATION: Allowance not reduced correctly" - ); + assert_eq!(allowance_before - allowance_after, 200, "INVARIANT VIOLATION: Allowance not reduced correctly"); } #[test] #[should_panic] fn invariant_allowance_cannot_exceed_approval() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let owner = Address::generate(&env); let spender = Address::generate(&env); let recipient = Address::generate(&env); - - client.deposit(&owner, &1000); + fund_and_deposit(&env, &sac, &client, &owner, 1000); client.approve(&owner, &spender, &100); - - // Attempting to spend more than approved should fail client.transfer_from(&spender, &owner, &recipient, &150); } - // ========================================================================= - // INVARIANT 6: No Double Spend - // ========================================================================= - /// A user cannot spend the same tokens twice (either directly or via approval). + #[test] + fn invariant_decrease_allowance_reduces_correctly() { + let (env, client, sac, _) = setup_fresh(); + let owner = Address::generate(&env); + let spender = Address::generate(&env); + fund_and_deposit(&env, &sac, &client, &owner, 1000); + client.approve(&owner, &spender, &500); + let before = client.allowance(&owner, &spender); + client.decrease_allowance(&owner, &spender, &200); + let after = client.allowance(&owner, &spender); + assert_eq!(before - after, 200, "INVARIANT VIOLATION: decrease_allowance did not reduce allowance by the correct amount"); + } + + #[test] + fn invariant_decrease_allowance_to_zero_resets_cleanly() { + let (env, client, sac, _) = setup_fresh(); + let owner = Address::generate(&env); + let spender = Address::generate(&env); + fund_and_deposit(&env, &sac, &client, &owner, 1000); + client.approve(&owner, &spender, &300); + client.decrease_allowance(&owner, &spender, &300); + assert_eq!(client.allowance(&owner, &spender), 0, "INVARIANT VIOLATION: Allowance not zero after full decrease"); + } + + #[test] + fn invariant_decrease_allowance_preserves_supply() { + let (env, client, sac, _) = setup_fresh(); + let owner = Address::generate(&env); + let spender = Address::generate(&env); + fund_and_deposit(&env, &sac, &client, &owner, 1000); + client.approve(&owner, &spender, &500); + let supply_before = client.total_supply(); + client.decrease_allowance(&owner, &spender, &100); + assert_eq!(client.total_supply(), supply_before, "INVARIANT VIOLATION: decrease_allowance changed total supply"); + } + + #[test] + fn invariant_decrease_allowance_does_not_affect_balances() { + let (env, client, sac, _) = setup_fresh(); + let owner = Address::generate(&env); + let spender = Address::generate(&env); + fund_and_deposit(&env, &sac, &client, &owner, 1000); + client.approve(&owner, &spender, &500); + let owner_bal_before = client.balance_of(&owner); + let spender_bal_before = client.balance_of(&spender); + client.decrease_allowance(&owner, &spender, &100); + assert_eq!(client.balance_of(&owner), owner_bal_before, "INVARIANT VIOLATION: decrease_allowance changed owner balance"); + assert_eq!(client.balance_of(&spender), spender_bal_before, "INVARIANT VIOLATION: decrease_allowance changed spender balance"); + } + + #[test] + fn invariant_increase_allowance_preserves_invariants() { + let (env, client, sac, _) = setup_fresh(); + let owner = Address::generate(&env); + let spender = Address::generate(&env); + let users = [owner.clone(), spender.clone()]; + fund_and_deposit(&env, &sac, &client, &owner, 1000); + client.approve(&owner, &spender, &500); + let supply_before = client.total_supply(); + let owner_bal_before = client.balance_of(&owner); + let spender_bal_before = client.balance_of(&spender); + client.increase_allowance(&owner, &spender, &200); + assert_eq!(client.total_supply(), supply_before); + assert_eq!(client.balance_of(&owner), owner_bal_before); + assert_eq!(client.balance_of(&spender), spender_bal_before); + verify_supply_conservation(&env, &client, &users); + } + + #[test] + fn invariant_allowance_decrease_and_transfer_from_sequence() { + let (env, client, sac, _) = setup_fresh(); + let owner = Address::generate(&env); + let spender = Address::generate(&env); + let recipient = Address::generate(&env); + let users = [owner.clone(), spender.clone(), recipient.clone()]; + fund_and_deposit(&env, &sac, &client, &owner, 1000); + client.approve(&owner, &spender, &500); + client.decrease_allowance(&owner, &spender, &200); + assert_eq!(client.allowance(&owner, &spender), 300); + client.transfer_from(&spender, &owner, &recipient, &300); + assert_eq!(client.allowance(&owner, &spender), 0); + assert_eq!(client.balance_of(&recipient), 300); + verify_supply_conservation(&env, &client, &users); + } + #[test] #[should_panic] fn invariant_no_double_spend_direct() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let alice = Address::generate(&env); let bob = Address::generate(&env); let carol = Address::generate(&env); - - client.deposit(&alice, &100); - - // First transfer succeeds + fund_and_deposit(&env, &sac, &client, &alice, 100); client.transfer(&alice, &bob, &60); - - // Alice now has 40, trying to spend 60 more should fail client.transfer(&alice, &carol, &60); } - // ========================================================================= - // INVARIANT 7: Total Supply Monotonicity - // ========================================================================= - /// Total supply only increases via deposit and only decreases via withdraw/burn. - /// Transfers and approvals do not affect total supply. #[test] fn invariant_supply_only_changes_via_deposit_withdraw_burn() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let alice = Address::generate(&env); let bob = Address::generate(&env); - let initial_supply = client.total_supply(); assert_eq!(initial_supply, 0); - - // Deposit increases supply - client.deposit(&alice, &500); + fund_and_deposit(&env, &sac, &client, &alice, 500); assert_eq!(client.total_supply(), 500); verify_supply_conservation(&env, &client, &[alice.clone(), bob.clone()]); - - // Transfer does not change supply client.transfer(&alice, &bob, &200); - assert_eq!( - client.total_supply(), - 500, - "INVARIANT VIOLATION: Transfer changed total supply" - ); + assert_eq!(client.total_supply(), 500, "INVARIANT VIOLATION: Transfer changed total supply"); verify_supply_conservation(&env, &client, &[alice.clone(), bob.clone()]); - - // Approve does not change supply client.approve(&alice, &bob, &100); - assert_eq!( - client.total_supply(), - 500, - "INVARIANT VIOLATION: Approve changed total supply" - ); + assert_eq!(client.total_supply(), 500, "INVARIANT VIOLATION: Approve changed total supply"); verify_supply_conservation(&env, &client, &[alice.clone(), bob.clone()]); - - // Burn decreases supply client.burn(&alice, &100); assert_eq!(client.total_supply(), 400); verify_supply_conservation(&env, &client, &[alice, bob]); } - // ========================================================================= - // INVARIANT 8: Zero Amount Handling - // ========================================================================= - /// The contract should handle zero amounts appropriately. #[test] #[should_panic] fn invariant_deposit_zero_rejected() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let user = Address::generate(&env); - client.deposit(&user, &0); } #[test] #[should_panic] fn invariant_withdraw_zero_rejected() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let user = Address::generate(&env); - - client.deposit(&user, &100); + fund_and_deposit(&env, &sac, &client, &user, 100); client.withdraw(&user, &0); } #[test] #[should_panic] fn invariant_burn_zero_rejected() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let user = Address::generate(&env); - - client.deposit(&user, &100); + fund_and_deposit(&env, &sac, &client, &user, 100); client.burn(&user, &0); } - // ========================================================================= - // INVARIANT 9: Idempotency Properties - // ========================================================================= - /// Certain operations should have predictable idempotent-like behavior. #[test] fn invariant_approve_overwrites() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let owner = Address::generate(&env); let spender = Address::generate(&env); - - client.deposit(&owner, &1000); + fund_and_deposit(&env, &sac, &client, &owner, 1000); client.approve(&owner, &spender, &100); assert_eq!(client.allowance(&owner, &spender), 100); - - // New approval should overwrite, not add client.approve(&owner, &spender, &200); - assert_eq!( - client.allowance(&owner, &spender), - 200, - "INVARIANT VIOLATION: Approve did not overwrite previous allowance" - ); + assert_eq!(client.allowance(&owner, &spender), 200, "INVARIANT VIOLATION: Approve did not overwrite previous allowance"); } - // ========================================================================= - // PROPERTY-BASED INVARIANT TESTS - // ========================================================================= - /// These tests verify invariants across sequences of operations. - #[test] fn property_sequence_invariant() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let alice = Address::generate(&env); let bob = Address::generate(&env); let carol = Address::generate(&env); - - // Sequence of operations that should maintain invariants - client.deposit(&alice, &1000); + fund_and_deposit(&env, &sac, &client, &alice, 1000); verify_supply_conservation(&env, &client, &[alice.clone(), bob.clone(), carol.clone()]); - - client.deposit(&bob, &500); + fund_and_deposit(&env, &sac, &client, &bob, 500); verify_supply_conservation(&env, &client, &[alice.clone(), bob.clone(), carol.clone()]); - client.transfer(&alice, &bob, &200); verify_supply_conservation(&env, &client, &[alice.clone(), bob.clone(), carol.clone()]); - client.approve(&bob, &carol, &300); verify_supply_conservation(&env, &client, &[alice.clone(), bob.clone(), carol.clone()]); - client.transfer_from(&carol, &bob, &alice, &150); verify_supply_conservation(&env, &client, &[alice.clone(), bob.clone(), carol.clone()]); - client.withdraw(&alice, &100); - verify_supply_conservation(&env, &client, &[alice, bob, carol]); + verify_supply_conservation(&env, &client, &[alice.clone(), bob.clone(), carol.clone()]); // Verify final invariants let total_balance = client.balance_of(&alice) + client.balance_of(&bob) + client.balance_of(&carol); - - assert_eq!( - client.total_supply(), - total_balance, - "PROPERTY VIOLATION: Supply invariant broken after operation sequence" - ); - - assert!( - client.balance_of(&alice) >= 0 && client.balance_of(&bob) >= 0 && client.balance_of(&carol) >= 0, - "PROPERTY VIOLATION: Negative balance detected" - ); + assert_eq!(client.total_supply(), total_balance, "PROPERTY VIOLATION: Supply invariant broken after operation sequence"); + assert!(client.balance_of(&alice) >= 0 && client.balance_of(&bob) >= 0 && client.balance_of(&carol) >= 0, "PROPERTY VIOLATION: Negative balance detected"); } #[test] fn property_deposit_withdraw_symmetry() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let user = Address::generate(&env); - - // Deposit then withdraw same amount should return to initial state let initial_supply = client.total_supply(); let initial_balance = client.balance_of(&user); - - client.deposit(&user, &500); + fund_and_deposit(&env, &sac, &client, &user, 500); verify_supply_conservation(&env, &client, &[user.clone()]); - client.withdraw(&user, &500); verify_supply_conservation(&env, &client, &[user.clone()]); - - assert_eq!( - client.total_supply(), - initial_supply, - "PROPERTY VIOLATION: Deposit-withdraw symmetry broken for supply" - ); - - assert_eq!( - client.balance_of(&user), - initial_balance, - "PROPERTY VIOLATION: Deposit-withdraw symmetry broken for balance" - ); + assert_eq!(client.total_supply(), initial_supply, "PROPERTY VIOLATION: Deposit-withdraw symmetry broken for supply"); + assert_eq!(client.balance_of(&user), initial_balance, "PROPERTY VIOLATION: Deposit-withdraw symmetry broken for balance"); } #[test] fn property_transfer_reversibility_check() { - let (env, client, _) = setup_fresh(); + let (env, client, sac, _) = setup_fresh(); let alice = Address::generate(&env); let bob = Address::generate(&env); - - client.deposit(&alice, &1000); + fund_and_deposit(&env, &sac, &client, &alice, 1000); verify_supply_conservation(&env, &client, &[alice.clone(), bob.clone()]); - let alice_initial = client.balance_of(&alice); let bob_initial = client.balance_of(&bob); - - // Transfer A -> B client.transfer(&alice, &bob, &300); verify_supply_conservation(&env, &client, &[alice.clone(), bob.clone()]); - - // Transfer B -> A (reverse) client.transfer(&bob, &alice, &300); - verify_supply_conservation(&env, &client, &[alice, bob]); - + verify_supply_conservation(&env, &client, &[alice.clone(), bob.clone()]); // After round-trip, balances should be back to original assert_eq!( client.balance_of(&alice), @@ -1126,36 +1204,24 @@ mod invariants { #[test] fn property_multi_user_supply_conservation() { - let (env, client, _) = setup_fresh(); - let users: Vec
= (0..10).map(|_| Address::generate(&env)).collect(); - - // Random-like deposits + let (env, client, sac, _) = setup_fresh(); + let mut users = std::vec::Vec::new(); let mut total_deposited = 0_i128; - for (i, user) in users.iter().enumerate() { + for i in 0..10 { + let user = Address::generate(&env); let amount = ((i + 1) * 100) as i128; - client.deposit(user, &amount); + fund_and_deposit(&env, &sac, &client, &user, amount); total_deposited += amount; + users.push(user); verify_supply_conservation(&env, &client, &users); } - - assert_eq!( - client.total_supply(), - total_deposited, - "PROPERTY VIOLATION: Total supply doesn't match total deposited" - ); - - // Random-like transfers between users + assert_eq!(client.total_supply(), total_deposited, "PROPERTY VIOLATION: Total supply doesn't match total deposited"); for i in 0..5 { - let from = users.get(i).unwrap(); - let to = users.get(i + 1).unwrap(); + let from = &users[i]; + let to = &users[i + 1]; client.transfer(from, to, &50); verify_supply_conservation(&env, &client, &users); } - - assert_eq!( - client.total_supply(), - total_deposited, - "PROPERTY VIOLATION: Supply changed during transfers" - ); + assert_eq!(client.total_supply(), total_deposited, "PROPERTY VIOLATION: Supply changed during transfers"); } } diff --git a/contracts/yield/src/lib.rs b/contracts/yield/src/lib.rs index 4668f20a..3bc7516b 100644 --- a/contracts/yield/src/lib.rs +++ b/contracts/yield/src/lib.rs @@ -98,17 +98,13 @@ impl YieldDistribution { .get(&DataKey::TotalStaked) .unwrap_or(0); - // Transfer reward tokens into the contract - let reward_token: Address = env.storage().instance().get(&DataKey::RewardToken).unwrap(); - token::Client::new(&env, &reward_token).transfer( - &from, - &env.current_contract_address(), - &amount, - ); - + // Update state before external token transfer (reentrancy guard pattern). // If nobody is staking yet, rewards accumulate but can't be distributed — // they will be claimable once the first stake occurs (reward_per_token // stays 0 until then, so the deposited tokens sit idle). + let reward_token: Address = env.storage().instance().get(&DataKey::RewardToken).unwrap(); + + // CEI: update state before external token transfer if total_staked > 0 { let mut rpt: i128 = env .storage() @@ -124,6 +120,15 @@ impl YieldDistribution { .set(&DataKey::RewardPerTokenStored, &rpt); } + // Transfer reward tokens into the contract after state is updated + let reward_token: Address = env.storage().instance().get(&DataKey::RewardToken).unwrap(); + // External interaction last + token::Client::new(&env, &reward_token).transfer( + &from, + &env.current_contract_address(), + &amount, + ); + // Topic: event name only; from + amount in data. env.events() .publish((symbol_short!("dep_rwd"),), (from, amount)); @@ -147,13 +152,10 @@ impl YieldDistribution { // Settle any pending rewards before changing the stake Self::_update_reward(&env, &user); + // Update state before external token transfer (reentrancy guard pattern) let stake_token: Address = env.storage().instance().get(&DataKey::StakeToken).unwrap(); - token::Client::new(&env, &stake_token).transfer( - &user, - &env.current_contract_address(), - &amount, - ); + // CEI: update state before external token transfer let prev: i128 = Self::_stake_of(&env, &user); env.storage() .persistent() @@ -168,6 +170,14 @@ impl YieldDistribution { .instance() .set(&DataKey::TotalStaked, &total.checked_add(amount).expect("total staked overflow")); + let stake_token: Address = env.storage().instance().get(&DataKey::StakeToken).unwrap(); + // External interaction last + token::Client::new(&env, &stake_token).transfer( + &user, + &env.current_contract_address(), + &amount, + ); + // Topic: event name only; user + amount in data. env.events() .publish((symbol_short!("staked"),), (user, amount)); @@ -250,7 +260,6 @@ impl YieldDistribution { &reward, ); - // Topic: event name only; user + reward in data. env.events() .publish((symbol_short!("claimed"),), (user, reward)); } @@ -345,12 +354,25 @@ impl YieldDistribution { mod tests { use super::*; use soroban_sdk::{ + contract, contractimpl, symbol_short, testutils::Address as _, token::{Client as TokenClient, StellarAssetClient}, Address, Env, }; - fn setup() -> (Env, Address, Address, Address, Address, Address) { + #[contract] + pub struct MockRegistry; + #[contractimpl] + impl MockRegistry { + pub fn is_paused(env: Env) -> bool { + env.storage().instance().get(&symbol_short!("paused")).unwrap_or(false) + } + pub fn set_paused(env: Env, paused: bool) { + env.storage().instance().set(&symbol_short!("paused"), &paused); + } + } + + fn setup() -> (Env, Address, Address, Address, Address, Address, Address) { let env = Env::default(); env.mock_all_auths(); @@ -385,12 +407,13 @@ mod tests { alice, bob, reward_token_id.address(), + stake_token_id.address(), ) } #[test] fn test_stake_and_claim() { - let (env, contract_id, admin, alice, _bob, reward_token) = setup(); + let (env, contract_id, admin, alice, _bob, reward_token, _stake_token) = setup(); let client = YieldDistributionClient::new(&env, &contract_id); // Alice stakes 500_000 @@ -415,7 +438,7 @@ mod tests { #[test] fn test_proportional_split() { - let (env, contract_id, admin, alice, bob, _) = setup(); + let (env, contract_id, admin, alice, bob, _, _stake_token) = setup(); let client = YieldDistributionClient::new(&env, &contract_id); // Alice: 300_000, Bob: 700_000 → 30% / 70% split @@ -431,7 +454,7 @@ mod tests { #[test] fn test_rewards_accrue_correctly_after_late_stake() { - let (env, contract_id, admin, alice, bob, _) = setup(); + let (env, contract_id, admin, alice, bob, _, _stake_token) = setup(); let client = YieldDistributionClient::new(&env, &contract_id); // Alice stakes first, rewards deposited, then Bob joins @@ -450,7 +473,7 @@ mod tests { #[test] fn test_unstake_settles_rewards() { - let (env, contract_id, admin, alice, _bob, reward_token) = setup(); + let (env, contract_id, admin, alice, _bob, reward_token, _stake_token) = setup(); let client = YieldDistributionClient::new(&env, &contract_id); client.stake(&alice, &500_000); @@ -465,4 +488,80 @@ mod tests { let reward_client = TokenClient::new(&env, &reward_token); assert_eq!(reward_client.balance(&alice), 1_000); } + + #[test] + #[should_panic(expected = "contract is paused")] + fn test_pause_deposit_rewards() { + let (env, contract_id, admin, _alice, _bob, _, _stake_token) = setup(); + let client = YieldDistributionClient::new(&env, &contract_id); + + let registry_id = env.register_contract(None, MockRegistry); + let registry_client = MockRegistryClient::new(&env, ®istry_id); + registry_client.set_paused(&true); + + client.set_security_registry(®istry_id); + client.deposit_rewards(&admin, &1_000); + } + + #[test] + #[should_panic(expected = "amount must be positive")] + fn test_deposit_limit() { + let (env, contract_id, _admin, alice, _bob, _, _stake_token) = setup(); + let client = YieldDistributionClient::new(&env, &contract_id); + + client.stake(&alice, &0); + } + + #[test] + #[should_panic(expected = "amount must be positive")] + fn test_withdraw_limit_zero() { + let (env, contract_id, _admin, alice, _bob, _, _stake_token) = setup(); + let client = YieldDistributionClient::new(&env, &contract_id); + + client.stake(&alice, &100); + client.unstake(&alice, &0); + } + + #[test] + #[should_panic(expected = "insufficient stake")] + fn test_withdraw_limit_insufficient() { + let (env, contract_id, _admin, alice, _bob, _, _stake_token) = setup(); + let client = YieldDistributionClient::new(&env, &contract_id); + + client.stake(&alice, &100); + client.unstake(&alice, &200); + } + + #[test] + fn test_contract_invariants() { + let (env, contract_id, _admin, alice, bob, _reward_token, stake_token) = setup(); + let client = YieldDistributionClient::new(&env, &contract_id); + + let stake_client = TokenClient::new(&env, &stake_token); + + // Initial state + assert_eq!(client.total_staked(), 0); + assert_eq!(stake_client.balance(&contract_id), 0); + + // Alice stakes + client.stake(&alice, &300_000); + assert_eq!(client.total_staked(), 300_000); + assert_eq!(stake_client.balance(&contract_id), 300_000); + + // Bob stakes + client.stake(&bob, &700_000); + assert_eq!(client.total_staked(), 1_000_000); + assert_eq!(stake_client.balance(&contract_id), 1_000_000); + + // Total supply matches balance reserves invariant + assert_eq!(client.total_staked(), stake_client.balance(&contract_id)); + + // Alice unstakes partially + client.unstake(&alice, &100_000); + assert_eq!(client.total_staked(), 900_000); + assert_eq!(stake_client.balance(&contract_id), 900_000); + + // Sum of all stakes equals total staked + assert_eq!(client.stake_of(&alice) + client.stake_of(&bob), client.total_staked()); + } } diff --git a/dashboard/package.json b/dashboard/package.json index 00a9c3ba..9f31c7ec 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -18,11 +18,11 @@ "@stellar/stellar-sdk": "^14.6.1", "axios": "^1.13.6", "clsx": "^2.1.1", - "framer-motion": "^11.18.2", + "framer-motion": "^12.40.0", "lucide-react": "^0.446.0", - "react": "^18.3.1", + "react": "^19.2.7", "react-dom": "^18.3.1", - "tailwind-merge": "^2.6.1" + "tailwind-merge": "^3.6.0" }, "devDependencies": { "@chromatic-com/storybook": "^3.2.2", @@ -38,7 +38,7 @@ "@testing-library/react": "^16.3.2", "@types/jest": "^30.0.0", "@types/react": "^18.3.5", - "@types/react-dom": "^18.3.0", + "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/parser": "^7.3.1", "@vitejs/plugin-react": "^4.3.1", @@ -48,9 +48,9 @@ "jsdom": "^29.1.1", "postcss": "^8.5.8", "storybook": "^8.6.12", - "tailwindcss": "^3.4.19", + "tailwindcss": "^4.3.1", "typescript": "^5.5.3", - "vite": "^6.4.1", + "vite": "^6.4.3", "vitest": "^4.1.7" } } diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 4679ae02..019e5952 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -18,6 +18,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import type { UiConfig } from './types'; import { LogoMark } from './components/LogoMark'; import { NotificationBell } from './components/NotificationBell'; +import { UserAvatarDropdown } from './components/UserAvatarDropdown'; import { CopyablePublicKey } from './components/CopyablePublicKey'; import { FreighterAdapter } from './lib/wallet/FreighterAdapter'; @@ -27,7 +28,6 @@ const SEP24Flow = lazy(() => import('./components/SEP24Flow')); const KycStatusView = lazy(() => import('./components/KycStatusView')); const NotificationCenter = lazy(() => import('./components/NotificationCenter')); const NotificationPreferences = lazy(() => import('./components/NotificationPreferences')); -const AdminControls = lazy(() => import('./components/AdminControls')); const Sep38QuotePanel = lazy(() => import('./components/Sep38QuotePanel')); const ServiceStatusPanel = lazy(() => import('./components/ServiceStatusPanel')); const SettingsView = lazy(() => import('./components/SettingsView')); @@ -43,13 +43,31 @@ const defaultUiConfig: UiConfig = { { key: 'amount', label: 'Amount', required: true, placeholder: '500.00' }, ], withdraw: [ + { key: 'iban', label: 'IBAN', required: true, placeholder: 'DE89370400440532013000' }, { key: 'bankAccount', label: 'Bank Account', required: true, placeholder: 'Account number' }, + { key: 'beneficiaryAddress', label: 'Beneficiary Address', required: true, placeholder: 'Street, city, postal code' }, { key: 'amount', label: 'Amount', required: true, placeholder: '120.50' }, ], kyc: [ { key: 'firstName', label: 'First Name', required: true }, { key: 'lastName', label: 'Last Name', required: true }, { key: 'country', label: 'Country', required: true }, + { + key: 'id_photo_front', + label: 'Government ID (Front)', + required: true, + type: 'file', + accept: 'image/jpeg,image/png,application/pdf', + helpText: 'Clear photo of the front of your government-issued ID.', + }, + { + key: 'proof_of_address', + label: 'Proof of Address', + required: true, + type: 'file', + accept: 'image/jpeg,image/png,application/pdf', + helpText: 'Utility bill or bank statement dated within the last 90 days.', + }, ], }, }; @@ -336,6 +354,10 @@ const App = () => { ) : null} + setActiveTab('settings')} + onNotifications={() => setActiveTab('notifications')} + /> @@ -399,7 +421,13 @@ const App = () => { {activeTab === 'notification-preferences' && ( )} - {activeTab === 'kyc' && } + {activeTab === 'kyc' && ( + + )} {activeTab === 'settings' && ( )} diff --git a/dashboard/src/components/AdminControls.tsx b/dashboard/src/components/AdminControls.tsx index 09957f20..7816eb28 100644 --- a/dashboard/src/components/AdminControls.tsx +++ b/dashboard/src/components/AdminControls.tsx @@ -1,15 +1,31 @@ import React, { useState, useEffect } from 'react'; -import { Network, Trash2, CheckCircle2, AlertCircle } from 'lucide-react'; +import { Network, Trash2, CheckCircle2, AlertCircle, Bell, Key, Save } from 'lucide-react'; import { ConfirmModal } from './ConfirmModal'; interface AdminControlsProps { apiBaseUrl: string; } +interface WebhookConfig { + WEBHOOK_URL?: string; + WEBHOOK_SECRET?: string; + WEBHOOK_TIMEOUT_MS: number; + WEBHOOK_MAX_RETRIES: number; + WEBHOOK_RETRY_DELAY_MS: number; +} + export const AdminControls: React.FC = ({ apiBaseUrl }) => { const [network, setNetwork] = useState('TESTNET'); const [loading, setLoading] = useState(false); const [statusMessage, setStatusMessage] = useState<{ text: string; isError: boolean } | null>(null); + + // Webhook config state + const [webhookConfig, setWebhookConfig] = useState({ + WEBHOOK_TIMEOUT_MS: 5000, + WEBHOOK_MAX_RETRIES: 3, + WEBHOOK_RETRY_DELAY_MS: 500 + }); + const [isWebhookLoading, setIsWebhookLoading] = useState(false); // Modal States const [isNetworkModalOpen, setIsNetworkModalOpen] = useState(false); @@ -18,6 +34,7 @@ export const AdminControls: React.FC = ({ apiBaseUrl }) => { useEffect(() => { fetchCurrentNetwork(); + fetchWebhookConfig(); }, []); const fetchCurrentNetwork = async () => { @@ -34,6 +51,73 @@ export const AdminControls: React.FC = ({ apiBaseUrl }) => { } }; + const fetchWebhookConfig = async () => { + try { + setIsWebhookLoading(true); + const token = localStorage.getItem('authToken'); + const response = await fetch(`${apiBaseUrl}/api/config`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + if (response.ok) { + const data = await response.json(); + if (data.data) { + setWebhookConfig({ + WEBHOOK_URL: data.data.WEBHOOK_URL, + WEBHOOK_SECRET: data.data.WEBHOOK_SECRET, + WEBHOOK_TIMEOUT_MS: data.data.WEBHOOK_TIMEOUT_MS, + WEBHOOK_MAX_RETRIES: data.data.WEBHOOK_MAX_RETRIES, + WEBHOOK_RETRY_DELAY_MS: data.data.WEBHOOK_RETRY_DELAY_MS + }); + } + } + } catch (err) { + console.error('Failed to fetch webhook config:', err); + } finally { + setIsWebhookLoading(false); + } + }; + + const saveWebhookConfig = async () => { + try { + setLoading(true); + const token = localStorage.getItem('authToken'); + // First fetch full current config + const configRes = await fetch(`${apiBaseUrl}/api/config`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + if (!configRes.ok) throw new Error('Failed to fetch current config'); + + const fullConfig = await configRes.json(); + const newConfig = { + ...fullConfig.data, + ...webhookConfig + }; + + const saveRes = await fetch(`${apiBaseUrl}/api/config`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(newConfig) + }); + + if (!saveRes.ok) throw new Error('Failed to save webhook config'); + + showStatus('Webhook configuration saved successfully!', false); + } catch (err) { + showStatus(err instanceof Error ? err.message : 'Failed to save webhook configuration', true); + } finally { + setLoading(false); + } + }; + const handleNetworkChangeInitiate = (e: React.ChangeEvent) => { const value = e.target.value; if (value && value !== network) { @@ -179,6 +263,113 @@ export const AdminControls: React.FC = ({ apiBaseUrl }) => { Purge Queues + + {/* Webhook Settings */} +
+
+
+

+ + Webhook Configuration +

+

+ Configure webhook endpoints and settings for transaction and KYC event notifications. +

+
+
+ +
+
+ + setWebhookConfig({ ...webhookConfig, WEBHOOK_URL: e.target.value })} + placeholder="https://example.com/webhook" + disabled={isWebhookLoading || loading} + className="input-field w-full" + /> +
+ +
+ + setWebhookConfig({ ...webhookConfig, WEBHOOK_SECRET: e.target.value })} + placeholder="Your secret key" + disabled={isWebhookLoading || loading} + className="input-field w-full" + /> +
+ +
+
+ + setWebhookConfig({ ...webhookConfig, WEBHOOK_TIMEOUT_MS: parseInt(e.target.value, 10) })} + disabled={isWebhookLoading || loading} + className="input-field w-full" + /> +
+ +
+ + setWebhookConfig({ ...webhookConfig, WEBHOOK_MAX_RETRIES: parseInt(e.target.value, 10) })} + disabled={isWebhookLoading || loading} + className="input-field w-full" + /> +
+ +
+ + setWebhookConfig({ ...webhookConfig, WEBHOOK_RETRY_DELAY_MS: parseInt(e.target.value, 10) })} + disabled={isWebhookLoading || loading} + className="input-field w-full" + /> +
+
+ + +
+
{/* Network Switch Confirmation Modal */} diff --git a/dashboard/src/components/DashboardOverview.tsx b/dashboard/src/components/DashboardOverview.tsx index ee89a7c3..862ce58a 100644 --- a/dashboard/src/components/DashboardOverview.tsx +++ b/dashboard/src/components/DashboardOverview.tsx @@ -1,4 +1,5 @@ import { LogoMark } from './LogoMark'; +import { VolumeChart } from './VolumeChart'; import type { UiConfig } from '../types'; export const DashboardOverview = ({ uiConfig }: { uiConfig: UiConfig }) => ( @@ -25,12 +26,8 @@ export const DashboardOverview = ({ uiConfig }: { uiConfig: UiConfig }) => (
-
-

Volume Chart Placeholder

+
+

Anchor Branding

diff --git a/dashboard/src/components/KycDocumentUpload.tsx b/dashboard/src/components/KycDocumentUpload.tsx new file mode 100644 index 00000000..52275813 --- /dev/null +++ b/dashboard/src/components/KycDocumentUpload.tsx @@ -0,0 +1,154 @@ +import { useCallback, useMemo, useState } from 'react'; +import { Upload, AlertCircle, CheckCircle2 } from 'lucide-react'; +import type { FieldRequirement } from '../types'; +import { UploadProgressBar } from './UploadProgressBar'; +import { uploadDocument } from '../lib/kyc/uploadDocument'; + +type FileUploadState = { + progress: number; + status: 'idle' | 'uploading' | 'complete' | 'error'; + error?: string; + uploadId?: string; +}; + +type KycDocumentUploadProps = { + apiBaseUrl: string; + account: string; + fields: FieldRequirement[]; + onComplete?: (uploadIds: Record) => void; +}; + +const isFileField = (field: FieldRequirement) => field.type === 'file'; + +export const KycDocumentUpload = ({ apiBaseUrl, account, fields, onComplete }: KycDocumentUploadProps) => { + const fileFields = useMemo(() => fields.filter(isFileField), [fields]); + const [uploadStates, setUploadStates] = useState>({}); + + const updateFieldState = useCallback((key: string, patch: Partial) => { + setUploadStates((prev) => ({ + ...prev, + [key]: { ...(prev[key] ?? { progress: 0, status: 'idle' }), ...patch }, + })); + }, []); + + const handleFileChange = async (field: FieldRequirement, file: File | undefined) => { + if (!file) { + return; + } + + updateFieldState(field.key, { status: 'uploading', progress: 0, error: undefined }); + + try { + const result = await uploadDocument({ + apiBaseUrl, + account, + fieldName: field.key, + file, + onProgress: (percent) => updateFieldState(field.key, { progress: percent }), + }); + + updateFieldState(field.key, { + status: 'complete', + progress: 100, + uploadId: result.uploadId, + }); + } catch (error) { + updateFieldState(field.key, { + status: 'error', + error: error instanceof Error ? error.message : 'Upload failed', + }); + } + }; + + const completedUploads = useMemo(() => { + const ids: Record = {}; + for (const field of fileFields) { + const state = uploadStates[field.key]; + if (state?.status === 'complete' && state.uploadId) { + ids[field.key] = state.uploadId; + } + } + return ids; + }, [fileFields, uploadStates]); + + const allRequiredComplete = fileFields + .filter((f) => f.required) + .every((f) => uploadStates[f.key]?.status === 'complete'); + + const handleSubmit = () => { + if (allRequiredComplete) { + onComplete?.(completedUploads); + } + }; + + if (fileFields.length === 0) { + return null; + } + + return ( +
+

Document Uploads

+ {fileFields.map((field) => { + const state = uploadStates[field.key] ?? { progress: 0, status: 'idle' as const }; + + return ( +
+ + {field.helpText &&

{field.helpText}

} + +
+ + handleFileChange(field, e.target.files?.[0])} + /> + {state.status === 'complete' && ( + + )} + {state.status === 'error' && ( + + )} +
+ + {state.status === 'uploading' && ( +
+ +
+ )} + + {state.status === 'error' && state.error && ( +

{state.error}

+ )} +
+ ); + })} + + +
+ ); +}; + +export default KycDocumentUpload; diff --git a/dashboard/src/components/KycStatusView.tsx b/dashboard/src/components/KycStatusView.tsx index 7227fa56..55f80643 100644 --- a/dashboard/src/components/KycStatusView.tsx +++ b/dashboard/src/components/KycStatusView.tsx @@ -1,11 +1,69 @@ import { useState } from 'react'; -import { ShieldCheck, XCircle, AlertTriangle, RefreshCw, Mail, CheckCircle2, Clock } from 'lucide-react'; +import { ShieldCheck, XCircle, AlertTriangle, RefreshCw, Mail, CheckCircle2, Clock, FileWarning, User, MapPin, Camera } from 'lucide-react'; import type { UiConfig } from '../types'; import { RequirementList } from './RequirementList'; +import { KycDocumentUpload } from './KycDocumentUpload'; export type KycState = 'not_started' | 'pending' | 'approved' | 'rejected'; -export const KycStatusView = ({ uiConfig }: { uiConfig: UiConfig }) => { +type RejectionCategory = 'Document' | 'Identity' | 'Address' | 'Selfie'; + +type KycRejectionReason = { + code: string; + category: RejectionCategory; + field: string; + description: string; + action: string; + severity: 'high' | 'medium'; +}; + +const CATEGORY_ICON: Record = { + Document:
)} @@ -108,23 +178,57 @@ export const KycStatusView = ({ uiConfig }: { uiConfig: UiConfig }) => {
-

Verification Failed

-

- We were unable to verify your identity with the provided information. This may happen if documents are - unclear, expired, or details mismatch. +

Verification Failed

+

+ We were unable to verify your identity. Please review each issue below and resubmit with the corrected documents.

-
-
-