Skip to content
Open
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
63 changes: 63 additions & 0 deletions examples/shulam-compliance/README.md
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions examples/shulam-compliance/examples/basic-usage.ts
Original file line number Diff line number Diff line change
@@ -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);
84 changes: 84 additions & 0 deletions examples/shulam-compliance/src/client.ts
Original file line number Diff line number Diff line change
@@ -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<PromptResponse> {
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<void> {
// 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();
}
}
17 changes: 17 additions & 0 deletions examples/shulam-compliance/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
85 changes: 85 additions & 0 deletions examples/shulam-compliance/src/screener.ts
Original file line number Diff line number Diff line change
@@ -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<string, ComplianceStatus> = {
clear: 'clear',
held: 'held',
blocked: 'blocked',
pending: 'held',
error: 'held',
};

async function sleep(ms: number): Promise<void> {
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<ScreeningResponse> {
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<string, unknown>;

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');
}
}
53 changes: 53 additions & 0 deletions examples/shulam-compliance/src/screening-cache.ts
Original file line number Diff line number Diff line change
@@ -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<string, CacheEntry>();
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;
}
}
Loading