diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9a9e72..222bd24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,8 @@ on: jobs: build: runs-on: ubuntu-latest + env: + DATABASE_URL: postgres://anchor:anchor@localhost:5432/anchor_kit_smoke services: postgres: image: postgres:16 @@ -40,6 +42,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 b967b7a..9b87c91 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/ci/fallow-health-baseline.json b/ci/fallow-health-baseline.json index da62f86..f1250ca 100644 --- a/ci/fallow-health-baseline.json +++ b/ci/fallow-health-baseline.json @@ -1,4 +1,4 @@ { "healthScore": 60, - "hotspotCount": 41 + "hotspotCount": 42 } diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 4bf2b0d..68ab468 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1,16 +1,17 @@ -# 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. **For a comprehensive configuration reference with implementation status and usage guidance, see [Configuration Reference](configuration-reference.md).** ## Implemented Now (Foundation) -- 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. ## Implemented Now (Runtime) @@ -32,7 +33,11 @@ This file tracks where the codebase currently aligns or diverges from the canoni - Provider adapters for rails, remote signing, KYC, and rates. A local Stellar Keypair signer is implemented. - Protocol transaction state machines and provider integrations. -## 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. - Runtime protocol engines and provider integrations listed above remain deferred while foundation APIs and typing guarantees stabilize. - Directories under `src/core/sep6`, `src/core/sep24`, and `src/core/sep31` are reserved for future protocol engines; their presence does not indicate runtime conformance. diff --git a/src/runtime/database/sql-database-adapter.ts b/src/runtime/database/sql-database-adapter.ts index eb30c78..2aaedd3 100644 --- a/src/runtime/database/sql-database-adapter.ts +++ b/src/runtime/database/sql-database-adapter.ts @@ -1824,8 +1824,8 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { return { record, inserted: false }; } - await this.requirePostgres().query( - 'INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at, operation_key) VALUES ($1, $2, $3, $4::jsonb, $5, NULL, NULL, $6, $7)', + const insertedPg = await this.requirePostgres().query>( + 'INSERT INTO webhook_events (id, event_id, provider, payload, status, error_message, processed_at, created_at, operation_key) VALUES ($1, $2, $3, $4::jsonb, $5, NULL, NULL, $6, $7) ON CONFLICT(provider, event_id) DO NOTHING RETURNING *', [ input.id, eventId, @@ -1837,17 +1837,19 @@ export class SqlDatabaseAdapter implements DatabaseAdapter { ], ); - const response = await this.requirePostgres().query>( - 'SELECT * FROM webhook_events WHERE id = $1 LIMIT 1', - [input.id], - ); - - const row = response.rows[0]; + const row = + insertedPg.rows[0] ?? + ( + await this.requirePostgres().query>( + 'SELECT * FROM webhook_events WHERE provider = $1 AND event_id = $2 LIMIT 1', + [provider, eventId], + ) + ).rows[0]; if (!row) { throw new ConfigError('Failed to insert or retrieve webhook event'); } - return { record: this.mapWebhookRow(row), inserted: true }; + return { record: this.mapWebhookRow(row), inserted: insertedPg.rows.length > 0 }; } public async claimWebhookEventAttempt(input: { diff --git a/src/types/plugin.ts b/src/types/plugin.ts index c8a2a51..f8e18ca 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -2,7 +2,9 @@ import { RouteDefinition, SchemaDefinition } from './foundation'; import type { AnchorKitConfigSnapshot, Asset } from '@/types/config.ts'; import type { TransactionStatus } from './transaction-status.ts'; import type { DatabaseAdapter } from '@/runtime/interfaces.ts'; -import type { Transaction } from '@stellar/stellar-sdk'; +import type { Transaction as StellarTransaction } from '@stellar/stellar-sdk'; +import type { AnchorInstance } from '@/core/factory.ts'; +import type { InteractiveTransactionRecord } from '@/runtime/interfaces.ts'; export interface AnchorPluginContext< TConfig = unknown, @@ -41,9 +43,9 @@ export interface AnchorPluginHooks { /** Throw to reject with HTTP 400 `deposit_rejected` before persistence. */ onDepositRequest?: (ctx: DepositRequestHookContext) => Promise; onWithdrawalRequest?: (ctx: Context) => Promise; - onSep10Challenge?: (tx: Transaction) => Promise; + onSep10Challenge?: (tx: StellarTransaction) => Promise; onTransactionStatusChange?: ( - tx: unknown, + tx: InteractiveTransactionRecord, oldStatus: TransactionStatus, newStatus: TransactionStatus, ) => Promise; @@ -72,5 +74,5 @@ export interface AnchorPlugin { /** * Plugin initialization lifecycle */ - init?: (instance: unknown) => Promise | void; + init?: (instance: AnchorInstance) => Promise | void; } diff --git a/tests/runtime/postgres-adapter.integration.test.ts b/tests/runtime/postgres-adapter.integration.test.ts new file mode 100644 index 0000000..161939a --- /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.insertOrGetWebhookEvent({ + 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-status-hook.test.ts b/tests/types/plugin-status-hook.test.ts index 3101544..c22cfee 100644 --- a/tests/types/plugin-status-hook.test.ts +++ b/tests/types/plugin-status-hook.test.ts @@ -13,12 +13,38 @@ describe('AnchorPluginHooks.onTransactionStatusChange', () => { observedStatuses = [oldStatus, newStatus]; }; - await onStatusChange({}, 'pending_user', 'completed'); + await onStatusChange( + { + id: 'deposit-1', + account: 'GTEST', + kind: 'deposit', + assetCode: 'USD', + amount: '1', + status: 'pending_user', + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }, + 'pending_user', + 'completed', + ); expect(observedStatuses).toEqual(['pending_user', 'completed']); const invalidStatusCall = () => { - // @ts-expect-error Invalid transaction lifecycle status - void onStatusChange({}, 'pending', 'completed'); + void onStatusChange( + { + id: 'deposit-1', + account: 'GTEST', + kind: 'deposit', + assetCode: 'USD', + amount: '1', + status: 'pending_user', + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }, + // @ts-expect-error Invalid transaction lifecycle status + 'pending', + 'completed', + ); }; expect(invalidStatusCall).toBeTypeOf('function'); }); diff --git a/tests/types/plugin.test.ts b/tests/types/plugin.test.ts new file mode 100644 index 0000000..cd33ea0 --- /dev/null +++ b/tests/types/plugin.test.ts @@ -0,0 +1,63 @@ +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).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 be96579..a12afad 100644 --- a/tests/types/transaction-status.test.ts +++ b/tests/types/transaction-status.test.ts @@ -122,6 +122,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', () => {