Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
33 changes: 33 additions & 0 deletions anchor-kit-trd.md
Original file line number Diff line number Diff line change
@@ -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**

##
Expand Down
2 changes: 1 addition & 1 deletion ci/fallow-health-baseline.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"healthScore": 60,
"hotspotCount": 41
"hotspotCount": 42
}
21 changes: 13 additions & 8 deletions docs/implementation-status.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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.
Expand Down
20 changes: 11 additions & 9 deletions src/runtime/database/sql-database-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>(
'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,
Expand All @@ -1837,17 +1837,19 @@ export class SqlDatabaseAdapter implements DatabaseAdapter {
],
);

const response = await this.requirePostgres().query<Record<string, unknown>>(
'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<Record<string, unknown>>(
'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: {
Expand Down
10 changes: 6 additions & 4 deletions src/types/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -41,9 +43,9 @@ export interface AnchorPluginHooks {
/** Throw to reject with HTTP 400 `deposit_rejected` before persistence. */
onDepositRequest?: (ctx: DepositRequestHookContext) => Promise<void>;
onWithdrawalRequest?: (ctx: Context) => Promise<void>;
onSep10Challenge?: (tx: Transaction) => Promise<Transaction>;
onSep10Challenge?: (tx: StellarTransaction) => Promise<StellarTransaction>;
onTransactionStatusChange?: (
tx: unknown,
tx: InteractiveTransactionRecord,
oldStatus: TransactionStatus,
newStatus: TransactionStatus,
) => Promise<void>;
Expand Down Expand Up @@ -72,5 +74,5 @@ export interface AnchorPlugin {
/**
* Plugin initialization lifecycle
*/
init?: (instance: unknown) => Promise<void> | void;
init?: (instance: AnchorInstance) => Promise<void> | void;
}
62 changes: 62 additions & 0 deletions tests/runtime/postgres-adapter.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
32 changes: 29 additions & 3 deletions tests/types/plugin-status-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
63 changes: 63 additions & 0 deletions tests/types/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
34 changes: 34 additions & 0 deletions tests/types/transaction-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading