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
29 changes: 17 additions & 12 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
# PR Summary
## What does this PR do?

Adds focused tests for three behaviors:
This PR makes background startup and watcher shutdown recoverable, and invokes the declared deposit and SEP-10 challenge plugin hooks.

- sqlite-backed `getInteractiveTransactionById()` returns `null` for unknown transaction IDs
- `TransactionWatcher.stop()` before `start()` is a safe no-op
- webhook route accepts an empty request body and returns a generated `event_id`
- Stops the queue and earlier watchers after a later startup failure while preserving the original error.
- Coordinates watcher start/stop races and tolerates in-flight tick failures during shutdown.
- Runs deposit and challenge hooks in registration order with typed context; hook failures prevent persistence.
- Adds focused lifecycle and HTTP integration tests.

## How to test
## How to test?

- Run `bun test tests/runtime/sql-adapter-interactive-tx.test.ts tests/runtime/transaction-watcher.unit.test.ts tests/mvp-express.integration.test.ts`
- Confirm all tests pass
- Run `bun test` (434 tests pass across 45 files).
- For focused coverage, run `bun test tests/runtime/background-lifecycle.test.ts tests/runtime/transaction-watcher.unit.test.ts tests/mvp-express.integration.test.ts`.

## Checklist

- [x] My code follows the code style of this project.
- [ ] My code follows the code style of this project.
- [x] I have added tests for my changes.
- [ ] I have updated the documentation accordingly.
- [x] I have run `bun test` locally.
- [x] I have updated the hook contract documentation.
- [x] I have run `bun run test` locally.
- [ ] I have run `bun run lint` locally.

## Issue Reference

Closes #243, #240, and #245
Closes #584
Closes #585
Closes #589
Closes #590
15 changes: 14 additions & 1 deletion src/core/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { InMemoryQueueAdapter } from '@/runtime/queue/in-memory-queue.ts';
import { TransactionWatcher } from '@/runtime/watchers/transaction-watcher.ts';
import { DefaultWebhookProcessor } from '@/runtime/webhooks/default-webhook-processor.ts';
import { AnchorKitConfig } from '@/types/config.ts';
import { AnchorPlugin } from '@/types/plugin.ts';
import type { AnchorPlugin, AnchorPluginHooks } from '@/types/plugin.ts';

