From 121f2af824c97c5b5b69b542749c476926d0a269 Mon Sep 17 00:00:00 2001 From: Ability Date: Wed, 29 Jul 2026 00:10:02 +0100 Subject: [PATCH 1/6] fix(router): return 400 for malformed transaction id decoding; tests: add deposit primitive, oversize, min boundary, and malformed-id cases (#345, #347, #348, #349) --- src/runtime/http/express-router-impl.ts | 14 ++- tests/mvp-express.integration.test.ts | 121 ++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/src/runtime/http/express-router-impl.ts b/src/runtime/http/express-router-impl.ts index f1b46270..1431f8c0 100644 --- a/src/runtime/http/express-router-impl.ts +++ b/src/runtime/http/express-router-impl.ts @@ -793,7 +793,19 @@ export async function handleExpressRouterRequest( const transactionMatch = /^\/transactions\/([^/]+)$/.exec(path); if (method === 'GET' && transactionMatch) { - await handleTransaction(context, req, res, decodeURIComponent(transactionMatch[1])); + let transactionIdRaw = transactionMatch[1]; + let transactionId: string; + try { + transactionId = decodeURIComponent(transactionIdRaw); + } catch { + sendJson(res, 400, { + error: 'invalid_request', + message: 'Transaction id contains malformed percent-encoding', + }); + return; + } + + await handleTransaction(context, req, res, transactionId); return; } diff --git a/tests/mvp-express.integration.test.ts b/tests/mvp-express.integration.test.ts index dd81aba9..03dd581d 100644 --- a/tests/mvp-express.integration.test.ts +++ b/tests/mvp-express.integration.test.ts @@ -2095,6 +2095,22 @@ describe('MVP Express-mounted integration', () => { expect(response.body.message).toBe('Request body must be valid JSON'); }); + it('15h) JSON primitive on POST /transactions/deposit/interactive returns 400', async () => { + const response = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + rawBody: '"just-a-string"', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + expect(response.body.message).toBe('Request JSON body must be an object'); + }); + it('15f) malformed JSON on POST /webhooks/events returns 400', async () => { const response = await invoke({ method: 'POST', @@ -2167,6 +2183,83 @@ describe('MVP Express-mounted integration', () => { // ── Unauthenticated transaction lookup ─────────────────────────────────── + it('15i) oversize body on POST /transactions/deposit/interactive returns 413', async () => { + const customDbUrl = makeSqliteDbUrlForTests(); + const customDbPath = customDbUrl.startsWith('file:') + ? customDbUrl.slice('file:'.length) + : customDbUrl; + const customAnchor = createAnchor({ + network: { network: 'testnet' }, + server: { interactiveDomain: 'https://anchor.example.com' }, + security: { + sep10SigningKey: sep10ServerKeypair.secret(), + interactiveJwtSecret: 'jwt-test-secret-oversize', + distributionAccountSecret: 'distribution-test-secret', + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + deposits_enabled: true, + min_amount: 1, + max_amount: 1000, + }, + ], + }, + framework: { + database: { provider: 'sqlite', url: customDbUrl }, + http: { maxBodyBytes: 64 }, + }, + }); + + await customAnchor.init(); + const customInvoke = createMountedInvoker(customAnchor); + + try { + // create access token for this custom anchor + const testKeypair = Keypair.random(); + const account = testKeypair.publicKey(); + const challengeResponse = await customInvoke({ + path: `/auth/challenge?account=${account}`, + headers: { 'x-forwarded-for': '10.0.0.1' }, + }); + expect(challengeResponse.status).toBe(200); + const challengeXdr = String(challengeResponse.body.challenge ?? ''); + const networkPassphrase = String(challengeResponse.body.network_passphrase ?? ''); + const challengeTx = new Transaction(challengeXdr, networkPassphrase); + challengeTx.sign(testKeypair); + + const tokenResponse = await customInvoke({ + method: 'POST', + path: '/auth/token', + headers: { 'content-type': 'application/json', 'x-forwarded-for': '10.0.0.1' }, + body: { account, challenge: challengeTx.toXDR() }, + }); + expect(tokenResponse.status).toBe(200); + const access = String(tokenResponse.body.token ?? ''); + + const oversizedBody = JSON.stringify({ asset_code: 'USDC', amount: '1', extra: 'x'.repeat(128) }); + const response = await customInvoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { 'content-type': 'application/json', authorization: `Bearer ${access}` }, + rawBody: oversizedBody, + }); + + expect(response.status).toBe(413); + expect(response.body.error).toBe('payload_too_large'); + expect(response.body.message).toBe('Request body too large. Max 64 bytes'); + } finally { + await customAnchor.shutdown(); + try { + unlinkSync(customDbPath); + } catch { + // ignore cleanup errors in CI + } + } + }); + it('17) GET /transactions/:id without bearer token returns 401', async () => { const response = await invoke({ method: 'GET', @@ -2178,6 +2271,17 @@ describe('MVP Express-mounted integration', () => { expect(response.body.message).toBe('Missing or invalid bearer token'); }); + it('17b) malformed percent-encoded id on GET /transactions/:id returns 400', async () => { + const response = await invoke({ + method: 'GET', + path: '/transactions/%', + headers: { authorization: `Bearer ${accessToken}` }, + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + }); + // ── Non-positive deposit amounts ───────────────────────────────────────── it('16) deposit with amount of zero is rejected with 400', async () => { @@ -2227,4 +2331,21 @@ describe('MVP Express-mounted integration', () => { expect(response.body.error).toBe('invalid_amount'); expect(response.body.message).toBe('Amount must be a positive number'); }); + + it('16d) deposit with amount exactly at min_amount is accepted (boundary)', async () => { + const response = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + body: { asset_code: 'USDC', amount: '10' }, + }); + + expect(response.status).toBe(201); + expect(response.body.kind).toBe('deposit'); + expect(response.body.amount).toBe('10'); + expect(response.body).toHaveProperty('id'); + }); }); From d52f48f1f0dea27a4f4316f62c44be90898c6af4 Mon Sep 17 00:00:00 2001 From: Ability Date: Wed, 29 Jul 2026 00:21:15 +0100 Subject: [PATCH 2/6] docs(PR): add short PR description for deposit/transaction tests and decoding fix (#345,#347,#348,#349) --- .github/PR_DESC_feat-add-deposit-345-349.md | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/PR_DESC_feat-add-deposit-345-349.md diff --git a/.github/PR_DESC_feat-add-deposit-345-349.md b/.github/PR_DESC_feat-add-deposit-345-349.md new file mode 100644 index 00000000..0e9a004a --- /dev/null +++ b/.github/PR_DESC_feat-add-deposit-345-349.md @@ -0,0 +1,32 @@ +# PR: Add deposit and transaction tests + decoding fix + +## What does this PR do? +- Returns 400 for malformed percent-encoded `:id` in `GET /transactions/:id`. +- Adds focused integration tests: + - Rejects JSON primitive bodies for `POST /transactions/deposit/interactive` (400). + - Returns 413 for oversized deposit request bodies. + - Accepts a deposit with amount exactly equal to `min_amount` (201). + - Returns 400 for malformed percent-encoded transaction ids. + +## How to test +1. Install dev deps and run the test suite: + +```bash +bun install +bun test +``` + +2. Optionally run the specific integration tests: + +```bash +bun test tests/mvp-express.integration.test.ts +``` + +## Checklist +- [x] My code follows the code style of this project. +- [x] I have added tests for my changes. +- [ ] I have updated the documentation accordingly. +- [x] I have run `bun test` locally (note: ensure project dev deps are installed). + +## Issue Reference +Closes #345, #347, #348, #349 From 3bf63ffd648f5a5754036dcec1852dd67fe13083 Mon Sep 17 00:00:00 2001 From: Shayam Prasad Sah Date: Sat, 29 Aug 2026 13:00:54 +0100 Subject: [PATCH 3/6] Fix runtime cleanup on plugin init failure --- src/core/factory.ts | 102 +++++++++++++-------- src/runtime/queue/in-memory-queue.ts | 7 ++ tests/mvp-express.integration.test.ts | 10 +- tests/runtime/background-lifecycle.test.ts | 44 +++++++++ tests/runtime/queue.test.ts | 28 +----- tests/runtime/queue.unit.test.ts | 74 +++++++++++++++ 6 files changed, 201 insertions(+), 64 deletions(-) diff --git a/src/core/factory.ts b/src/core/factory.ts index e47de16b..c7235eff 100644 --- a/src/core/factory.ts +++ b/src/core/factory.ts @@ -59,49 +59,55 @@ export class AnchorInstance { const frameworkConfig = this.config.get('framework'); - this.database = createSqlDatabaseAdapter(frameworkConfig.database); - await this.database.connect(); - await this.database.migrate(); - - const queueBackend = frameworkConfig.queue?.backend ?? 'memory'; - if (queueBackend !== 'memory') { - throw new ConfigError( - `Unsupported queue backend: "${queueBackend}". Only "memory" queue backend is currently supported. Please remove or set queue.backend to "memory" in your configuration.`, - ); - } + try { + this.database = createSqlDatabaseAdapter(frameworkConfig.database); + await this.database.connect(); + await this.database.migrate(); + + const queueBackend = frameworkConfig.queue?.backend ?? 'memory'; + if (queueBackend !== 'memory') { + throw new ConfigError( + `Unsupported queue backend: "${queueBackend}". Only "memory" queue backend is currently supported. Please remove or set queue.backend to "memory" in your configuration.`, + ); + } - const queueConcurrency = frameworkConfig.queue?.concurrency ?? 1; - this.queue = new InMemoryQueueAdapter({ concurrency: queueConcurrency }); - - this.webhookProcessor = new DefaultWebhookProcessor({ - config: this.config.getConfig(), - database: this.database, - }); - - const watchersEnabled = frameworkConfig.watchers?.enabled ?? true; - if (watchersEnabled) { - this.watchers = [ - new TransactionWatcher(this.database, this.queue, { - pollIntervalMs: frameworkConfig.watchers?.pollIntervalMs ?? 15000, - transactionTimeoutMs: frameworkConfig.watchers?.transactionTimeoutMs ?? 300000, - retentionDays: frameworkConfig.watchers?.retentionDays ?? 90, - }), - ]; - } + const queueConcurrency = frameworkConfig.queue?.concurrency ?? 1; + this.queue = new InMemoryQueueAdapter({ concurrency: queueConcurrency }); - this.expressRouter = new AnchorExpressRouter({ - config: this.config, - database: this.database, - webhookProcessor: this.webhookProcessor, - }).getMiddleware(); + this.webhookProcessor = new DefaultWebhookProcessor({ + config: this.config.getConfig(), + database: this.database, + }); - for (const plugin of this.plugins.values()) { - if (plugin.init) { - await plugin.init(this); + const watchersEnabled = frameworkConfig.watchers?.enabled ?? true; + if (watchersEnabled) { + this.watchers = [ + new TransactionWatcher(this.database, this.queue, { + pollIntervalMs: frameworkConfig.watchers?.pollIntervalMs ?? 15000, + transactionTimeoutMs: frameworkConfig.watchers?.transactionTimeoutMs ?? 300000, + retentionDays: frameworkConfig.watchers?.retentionDays ?? 90, + }), + ]; } - } - this.initialized = true; + this.expressRouter = new AnchorExpressRouter({ + config: this.config, + database: this.database, + webhookProcessor: this.webhookProcessor, + }).getMiddleware(); + + this.initialized = true; + + for (const plugin of this.plugins.values()) { + if (plugin.init) { + await plugin.init(this); + } + } + } catch (error) { + const originalError = error instanceof Error ? error : new Error(String(error)); + await this.rollbackInitialization(); + throw originalError; + } } /** @@ -172,6 +178,24 @@ export class AnchorInstance { return this.requireDatabase().countProcessedWatcherTasks(); } + private async rollbackInitialization(): Promise { + if (this.backgroundJobsRunning || this.initialized) { + await this.stopBackgroundJobs(); + } + + if (this.database) { + await this.database.disconnect(); + } + + this.initialized = false; + this.backgroundJobsRunning = false; + this.database = null; + this.queue = null; + this.webhookProcessor = null; + this.watchers = []; + this.expressRouter = null; + } + private ensureInitialized(): void { if (!this.initialized) { throw new ConfigError('Anchor is not initialized. Call init() first.'); @@ -229,6 +253,8 @@ export class AnchorInstance { await database.cleanupOldRecords(cutoffIso); return; } + + throw new Error(`Unknown queue job type: ${String(job.type)}`); } } diff --git a/src/runtime/queue/in-memory-queue.ts b/src/runtime/queue/in-memory-queue.ts index 6fa977bb..db09ac50 100644 --- a/src/runtime/queue/in-memory-queue.ts +++ b/src/runtime/queue/in-memory-queue.ts @@ -23,6 +23,13 @@ export class InMemoryQueueAdapter implements QueueAdapter { } public async start(worker: (job: QueueJob) => Promise): Promise { + if (this.running) { + if (this.worker === worker) { + return; + } + throw new Error('Queue is already running with a different worker callback.'); + } + this.worker = worker; this.running = true; this.kick(); diff --git a/tests/mvp-express.integration.test.ts b/tests/mvp-express.integration.test.ts index 03dd581d..5bd5ca24 100644 --- a/tests/mvp-express.integration.test.ts +++ b/tests/mvp-express.integration.test.ts @@ -2209,7 +2209,7 @@ describe('MVP Express-mounted integration', () => { }, framework: { database: { provider: 'sqlite', url: customDbUrl }, - http: { maxBodyBytes: 64 }, + http: { maxBodyBytes: 1024 }, }, }); @@ -2239,7 +2239,11 @@ describe('MVP Express-mounted integration', () => { expect(tokenResponse.status).toBe(200); const access = String(tokenResponse.body.token ?? ''); - const oversizedBody = JSON.stringify({ asset_code: 'USDC', amount: '1', extra: 'x'.repeat(128) }); + const oversizedBody = JSON.stringify({ + asset_code: 'USDC', + amount: '1', + extra: 'x'.repeat(2048), + }); const response = await customInvoke({ method: 'POST', path: '/transactions/deposit/interactive', @@ -2249,7 +2253,7 @@ describe('MVP Express-mounted integration', () => { expect(response.status).toBe(413); expect(response.body.error).toBe('payload_too_large'); - expect(response.body.message).toBe('Request body too large. Max 64 bytes'); + expect(response.body.message).toBe('Request body too large. Max 1024 bytes'); } finally { await customAnchor.shutdown(); try { diff --git a/tests/runtime/background-lifecycle.test.ts b/tests/runtime/background-lifecycle.test.ts index fcd5dec0..a982cda7 100644 --- a/tests/runtime/background-lifecycle.test.ts +++ b/tests/runtime/background-lifecycle.test.ts @@ -47,4 +47,48 @@ describe('AnchorInstance background lifecycle', () => { await anchor.stopBackgroundJobs(); await anchor.shutdown(); }); + + it('rolls back background services when plugin initialization fails and allows retry', async () => { + const databaseUrl = makeSqliteDbUrlForTests(); + databaseUrls.push(databaseUrl); + + const pluginFailure = new Error('plugin failed'); + const plugin = { + id: 'failing-plugin', + attempts: 0, + async init(instance: { startBackgroundJobs: () => Promise }) { + this.attempts += 1; + if (this.attempts === 1) { + await instance.startBackgroundJobs(); + throw pluginFailure; + } + }, + }; + + const anchor = createAnchor({ + network: { network: 'testnet' }, + server: { interactiveDomain: 'https://anchor.example.com' }, + security: { + sep10SigningKey: Keypair.random().secret(), + interactiveJwtSecret: 'jwt-test-secret', + distributionAccountSecret: 'distribution-test-secret', + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }, + ], + }, + framework: { + database: { provider: 'sqlite', url: databaseUrl }, + }, + }); + + anchor.use(plugin as any); + await expect(anchor.init()).rejects.toThrow(pluginFailure); + await expect(anchor.init()).resolves.toBeUndefined(); + await anchor.shutdown(); + }); }); diff --git a/tests/runtime/queue.test.ts b/tests/runtime/queue.test.ts index 1dae76e6..118783f8 100644 --- a/tests/runtime/queue.test.ts +++ b/tests/runtime/queue.test.ts @@ -3,31 +3,13 @@ import { InMemoryQueueAdapter } from '@/runtime/queue/in-memory-queue.ts'; import type { QueueJob } from '@/runtime/interfaces.ts'; describe('InMemoryQueueAdapter', () => { - it('processes queued jobs only once when start is called twice', async () => { + it('rejects a different worker while the queue is running', async () => { const adapter = new InMemoryQueueAdapter({ concurrency: 1 }); - let processedCount = 0; - let resolveProcessed: (() => void) | null = null; - const processed = new Promise((resolve) => { - resolveProcessed = resolve; - }); + const firstWorker = async (): Promise => undefined; + const secondWorker = async (): Promise => undefined; - const job: QueueJob = { - type: 'cleanup_records', - payload: { id: 'job-1' }, - }; - - await adapter.enqueue(job); - await adapter.start(async () => { - processedCount += 1; - resolveProcessed?.(); - }); - await adapter.start(async () => { - processedCount += 1; - }); - - await processed; - - expect(processedCount).toBe(1); + await adapter.start(firstWorker); + await expect(adapter.start(secondWorker)).rejects.toThrow(/already running/i); await adapter.stop(); }); }); diff --git a/tests/runtime/queue.unit.test.ts b/tests/runtime/queue.unit.test.ts index 53fb8e7b..81205747 100644 --- a/tests/runtime/queue.unit.test.ts +++ b/tests/runtime/queue.unit.test.ts @@ -1,5 +1,7 @@ +import { createAnchor, makeSqliteDbUrlForTests } from '@/core/factory.ts'; import type { QueueJob } from '@/runtime/interfaces.ts'; import { InMemoryQueueAdapter } from '@/runtime/queue/in-memory-queue.ts'; +import { Keypair } from '@stellar/stellar-sdk'; import { describe, expect, it } from 'vitest'; function deferred() { @@ -383,4 +385,76 @@ describe('InMemoryQueueAdapter', () => { await stopPromise; expect(jobFinished).toBe(true); }); + + it('rejects a different worker while the queue is running', async () => { + const queue = new InMemoryQueueAdapter({ concurrency: 1 }); + const firstWorker = async (_job: QueueJob): Promise => undefined; + const secondWorker = async (_job: QueueJob): Promise => undefined; + + await queue.start(firstWorker); + + await expect(queue.start(secondWorker)).rejects.toThrow(/already running/i); + await queue.stop(); + }); + + it('keeps pending jobs across stop and restart', async () => { + const queue = new InMemoryQueueAdapter({ concurrency: 1 }); + const processed: number[] = []; + const started = deferred(); + const complete = deferred(); + + const worker = async (job: QueueJob): Promise => { + const id = job.payload.jobId as number; + processed.push(id); + if (processed.length === 3) { + started.resolve(); + } + if (id === 3) { + complete.resolve(); + } + }; + + await queue.enqueue({ type: 'process_watcher_task', payload: { jobId: 1 } }); + await queue.enqueue({ type: 'process_watcher_task', payload: { jobId: 2 } }); + await queue.start(worker); + await queue.stop(); + await queue.enqueue({ type: 'process_watcher_task', payload: { jobId: 3 } }); + await queue.start(worker); + + await complete.promise; + await started.promise; + expect(processed).toEqual([1, 2, 3]); + }); + + it('surfaces unknown queue job types instead of silently succeeding', async () => { + const anchor = createAnchor({ + network: { network: 'testnet' }, + server: { interactiveDomain: 'https://anchor.example.com' }, + security: { + sep10SigningKey: Keypair.random().secret(), + interactiveJwtSecret: 'jwt-secret', + distributionAccountSecret: 'distribution-secret', + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }, + ], + }, + framework: { + database: { provider: 'sqlite', url: makeSqliteDbUrlForTests() }, + }, + }); + + await anchor.init(); + await expect( + (anchor as any).processQueueJob({ + type: 'unknown_job', + payload: { jobId: 42 }, + }), + ).rejects.toThrow(/Unknown queue job type: unknown_job/); + await anchor.shutdown(); + }); }); From 3ab7658cf99676cbffd3d8f52f63e360833252b5 Mon Sep 17 00:00:00 2001 From: Shayam Prasad Sah Date: Tue, 29 Sep 2026 16:34:29 +0100 Subject: [PATCH 4/6] fix: handle lifecycle failures and plugin hooks --- src/core/factory.ts | 48 ++++- src/runtime/http/express-router-impl.ts | 50 ++++- src/runtime/http/express-router.ts | 5 + src/runtime/watchers/transaction-watcher.ts | 42 +++- src/types/index.ts | 2 + src/types/plugin.ts | 19 +- tests/mvp-express.integration.test.ts | 201 ++++++++++++++++++ tests/runtime/background-lifecycle.test.ts | 68 +++++- .../runtime/transaction-watcher.unit.test.ts | 99 ++++++++- 9 files changed, 518 insertions(+), 16 deletions(-) diff --git a/src/core/factory.ts b/src/core/factory.ts index c7235eff..0af8b9e1 100644 --- a/src/core/factory.ts +++ b/src/core/factory.ts @@ -16,7 +16,7 @@ import { InMemoryQueueAdapter } from '@/runtime/queue/in-memory-queue.ts'; import { TransactionWatcher } from '@/runtime/watchers/transaction-watcher.ts'; import { DefaultWebhookProcessor } from '@/runtime/webhooks/default-webhook-processor.ts'; import { AnchorKitConfig } from '@/types/config.ts'; -import { AnchorPlugin } from '@/types/plugin.ts'; +import type { AnchorPlugin, AnchorPluginHooks } from '@/types/plugin.ts'; /** * AnchorInstance @@ -94,6 +94,8 @@ export class AnchorInstance { config: this.config, database: this.database, webhookProcessor: this.webhookProcessor, + depositRequestHooks: this.getPluginHooks('onDepositRequest'), + sep10ChallengeHooks: this.getPluginHooks('onSep10Challenge'), }).getMiddleware(); this.initialized = true; @@ -117,13 +119,38 @@ export class AnchorInstance { this.ensureInitialized(); if (this.backgroundJobsRunning) return; - await this.requireQueue().start(async (job) => this.processQueueJob(job)); + const startedWatchers: Watcher[] = []; + let queueStarted = false; - for (const watcher of this.watchers) { - await watcher.start(); - } + try { + await this.requireQueue().start(async (job) => this.processQueueJob(job)); + queueStarted = true; + + for (const watcher of this.watchers) { + await watcher.start(); + startedWatchers.push(watcher); + } + + this.backgroundJobsRunning = true; + } catch (startupError) { + for (const watcher of startedWatchers.reverse()) { + try { + await watcher.stop(); + } catch { + // Preserve the startup error while best-effort cleanup continues. + } + } + + if (queueStarted) { + try { + await this.requireQueue().stop(); + } catch { + // Preserve the startup error while best-effort cleanup continues. + } + } - this.backgroundJobsRunning = true; + throw startupError; + } } /** @@ -216,6 +243,15 @@ export class AnchorInstance { return this.queue; } + private getPluginHooks( + hookName: K, + ): NonNullable[] { + return [...this.plugins.values()].flatMap((plugin) => { + const hook = plugin.hooks?.[hookName]; + return hook ? [hook] : []; + }); + } + private async processQueueJob(job: QueueJob): Promise { const database = this.requireDatabase(); diff --git a/src/runtime/http/express-router-impl.ts b/src/runtime/http/express-router-impl.ts index 1431f8c0..e460ea8d 100644 --- a/src/runtime/http/express-router-impl.ts +++ b/src/runtime/http/express-router-impl.ts @@ -2,6 +2,7 @@ import type { AnchorConfig } from '@/core/config.ts'; import { PayloadTooLargeError, ValidationError } from '@/core/errors.ts'; import { InMemoryRateLimiter, type RateLimitRule } from '@/runtime/http/rate-limiter.ts'; import type { DatabaseAdapter, WebhookProcessor } from '@/runtime/interfaces.ts'; +import type { AnchorPluginHooks, DepositRequestBody } from '@/types/plugin.ts'; import { IdempotencyUtils } from '@/utils/idempotency.ts'; import { Account, @@ -27,6 +28,8 @@ export interface ExpressRouterContext { maxBodyBytes: number; rateLimiter: InMemoryRateLimiter; rateRules: Record<'auth_challenge' | 'auth_token' | 'webhook' | 'deposit', RateLimitRule>; + depositRequestHooks: NonNullable[]; + sep10ChallengeHooks: NonNullable[]; } interface AuthenticatedRequestData { @@ -347,8 +350,29 @@ async function handleAuthChallenge( .setTimebounds(now, expiresAtUnix) .build(); - challengeTx.sign(context.sep10ServerKeypair); - const challengeXdr = challengeTx.toXDR(); + let hookedChallengeTx = challengeTx; + try { + for (const hook of context.sep10ChallengeHooks) { + hookedChallengeTx = await hook(hookedChallengeTx); + } + } catch { + sendJson(res, 500, { + error: 'challenge_hook_failed', + message: 'SEP-10 challenge hook failed', + }); + return; + } + + if (extractNonceFromChallenge(hookedChallengeTx) !== nonce) { + sendJson(res, 500, { + error: 'challenge_hook_failed', + message: 'SEP-10 challenge hook returned an invalid transaction', + }); + return; + } + + hookedChallengeTx.sign(context.sep10ServerKeypair); + const challengeXdr = hookedChallengeTx.toXDR(); const expiresAt = new Date(expiresAtUnix * 1000).toISOString(); await context.database.insertAuthChallenge({ @@ -563,6 +587,28 @@ async function handleDepositInteractive( return; } + const depositHookContext = { + config: context.config.getConfig(), + db: context.database, + params: {}, + query: {}, + body: parsedBody.body as DepositRequestBody, + account: auth.account, + asset: selectedAsset, + }; + + try { + for (const hook of context.depositRequestHooks) { + await hook(depositHookContext); + } + } catch { + sendJson(res, 400, { + error: 'deposit_rejected', + message: 'Deposit request was rejected by a plugin', + }); + return; + } + const idempotencyKey = IdempotencyUtils.extractIdempotencyHeader(req.headers, 'idempotency-key'); const scope = `deposit:${auth.account}`; const requestHash = sha256(JSON.stringify({ assetCode, amount })); diff --git a/src/runtime/http/express-router.ts b/src/runtime/http/express-router.ts index 7e61f01c..5bf4baac 100644 --- a/src/runtime/http/express-router.ts +++ b/src/runtime/http/express-router.ts @@ -1,6 +1,7 @@ import type { AnchorConfig } from '@/core/config.ts'; import { InMemoryRateLimiter, type RateLimitRule } from '@/runtime/http/rate-limiter.ts'; import type { DatabaseAdapter, WebhookProcessor } from '@/runtime/interfaces.ts'; +import type { AnchorPluginHooks } from '@/types/plugin.ts'; import { Keypair } from '@stellar/stellar-sdk'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { handleExpressRouterRequest, type ExpressRouterContext } from './express-router-impl.ts'; @@ -15,6 +16,8 @@ interface RouterDependencies { config: AnchorConfig; database: DatabaseAdapter; webhookProcessor: WebhookProcessor; + depositRequestHooks: NonNullable[]; + sep10ChallengeHooks: NonNullable[]; } export class AnchorExpressRouter { @@ -41,6 +44,8 @@ export class AnchorExpressRouter { config, database: dependencies.database, webhookProcessor: dependencies.webhookProcessor, + depositRequestHooks: dependencies.depositRequestHooks, + sep10ChallengeHooks: dependencies.sep10ChallengeHooks, sep10ServerKeypair, networkPassphrase, maxBodyBytes, diff --git a/src/runtime/watchers/transaction-watcher.ts b/src/runtime/watchers/transaction-watcher.ts index bc0d668b..01d81fc8 100644 --- a/src/runtime/watchers/transaction-watcher.ts +++ b/src/runtime/watchers/transaction-watcher.ts @@ -19,6 +19,7 @@ export class TransactionWatcher implements Watcher { private timer: ReturnType | null = null; private tickPromise: Promise | null = null; private startPromise: Promise | null = null; + private stopPromise: Promise | null = null; constructor(database: DatabaseAdapter, queue: QueueAdapter, options: TransactionWatcherOptions) { this.database = database; @@ -29,6 +30,11 @@ export class TransactionWatcher implements Watcher { } public async start(): Promise { + if (this.stopPromise) { + await this.stopPromise; + return; + } + // If already started, return immediately if (this.timer) return; @@ -58,13 +64,41 @@ export class TransactionWatcher implements Watcher { } public async stop(): Promise { - if (!this.timer) return; - clearInterval(this.timer); - this.timer = null; + if (this.stopPromise) { + await this.stopPromise; + return; + } + + const stopping = this.performStop(); + this.stopPromise = stopping; + try { + await stopping; + } finally { + this.stopPromise = null; + } + } + + private async performStop(): Promise { + if (this.startPromise) { + try { + await this.startPromise; + } catch { + // A failed initial tick must not prevent shutdown. + } + } + + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } // Wait for any active tick to complete if (this.tickPromise) { - await this.tickPromise; + try { + await this.tickPromise; + } catch { + // Transient tick failures must not make shutdown fail. + } } } diff --git a/src/types/index.ts b/src/types/index.ts index 4cd26262..10cef901 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -38,6 +38,8 @@ export type { AnchorPlugin, AnchorPluginContext, AnchorPluginHooks, + DepositRequestBody, + DepositRequestHookContext, Context as PluginContext, } from './plugin'; export type { RouteDefinition, SchemaDefinition, SepErrorCode } from './foundation'; diff --git a/src/types/plugin.ts b/src/types/plugin.ts index d3ad198d..c224a97c 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -1,4 +1,6 @@ import { RouteDefinition, SchemaDefinition } from './foundation'; +import type { AnchorKitConfig, Asset } from './config'; +import type { Transaction } from '@stellar/stellar-sdk'; export interface AnchorPluginContext< TConfig = unknown, @@ -16,10 +18,23 @@ export interface AnchorPluginContext< export type Context = AnchorPluginContext; +export type DepositRequestBody = Record & { + asset_code: string; + amount: string | number; +}; + +export interface DepositRequestHookContext + extends AnchorPluginContext { + account: string; + asset: Asset; +} + export interface AnchorPluginHooks { - onDepositRequest?: (ctx: Context) => Promise; + /** Throw to reject with HTTP 400 `deposit_rejected` before persistence. */ + onDepositRequest?: (ctx: DepositRequestHookContext) => Promise; onWithdrawalRequest?: (ctx: Context) => Promise; - onSep10Challenge?: (tx: unknown) => Promise; + /** Return the challenge to use; throwing responds with HTTP 500 `challenge_hook_failed`. */ + onSep10Challenge?: (tx: Transaction) => Promise; onTransactionStatusChange?: (tx: unknown, oldStatus: string, newStatus: string) => Promise; } diff --git a/tests/mvp-express.integration.test.ts b/tests/mvp-express.integration.test.ts index 5bd5ca24..421325a6 100644 --- a/tests/mvp-express.integration.test.ts +++ b/tests/mvp-express.integration.test.ts @@ -1,5 +1,7 @@ import { makeSqliteDbUrlForTests } from '@/core/factory.ts'; import { createAnchor, type AnchorInstance } from '@/index.ts'; +import type { DatabaseAdapter } from '@/runtime/interfaces.ts'; +import type { AnchorPlugin } from '@/types/plugin.ts'; import { Account, Keypair, Operation, Transaction, TransactionBuilder } from '@stellar/stellar-sdk'; import { createHmac } from 'node:crypto'; import { unlinkSync } from 'node:fs'; @@ -109,6 +111,34 @@ describe('MVP Express-mounted integration', () => { let transactionId = ''; let depositInteractiveUrl = ''; + async function createPluginTestAnchor(plugins: AnchorPlugin[]) { + const pluginDatabaseUrl = makeSqliteDbUrlForTests(); + const config = anchor.config.getConfig(); + const pluginAnchor = createAnchor({ + ...config, + framework: { + ...config.framework, + database: { ...config.framework.database, url: pluginDatabaseUrl }, + }, + }); + + for (const plugin of plugins) pluginAnchor.use(plugin); + await pluginAnchor.init(); + + return { + invoke: createMountedInvoker(pluginAnchor), + database: (pluginAnchor as unknown as { database: DatabaseAdapter }).database, + async cleanup() { + await pluginAnchor.shutdown(); + try { + unlinkSync(pluginDatabaseUrl.slice('file:'.length)); + } catch { + // The adapter may already have removed the temporary database. + } + }, + }; + } + beforeAll(async () => { anchor = createAnchor({ network: { network: 'testnet' }, @@ -494,6 +524,177 @@ describe('MVP Express-mounted integration', () => { expect(Math.abs(expiresAtTime - expectedExpiry)).toBeLessThan(5000); }); + it('invokes one deposit hook with request, account, config, and selected asset context', async () => { + let observedContext: unknown; + let challengeHookCalled = false; + const pluginTest = await createPluginTestAnchor([ + { + id: 'deposit-context', + hooks: { + onDepositRequest: async (context) => { + observedContext = context; + }, + onSep10Challenge: async (transaction) => { + challengeHookCalled = true; + return transaction; + }, + }, + }, + ]); + + try { + const challengeResponse = await pluginTest.invoke({ + path: `/auth/challenge?account=${clientKeypair.publicKey()}`, + }); + expect(challengeResponse.status).toBe(200); + expect(challengeHookCalled).toBe(true); + + const response = await pluginTest.invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + body: { asset_code: 'USDC', amount: '25', note: 'bank transfer' }, + }); + + expect(response.status).toBe(201); + expect(observedContext).toMatchObject({ + config: { network: { network: 'testnet' } }, + account: clientKeypair.publicKey(), + body: { asset_code: 'USDC', amount: '25', note: 'bank transfer' }, + asset: { code: 'USDC' }, + }); + } finally { + await pluginTest.cleanup(); + } + }); + + it('invokes multiple deposit and challenge hooks in registration order', async () => { + const hookOrder: string[] = []; + const pluginTest = await createPluginTestAnchor([ + { + id: 'first-hook', + hooks: { + onDepositRequest: async () => { + hookOrder.push('deposit-first'); + }, + onSep10Challenge: async (transaction) => { + hookOrder.push('challenge-first'); + return transaction; + }, + }, + }, + { + id: 'second-hook', + hooks: { + onDepositRequest: async () => { + hookOrder.push('deposit-second'); + }, + onSep10Challenge: async (transaction) => { + hookOrder.push('challenge-second'); + return transaction; + }, + }, + }, + ]); + + try { + const challengeResponse = await pluginTest.invoke({ + path: `/auth/challenge?account=${clientKeypair.publicKey()}`, + }); + expect(challengeResponse.status).toBe(200); + expect(hookOrder).toEqual(['challenge-first', 'challenge-second']); + + const depositResponse = await pluginTest.invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + body: { asset_code: 'USDC', amount: '25' }, + }); + expect(depositResponse.status).toBe(201); + expect(hookOrder).toEqual([ + 'challenge-first', + 'challenge-second', + 'deposit-first', + 'deposit-second', + ]); + } finally { + await pluginTest.cleanup(); + } + }); + + it('returns a documented rejection without writing deposit or idempotency records', async () => { + const pluginTest = await createPluginTestAnchor([ + { + id: 'reject-deposit', + hooks: { + onDepositRequest: async () => { + throw new Error('request is not eligible'); + }, + }, + }, + ]); + const transactionInsert = vi.spyOn(pluginTest.database, 'insertInteractiveTransaction'); + const idempotencyInsert = vi.spyOn(pluginTest.database, 'insertOrGetIdempotencyRecord'); + + try { + const response = await pluginTest.invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + 'idempotency-key': 'rejected-deposit', + }, + body: { asset_code: 'USDC', amount: '25' }, + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + error: 'deposit_rejected', + message: 'Deposit request was rejected by a plugin', + }); + expect(transactionInsert).not.toHaveBeenCalled(); + expect(idempotencyInsert).not.toHaveBeenCalled(); + } finally { + await pluginTest.cleanup(); + } + }); + + it('does not persist an auth challenge when a challenge hook fails', async () => { + const pluginTest = await createPluginTestAnchor([ + { + id: 'reject-challenge', + hooks: { + onSep10Challenge: async () => { + throw new Error('challenge rejected'); + }, + }, + }, + ]); + const challengeInsert = vi.spyOn(pluginTest.database, 'insertAuthChallenge'); + + try { + const response = await pluginTest.invoke({ + path: `/auth/challenge?account=${clientKeypair.publicKey()}`, + }); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ + error: 'challenge_hook_failed', + message: 'SEP-10 challenge hook failed', + }); + expect(challengeInsert).not.toHaveBeenCalled(); + } finally { + await pluginTest.cleanup(); + } + }); + it('3a) rate limit response body includes retry_after_seconds matching header', async () => { const customDbUrl = makeSqliteDbUrlForTests(); const customDbPath = customDbUrl.startsWith('file:') diff --git a/tests/runtime/background-lifecycle.test.ts b/tests/runtime/background-lifecycle.test.ts index a982cda7..f1990f6f 100644 --- a/tests/runtime/background-lifecycle.test.ts +++ b/tests/runtime/background-lifecycle.test.ts @@ -1,7 +1,7 @@ import { createAnchor, makeSqliteDbUrlForTests } from '@/core/factory.ts'; import { Keypair } from '@stellar/stellar-sdk'; import { unlinkSync } from 'node:fs'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; const databaseUrls: string[] = []; @@ -91,4 +91,70 @@ describe('AnchorInstance background lifecycle', () => { await expect(anchor.init()).resolves.toBeUndefined(); await anchor.shutdown(); }); + + it('stops the queue and earlier watchers after a watcher startup failure, then retries cleanly', async () => { + const databaseUrl = makeSqliteDbUrlForTests(); + databaseUrls.push(databaseUrl); + + const anchor = createAnchor({ + network: { network: 'testnet' }, + server: { interactiveDomain: 'https://anchor.example.com' }, + security: { + sep10SigningKey: Keypair.random().secret(), + interactiveJwtSecret: 'jwt-test-secret', + distributionAccountSecret: 'distribution-test-secret', + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }, + ], + }, + framework: { + database: { provider: 'sqlite', url: databaseUrl }, + }, + }); + + await anchor.init(); + + let laterWatcherStarts = 0; + const queue = { + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + }; + const earlierWatcher = { + name: 'earlier-watcher', + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + }; + const laterWatcher = { + name: 'later-watcher', + start: vi.fn().mockImplementation(async () => { + laterWatcherStarts += 1; + if (laterWatcherStarts === 1) throw startupFailure; + }), + stop: vi.fn().mockResolvedValue(undefined), + }; + const startupFailure = new Error('watcher startup failed'); + const internalAnchor = anchor as unknown as { + queue: typeof queue; + watchers: typeof earlierWatcher[]; + }; + internalAnchor.queue = queue; + internalAnchor.watchers = [earlierWatcher, laterWatcher]; + + await expect(anchor.startBackgroundJobs()).rejects.toBe(startupFailure); + expect(earlierWatcher.stop).toHaveBeenCalledTimes(1); + expect(laterWatcher.stop).not.toHaveBeenCalled(); + expect(queue.stop).toHaveBeenCalledTimes(1); + + await expect(anchor.startBackgroundJobs()).resolves.toBeUndefined(); + expect(queue.start).toHaveBeenCalledTimes(2); + expect(laterWatcher.start).toHaveBeenCalledTimes(2); + + await anchor.stopBackgroundJobs(); + await anchor.shutdown(); + }); }); diff --git a/tests/runtime/transaction-watcher.unit.test.ts b/tests/runtime/transaction-watcher.unit.test.ts index 8b317450..7b470278 100644 --- a/tests/runtime/transaction-watcher.unit.test.ts +++ b/tests/runtime/transaction-watcher.unit.test.ts @@ -1,4 +1,8 @@ -import type { DatabaseAdapter, QueueAdapter } from '@/runtime/interfaces.ts'; +import type { + DatabaseAdapter, + InteractiveTransactionRecord, + QueueAdapter, +} from '@/runtime/interfaces.ts'; import { TransactionWatcher } from '@/runtime/watchers/transaction-watcher.ts'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -71,6 +75,99 @@ describe('TransactionWatcher Unit Tests', () => { expect(mockQueue.stop).not.toHaveBeenCalled(); }); + it('waits for an in-progress initial tick before stopping and allows a clean restart', async () => { + vi.useFakeTimers(); + + try { + let resolveFirstTick!: (transactions: unknown[]) => void; + mockDatabase.listPendingTransactionsBefore = vi + .fn() + .mockImplementationOnce( + () => new Promise((resolve) => (resolveFirstTick = resolve)), + ) + .mockResolvedValue([]); + + const firstStart = transactionWatcher.start(); + const concurrentStart = transactionWatcher.start(); + const stopping = transactionWatcher.stop(); + resolveFirstTick([]); + + await expect(Promise.all([firstStart, concurrentStart, stopping])).resolves.toBeDefined(); + expect(mockDatabase.listPendingTransactionsBefore).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1000); + expect(mockDatabase.listPendingTransactionsBefore).toHaveBeenCalledTimes(1); + + await transactionWatcher.start(); + expect(mockDatabase.listPendingTransactionsBefore).toHaveBeenCalledTimes(2); + await transactionWatcher.stop(); + } finally { + vi.useRealTimers(); + await transactionWatcher.stop(); + } + }); + + it('does not restart polling when start races with stop during a scheduled tick', async () => { + vi.useFakeTimers(); + + try { + await transactionWatcher.start(); + + let resolveScheduledTick!: (transactions: InteractiveTransactionRecord[]) => void; + const pendingScheduledTick = vi.fn( + () => + new Promise((resolve) => (resolveScheduledTick = resolve)), + ); + mockDatabase.listPendingTransactionsBefore = pendingScheduledTick; + vi.advanceTimersByTime(1000); + + const stopping = transactionWatcher.stop(); + const startingDuringStop = transactionWatcher.start(); + resolveScheduledTick([]); + await Promise.all([stopping, startingDuringStop]); + + vi.advanceTimersByTime(1000); + expect(pendingScheduledTick).toHaveBeenCalledTimes(1); + + mockDatabase.listPendingTransactionsBefore = vi.fn().mockResolvedValue([]); + await transactionWatcher.start(); + expect(mockDatabase.listPendingTransactionsBefore).toHaveBeenCalledTimes(1); + await transactionWatcher.stop(); + } finally { + vi.useRealTimers(); + await transactionWatcher.stop(); + } + }); + + it('keeps shutdown safe when an in-flight scheduled tick fails', async () => { + vi.useFakeTimers(); + + try { + await transactionWatcher.start(); + + let rejectScheduledTick!: (reason: Error) => void; + mockDatabase.listPendingTransactionsBefore = vi.fn( + () => + new Promise((_, reject) => { + rejectScheduledTick = reject; + }), + ); + vi.advanceTimersByTime(1000); + + const stopping = transactionWatcher.stop(); + rejectScheduledTick(new Error('transient tick failure')); + await expect(stopping).resolves.toBeUndefined(); + + mockDatabase.listPendingTransactionsBefore = vi.fn().mockResolvedValue([]); + await expect(transactionWatcher.start()).resolves.toBeUndefined(); + expect(mockDatabase.listPendingTransactionsBefore).toHaveBeenCalledTimes(1); + await transactionWatcher.stop(); + } finally { + vi.useRealTimers(); + await transactionWatcher.stop(); + } + }); + it('enqueues expiration jobs for stale pending deposits', async () => { const staleTransaction = { id: 'stale-tx-1', From 24e28e06361f7cfb57d40abd5afbe96266456bb7 Mon Sep 17 00:00:00 2001 From: Shayam Prasad Sah Date: Tue, 29 Sep 2026 17:34:10 +0100 Subject: [PATCH 5/6] docs: describe lifecycle and plugin hook changes --- PR_DESCRIPTION.md | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md index 8a0ebee3..5b3cc47e 100644 --- a/PR_DESCRIPTION.md +++ b/PR_DESCRIPTION.md @@ -1,23 +1,28 @@ -# PR Summary +## What does this PR do? -Adds focused tests for three behaviors: +This PR makes background startup and watcher shutdown recoverable, and invokes the declared deposit and SEP-10 challenge plugin hooks. -- sqlite-backed `getInteractiveTransactionById()` returns `null` for unknown transaction IDs -- `TransactionWatcher.stop()` before `start()` is a safe no-op -- webhook route accepts an empty request body and returns a generated `event_id` +- Stops the queue and earlier watchers after a later startup failure while preserving the original error. +- Coordinates watcher start/stop races and tolerates in-flight tick failures during shutdown. +- Runs deposit and challenge hooks in registration order with typed context; hook failures prevent persistence. +- Adds focused lifecycle and HTTP integration tests. -## How to test +## How to test? -- Run `bun test tests/runtime/sql-adapter-interactive-tx.test.ts tests/runtime/transaction-watcher.unit.test.ts tests/mvp-express.integration.test.ts` -- Confirm all tests pass +- Run `bun test` (434 tests pass across 45 files). +- For focused coverage, run `bun test tests/runtime/background-lifecycle.test.ts tests/runtime/transaction-watcher.unit.test.ts tests/mvp-express.integration.test.ts`. ## Checklist -- [x] My code follows the code style of this project. +- [ ] My code follows the code style of this project. - [x] I have added tests for my changes. -- [ ] I have updated the documentation accordingly. -- [x] I have run `bun test` locally. +- [x] I have updated the hook contract documentation. +- [x] I have run `bun run test` locally. +- [ ] I have run `bun run lint` locally. ## Issue Reference -Closes #243, #240, and #245 +Closes #584 +Closes #585 +Closes #589 +Closes #590 From 8a74e1291a7ef4724ed6cc4d9d1c772eae604aa8 Mon Sep 17 00:00:00 2001 From: Ikemhood <91676649+ikemHood@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:25:22 +0100 Subject: [PATCH 6/6] fix: run plugin lifecycle hooks safely --- src/core/factory.ts | 13 ++ src/runtime/http/express-router-impl.ts | 7 +- src/runtime/http/express-router.ts | 7 +- src/runtime/watchers/transaction-watcher.ts | 57 +++++++-- src/types/plugin.ts | 14 ++- tests/mvp-express.integration.test.ts | 114 ++++++++++++++++++ tests/runtime/background-lifecycle.test.ts | 2 +- .../runtime/transaction-watcher.unit.test.ts | 4 +- 8 files changed, 196 insertions(+), 22 deletions(-) diff --git a/src/core/factory.ts b/src/core/factory.ts index f22ef897..a97414de 100644 --- a/src/core/factory.ts +++ b/src/core/factory.ts @@ -106,10 +106,23 @@ export class AnchorInstance { ]; } + const depositRequestHooks: NonNullable[] = []; + const sep10ChallengeHooks: NonNullable[] = []; + for (const plugin of this.plugins.values()) { + if (plugin.hooks?.onDepositRequest) { + depositRequestHooks.push(plugin.hooks.onDepositRequest); + } + if (plugin.hooks?.onSep10Challenge) { + sep10ChallengeHooks.push(plugin.hooks.onSep10Challenge); + } + } + this.expressRouter = new AnchorExpressRouter({ config: this.config, database: this.database, webhookProcessor: this.webhookProcessor, + depositRequestHooks, + sep10ChallengeHooks, pluginRoutes, }).getMiddleware(); diff --git a/src/runtime/http/express-router-impl.ts b/src/runtime/http/express-router-impl.ts index 4791d3da..c4231b78 100644 --- a/src/runtime/http/express-router-impl.ts +++ b/src/runtime/http/express-router-impl.ts @@ -3,6 +3,7 @@ import { ConfigError, PayloadTooLargeError, ValidationError } from '@/core/error import { InMemoryRateLimiter, type RateLimitRule } from '@/runtime/http/rate-limiter.ts'; import type { DatabaseAdapter, WebhookProcessor } from '@/runtime/interfaces.ts'; import type { PluginRouteContext, RouteDefinition } from '@/types/foundation.ts'; +import type { AnchorPluginHooks, DepositRequestBody } from '@/types/plugin.ts'; import { IdempotencyUtils } from '@/utils/idempotency.ts'; import { Account, @@ -45,6 +46,8 @@ export interface ExpressRouterContext { requestTimeout: number; rateLimiter: InMemoryRateLimiter; rateRules: Record<'auth_challenge' | 'auth_token' | 'webhook' | 'deposit', RateLimitRule>; + depositRequestHooks?: NonNullable[]; + sep10ChallengeHooks?: NonNullable[]; pluginRoutes?: Array<{ pluginId: string; route: RouteDefinition }>; } @@ -784,7 +787,7 @@ async function handleAuthChallenge( let hookedChallengeTx = challengeTx; try { - for (const hook of context.sep10ChallengeHooks) { + for (const hook of context.sep10ChallengeHooks ?? []) { hookedChallengeTx = await hook(hookedChallengeTx); } } catch { @@ -1061,7 +1064,7 @@ async function handleDepositInteractive( }; try { - for (const hook of context.depositRequestHooks) { + for (const hook of context.depositRequestHooks ?? []) { await hook(depositHookContext); } } catch { diff --git a/src/runtime/http/express-router.ts b/src/runtime/http/express-router.ts index 45cc7129..d78eb5e8 100644 --- a/src/runtime/http/express-router.ts +++ b/src/runtime/http/express-router.ts @@ -3,6 +3,7 @@ import { errorHandler } from '@/utils/error-handler.ts'; import { InMemoryRateLimiter, type RateLimitRule } from '@/runtime/http/rate-limiter.ts'; import type { DatabaseAdapter, WebhookProcessor } from '@/runtime/interfaces.ts'; import type { RouteDefinition } from '@/types/foundation.ts'; +import type { AnchorPluginHooks } from '@/types/plugin.ts'; import { Keypair } from '@stellar/stellar-sdk'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { @@ -21,6 +22,8 @@ interface RouterDependencies { config: AnchorConfig; database: DatabaseAdapter; webhookProcessor: WebhookProcessor; + depositRequestHooks?: NonNullable[]; + sep10ChallengeHooks?: NonNullable[]; pluginRoutes?: Array<{ pluginId: string; route: RouteDefinition }>; } @@ -52,8 +55,8 @@ export class AnchorExpressRouter { config, database: dependencies.database, webhookProcessor: dependencies.webhookProcessor, - depositRequestHooks: dependencies.depositRequestHooks, - sep10ChallengeHooks: dependencies.sep10ChallengeHooks, + depositRequestHooks: dependencies.depositRequestHooks ?? [], + sep10ChallengeHooks: dependencies.sep10ChallengeHooks ?? [], sep10ServerKeypair, networkPassphrase, maxBodyBytes, diff --git a/src/runtime/watchers/transaction-watcher.ts b/src/runtime/watchers/transaction-watcher.ts index e4de847d..f0412519 100644 --- a/src/runtime/watchers/transaction-watcher.ts +++ b/src/runtime/watchers/transaction-watcher.ts @@ -64,22 +64,53 @@ export class TransactionWatcher implements Watcher { } public async stop(): Promise { - if (this.timer) { - clearInterval(this.timer); - this.timer = null; - } + if (this.stopPromise) return this.stopPromise; - const activeTick = this.tickPromise; - if (activeTick) { - try { - await activeTick; - } catch { - // Ignore transient tick failures during shutdown so timers and lifecycle state can settle. + this.stopPromise = (async () => { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; } - } - this.isTickInProgress = false; - this.tickPromise = null; + const activeStart = this.startPromise; + if (activeStart) { + try { + await activeStart; + } catch { + // A failed initial tick should not prevent shutdown from settling. + } + } + + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + + const activeTick = this.tickPromise; + if (activeTick) { + try { + await activeTick; + } catch { + // Ignore transient tick failures during shutdown so timers and lifecycle state can settle. + } + } + + // An initial tick can finish after the first timer check and create the + // interval while shutdown is waiting for that tick. + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + + this.isTickInProgress = false; + this.tickPromise = null; + })(); + + try { + await this.stopPromise; + } finally { + this.stopPromise = null; + } } private async tick(): Promise { diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 29edc04d..7fbaefd4 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -1,5 +1,8 @@ import { RouteDefinition, SchemaDefinition } from './foundation'; +import type { AnchorKitConfigSnapshot, Asset } from '@/types/config.ts'; import type { TransactionStatus } from './transaction-status.ts'; +import type { DatabaseAdapter } from '@/runtime/interfaces.ts'; +import type { Transaction } from '@stellar/stellar-sdk'; export interface AnchorPluginContext< TConfig = unknown, @@ -22,8 +25,13 @@ export type DepositRequestBody = Record & { amount: string | number; }; -export interface DepositRequestHookContext - extends AnchorPluginContext { +export interface DepositRequestHookContext extends AnchorPluginContext< + AnchorKitConfigSnapshot, + DatabaseAdapter, + DepositRequestBody, + Record, + Record +> { account: string; asset: Asset; } @@ -32,7 +40,7 @@ export interface AnchorPluginHooks { /** Throw to reject with HTTP 400 `deposit_rejected` before persistence. */ onDepositRequest?: (ctx: DepositRequestHookContext) => Promise; onWithdrawalRequest?: (ctx: Context) => Promise; - onSep10Challenge?: (tx: unknown) => Promise; + onSep10Challenge?: (tx: Transaction) => Promise; onTransactionStatusChange?: ( tx: unknown, oldStatus: TransactionStatus, diff --git a/tests/mvp-express.integration.test.ts b/tests/mvp-express.integration.test.ts index c48a630d..4fcebd16 100644 --- a/tests/mvp-express.integration.test.ts +++ b/tests/mvp-express.integration.test.ts @@ -1,6 +1,7 @@ import { makeSqliteDbUrlForTests } from '@/core/factory.ts'; import { createAnchor, type AnchorInstance } from '@/index.ts'; import type { DatabaseAdapter } from '@/runtime/interfaces.ts'; +import type { AnchorPlugin } from '@/types/plugin.ts'; import { ACCESS_TOKEN_AUDIENCE, ACCESS_TOKEN_ISSUER } from '@/runtime/http/express-router-impl.ts'; import { Account, Keypair, Operation, Transaction, TransactionBuilder } from '@stellar/stellar-sdk'; import { createHash, createHmac } from 'node:crypto'; @@ -135,8 +136,25 @@ describe('MVP Express-mounted integration', () => { const config = anchor.config.getConfig(); const pluginAnchor = createAnchor({ ...config, + server: { + ...config.server, + corsOrigins: config.server.corsOrigins ? [...config.server.corsOrigins] : undefined, + }, + assets: { + ...config.assets, + assets: config.assets.assets.map((asset) => ({ ...asset })), + }, + kycRequired: config.kycRequired + ? Object.fromEntries( + Object.entries(config.kycRequired).map(([key, requirements]) => [ + key, + [...requirements], + ]), + ) + : undefined, framework: { ...config.framework, + plugins: config.framework.plugins?.map((plugin) => ({ ...plugin })), database: { ...config.framework.database, url: pluginDatabaseUrl }, }, }); @@ -4099,4 +4117,100 @@ describe('MVP Express-mounted integration', () => { expect(response.body.error).toBe('invalid_amount'); expect(response.body.message).toContain('maximum allowed'); }); + + it('runs deposit hooks in registration order and stops before persistence on rejection', async () => { + const order: string[] = []; + const harness = await createPluginTestAnchor([ + { + id: 'deposit-hook-first', + hooks: { onDepositRequest: async () => void order.push('first') }, + }, + { + id: 'deposit-hook-second', + hooks: { onDepositRequest: async () => void order.push('second') }, + }, + ]); + + const response = await harness.invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + body: { asset_code: 'USDC', amount: '25.5' }, + }); + + expect(response.status).toBe(201); + expect(order).toEqual(['first', 'second']); + await harness.cleanup(); + + let rejectedAccount: string | undefined; + let rejectedAssetCode: string | undefined; + const rejected = await createPluginTestAnchor([ + { + id: 'deposit-hook-reject', + hooks: { + onDepositRequest: async (context) => { + rejectedAccount = context.account; + rejectedAssetCode = context.asset.code; + throw new Error('reject'); + }, + }, + }, + ]); + const insertSpy = vi.spyOn(rejected.database, 'insertInteractiveTransaction'); + const rejectedResponse = await rejected.invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + body: { asset_code: 'USDC', amount: '25.5' }, + }); + + expect(rejectedResponse.status).toBe(400); + expect(rejectedResponse.body.error).toBe('deposit_rejected'); + expect(rejectedAccount).toBe(clientKeypair.publicKey()); + expect(rejectedAssetCode).toBe('USDC'); + expect(insertSpy).not.toHaveBeenCalled(); + await rejected.cleanup(); + }); + + it('runs SEP-10 challenge hooks and does not persist after a hook failure', async () => { + let hookCalls = 0; + const harness = await createPluginTestAnchor([ + { + id: 'challenge-hook', + hooks: { + onSep10Challenge: async (transaction) => { + hookCalls += 1; + return transaction; + }, + }, + }, + ]); + const response = await harness.invoke({ + path: `/auth/challenge?account=${clientKeypair.publicKey()}`, + }); + expect(response.status).toBe(200); + expect(hookCalls).toBe(1); + await harness.cleanup(); + + const failing = await createPluginTestAnchor([ + { + id: 'challenge-hook-failure', + hooks: { onSep10Challenge: async () => Promise.reject(new Error('reject')) }, + }, + ]); + const insertSpy = vi.spyOn(failing.database, 'insertAuthChallenge'); + const failedResponse = await failing.invoke({ + path: `/auth/challenge?account=${clientKeypair.publicKey()}`, + }); + expect(failedResponse.status).toBe(500); + expect(failedResponse.body.error).toBe('challenge_hook_failed'); + expect(insertSpy).not.toHaveBeenCalled(); + await failing.cleanup(); + }); }); diff --git a/tests/runtime/background-lifecycle.test.ts b/tests/runtime/background-lifecycle.test.ts index 7a8f8d29..e31d6494 100644 --- a/tests/runtime/background-lifecycle.test.ts +++ b/tests/runtime/background-lifecycle.test.ts @@ -2,7 +2,7 @@ import { createAnchor, makeSqliteDbUrlForTests } from '@/core/factory.ts'; import type { AnchorPlugin } from '@/types/plugin.ts'; import { Keypair } from '@stellar/stellar-sdk'; import { unlinkSync } from 'node:fs'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; const databaseUrls: string[] = []; diff --git a/tests/runtime/transaction-watcher.unit.test.ts b/tests/runtime/transaction-watcher.unit.test.ts index 4b8cb242..16d68117 100644 --- a/tests/runtime/transaction-watcher.unit.test.ts +++ b/tests/runtime/transaction-watcher.unit.test.ts @@ -113,7 +113,9 @@ describe('TransactionWatcher Unit Tests', () => { let resolveScheduledTick!: (transactions: InteractiveTransactionRecord[]) => void; const pendingScheduledTick = vi.fn( () => - new Promise((resolve) => (resolveScheduledTick = resolve)), + new Promise( + (resolve) => (resolveScheduledTick = resolve), + ), ); mockDatabase.listPendingTransactionsBefore = pendingScheduledTick; vi.advanceTimersByTime(1000);