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 bf744d6cdcccd9e6fe40fc2190a3e7d751adc95f Mon Sep 17 00:00:00 2001 From: Shayam Prasad Sah Date: Tue, 20 Oct 2026 13:47:28 +0100 Subject: [PATCH 5/5] feat: track scope and acceptance gates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 25 ++++++++ anchor-kit-trd.md | 33 ++++++++++ docs/implementation-status.md | 54 +++++++--------- src/runtime/database/sql-database-adapter.ts | 33 +++++----- src/types/index.ts | 1 + src/types/plugin.ts | 13 +++- src/types/transaction-status.ts | 33 ++++++++++ .../postgres-adapter.integration.test.ts | 62 ++++++++++++++++++ tests/types/plugin.test.ts | 64 +++++++++++++++++++ tests/types/transaction-status.test.ts | 35 ++++++++++ 10 files changed, 301 insertions(+), 52 deletions(-) create mode 100644 tests/runtime/postgres-adapter.integration.test.ts create mode 100644 tests/types/plugin.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a22d6b9d..7de7346a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,23 @@ on: jobs: build: runs-on: ubuntu-latest + env: + DATABASE_URL: postgres://anchor_kit:anchor_kit@localhost:5432/anchor_kit_test + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: anchor_kit + POSTGRES_PASSWORD: anchor_kit + POSTGRES_DB: anchor_kit_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U anchor_kit -d anchor_kit_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - uses: actions/checkout@v4 @@ -26,6 +43,14 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Verify PostgreSQL test service + run: | + test -n "$DATABASE_URL" + bun -e 'const { Client } = await import("pg"); const client = new Client({ connectionString: process.env.DATABASE_URL }); await client.connect(); await client.query("SELECT 1"); await client.end();' + + - name: PostgreSQL adapter assertions + run: bun test tests/runtime/postgres-adapter.integration.test.ts + - name: Lint run: bun run lint diff --git a/anchor-kit-trd.md b/anchor-kit-trd.md index b967b7a6..9b87c91a 100644 --- a/anchor-kit-trd.md +++ b/anchor-kit-trd.md @@ -1,3 +1,36 @@ +# Anchor-Kit Product Scope and Runtime Decisions + +This section is the authoritative current scope for contributors. The design material below is retained as a long-term reference; examples and future-state descriptions in it are not evidence that a feature is implemented. + +## Runtime and integration decisions + +- **Bun is the supported runtime today.** Bun is required for development and tests, and the SQLite adapter imports `bun:sqlite`. Node.js is not a supported runtime target until that dependency is isolated or replaced and Node compatibility is tested. +- **Express is the only supported HTTP integration.** Anchor-Kit provides an Express-compatible router; the host application owns the HTTP server lifecycle. +- **Persistence uses raw SQL.** The built-in adapter supports SQLite for development/tests and PostgreSQL for deployment. No ORM is used. +- **Custom database, queue, signer, KYC, and rail adapters are not public extension points yet.** Do not document or rely on adapter substitution as a shipped capability. + +## Milestones and gates + +1. **Foundation and MVP (current):** configuration, core instance lifecycle, Express routes, minimal SEP-10 challenge/token behavior, a minimal SEP-24 interactive deposit, SQL persistence, webhook processing, and in-process background processing. Treat protocol and provider coverage as partial; production readiness is not implied. +2. **Typed lifecycle and persistence proof:** plugin contracts and the first deposit status graph are typed; required CI connects to PostgreSQL, migrates its disposable database, and verifies adapter persistence and duplicate-delivery behavior. +3. **Protocol and provider expansion (deferred):** implement only with explicit scope, current official protocol/provider contracts, and maintainer review for financial or concurrency-sensitive behavior. + +## Explicitly deferred + +- Hono, Fastify, Deno, and Node.js runtime support. +- SEP-6, SEP-31, and SEP-38 protocol flows, plus complete SEP-24 coverage. +- Withdrawals. +- Enterprise signing, live KYC-provider integration, and production provider/rail integrations. +- Public custom-adapter APIs and ORM-based persistence. + +Deferred work must remain labeled planned until the corresponding implementation and acceptance tests land. + +## Focused acceptance checks + +- `bun test tests/types/transaction-status.test.ts tests/types/plugin.test.ts` — passes; checks the typed deposit transitions and plugin contracts, including compile-time rejection fixtures. +- `bun test tests/runtime/postgres-adapter.integration.test.ts` — with `DATABASE_URL` pointing at a disposable PostgreSQL database, passes migration, persistence, and concurrent duplicate-delivery assertions. Without the variable the focused suite is skipped; CI sets it and separately fails if PostgreSQL setup or connectivity fails. +- `bun run typecheck` — passes TypeScript and lint checks. + # **Architecting the Financial Bridge: A Technical Specification for the Anchor-in-a-Box SDK** ## diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 52f70b76..e24a9db0 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1,39 +1,31 @@ -# Implementation Status (Canonical: `anchor-kit-unified.md`) +# Implementation Status -This file tracks where the codebase currently aligns or diverges from the canonical spec (`anchor-kit-unified.md`) and supporting planning docs (`anchor-kit-trd.md`, `anchor-kit SDK Plan.md`, `anchor-kit-plan.md`). +The current product scope and runtime decisions are defined in [`anchor-kit-trd.md`](../anchor-kit-trd.md). This document records implementation status; planning material elsewhere in the repository does not imply that planned behavior is shipped. -## Implemented Now (Foundation) +## Implemented Now (Partial MVP) -- Factory and core instance lifecycle: `createAnchor`, `AnchorInstance`. -- Configuration model and runtime validation: `AnchorConfig` + `AnchorKitConfig`. -- Error hierarchy: `AnchorKitError`, `ConfigError`, `ValidationError`, `SepProtocolError`, `TransactionStateError`, `RailError`, `NetworkError`, `CryptoError`. -- Public type surface for config, transaction states, customer/KYC primitives, and SEP-24 transaction response types. -- Utility layer: validation, decimal math, cryptographic helpers, idempotency helper, Stellar helper functions. +- Factory and core instance lifecycle: `createAnchor`, `AnchorInstance`, and plugin registration/initialization. +- Configuration validation and public error/type surfaces. +- Express-compatible routes for the current minimal SEP-10 and SEP-24 deposit flows; the SDK does not own the HTTP server. +- Built-in raw SQL adapter for SQLite and PostgreSQL, including migrations and persistence for the current runtime records. +- Webhook signature verification, persistence/deduplication, in-process queue, and watcher lifecycle. +- Utility layer: validation, decimal math, cryptographic helpers, idempotency utilities, and Stellar helpers. -## Planned Per Unified Spec (Not Yet Implemented) +These are partial implementations, not complete SEP compliance or production-ready provider integrations. The SQLite adapter's `bun:sqlite` dependency makes Bun the supported runtime. -- Protocol modules: SEP-10, SEP-12, SEP-6, SEP-24 runtime flows, SEP-31, SEP-38. -- Adapter implementations: database adapters, rail adapters, signer adapters, KYC provider adapters, rate adapters. -- Orchestration/state modules: transaction state machine runtime, webhook processor, watchers/workers, server adapters. +## Planned (Not Yet Implemented) -## Intentionally Deferred +- Full SEP-6, SEP-24, SEP-31, and SEP-38 protocol flows; withdrawals. +- Node.js, Hono, Fastify, and Deno support. +- Public custom database/queue/signer/KYC/rail adapter extension points. +- Enterprise signing, live KYC providers, and production rail/provider integrations. +- A runtime transaction state machine beyond the exported initial deposit transition contract. -- All runtime protocol engines and provider integrations listed above remain deferred while foundation APIs and typing guarantees stabilize. -- Empty module directories under `src/core/sep6`, `src/core/sep24`, `src/core/sep31`, and `src/services` are placeholders only. +## Milestone Gates -## Drift Matrix - -| Area | Status | Classification | Source Expectation | Current Code State | Action Taken | -| ---------------------------- | --------------- | -------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| Root SDK API | Partial | Digression | Unified docs center API on `createAnchor` factory | Root package entrypoint exported only types before cleanup | Exported `createAnchor` and `AnchorInstance` from root entrypoints | -| Error naming | Mismatch | Digression | Foundation error is `ConfigError` in implementation | Tests still imported `ConfigurationError` | Removed stale `ConfigurationError` references and aligned tests to `ConfigError` | -| Transaction error typing | Conflicting | Duplicate | SEP-24 `TransactionNotFoundError` discriminator type is `not_found` | Duplicate `TransactionNotFoundError` existed in `foundation.ts` with incompatible shape | Removed conflicting foundation duplicate; SEP-24 type is authoritative | -| Plugin/foundation interfaces | Weakly typed | Digression | Unified docs emphasize strong type safety | Multiple interfaces used explicit `any` | Replaced `any` with `unknown`/typed records and generic plugin context | -| Type-safety enforcement | Incomplete | Gap | Strong type guarantees and compile-time safety | `no-explicit-any` was warning only; `typecheck` did not include lint gate | `no-explicit-any` set to error and `typecheck` now runs `tsc` + `eslint --max-warnings 0` | -| Docs vs implementation | Overstated | Digression | Unified docs include planned protocol/adapter architecture | README/Architecture implied protocol modules already available | Updated docs to separate implemented foundation from planned modules | -| Placeholder modules | Expected future | Gap | Unified docs define future protocol/service modules | Placeholder directories existed without guidance | Added explicit stub READMEs describing planned status | - -## Notes on Supporting Docs - -- `anchor-kit-unified.md` is treated as the canonical technical target. -- `anchor-kit-trd.md`, `anchor-kit SDK Plan.md`, and `anchor-kit-plan.md` remain useful design/roadmap references, but may describe features ahead of current implementation. +| Gate | Evidence | Focused command | Expected result | +| --- | --- | --- | --- | +| Public scope is tracked and planned work is labeled | This document and [`anchor-kit-trd.md`](../anchor-kit-trd.md) | Review those tracked files in a fresh clone | Current runtime decisions, milestones, and deferred backlog are explicit | +| Plugin hooks and first-deposit transitions are typed | Compile-time fixtures and status graph tests | `bun test tests/types/transaction-status.test.ts tests/types/plugin.test.ts` | Tests pass; invalid fields/statuses are compile-time errors | +| PostgreSQL adapter is exercised in CI | Required PostgreSQL service and integration test | `bun test tests/runtime/postgres-adapter.integration.test.ts` | Connects, migrates, and passes persistence/concurrent deduplication assertions | +| Type safety/lint gate | Repository typecheck script | `bun run typecheck` | TypeScript and ESLint pass | diff --git a/src/runtime/database/sql-database-adapter.ts b/src/runtime/database/sql-database-adapter.ts index 15ce9ee3..bd2d5e55 100644 --- a/src/runtime/database/sql-database-adapter.ts +++ b/src/runtime/database/sql-database-adapter.ts @@ -503,17 +503,8 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { return { record: this.mapWebhookRow(inserted), inserted: true }; } - const existingPg = await this.requirePostgres().query>( - 'SELECT * FROM webhook_events WHERE event_id = $1 LIMIT 1', - [input.eventId], - ); - - if (existingPg.rows[0]) { - return { record: this.mapWebhookRow(existingPg.rows[0]), inserted: false }; - } - - await this.requirePostgres().query( - 'INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) VALUES ($1, $2, $3, $4::jsonb, $5, NULL, NULL, $6)', + const insertedPg = await this.requirePostgres().query>( + 'INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at) VALUES ($1, $2, $3, $4::jsonb, $5, NULL, NULL, $6) ON CONFLICT (event_id) DO NOTHING RETURNING *', [ input.id, input.eventId, @@ -524,17 +515,23 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { ], ); - const insertedPg = await this.requirePostgres().query>( - 'SELECT * FROM webhook_events WHERE id = $1 LIMIT 1', - [input.id], - ); + const insertedRow = insertedPg.rows[0]; + if (insertedRow) { + return { record: this.mapWebhookRow(insertedRow), inserted: true }; + } - const row = insertedPg.rows[0]; + const existingPg = await this.requirePostgres().query>( + 'SELECT * FROM webhook_events WHERE event_id = $1 LIMIT 1', + [input.eventId], + ); + const row = existingPg.rows[0]; if (!row) { - throw new ConfigError('Failed to insert webhook event'); + throw new ConfigError( + 'Webhook event conflict occurred but the existing record could not be found', + ); } - return { record: this.mapWebhookRow(row), inserted: true }; + return { record: this.mapWebhookRow(row), inserted: false }; } public async updateWebhookEventStatus(input: { diff --git a/src/types/index.ts b/src/types/index.ts index 4cd26262..e646ea51 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,7 @@ export { TRANSACTION_STATUSES, + getAllowedNextTransactionStatuses, isPendingTransactionStatus, isTerminalTransactionStatus, isTransactionStatus, diff --git a/src/types/plugin.ts b/src/types/plugin.ts index d3ad198d..74451131 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -1,4 +1,7 @@ import { RouteDefinition, SchemaDefinition } from './foundation'; +import type { AnchorInstance } from '../core/factory.ts'; +import type { Transaction } from './transaction.ts'; +import type { TransactionStatus } from './transaction-status.ts'; export interface AnchorPluginContext< TConfig = unknown, @@ -19,8 +22,12 @@ export type Context = AnchorPluginContext; export interface AnchorPluginHooks { onDepositRequest?: (ctx: Context) => Promise; onWithdrawalRequest?: (ctx: Context) => Promise; - onSep10Challenge?: (tx: unknown) => Promise; - onTransactionStatusChange?: (tx: unknown, oldStatus: string, newStatus: string) => Promise; + onSep10Challenge?: (tx: Transaction) => Promise; + onTransactionStatusChange?: ( + tx: Transaction, + oldStatus: TransactionStatus, + newStatus: TransactionStatus, + ) => Promise; } export interface AnchorPlugin { @@ -46,5 +53,5 @@ export interface AnchorPlugin { /** * Plugin initialization lifecycle */ - init?: (instance: unknown) => Promise | void; + init?: (instance: AnchorInstance) => Promise | void; } diff --git a/src/types/transaction-status.ts b/src/types/transaction-status.ts index 4879aafe..d0786900 100644 --- a/src/types/transaction-status.ts +++ b/src/types/transaction-status.ts @@ -63,6 +63,39 @@ export function isTerminalTransactionStatus( /** Union of all pending transaction statuses. */ export type PendingTransactionStatus = Extract; +const DEPOSIT_TRANSACTION_TRANSITIONS = { + incomplete: ['pending_user', 'pending_anchor', 'pending_user_transfer_start', 'error', 'expired'], + pending_user: ['pending_anchor', 'error', 'expired'], + pending_anchor: ['pending_user', 'pending_user_transfer_start', 'error', 'expired'], + pending_user_transfer_start: [ + 'pending_user_transfer_complete', + 'pending_external', + 'error', + 'expired', + ], + pending_user_transfer_complete: ['pending_external', 'pending_stellar', 'error', 'expired'], + pending_external: ['pending_stellar', 'error', 'expired'], + pending_stellar: ['pending_trust', 'completed', 'error', 'expired'], + pending_trust: ['pending_stellar', 'error', 'expired'], + completed: [], + refunded: [], + expired: [], + error: [], + no_market: [], + too_small: [], + too_large: [], +} as const satisfies Record; + +/** + * Returns the next statuses supported by the initial deposit lifecycle. + * Terminal statuses and SEP-24 states outside this deposit slice return no next states. + */ +export function getAllowedNextTransactionStatuses( + status: TransactionStatus, +): readonly TransactionStatus[] { + return DEPOSIT_TRANSACTION_TRANSITIONS[status]; +} + const PENDING_TRANSACTION_STATUSES: ReadonlySet = new Set([ 'pending_anchor', 'pending_user_transfer_start', diff --git a/tests/runtime/postgres-adapter.integration.test.ts b/tests/runtime/postgres-adapter.integration.test.ts new file mode 100644 index 00000000..839290fe --- /dev/null +++ b/tests/runtime/postgres-adapter.integration.test.ts @@ -0,0 +1,62 @@ +import { randomUUID } from 'node:crypto'; +import { createSqlDatabaseAdapter } from '@/runtime/database/sql-database-adapter.ts'; +import type { DatabaseAdapter } from '@/runtime/interfaces.ts'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const databaseUrl = process.env.DATABASE_URL; +const postgresTest = databaseUrl ? describe : describe.skip; + +postgresTest('SqlDatabaseAdapter – PostgreSQL integration', () => { + let db: DatabaseAdapter; + + beforeAll(async () => { + if (!databaseUrl) { + throw new Error('DATABASE_URL is required to run PostgreSQL adapter assertions'); + } + + db = createSqlDatabaseAdapter({ provider: 'postgres', url: databaseUrl }); + await db.connect(); + await db.migrate(); + }); + + afterAll(async () => { + await db?.disconnect(); + }); + + it('connects, migrates, and persists interactive transactions', async () => { + const id = randomUUID(); + const inserted = await db.insertInteractiveTransaction({ + id, + account: 'GTEST1234', + kind: 'deposit', + assetCode: 'USDC', + amount: '25.00', + status: 'pending_user_transfer_start', + }); + + expect(inserted.id).toBe(id); + expect(await db.getInteractiveTransactionById(id)).toMatchObject({ + id, + kind: 'deposit', + status: 'pending_user_transfer_start', + }); + }); + + it('atomically deduplicates concurrent deliveries of the same webhook event', async () => { + const eventId = `evt-${randomUUID()}`; + const results = await Promise.all( + Array.from({ length: 8 }, (_, index) => + db.insertWebhookEvent({ + id: randomUUID(), + eventId, + provider: 'ci-test', + payload: { delivery: index }, + }), + ), + ); + + expect(results.filter(({ inserted }) => inserted)).toHaveLength(1); + expect(new Set(results.map(({ record }) => record.id)).size).toBe(1); + expect(results.every(({ record }) => record.eventId === eventId)).toBe(true); + }); +}); diff --git a/tests/types/plugin.test.ts b/tests/types/plugin.test.ts new file mode 100644 index 00000000..0b9dc4e3 --- /dev/null +++ b/tests/types/plugin.test.ts @@ -0,0 +1,64 @@ +import type { AnchorPlugin, AnchorPluginHooks } from '@/types/plugin.ts'; +import type { AnchorInstance } from '@/core/factory.ts'; +import type { Transaction } from '@/types/transaction.ts'; +import type { TransactionStatus } from '@/types/transaction-status.ts'; +import { describe, expect, it } from 'vitest'; + +describe('AnchorPlugin type contracts', () => { + it('types initialization and transaction hooks without casts', () => { + const transaction: Transaction = { + id: 'deposit-1', + kind: 'deposit', + status: 'pending_external', + amount_in: { amount: '25.00', asset: 'USD' }, + }; + const hooks: AnchorPluginHooks = { + onSep10Challenge: async (tx) => { + expect(tx.id).toBeDefined(); + expect(tx.amount_in?.amount).toBeDefined(); + return tx; + }, + onTransactionStatusChange: async (tx, oldStatus, newStatus) => { + expect(tx.kind).toBe('deposit'); + const previous: TransactionStatus = oldStatus; + const next: TransactionStatus = newStatus; + expect(previous).toBe('pending_external'); + expect(next).toBe('pending_stellar'); + }, + }; + const plugin: AnchorPlugin = { + id: 'typed-plugin', + init: (instance) => { + const anchor: AnchorInstance = instance; + expect(anchor.config).toBeDefined(); + }, + hooks, + }; + + expect(plugin.id).toBe('typed-plugin'); + expect(transaction.amount_in?.amount).toBe('25.00'); + }); + + it('rejects unsupported transaction fields and status values at compile time', () => { + const hooks: AnchorPluginHooks = { + onTransactionStatusChange: async (tx, _oldStatus, _newStatus) => { + // @ts-expect-error — transaction records have no unsupported field + expect(tx.unsupportedField).toBeUndefined(); + }, + }; + const plugin: AnchorPlugin = { + id: 'invalid-plugin', + hooks: { + onTransactionStatusChange: async (_tx, oldStatus, _newStatus) => { + // @ts-expect-error — hook status arguments are TransactionStatus + const invalid: TransactionStatus = 'invalid'; + expect(invalid).toBeDefined(); + expect(oldStatus).toBeDefined(); + }, + }, + }; + + expect(hooks).toBeDefined(); + expect(plugin).toBeDefined(); + }); +}); diff --git a/tests/types/transaction-status.test.ts b/tests/types/transaction-status.test.ts index 830d11a7..2a861260 100644 --- a/tests/types/transaction-status.test.ts +++ b/tests/types/transaction-status.test.ts @@ -1,4 +1,5 @@ import { + getAllowedNextTransactionStatuses, isPendingTransactionStatus, isTerminalTransactionStatus, TRANSACTION_STATUSES, @@ -110,6 +111,40 @@ describe('TransactionStatus', () => { throw new Error('expected status to narrow to PendingTransactionStatus'); }); + it('exposes the allowed first-deposit lifecycle transitions', () => { + expect(getAllowedNextTransactionStatuses('incomplete')).toEqual([ + 'pending_user', + 'pending_anchor', + 'pending_user_transfer_start', + 'error', + 'expired', + ]); + expect(getAllowedNextTransactionStatuses('pending_anchor')).toContain( + 'pending_user_transfer_start', + ); + expect(getAllowedNextTransactionStatuses('pending_user')).toContain('pending_anchor'); + expect(getAllowedNextTransactionStatuses('pending_external')).toEqual([ + 'pending_stellar', + 'error', + 'expired', + ]); + expect(getAllowedNextTransactionStatuses('pending_stellar')).toContain('completed'); + expect(getAllowedNextTransactionStatuses('pending_stellar')).toContain('pending_trust'); + expect(getAllowedNextTransactionStatuses('pending_trust')).toContain('pending_stellar'); + }); + + it('rejects regressions and transitions out of terminal statuses', () => { + expect(getAllowedNextTransactionStatuses('pending_stellar')).not.toContain('pending_external'); + expect(getAllowedNextTransactionStatuses('completed')).toEqual([]); + expect(getAllowedNextTransactionStatuses('expired')).toEqual([]); + }); + + it('exposes allowed next statuses as a checked union', () => { + const allowed: readonly TransactionStatus[] = + getAllowedNextTransactionStatuses('pending_stellar'); + expect(allowed).toContain('completed'); + }); + // -- compile-time checks (tsc catches these before tests even run) -- it('accepts every valid status', () => {