/**
* AnchorInstance
Expand Down Expand Up @@ -106,10 +106,23 @@ export class AnchorInstance {
];
}

const depositRequestHooks: NonNullable<AnchorPluginHooks['onDepositRequest']>[] = [];
const sep10ChallengeHooks: NonNullable<AnchorPluginHooks['onSep10Challenge']>[] = [];
for (const plugin of this.plugins.values()) {
if (plugin.hooks?.onDepositRequest) {
depositRequestHooks.push(plugin.hooks.onDepositRequest);
}
if (plugin.hooks?.onSep10Challenge) {
sep10ChallengeHooks.push(plugin.hooks.onSep10Challenge);
}
}

this.expressRouter = new AnchorExpressRouter({
config: this.config,
database: this.database,
webhookProcessor: this.webhookProcessor,
depositRequestHooks,
sep10ChallengeHooks,
pluginRoutes,
}).getMiddleware();

Expand Down
50 changes: 48 additions & 2 deletions src/runtime/http/express-router-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ConfigError, PayloadTooLargeError, ValidationError } from '@/core/error
import { InMemoryRateLimiter, type RateLimitRule } from '@/runtime/http/rate-limiter.ts';
import type { DatabaseAdapter, WebhookProcessor } from '@/runtime/interfaces.ts';
import type { PluginRouteContext, RouteDefinition } from '@/types/foundation.ts';
import type { AnchorPluginHooks, DepositRequestBody } from '@/types/plugin.ts';
import { IdempotencyUtils } from '@/utils/idempotency.ts';
import {
Account,
Expand Down Expand Up @@ -45,6 +46,8 @@ export interface ExpressRouterContext {
requestTimeout: number;
rateLimiter: InMemoryRateLimiter;
rateRules: Record<'auth_challenge' | 'auth_token' | 'webhook' | 'deposit', RateLimitRule>;
depositRequestHooks?: NonNullable<AnchorPluginHooks['onDepositRequest']>[];
sep10ChallengeHooks?: NonNullable<AnchorPluginHooks['onSep10Challenge']>[];
pluginRoutes?: Array<{ pluginId: string; route: RouteDefinition }>;
}

Expand Down Expand Up @@ -782,8 +785,29 @@ async function handleAuthChallenge(

const challengeTx = challengeBuilder.build();

challengeTx.sign(context.sep10ServerKeypair);
const challengeXdr = challengeTx.toXDR();
let hookedChallengeTx = challengeTx;
try {
for (const hook of context.sep10ChallengeHooks ?? []) {
hookedChallengeTx = await hook(hookedChallengeTx);
}
} catch {
sendJson(res, 500, {
error: 'challenge_hook_failed',
message: 'SEP-10 challenge hook failed',
});
return;
}

if (extractNonceFromChallenge(hookedChallengeTx) !== nonce) {
sendJson(res, 500, {
error: 'challenge_hook_failed',
message: 'SEP-10 challenge hook returned an invalid transaction',
});
return;
}

hookedChallengeTx.sign(context.sep10ServerKeypair);
const challengeXdr = hookedChallengeTx.toXDR();
const expiresAt = new Date(expiresAtUnix * 1000).toISOString();

await context.database.insertAuthChallenge({
Expand Down Expand Up @@ -1029,6 +1053,28 @@ async function handleDepositInteractive(
return;
}

const depositHookContext = {
config: context.config.getConfig(),
db: context.database,
params: {},
query: {},
body: parsedBody.body as DepositRequestBody,
account: auth.account,
asset: selectedAsset,
};

try {
for (const hook of context.depositRequestHooks ?? []) {
await hook(depositHookContext);
}
} catch {
sendJson(res, 400, {
error: 'deposit_rejected',
message: 'Deposit request was rejected by a plugin',
});
return;
}

const idempotencyKey = IdempotencyUtils.extractIdempotencyHeader(req.headers, 'idempotency-key');

const IDEMPOTENCY_KEY_MAX_BYTES = 255;
Expand Down
5 changes: 5 additions & 0 deletions src/runtime/http/express-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { errorHandler } from '@/utils/error-handler.ts';
import { InMemoryRateLimiter, type RateLimitRule } from '@/runtime/http/rate-limiter.ts';
import type { DatabaseAdapter, WebhookProcessor } from '@/runtime/interfaces.ts';
import type { RouteDefinition } from '@/types/foundation.ts';
import type { AnchorPluginHooks } from '@/types/plugin.ts';
import { Keypair } from '@stellar/stellar-sdk';
import type { IncomingMessage, ServerResponse } from 'node:http';
import {
Expand All @@ -21,6 +22,8 @@ interface RouterDependencies {
config: AnchorConfig;
database: DatabaseAdapter;
webhookProcessor: WebhookProcessor;
depositRequestHooks?: NonNullable<AnchorPluginHooks['onDepositRequest']>[];
sep10ChallengeHooks?: NonNullable<AnchorPluginHooks['onSep10Challenge']>[];
pluginRoutes?: Array<{ pluginId: string; route: RouteDefinition }>;
}

Expand Down Expand Up @@ -52,6 +55,8 @@ export class AnchorExpressRouter {
config,
database: dependencies.database,
webhookProcessor: dependencies.webhookProcessor,
depositRequestHooks: dependencies.depositRequestHooks ?? [],
sep10ChallengeHooks: dependencies.sep10ChallengeHooks ?? [],
sep10ServerKeypair,
networkPassphrase,
maxBodyBytes,
Expand Down
63 changes: 50 additions & 13 deletions src/runtime/watchers/transaction-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class TransactionWatcher implements Watcher {
private timer: ReturnType<typeof setInterval> | null = null;
private tickPromise: Promise<void> | null = null;
private startPromise: Promise<void> | null = null;
private stopPromise: Promise<void> | null = null;

constructor(database: DatabaseAdapter, queue: QueueAdapter, options: TransactionWatcherOptions) {
this.database = database;
Expand All @@ -29,6 +30,11 @@ export class TransactionWatcher implements Watcher {
}

public async start(): Promise<void> {
if (this.stopPromise) {
await this.stopPromise;
return;
}

// If already started, return immediately
if (this.timer) return;

Expand Down Expand Up @@ -58,22 +64,53 @@ export class TransactionWatcher implements Watcher {
}

public async stop(): Promise<void> {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
if (this.stopPromise) return this.stopPromise;

const activeTick = this.tickPromise;
if (activeTick) {
try {
await activeTick;
} catch {
// Ignore transient tick failures during shutdown so timers and lifecycle state can settle.
this.stopPromise = (async () => {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}

this.isTickInProgress = false;
this.tickPromise = null;
const activeStart = this.startPromise;
if (activeStart) {
try {
await activeStart;
} catch {
// A failed initial tick should not prevent shutdown from settling.
}
}

if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}

const activeTick = this.tickPromise;
if (activeTick) {
try {
await activeTick;
} catch {
// Ignore transient tick failures during shutdown so timers and lifecycle state can settle.
}
}

// An initial tick can finish after the first timer check and create the
// interval while shutdown is waiting for that tick.
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}

this.isTickInProgress = false;
this.tickPromise = null;
})();

try {
await this.stopPromise;
} finally {
this.stopPromise = null;
}
}

private async tick(): Promise<void> {
Expand Down
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export type {
AnchorPlugin,
AnchorPluginContext,
AnchorPluginHooks,
DepositRequestBody,
DepositRequestHookContext,
Context as PluginContext,
} from './plugin';
export type { RouteDefinition, SchemaDefinition, SepErrorCode } from './foundation';
24 changes: 22 additions & 2 deletions src/types/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { RouteDefinition, SchemaDefinition } from './foundation';
import type { AnchorKitConfigSnapshot, Asset } from '@/types/config.ts';
import type { TransactionStatus } from './transaction-status.ts';
import type { DatabaseAdapter } from '@/runtime/interfaces.ts';
import type { Transaction } from '@stellar/stellar-sdk';

export interface AnchorPluginContext<
TConfig = unknown,
Expand All @@ -17,10 +20,27 @@ export interface AnchorPluginContext<

export type Context = AnchorPluginContext;

export type DepositRequestBody = Record<string, unknown> & {
asset_code: string;
amount: string | number;
};

export interface DepositRequestHookContext extends AnchorPluginContext<
AnchorKitConfigSnapshot,
DatabaseAdapter,
DepositRequestBody,
Record<string, never>,
Record<string, never>
> {
account: string;
asset: Asset;
}

export interface AnchorPluginHooks {
onDepositRequest?: (ctx: Context) => Promise<void>;
/** Throw to reject with HTTP 400 `deposit_rejected` before persistence. */
onDepositRequest?: (ctx: DepositRequestHookContext) => Promise<void>;
onWithdrawalRequest?: (ctx: Context) => Promise<void>;
onSep10Challenge?: (tx: unknown) => Promise<unknown>;
onSep10Challenge?: (tx: Transaction) => Promise<Transaction>;
onTransactionStatusChange?: (
tx: unknown,
oldStatus: TransactionStatus,
Expand Down
Loading
Loading