diff --git a/examples/shulam-compliance/README.md b/examples/shulam-compliance/README.md new file mode 100644 index 0000000..27ef91a --- /dev/null +++ b/examples/shulam-compliance/README.md @@ -0,0 +1,63 @@ +# @shulam-inc/bankr-compliance-wrapper + +Compliance-aware wrapper for `@bankr/sdk`. Screens wallet addresses via Shulam's CaaS API before every `prompt()` call. + +## Quickstart + +```typescript +import { CompliantBankrClient, ComplianceHoldError } from '@shulam-inc/bankr-compliance-wrapper'; + +const client = new CompliantBankrClient( + { apiKey: process.env.BANKR_API_KEY }, + { shulamApiKey: process.env.SHULAM_API_KEY }, +); + +try { + await client.prompt('Send 10 USDC', { walletAddress: '0x...' }); +} catch (err) { + if (err instanceof ComplianceHoldError) { + console.error(err.status, err.matchConfidence); + } +} +``` + +## API + +### `CompliantBankrClient` + +Extends `BankrClient` with compliance pre-screening. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `shulamApiKey` | `string` | required | Shulam API key | +| `shulamApiUrl` | `string` | `https://api.shulam.xyz` | API base URL | +| `skipCompliance` | `boolean` | `false` | Skip screening globally | +| `cacheTtlMs` | `number` | `300000` | Cache TTL (5 min) | + +### `ComplianceHoldError` + +Thrown when a wallet is `held` or `blocked`. + +| Property | Type | Description | +|----------|------|-------------| +| `status` | `'held' \| 'blocked'` | Compliance status | +| `walletAddress` | `string` | The screened address | +| `matchConfidence` | `number` | 0.0–1.0 match confidence | + +### Per-call options + +```typescript +// Skip compliance for this call +await client.prompt('Query only', { skipCompliance: true }); + +// Screen a specific address +await client.prompt('Pay merchant', { walletAddress: '0x...' }); +``` + +## Free Tier + +100 screens/day. Get a key at [api.shulam.xyz/register](https://api.shulam.xyz/register). + +## License + +MIT diff --git a/examples/shulam-compliance/examples/basic-usage.ts b/examples/shulam-compliance/examples/basic-usage.ts new file mode 100644 index 0000000..050fbf2 --- /dev/null +++ b/examples/shulam-compliance/examples/basic-usage.ts @@ -0,0 +1,44 @@ +/** + * Basic usage example for @shulam-inc/bankr-compliance-wrapper. + * + * Demonstrates: construct → prompt → catch ComplianceHoldError. + */ + +import { CompliantBankrClient, ComplianceHoldError } from '@shulam-inc/bankr-compliance-wrapper'; + +async function main() { + // Create a compliance-aware Bankr client + const client = new CompliantBankrClient( + { apiKey: process.env.BANKR_API_KEY! }, + { + shulamApiKey: process.env.SHULAM_API_KEY!, + shulamApiUrl: 'https://api.shulam.xyz', + cacheTtlMs: 300_000, // 5 minutes + }, + ); + + try { + // This will screen the wallet BEFORE prompting + const response = await client.prompt('Send 10 USDC to merchant', { + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + }); + + console.log('Response:', response.content); + } catch (err) { + if (err instanceof ComplianceHoldError) { + console.error(`Compliance check failed: ${err.status}`); + console.error(`Address: ${err.walletAddress}`); + console.error(`Match confidence: ${err.matchConfidence}`); + + if (err.status === 'blocked') { + console.error('This address is on a sanctions list. Transaction aborted.'); + } else { + console.error('Address is under review. Try again later.'); + } + } else { + throw err; + } + } +} + +main().catch(console.error); diff --git a/examples/shulam-compliance/src/client.ts b/examples/shulam-compliance/src/client.ts new file mode 100644 index 0000000..0f53e1f --- /dev/null +++ b/examples/shulam-compliance/src/client.ts @@ -0,0 +1,84 @@ +/** + * CompliantBankrClient — extends BankrClient with pre-prompt compliance screening. + * + * Every call to prompt() screens the wallet address first. + * Throws ComplianceHoldError if the address is held or blocked. + */ + +import { BankrClient } from '@bankr/sdk'; +import type { PromptResponse } from '@bankr/sdk'; +import { ComplianceHoldError } from './types.js'; +import type { CompliantBankrConfig, BankrPromptOptions } from './types.js'; +import { ScreeningCache } from './screening-cache.js'; +import { Screener } from './screener.js'; + +export class CompliantBankrClient extends BankrClient { + private readonly screener: Screener; + private readonly screeningCache: ScreeningCache; + private readonly defaultSkipCompliance: boolean; + + constructor( + bankrConfig: { apiKey: string; [key: string]: unknown }, + complianceConfig: CompliantBankrConfig, + ) { + super(bankrConfig); + this.screener = new Screener( + complianceConfig.shulamApiKey, + complianceConfig.shulamApiUrl, + ); + this.screeningCache = new ScreeningCache(complianceConfig.cacheTtlMs); + this.defaultSkipCompliance = complianceConfig.skipCompliance ?? false; + } + + async prompt( + input: string, + options?: BankrPromptOptions, + ): Promise { + const skipCompliance = + options?.skipCompliance ?? this.defaultSkipCompliance; + const walletAddress = options?.walletAddress; + + if (!skipCompliance && walletAddress) { + await this.screenAddress(walletAddress); + } + + // Pass through to parent BankrClient.prompt() + return super.prompt(input, options); + } + + private async screenAddress(address: string): Promise { + // Check cache first + const cached = this.screeningCache.get(address); + if (cached) { + if (cached.status !== 'clear') { + throw new ComplianceHoldError( + cached.status, + address, + cached.matchConfidence, + ); + } + return; + } + + const result = await this.screener.screen(address); + this.screeningCache.set(address, result); + + if (result.status !== 'clear') { + throw new ComplianceHoldError( + result.status, + address, + result.matchConfidence, + ); + } + } + + /** Invalidate the screening cache for an address. */ + invalidateCache(address: string): void { + this.screeningCache.invalidate(address); + } + + /** Clear the entire screening cache. */ + clearCache(): void { + this.screeningCache.clear(); + } +} diff --git a/examples/shulam-compliance/src/index.ts b/examples/shulam-compliance/src/index.ts new file mode 100644 index 0000000..7acd2a3 --- /dev/null +++ b/examples/shulam-compliance/src/index.ts @@ -0,0 +1,17 @@ +/** + * @shulam-inc/bankr-compliance-wrapper + * + * Compliance-aware wrapper for @bankr/sdk. + * Screens wallet addresses via Shulam CaaS API before every prompt(). + */ + +export { CompliantBankrClient } from './client.js'; +export { ComplianceHoldError } from './types.js'; +export type { + CompliantBankrConfig, + ComplianceStatus, + ScreeningResponse, + BankrPromptOptions, +} from './types.js'; +export { ScreeningCache } from './screening-cache.js'; +export { Screener } from './screener.js'; diff --git a/examples/shulam-compliance/src/screener.ts b/examples/shulam-compliance/src/screener.ts new file mode 100644 index 0000000..ae83d40 --- /dev/null +++ b/examples/shulam-compliance/src/screener.ts @@ -0,0 +1,85 @@ +/** + * Raw-fetch compliance screener — calls Shulam CaaS API. + * + * No SDK dependency: uses `fetch` directly with exponential backoff. + * Maps API `matchScore` → internal `matchConfidence`. + */ + +import type { ComplianceStatus, ScreeningResponse } from './types.js'; + +const STATUS_MAP: Record = { + clear: 'clear', + held: 'held', + blocked: 'blocked', + pending: 'held', + error: 'held', +}; + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export class Screener { + private readonly apiKey: string; + private readonly baseUrl: string; + + constructor(apiKey: string, baseUrl = 'https://api.shulam.xyz') { + this.apiKey = apiKey; + this.baseUrl = baseUrl; + } + + async screen(address: string): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= 3; attempt++) { + let response: Response; + + try { + response = await fetch(`${this.baseUrl}/v1/compliance/screen`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': this.apiKey, + }, + body: JSON.stringify({ address }), + }); + } catch (err) { + // Network error — retry with backoff + lastError = err instanceof Error ? err : new Error(String(err)); + if (attempt < 3) { + await sleep(Math.min(1000 * 2 ** attempt, 30_000)); + } + continue; + } + + // 429 — retry with backoff + if (response.status === 429) { + const retryAfter = response.headers.get('Retry-After'); + const delayMs = retryAfter + ? parseInt(retryAfter, 10) * 1000 + : Math.min(1000 * 2 ** attempt, 30_000); + await sleep(delayMs); + continue; + } + + // Non-OK (4xx/5xx except 429) — throw immediately, no retry + if (!response.ok) { + const body = await response.text(); + throw new Error(`Screening failed (${response.status}): ${body}`); + } + + const data = await response.json() as Record; + + return { + status: STATUS_MAP[data.status as string] ?? 'held', + // Map SDK's matchScore → our matchConfidence + matchConfidence: (data.matchScore as number) ?? 0, + screenedAt: (data.screenedAt as string) ?? new Date().toISOString(), + listsChecked: (data.listsChecked as string[]) ?? ['OFAC_SDN'], + holdId: (data.holdId as string) ?? null, + }; + } + + throw lastError ?? new Error('Screening request failed after retries'); + } +} diff --git a/examples/shulam-compliance/src/screening-cache.ts b/examples/shulam-compliance/src/screening-cache.ts new file mode 100644 index 0000000..ed71e3f --- /dev/null +++ b/examples/shulam-compliance/src/screening-cache.ts @@ -0,0 +1,53 @@ +/** + * In-memory screening cache with TTL expiry. + * Default TTL: 5 minutes. + */ + +import type { ScreeningResponse } from './types.js'; + +interface CacheEntry { + result: ScreeningResponse; + cachedAt: number; +} + +export class ScreeningCache { + private readonly cache = new Map(); + private readonly ttlMs: number; + + constructor(ttlMs = 300_000) { + this.ttlMs = ttlMs; + } + + get(address: string): ScreeningResponse | null { + const key = address.toLowerCase(); + const entry = this.cache.get(key); + + if (!entry) return null; + + if (Date.now() - entry.cachedAt > this.ttlMs) { + this.cache.delete(key); + return null; + } + + return entry.result; + } + + set(address: string, result: ScreeningResponse): void { + this.cache.set(address.toLowerCase(), { + result, + cachedAt: Date.now(), + }); + } + + invalidate(address: string): void { + this.cache.delete(address.toLowerCase()); + } + + clear(): void { + this.cache.clear(); + } + + get size(): number { + return this.cache.size; + } +} diff --git a/examples/shulam-compliance/src/types.ts b/examples/shulam-compliance/src/types.ts new file mode 100644 index 0000000..04131bf --- /dev/null +++ b/examples/shulam-compliance/src/types.ts @@ -0,0 +1,65 @@ +/** + * Types for the Bankr Compliance Wrapper. + * + * Uses `matchConfidence` (our internal type) — callers using the SDK see `matchScore`. + */ + +export type ComplianceStatus = 'clear' | 'held' | 'blocked'; + +export interface CompliantBankrConfig { + /** Shulam API key for compliance screening. */ + shulamApiKey: string; + + /** Shulam API base URL. Defaults to https://api.shulam.xyz */ + shulamApiUrl?: string; + + /** Skip compliance screening for specific calls. Default: false */ + skipCompliance?: boolean; + + /** Screening cache TTL in milliseconds. Default: 300_000 (5 minutes) */ + cacheTtlMs?: number; +} + +export interface ScreeningResponse { + status: ComplianceStatus; + matchConfidence: number; + screenedAt: string; + listsChecked: string[]; + holdId: string | null; +} + +/** + * Thrown when a wallet address is held or blocked by compliance screening. + */ +export class ComplianceHoldError extends Error { + public readonly status: ComplianceStatus; + public readonly walletAddress: string; + public readonly matchConfidence: number; + + constructor( + status: ComplianceStatus, + walletAddress: string, + matchConfidence: number, + ) { + const msg = + status === 'blocked' + ? `Address ${walletAddress} is blocked (sanctions match, confidence: ${matchConfidence.toFixed(2)})` + : `Address ${walletAddress} is held for review (match confidence: ${matchConfidence.toFixed(2)})`; + super(msg); + this.name = 'ComplianceHoldError'; + this.status = status; + this.walletAddress = walletAddress; + this.matchConfidence = matchConfidence; + } +} + +export interface BankrPromptOptions { + /** Wallet address to screen before prompting. */ + walletAddress?: string; + + /** Skip compliance for this specific call. */ + skipCompliance?: boolean; + + /** Pass-through to BankrClient.prompt() */ + [key: string]: unknown; +}