From 2527f5797987f743515886c53b28fc54b75242c8 Mon Sep 17 00:00:00 2001 From: Ability Date: Thu, 25 Jun 2026 12:41:20 +0100 Subject: [PATCH 1/5] test: add sqlite missing transaction lookup, watcher stop-before-start no-op, and empty webhook body coverage --- tests/mvp-express.integration.test.ts | 20 +++++++++++++++++++ .../sql-adapter-interactive-tx.test.ts | 5 +++++ .../runtime/transaction-watcher.unit.test.ts | 7 +++++++ 3 files changed, 32 insertions(+) diff --git a/tests/mvp-express.integration.test.ts b/tests/mvp-express.integration.test.ts index cc0f2f8c..17557d1b 100644 --- a/tests/mvp-express.integration.test.ts +++ b/tests/mvp-express.integration.test.ts @@ -802,6 +802,26 @@ describe('MVP Express-mounted integration', () => { expect((response.body.event_id as string).length).toBeGreaterThan(0); }); + it('8f) webhook route accepts an empty body and generates event_id for signed empty payloads', async () => { + const signature = createHmac('sha256', 'webhook-test-secret').update('').digest('hex'); + + const response = await invoke({ + method: 'POST', + path: '/webhooks/events', + headers: { + 'content-type': 'application/json', + 'x-webhook-provider': 'generic', + 'x-anchor-signature': signature, + }, + }); + + expect(response.status).toBe(200); + expect(response.body.received).toBe(true); + expect(response.body.duplicate).toBe(false); + expect(typeof response.body.event_id).toBe('string'); + expect((response.body.event_id as string).length).toBeGreaterThan(0); + }); + it('8e) webhook success response includes received_at ISO timestamp', async () => { const payload = { id: 'evt_received_at_check', diff --git a/tests/runtime/sql-adapter-interactive-tx.test.ts b/tests/runtime/sql-adapter-interactive-tx.test.ts index 48f0723b..b63fb48f 100644 --- a/tests/runtime/sql-adapter-interactive-tx.test.ts +++ b/tests/runtime/sql-adapter-interactive-tx.test.ts @@ -65,4 +65,9 @@ describe('SqlDatabaseAdapter – interactive transaction status updates', () => globalThis.Date = RealDate; } }); + + it('returns null for getInteractiveTransactionById when the transaction does not exist', async () => { + const fetched = await db.getInteractiveTransactionById(randomUUID()); + expect(fetched).toBeNull(); + }); }); diff --git a/tests/runtime/transaction-watcher.unit.test.ts b/tests/runtime/transaction-watcher.unit.test.ts index 36d19259..0b5da97a 100644 --- a/tests/runtime/transaction-watcher.unit.test.ts +++ b/tests/runtime/transaction-watcher.unit.test.ts @@ -64,6 +64,13 @@ describe('TransactionWatcher Unit Tests', () => { }); }); + it('treats stop() before start() as a safe no-op', async () => { + await expect(transactionWatcher.stop()).resolves.toBeUndefined(); + expect(mockDatabase.listPendingTransactionsBefore).not.toHaveBeenCalled(); + expect(mockQueue.enqueue).not.toHaveBeenCalled(); + expect(mockQueue.stop).not.toHaveBeenCalled(); + }); + it('enqueues expiration jobs for stale pending deposits', async () => { const staleTransaction = { id: 'stale-tx-1', From d7ede6403108a27da62e32e29ae6aeb4bedf7ca9 Mon Sep 17 00:00:00 2001 From: Ability Date: Mon, 27 Jul 2026 13:00:04 +0100 Subject: [PATCH 2/5] feat: enhance /info endpoint to include website when configured and add related tests --- PR_DESCRIPTION.md | 22 ++++++++++++ src/index.ts | 4 ++- src/runtime/http/express-router.ts | 4 +++ tests/mvp-express.integration.test.ts | 51 +++++++++++++++++++++++++-- tests/verify-exports.test.ts | 34 +++++++++++++++++- 5 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 PR_DESCRIPTION.md diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 00000000..0e91f4ee --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,22 @@ +# PR Summary + +Adds focused tests for three behaviors: +- 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` + +## 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 + +## 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. + +## Issue Reference + +Closes #243, #240, and #245 diff --git a/src/index.ts b/src/index.ts index 75c1ab10..ab075c90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,9 +9,11 @@ export * from './types'; export { AnchorInstance, createAnchor, makeSqliteDbUrlForTests } from './core/factory'; export * from './core/errors'; export * as utils from './utils'; -export { AssetSchema, DatabaseUrlSchema, SecurityConfigSchema } from './utils'; +export { AssetSchema, DatabaseUrlSchema, SecurityConfigSchema, StellarUtils } from './utils'; export type { + AuthChallengeRecord, DatabaseAdapter, + InteractiveTransactionRecord, QueueAdapter, Watcher, WebhookProcessor, diff --git a/src/runtime/http/express-router.ts b/src/runtime/http/express-router.ts index 6b1b3dab..ef9f4d37 100644 --- a/src/runtime/http/express-router.ts +++ b/src/runtime/http/express-router.ts @@ -258,6 +258,10 @@ export class AnchorExpressRouter { responseBody.support_email = fullConfig.operational.supportEmail; } + if (fullConfig.operational?.website) { + responseBody.website = fullConfig.operational.website; + } + sendJson(res, 200, responseBody); return; } diff --git a/tests/mvp-express.integration.test.ts b/tests/mvp-express.integration.test.ts index 17557d1b..c49dd823 100644 --- a/tests/mvp-express.integration.test.ts +++ b/tests/mvp-express.integration.test.ts @@ -234,13 +234,60 @@ describe('MVP Express-mounted integration', () => { } }); - it('2c) /info omits support_email when not configured', async () => { + it('2c) /info includes website when configured', async () => { + const customDbUrl = makeSqliteDbUrlForTests(); + const customAnchor = createAnchor({ + network: { network: 'testnet' }, + server: {}, + security: { + sep10SigningKey: sep10ServerKeypair.secret(), + interactiveJwtSecret: 'jwt-test-secret-website', + distributionAccountSecret: 'distribution-test-secret', + }, + assets: { + assets: [ + { + code: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + }, + ], + }, + operational: { website: 'https://anchor.example.com' }, + framework: { + database: { provider: 'sqlite', url: customDbUrl }, + }, + }); + + await customAnchor.init(); + const customInvoke = createMountedInvoker(customAnchor); + const response = await customInvoke({ path: '/info' }); + expect(response.status).toBe(200); + expect(response.body.website).toBe('https://anchor.example.com'); + + await customAnchor.shutdown(); + const customDbPath = customDbUrl.startsWith('file:') + ? customDbUrl.slice('file:'.length) + : customDbUrl; + try { + unlinkSync(customDbPath); + } catch { + /* ignore */ + } + }); + + it('2d) /info omits website when not configured', async () => { + const response = await invoke({ path: '/info' }); + expect(response.status).toBe(200); + expect(response.body).not.toHaveProperty('website'); + }); + + it('2e) /info omits support_email when not configured', async () => { const response = await invoke({ path: '/info' }); expect(response.status).toBe(200); expect(response.body).not.toHaveProperty('support_email'); }); - it('2d) /info omits interactive_domain when not configured', async () => { + it('2f) /info omits interactive_domain when not configured', async () => { const customDbUrl = makeSqliteDbUrlForTests(); const customAnchor = createAnchor({ network: { network: 'testnet' }, diff --git a/tests/verify-exports.test.ts b/tests/verify-exports.test.ts index 9d5e79da..7be13291 100644 --- a/tests/verify-exports.test.ts +++ b/tests/verify-exports.test.ts @@ -5,7 +5,7 @@ import { makeSqliteDbUrlForTests, utils, } from '../src/index'; -import type { TransactionKind } from '../src/index'; +import type { AuthChallengeRecord, InteractiveTransactionRecord, TransactionKind } from '../src/index'; import { describe, expect, it } from 'vitest'; describe('Export Verification', () => { @@ -47,6 +47,38 @@ describe('Export Verification', () => { expect(typeof makeSqliteDbUrlForTests).toBe('function'); expect(makeSqliteDbUrlForTests()).toMatch(/^file:/); }); + + it('should export StellarUtils at the top level', async () => { + const { StellarUtils } = await import('../src/index'); + + expect(StellarUtils).toBeDefined(); + expect(typeof StellarUtils.validateAccountId).toBe('function'); + expect(StellarUtils).toBe(utils.StellarUtils); + }); + + it('should export runtime record types at the top level', () => { + const authChallengeRecord: AuthChallengeRecord = { + id: 'auth-1', + account: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + challenge: 'challenge', + expiresAt: '2026-07-27T00:00:00.000Z', + consumedAt: null, + createdAt: '2026-07-27T00:00:00.000Z', + }; + const interactiveTransactionRecord: InteractiveTransactionRecord = { + id: 'tx-1', + account: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + kind: 'deposit', + assetCode: 'USDC', + amount: '100', + status: 'pending', + createdAt: '2026-07-27T00:00:00.000Z', + updatedAt: '2026-07-27T00:00:00.000Z', + }; + + expect(authChallengeRecord.id).toBe('auth-1'); + expect(interactiveTransactionRecord.kind).toBe('deposit'); + }); }); describe('AssetSchema Validation', () => { From 5f8c0064532e4d14bc433947a05187656ce2d82a Mon Sep 17 00:00:00 2001 From: Shayam Prasad Sah Date: Sun, 30 Aug 2026 17:17:34 +0100 Subject: [PATCH 3/5] fix: enforce strict cleanup cutoff and retention validation --- README.md | 6 +++ src/core/errors.ts | 12 +++++ src/runtime/database/sql-database-adapter.ts | 22 ++++++-- src/utils/validation.ts | 16 ++++++ tests/core/config.test.ts | 27 ++++++++++ tests/runtime/sql-adapter-cleanup.test.ts | 54 +++++++++++++++++++- 6 files changed, 133 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9bfbe8d1..eb733e94 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,12 @@ Background processing is explicit and host-controlled. `startBackgroundJobs()` and `stopBackgroundJobs()` are idempotent and safe to call more than once. +### Retention cutoff semantics + +Cleanup uses a strict retention cutoff. Rows with timestamps exactly equal to the cutoff are kept, and only records strictly older than the cutoff are deleted. This applies consistently across auth challenges, idempotency keys, processed webhook events, and completed watcher tasks. + +Persisted JSON payloads are also validated strictly. If a stored `payload` column is malformed, the adapter throws a `MalformedPersistedDataError` instead of silently replacing it with an empty object. + ## Testing For tests and local development, `makeSqliteDbUrlForTests` creates a temporary SQLite database URL that you can import directly from `anchor-kit`. diff --git a/src/core/errors.ts b/src/core/errors.ts index 85e37cf0..df3ac816 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -123,3 +123,15 @@ export class CryptoError extends AnchorKitError { super(message, context); } } + +/** + * Error raised when persisted JSON payloads are malformed or cannot be decoded into the expected object shape. + */ +export class MalformedPersistedDataError extends AnchorKitError { + public readonly statusCode = 500; + public readonly errorCode = 'MALFORMED_PERSISTED_DATA'; + + constructor(message: string, context?: Record) { + super(message, context); + } +} diff --git a/src/runtime/database/sql-database-adapter.ts b/src/runtime/database/sql-database-adapter.ts index 4ff0a7af..15ce9ee3 100644 --- a/src/runtime/database/sql-database-adapter.ts +++ b/src/runtime/database/sql-database-adapter.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Database } from 'bun:sqlite'; -import { ConfigError } from '@/core/errors.ts'; +import { ConfigError, MalformedPersistedDataError } from '@/core/errors.ts'; import type { AuthChallengeRecord, DatabaseAdapter, @@ -35,10 +35,24 @@ function toSqlitePath(url: string): string { } function parseJsonObject(value: string): Record { - const parsed: unknown = JSON.parse(value); + let parsed: unknown; + + try { + parsed = JSON.parse(value); + } catch (error) { + throw new MalformedPersistedDataError( + `Malformed persisted JSON payload: ${error instanceof Error ? error.message : String(error)}`, + { value: value.slice(0, 200) }, + ); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return {}; + throw new MalformedPersistedDataError( + 'Persisted JSON payload must decode to a plain object; arrays and primitive values are not allowed.', + { value: value.slice(0, 200) }, + ); } + return parsed as Record; } @@ -623,6 +637,8 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { } public async cleanupOldRecords(cutoffIso: string): Promise { + // Retention uses a strict cutoff: values exactly equal to the cutoff are retained, + // while only entries strictly older than the cutoff are removed. if (this.sqlite) { this.sqlite.prepare('DELETE FROM auth_challenges WHERE expires_at < ?').run(cutoffIso); this.sqlite.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoffIso); diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 26252c61..b7122d49 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -250,6 +250,22 @@ export const AnchorKitConfigSchema = { if (framework.queue?.concurrency !== undefined && framework.queue.concurrency < 1) { throw new Error('framework.queue.concurrency must be >= 1'); } + if ( + framework.watchers?.retentionDays !== undefined && + (!Number.isInteger(framework.watchers.retentionDays) || + !Number.isSafeInteger(framework.watchers.retentionDays) || + framework.watchers.retentionDays <= 0) + ) { + throw new Error('framework.watchers.retentionDays must be a positive safe integer'); + } + if ( + config.operational?.transactionRetentionDays !== undefined && + (!Number.isInteger(config.operational.transactionRetentionDays) || + !Number.isSafeInteger(config.operational.transactionRetentionDays) || + config.operational.transactionRetentionDays <= 0) + ) { + throw new Error('operational.transactionRetentionDays must be a positive safe integer'); + } if ( framework.watchers?.pollIntervalMs !== undefined && framework.watchers.pollIntervalMs < 10 diff --git a/tests/core/config.test.ts b/tests/core/config.test.ts index 30dfd0f3..bb0627d5 100644 --- a/tests/core/config.test.ts +++ b/tests/core/config.test.ts @@ -46,6 +46,33 @@ describe('AnchorConfig', () => { expect(op?.corsEnabled).toBe(true); expect(op?.transactionRetentionDays).toBe(90); }); + + it('should reject invalid transaction retention values', () => { + const invalidValues = [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, 2 ** 53]; + + invalidValues.forEach((value) => { + const invalidConfig: AnchorKitConfig = { + ...validBaseConfig, + operational: { + transactionRetentionDays: value, + }, + }; + + expect(() => new AnchorConfig(invalidConfig).validate()).toThrow(/transactionRetentionDays/); + }); + }); + + it('should accept valid positive integer retention values unchanged', () => { + const config = new AnchorConfig({ + ...validBaseConfig, + operational: { + transactionRetentionDays: 30, + }, + }); + + expect(config.get('operational')?.transactionRetentionDays).toBe(30); + expect(() => config.validate()).not.toThrow(); + }); }); describe('getAsset()', () => { diff --git a/tests/runtime/sql-adapter-cleanup.test.ts b/tests/runtime/sql-adapter-cleanup.test.ts index 848115f6..540e4234 100644 --- a/tests/runtime/sql-adapter-cleanup.test.ts +++ b/tests/runtime/sql-adapter-cleanup.test.ts @@ -33,9 +33,10 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { } }); - it('removes expired operational rows and leaves rows that are not cleanup-eligible', async () => { + it('retains rows exactly on the cleanup cutoff while removing strictly older rows', async () => { const challengeExpired = `challenge-expired-${randomUUID()}`; const challengeKept = `challenge-kept-${randomUUID()}`; + const challengeExact = `challenge-exact-${randomUUID()}`; await db.insertAuthChallenge({ id: randomUUID(), @@ -43,6 +44,12 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { challenge: challengeExpired, expiresAt: BEFORE, }); + await db.insertAuthChallenge({ + id: randomUUID(), + account: 'GEXACT', + challenge: challengeExact, + expiresAt: CUTOFF, + }); await db.insertAuthChallenge({ id: randomUUID(), account: 'GKEPT', @@ -51,6 +58,7 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { }); const idemOldId = randomUUID(); + const idemExactId = randomUUID(); const idemNewId = randomUUID(); const scope = `scope-${randomUUID()}`; raw @@ -59,6 +67,12 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run(idemOldId, scope, 'old-key', 'hash-a', 200, '{}', BEFORE); + raw + .prepare( + `INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(idemExactId, scope, 'exact-key', 'hash-e', 200, '{}', CUTOFF); raw .prepare( `INSERT INTO idempotency_keys (id, scope, idempotency_key, request_hash, status_code, response_body, created_at) @@ -67,6 +81,7 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { .run(idemNewId, scope, 'new-key', 'hash-b', 200, '{}', AFTER); const whOldProcessedId = randomUUID(); + const whExactProcessedId = randomUUID(); const whOldPendingId = randomUUID(); const whNewProcessedId = randomUUID(); const payload = '{}'; @@ -77,6 +92,12 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, ) .run(whOldProcessedId, `evt-op-${randomUUID()}`, 'test', payload, BEFORE, BEFORE); + raw + .prepare( + `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)`, + ) + .run(whExactProcessedId, `evt-exact-${randomUUID()}`, 'test', payload, CUTOFF, CUTOFF); raw .prepare( `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) @@ -91,6 +112,7 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { .run(whNewProcessedId, `evt-new-${randomUUID()}`, 'test', payload, AFTER, AFTER); const wOldProcessedId = randomUUID(); + const wExactProcessedId = randomUUID(); const wOldPendingId = randomUUID(); const wNewProcessedId = randomUUID(); const taskPayload = '{}'; @@ -101,6 +123,12 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, ) .run(wOldProcessedId, taskPayload, BEFORE, BEFORE); + raw + .prepare( + `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) + VALUES (?, 'w', ?, 'processed', NULL, ?, ?)`, + ) + .run(wExactProcessedId, taskPayload, CUTOFF, CUTOFF); raw .prepare( `INSERT INTO watcher_tasks (id, watcher_name, payload, status, error_message, processed_at, created_at) @@ -117,10 +145,12 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { await db.cleanupOldRecords(CUTOFF); expect(await db.getAuthChallengeByChallenge(challengeExpired)).toBeNull(); + expect(await db.getAuthChallengeByChallenge(challengeExact)).not.toBeNull(); const keptAuth = await db.getAuthChallengeByChallenge(challengeKept); expect(keptAuth).not.toBeNull(); expect(await db.getIdempotencyRecord(scope, 'old-key')).toBeNull(); + expect(await db.getIdempotencyRecord(scope, 'exact-key')).not.toBeNull(); expect(await db.getIdempotencyRecord(scope, 'new-key')).not.toBeNull(); const webhookCount = (id: string) => @@ -132,6 +162,7 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { ).c, ); expect(webhookCount(whOldProcessedId)).toBe(0); + expect(webhookCount(whExactProcessedId)).toBe(1); expect(webhookCount(whOldPendingId)).toBe(1); expect(webhookCount(whNewProcessedId)).toBe(1); @@ -144,7 +175,28 @@ describe('SqlDatabaseAdapter – cleanupOldRecords (sqlite)', () => { ).c, ); expect(watcherCount(wOldProcessedId)).toBe(0); + expect(watcherCount(wExactProcessedId)).toBe(1); expect(watcherCount(wOldPendingId)).toBe(1); expect(watcherCount(wNewProcessedId)).toBe(1); }); + + it('throws when persisted JSON payloads are malformed instead of silently coercing to empty objects', async () => { + const eventId = `evt-invalid-${randomUUID()}`; + const invalidRowId = randomUUID(); + raw + .prepare( + `INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) + VALUES (?, ?, ?, ?, 'processed', NULL, ?, ?)` , + ) + .run(invalidRowId, eventId, 'test', '{not valid json}', CUTOFF, CUTOFF); + + await expect( + db.insertWebhookEvent({ + id: randomUUID(), + eventId, + provider: 'test', + payload: {}, + }), + ).rejects.toThrow(/malformed/i); + }); }); From 92480fa82aa9d487041eb592515f801f8eb516fb Mon Sep 17 00:00:00 2001 From: Shayam Prasad Sah Date: Wed, 30 Sep 2026 01:53:42 +0100 Subject: [PATCH 4/5] fix: align deposit and webhook responses --- README.md | 2 + src/runtime/http/express-router.ts | 36 +++++++-- src/runtime/interfaces.ts | 2 +- .../webhooks/default-webhook-processor.ts | 5 +- src/types/config.ts | 1 + tests/mvp-express.integration.test.ts | 75 +++++++++++++++++++ .../default-webhook-processor.test.ts | 44 +++++++++++ tests/webhook-fallback.test.ts | 44 +++++++++++ 8 files changed, 200 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index eb733e94..7d29d566 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,8 @@ curl -s \ -d '{"asset_code":"USDC","amount":"25"}' ``` +Decimal strings are stored exactly as provided and must use plain decimal notation. JSON numeric amounts are stored as non-exponent decimal strings; unsafe integer values are rejected. + Look up a transaction by id: ```bash diff --git a/src/runtime/http/express-router.ts b/src/runtime/http/express-router.ts index ef9f4d37..4f0c6cdb 100644 --- a/src/runtime/http/express-router.ts +++ b/src/runtime/http/express-router.ts @@ -1,4 +1,5 @@ import { version } from '../../../package.json'; +import Big from 'big.js'; import type { AnchorConfig } from '@/core/config.ts'; import { ValidationError } from '@/core/errors.ts'; import { InMemoryRateLimiter, type RateLimitRule } from '@/runtime/http/rate-limiter.ts'; @@ -106,6 +107,22 @@ function sha256(input: string): string { return createHash('sha256').update(input).digest('hex'); } +function normalizeDepositAmount(value: unknown): string | null { + if (typeof value === 'string') { + return value; + } + + if ( + typeof value !== 'number' || + !Number.isFinite(value) || + (Number.isInteger(value) && !Number.isSafeInteger(value)) + ) { + return null; + } + + return new Big(value).toFixed(); +} + function readBearerToken(req: IncomingMessage): string | null { const authHeader = req.headers.authorization; if (!authHeader) return null; @@ -453,11 +470,9 @@ export class AnchorExpressRouter { const rawBody = await readRawBody(req, this.maxBodyBytes); const body = jsonParseObject(rawBody); const assetCode = typeof body.asset_code === 'string' ? body.asset_code : ''; - const amountRaw = body.amount; - const amount = - typeof amountRaw === 'string' || typeof amountRaw === 'number' ? `${amountRaw}` : ''; + const amount = normalizeDepositAmount(body.amount); - if (!assetCode || !amount) { + if (!assetCode || amount === null || !amount) { sendJson(res, 400, { error: 'invalid_request', message: 'Body must include asset_code and amount', @@ -472,7 +487,11 @@ export class AnchorExpressRouter { } const numericAmount = toNumber(amount); - if (!Number.isFinite(numericAmount) || numericAmount <= 0) { + if ( + !/^-?\d+(\.\d+)?$/.test(amount) || + !Number.isFinite(numericAmount) || + numericAmount <= 0 + ) { sendJson(res, 400, { error: 'invalid_amount', message: 'Amount must be a positive number', @@ -516,8 +535,10 @@ export class AnchorExpressRouter { return; } + const cachedResponse = JSON.parse(existing.responseBody) as Record; sendJson(res, existing.statusCode, { - ...(JSON.parse(existing.responseBody) as Record), + ...cachedResponse, + updated_at: cachedResponse.updated_at ?? cachedResponse.created_at, idempotency_replay: true, }); return; @@ -545,6 +566,7 @@ export class AnchorExpressRouter { asset_issuer: selectedAsset.issuer, interactive_url: `${this.config.get('server').interactiveDomain ?? 'http://localhost:3000'}/deposit/${created.id}`, created_at: created.createdAt, + updated_at: created.updatedAt, }, }; @@ -652,6 +674,8 @@ export class AnchorExpressRouter { sendJson(res, 400, { error: 'webhook_error', message: 'Webhook processing failed', + event_id: eventId, + provider, }); } return; diff --git a/src/runtime/interfaces.ts b/src/runtime/interfaces.ts index 718e3ffc..32728c74 100644 --- a/src/runtime/interfaces.ts +++ b/src/runtime/interfaces.ts @@ -134,7 +134,7 @@ export interface WebhookProcessor { eventId: string; provider: string; payload: Record; - rawBody: string; + rawBody: string | Buffer | Uint8Array; signature?: string; }): Promise<{ duplicate: boolean; eventId: string }>; } diff --git a/src/runtime/webhooks/default-webhook-processor.ts b/src/runtime/webhooks/default-webhook-processor.ts index 149abc0d..14b9874a 100644 --- a/src/runtime/webhooks/default-webhook-processor.ts +++ b/src/runtime/webhooks/default-webhook-processor.ts @@ -35,7 +35,7 @@ export class DefaultWebhookProcessor implements WebhookProcessor { eventId: string; provider: string; payload: Record; - rawBody: string; + rawBody: string | Buffer | Uint8Array; signature?: string; }): Promise<{ duplicate: boolean; eventId: string }> { this.verifySignatureIfEnabled(input); @@ -62,6 +62,7 @@ export class DefaultWebhookProcessor implements WebhookProcessor { { receivedAt: insertion.record.createdAt, signature: input.signature, + rawBody: input.rawBody, }, ); @@ -84,7 +85,7 @@ export class DefaultWebhookProcessor implements WebhookProcessor { private verifySignatureIfEnabled(input: { payload: Record; - rawBody: string; + rawBody: string | Buffer | Uint8Array; signature?: string; }): void { const verifyEnabled = this.config.security.verifyWebhookSignatures ?? true; diff --git a/src/types/config.ts b/src/types/config.ts index a3262772..b04c6dba 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -636,6 +636,7 @@ export interface AnchorKitConfig { context: { receivedAt: string; signature?: string; + rawBody: string | Buffer | Uint8Array; }, ) => Promise | void; }; diff --git a/tests/mvp-express.integration.test.ts b/tests/mvp-express.integration.test.ts index c49dd823..7d2e4f00 100644 --- a/tests/mvp-express.integration.test.ts +++ b/tests/mvp-express.integration.test.ts @@ -107,6 +107,7 @@ describe('MVP Express-mounted integration', () => { let accessToken = ''; let transactionId = ''; let depositInteractiveUrl = ''; + let transactionCreatedAt = ''; beforeAll(async () => { anchor = createAnchor({ @@ -480,6 +481,74 @@ describe('MVP Express-mounted integration', () => { expect(response.body.message).toContain('minimum allowed of 10'); }); + it('5f) deposit amount strings stay exact and numeric amounts are canonicalized', async () => { + const stringAmountResponse = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + body: { asset_code: 'USDC', amount: '25.500' }, + }); + + expect(stringAmountResponse.status).toBe(201); + expect(stringAmountResponse.body.amount).toBe('25.500'); + + const integerAmountResponse = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + body: { asset_code: 'USDC', amount: 30 }, + }); + + expect(integerAmountResponse.status).toBe(201); + expect(integerAmountResponse.body.amount).toBe('30'); + + const numericAmountResponse = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + body: { asset_code: 'USDC', amount: 25.5 }, + }); + + expect(numericAmountResponse.status).toBe(201); + expect(numericAmountResponse.body.amount).toBe('25.5'); + + const exponentAmountResponse = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + body: { asset_code: 'USDC', amount: 1e2 }, + }); + + expect(exponentAmountResponse.status).toBe(201); + expect(exponentAmountResponse.body.amount).toBe('100'); + + for (const amount of ['1e2', 9007199254740992]) { + const response = await invoke({ + method: 'POST', + path: '/transactions/deposit/interactive', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + }, + body: { asset_code: 'USDC', amount }, + }); + + expect(response.status).toBe(400); + } + }); + it('5c) deposit with unknown asset_code is rejected', async () => { const response = await invoke({ method: 'POST', @@ -596,7 +665,9 @@ describe('MVP Express-mounted integration', () => { expect(response.status).toBe(201); transactionId = String(response.body.id ?? ''); depositInteractiveUrl = String(response.body.interactive_url ?? ''); + transactionCreatedAt = String(response.body.created_at ?? ''); expect(transactionId.length).toBeGreaterThan(0); + expect(response.body.updated_at).toBe(transactionCreatedAt); expect(response.body.status).toBe('pending_user_transfer_start'); expect(response.body.asset_issuer).toBe( 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', @@ -635,6 +706,8 @@ describe('MVP Express-mounted integration', () => { expect(response.status).toBe(201); expect(response.body.id).toBe(transactionId); expect(response.body.interactive_url).toBe(depositInteractiveUrl); + expect(response.body.created_at).toBe(transactionCreatedAt); + expect(response.body.updated_at).toBe(transactionCreatedAt); expect(response.body.status).toBe('pending_user_transfer_start'); expect(response.body.idempotency_replay).toBe(true); }); @@ -650,6 +723,8 @@ describe('MVP Express-mounted integration', () => { expect(response.status).toBe(200); expect(response.body.id).toBe(transactionId); + expect(response.body.created_at).toBe(transactionCreatedAt); + expect(response.body.updated_at).toBe(transactionCreatedAt); expect(response.body.asset_code).toBe('USDC'); expect(response.body.asset_issuer).toBe( 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', diff --git a/tests/runtime/webhooks/default-webhook-processor.test.ts b/tests/runtime/webhooks/default-webhook-processor.test.ts index 177bac91..29d8aac6 100644 --- a/tests/runtime/webhooks/default-webhook-processor.test.ts +++ b/tests/runtime/webhooks/default-webhook-processor.test.ts @@ -1,6 +1,7 @@ import { DefaultWebhookProcessor } from '../../../src/runtime/webhooks/default-webhook-processor.ts'; import type { AnchorKitConfig } from '../../../src/types/config.ts'; import type { DatabaseAdapter, WebhookEventRecord } from '../../../src/runtime/interfaces.ts'; +import { createHmac } from 'node:crypto'; describe('DefaultWebhookProcessor', () => { let processor: DefaultWebhookProcessor; @@ -92,4 +93,47 @@ describe('DefaultWebhookProcessor', () => { expect(result.eventId).toBe('evt_duplicate'); expect(callbackInvokedCount).toBe(0); }); + + test('callback receives the exact signed raw body for strings and buffers', async () => { + const secret = 'webhook-secret'; + const callbackRawBodies: Array = []; + const config: AnchorKitConfig = { + network: { network: 'testnet' }, + server: { interactiveDomain: 'test.example.com' }, + assets: { assets: [] }, + framework: { database: { provider: 'sqlite', url: 'file::memory:' } }, + security: { + sep10SigningKey: 'SCZJBZ6S7HWMQVT7DM74JVHVDKCEE5P6I6T3E5M7LJM6LJM6LJM6LJM6', + interactiveJwtSecret: 'test-jwt-secret', + distributionAccountSecret: 'test-distribution-secret', + webhookSecret: secret, + verifyWebhookSignatures: true, + }, + webhooks: { + onEvent: async (_event, context) => { + callbackRawBodies.push(context.rawBody); + }, + }, + }; + const verifiedProcessor = new DefaultWebhookProcessor({ + config, + database: mockDatabase as DatabaseAdapter, + }); + const rawBodies = ['{"amount":"25.00"}', Buffer.from('{"amount":"25.00"}')]; + + for (const [index, rawBody] of rawBodies.entries()) { + const signature = createHmac('sha256', secret).update(rawBody).digest('hex'); + await verifiedProcessor.process({ + eventId: `evt_signed_${index}`, + provider: 'test-provider', + payload: { amount: '25.00' }, + rawBody, + signature, + }); + } + + expect(callbackRawBodies[0]).toBe(rawBodies[0]); + expect(Buffer.isBuffer(callbackRawBodies[1])).toBe(true); + expect(callbackRawBodies[1]).toEqual(rawBodies[1]); + }); }); diff --git a/tests/webhook-fallback.test.ts b/tests/webhook-fallback.test.ts index 2f8aca94..db82e661 100644 --- a/tests/webhook-fallback.test.ts +++ b/tests/webhook-fallback.test.ts @@ -96,6 +96,7 @@ describe('Webhook Provider Fallback', () => { const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice('file:'.length) : dbUrl; let lastProvider = ''; + let failWebhookCallback = false; let anchor: AnchorInstance; let invoke: (options: TestRequestOptions) => Promise; @@ -121,6 +122,9 @@ describe('Webhook Provider Fallback', () => { webhooks: { onEvent: async (event) => { lastProvider = event.provider; + if (failWebhookCallback) { + throw new Error('private callback details'); + } }, }, }); @@ -240,4 +244,44 @@ describe('Webhook Provider Fallback', () => { expect(response.status).toBe(200); expect(lastProvider).toBe('body-provider'); }); + + it.each([ + ['header', { 'x-webhook-provider': 'header-provider' }, undefined, 'header-provider'], + ['body', {}, 'body-provider', 'body-provider'], + ['default', {}, undefined, 'generic'], + ])( + 'Failure response includes provider from %s', + async (source, headers, bodyProvider, provider) => { + const eventId = `evt_failure_${source}`; + const payload = { + id: eventId, + type: 'test', + ...(bodyProvider ? { provider: bodyProvider } : {}), + }; + const signature = createHmac('sha256', 'webhook-test-secret') + .update(JSON.stringify(payload)) + .digest('hex'); + + failWebhookCallback = true; + const response = await invoke({ + method: 'POST', + path: '/webhooks/events', + headers: { + 'content-type': 'application/json', + ...headers, + 'x-anchor-signature': signature, + }, + body: payload, + }); + failWebhookCallback = false; + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + error: 'webhook_error', + message: 'Webhook processing failed', + event_id: eventId, + provider, + }); + }, + ); }); From 6e6466b14d75c8e1836801e3179c4edf37689947 Mon Sep 17 00:00:00 2001 From: Ikemhood <91676649+ikemHood@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:25:28 +0100 Subject: [PATCH 5/5] fix: normalize deposit and webhook response fields --- src/runtime/http/express-router-impl.ts | 12 +++++++++++- tests/mvp-express.integration.test.ts | 18 ++++++++++++++---- tests/webhook-fallback.test.ts | 4 ---- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/runtime/http/express-router-impl.ts b/src/runtime/http/express-router-impl.ts index 09f3553d..e688ab3c 100644 --- a/src/runtime/http/express-router-impl.ts +++ b/src/runtime/http/express-router-impl.ts @@ -4,6 +4,7 @@ import { InMemoryRateLimiter, type RateLimitRule } from '@/runtime/http/rate-lim import type { DatabaseAdapter, WebhookProcessor } from '@/runtime/interfaces.ts'; import type { PluginRouteContext, RouteDefinition } from '@/types/foundation.ts'; import { IdempotencyUtils } from '@/utils/idempotency.ts'; +import Big from 'big.js'; import { Account, Keypair, @@ -971,7 +972,13 @@ async function handleDepositInteractive( typeof parsedBody.body.asset_code === 'string' ? parsedBody.body.asset_code : ''; const amountRaw = parsedBody.body.amount; const amount = - typeof amountRaw === 'number' || typeof amountRaw === 'string' ? `${amountRaw}` : ''; + typeof amountRaw === 'number' + ? Number.isFinite(amountRaw) + ? new Big(amountRaw).toFixed() + : `${amountRaw}` + : typeof amountRaw === 'string' + ? amountRaw + : ''; if (!assetCode || !amount) { sendJson(res, 400, { @@ -1109,6 +1116,7 @@ async function handleDepositInteractive( account: auth.account, interactive_url: buildInteractiveUrl(serverConfig.interactiveDomain, transactionId), created_at: createdAt, + updated_at: createdAt, }; try { @@ -1161,6 +1169,7 @@ async function handleDepositInteractive( account: created.account, interactive_url: buildInteractiveUrl(serverConfig.interactiveDomain, created.id), created_at: created.createdAt, + updated_at: created.updatedAt, }); } @@ -1319,6 +1328,7 @@ async function handleWebhook( error: 'webhook_error', message: 'Webhook processing failed', event_id: eventId, + provider, }); } } diff --git a/tests/mvp-express.integration.test.ts b/tests/mvp-express.integration.test.ts index da022c24..e42abead 100644 --- a/tests/mvp-express.integration.test.ts +++ b/tests/mvp-express.integration.test.ts @@ -124,13 +124,12 @@ describe('MVP Express-mounted integration', () => { const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice('file:'.length) : dbUrl; let webhookCallbackCount = 0; + let lastWebhookRawBody: string | Buffer | Uint8Array | undefined; let anchor: AnchorInstance; let invoke: (options: TestRequestOptions) => Promise; let accessToken = ''; let transactionId = ''; let depositInteractiveUrl = ''; - let transactionCreatedAt = ''; - beforeAll(async () => { anchor = createAnchor({ network: { network: 'testnet' }, @@ -185,8 +184,9 @@ describe('MVP Express-mounted integration', () => { }, }, webhooks: { - onEvent: async () => { + onEvent: async (_event, callbackContext) => { webhookCallbackCount += 1; + lastWebhookRawBody = callbackContext.rawBody; }, }, }); @@ -1500,12 +1500,14 @@ describe('MVP Express-mounted integration', () => { authorization: `Bearer ${accessToken}`, 'idempotency-key': 'deposit-numeric-amount', }, - body: { asset_code: 'USDC', amount: 50 }, + rawBody: '{"asset_code":"USDC","amount":5e1}', }); expect(response.status).toBe(201); expect(response.body.id).toBeTruthy(); expect(response.body.status).toBe('pending_user_transfer_start'); + expect(response.body.amount).toBe('50'); + expect(response.body.updated_at).toBe(response.body.created_at); }); it('6) authorized deposit interactive creates persistent transaction', async () => { @@ -1547,6 +1549,7 @@ describe('MVP Express-mounted integration', () => { expect(createResponse.status).toBe(201); expect(createResponse.body.amount).toBe(submittedAmount); + expect(createResponse.body.updated_at).toBe(createResponse.body.created_at); const persistedTransaction = await ( anchor as unknown as { @@ -1571,6 +1574,7 @@ describe('MVP Express-mounted integration', () => { expect(lookupResponse.status).toBe(200); expect(lookupResponse.body.amount).toBe(submittedAmount); + expect(lookupResponse.body.updated_at).toBe(createResponse.body.updated_at); }); it('6b) deposit with SAME idempotency-key but DIFFERENT body is rejected', async () => { @@ -2114,6 +2118,7 @@ describe('MVP Express-mounted integration', () => { error: 'webhook_error', event_id: 'evt_misconfigured', message: 'Webhook processing failed', + provider: 'generic', }); const signature = createHmac('sha256', 'any-secret') @@ -2136,6 +2141,7 @@ describe('MVP Express-mounted integration', () => { error: 'webhook_error', event_id: 'evt_misconfigured', message: 'Webhook processing failed', + provider: 'generic', }); expect(misconfiguredWebhookCallbackCount).toBe(0); @@ -2306,6 +2312,7 @@ describe('MVP Express-mounted integration', () => { expect(response.body.received).toBe(true); expect(response.body.duplicate).toBe(false); expect(response.body.event_id).toBe('evt_buffer'); + expect(lastWebhookRawBody).toEqual(Buffer.from(payloadText)); }); it('8h) webhook route treats whitespace-only ids as missing and generates an event id', async () => { @@ -2548,6 +2555,7 @@ describe('MVP Express-mounted integration', () => { expect(response.status).toBe(400); expect(response.body.error).toBe('webhook_error'); expect(response.body.event_id).toBe('evt_err_1'); + expect(response.body.provider).toBe('generic'); await customAnchor.shutdown(); const customDbPath = customDbUrl.startsWith('file:') @@ -3065,6 +3073,7 @@ describe('MVP Express-mounted integration', () => { expect(firstResponse.status).toBe(201); expect(firstResponse.body.account).toBe(clientKeypair.publicKey()); + expect(firstResponse.body.updated_at).toBe(firstResponse.body.created_at); const firstTxId = firstResponse.body.id; const secondResponse = await invoke({ @@ -3080,6 +3089,7 @@ describe('MVP Express-mounted integration', () => { expect(secondResponse.status).toBe(201); expect(secondResponse.body.id).toBe(firstTxId); + expect(secondResponse.body.updated_at).toBe(firstResponse.body.updated_at); }); it('12a) an in-flight idempotency reservation returns a retry response', async () => { diff --git a/tests/webhook-fallback.test.ts b/tests/webhook-fallback.test.ts index eae0b49d..5c765d89 100644 --- a/tests/webhook-fallback.test.ts +++ b/tests/webhook-fallback.test.ts @@ -96,7 +96,6 @@ describe('Webhook Provider Fallback', () => { const dbPath = dbUrl.startsWith('file:') ? dbUrl.slice('file:'.length) : dbUrl; let lastProvider = ''; - let failWebhookCallback = false; let anchor: AnchorInstance; let invoke: (options: TestRequestOptions) => Promise; @@ -122,9 +121,6 @@ describe('Webhook Provider Fallback', () => { webhooks: { onEvent: async (event) => { lastProvider = event.provider; - if (failWebhookCallback) { - throw new Error('private callback details'); - } }, }, });