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
103 changes: 103 additions & 0 deletions packages/stellar/src/asset-compliance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { checkAssetCompliance, loadComplianceConfig } from './asset-compliance';
import type { ComplianceConfig } from './asset-compliance';

const ISSUER_A = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF';
const ISSUER_B = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBTFC';

const baseConfig: ComplianceConfig = {
blocklist: [{ issuer: ISSUER_A, reason: 'Sanctioned entity' }],
jurisdictionRules: [{ jurisdiction: 'US', blockedAssets: ['TOKEN'] }],
};

describe('checkAssetCompliance', () => {
it('canDeploy is always true', () => {
const result = checkAssetCompliance('TOKEN', ISSUER_A, baseConfig);
expect(result.canDeploy).toBe(true);
});

it('returns no warnings for a clean asset', () => {
const result = checkAssetCompliance('USDC', ISSUER_B, baseConfig);
expect(result.warnings).toHaveLength(0);
expect(result.envVars).toEqual({});
});

it('flags blocked issuer', () => {
const result = checkAssetCompliance('USDC', ISSUER_A, baseConfig);
expect(result.warnings.some((w) => w.flag === 'COMPLIANCE_ISSUER_BLOCKED')).toBe(true);
expect(result.envVars.COMPLIANCE_ISSUER_BLOCKED).toBe('true');
});

it('flags KYC required when issuer is blocked', () => {
const result = checkAssetCompliance('USDC', ISSUER_A, baseConfig);
expect(result.envVars.COMPLIANCE_KYC_REQUIRED).toBe('true');
});

it('flags jurisdiction restriction for blocked asset code', () => {
const result = checkAssetCompliance('TOKEN', ISSUER_B, baseConfig);
expect(result.warnings.some((w) => w.flag === 'COMPLIANCE_JURISDICTION_RESTRICTED')).toBe(true);
expect(result.envVars.COMPLIANCE_JURISDICTION_RESTRICTED).toBe('true');
expect(result.envVars.COMPLIANCE_RESTRICTED_JURISDICTIONS).toBe('US');
});

it('lists all restricted jurisdictions comma-separated', () => {
const config: ComplianceConfig = {
blocklist: [],
jurisdictionRules: [
{ jurisdiction: 'US', blockedAssets: ['TOKEN'] },
{ jurisdiction: 'CN', blockedAssets: ['TOKEN'] },
],
};
const result = checkAssetCompliance('TOKEN', ISSUER_B, config);
const jurisdictions = result.envVars.COMPLIANCE_RESTRICTED_JURISDICTIONS!.split(',');
expect(jurisdictions).toContain('US');
expect(jurisdictions).toContain('CN');
});

it('is case-insensitive for asset code', () => {
const result = checkAssetCompliance('token', ISSUER_B, baseConfig);
expect(result.warnings.some((w) => w.flag === 'COMPLIANCE_JURISDICTION_RESTRICTED')).toBe(true);
});

it('is case-insensitive for issuer address', () => {
const result = checkAssetCompliance('USDC', ISSUER_A.toLowerCase(), baseConfig);
expect(result.envVars.COMPLIANCE_ISSUER_BLOCKED).toBe('true');
});

it('sets KYC required when jurisdiction restriction triggers', () => {
const result = checkAssetCompliance('TOKEN', ISSUER_B, baseConfig);
expect(result.envVars.COMPLIANCE_KYC_REQUIRED).toBe('true');
});

it('does not set KYC when no issues', () => {
const result = checkAssetCompliance('USDC', ISSUER_B, baseConfig);
expect(result.envVars.COMPLIANCE_KYC_REQUIRED).toBeUndefined();
});

it('accumulates both issuer and jurisdiction warnings', () => {
const result = checkAssetCompliance('TOKEN', ISSUER_A, baseConfig);
const flags = result.warnings.map((w) => w.flag);
expect(flags).toContain('COMPLIANCE_ISSUER_BLOCKED');
expect(flags).toContain('COMPLIANCE_JURISDICTION_RESTRICTED');
});

it('works with empty config (no blocklist, no rules)', () => {
const result = checkAssetCompliance('TOKEN', ISSUER_A, { blocklist: [], jurisdictionRules: [] });
expect(result.canDeploy).toBe(true);
expect(result.warnings).toHaveLength(0);
});
});

describe('loadComplianceConfig', () => {
it('returns empty lists when no override and no env vars', () => {
const config = loadComplianceConfig({ blocklist: [], jurisdictionRules: [] });
expect(config.blocklist).toEqual([]);
expect(config.jurisdictionRules).toEqual([]);
});

it('uses provided override directly', () => {
const config = loadComplianceConfig(baseConfig);
expect(config.blocklist).toHaveLength(1);
expect(config.jurisdictionRules).toHaveLength(1);
});
});
171 changes: 171 additions & 0 deletions packages/stellar/src/asset-compliance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Stellar Asset Compliance Checking Service (#777)
*
* Verifies assets against a configurable blocklist and propagates compliance
* flags (KYC required, restricted jurisdiction) to generated template
* environment variables.
*
* Flag propagation never blocks deployment — violations are recorded as
* deployment log warnings only.
*/

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface BlocklistEntry {
/** Stellar account ID (G...) of the asset issuer. */
issuer: string;
reason: string;
}

export interface JurisdictionRule {
/** ISO 3166-1 alpha-2 country code, e.g. "US", "CN". */
jurisdiction: string;
/** Asset codes blocked in this jurisdiction, e.g. ["TOKEN", "XYZ"]. */
blockedAssets: string[];
}

export interface ComplianceConfig {
blocklist: BlocklistEntry[];
jurisdictionRules: JurisdictionRule[];
}

export type ComplianceFlagKey =
| 'COMPLIANCE_KYC_REQUIRED'
| 'COMPLIANCE_ISSUER_BLOCKED'
| 'COMPLIANCE_JURISDICTION_RESTRICTED'
| 'COMPLIANCE_RESTRICTED_JURISDICTIONS';

/** Environment variables propagated to the generated template configuration. */
export type ComplianceEnvVars = Partial<Record<ComplianceFlagKey, string>>;

export interface ComplianceWarning {
flag: ComplianceFlagKey;
message: string;
}

export interface ComplianceCheckResult {
/** Always true — compliance issues are warnings, not deployment blockers. */
canDeploy: boolean;
warnings: ComplianceWarning[];
/** Variables to merge into the deployment environment. */
envVars: ComplianceEnvVars;
}

// ---------------------------------------------------------------------------
// Default config loader (override in tests or provide your own)
// ---------------------------------------------------------------------------

/**
* Load compliance config from the environment or a provided source.
*
* In production the blocklist and jurisdiction rules live in Supabase and
* are injected via `COMPLIANCE_BLOCKLIST_JSON` / `COMPLIANCE_JURISDICTION_JSON`
* environment variables (serialised JSON arrays).
*/
export function loadComplianceConfig(override?: Partial<ComplianceConfig>): ComplianceConfig {
let blocklist: BlocklistEntry[] = override?.blocklist ?? [];
let jurisdictionRules: JurisdictionRule[] = override?.jurisdictionRules ?? [];

if (!override?.blocklist) {
try {
const raw = process.env.COMPLIANCE_BLOCKLIST_JSON;
if (raw) blocklist = JSON.parse(raw) as BlocklistEntry[];
} catch {
// Malformed env var — treat as empty blocklist
}
}

if (!override?.jurisdictionRules) {
try {
const raw = process.env.COMPLIANCE_JURISDICTION_JSON;
if (raw) jurisdictionRules = JSON.parse(raw) as JurisdictionRule[];
} catch {
// Malformed env var — treat as empty rules
}
}

return { blocklist, jurisdictionRules };
}

// ---------------------------------------------------------------------------
// Core compliance check
// ---------------------------------------------------------------------------

/**
* Run compliance checks for a Stellar asset before template generation.
*
* Checks performed:
* 1. Issuer blocklist — flags `COMPLIANCE_ISSUER_BLOCKED`
* 2. Jurisdiction restrictions — flags `COMPLIANCE_JURISDICTION_RESTRICTED`
* and `COMPLIANCE_RESTRICTED_JURISDICTIONS`
* 3. KYC requirement — flags `COMPLIANCE_KYC_REQUIRED` when either of the
* above triggers (i.e. a restricted asset always requires KYC)
*
* All flags are propagated as environment variables and recorded as deployment
* log warnings. Deployment is never blocked.
*
* @param assetCode - Stellar asset code, e.g. "USDC"
* @param issuer - Issuer G-address
* @param config - Compliance config (defaults to env-loaded config)
* @returns Check result with `canDeploy: true`, warnings, and envVars
*
* @example
* ```typescript
* const result = checkAssetCompliance('TOKEN', 'GABC...', config);
* // Merge envVars into deployment environment
* Object.assign(deploymentEnv, result.envVars);
* // Write warnings to deployment log
* result.warnings.forEach(w => log.warn(w.message));
* ```
*/
export function checkAssetCompliance(
assetCode: string,
issuer: string,
config?: Partial<ComplianceConfig>,
): ComplianceCheckResult {
const { blocklist, jurisdictionRules } = loadComplianceConfig(config);

const warnings: ComplianceWarning[] = [];
const envVars: ComplianceEnvVars = {};

// 1. Issuer blocklist check
const blockEntry = blocklist.find(
(e) => e.issuer.toLowerCase() === issuer.toLowerCase(),
);
if (blockEntry) {
warnings.push({
flag: 'COMPLIANCE_ISSUER_BLOCKED',
message: `Asset issuer ${issuer} is on the compliance blocklist: ${blockEntry.reason}`,
});
envVars.COMPLIANCE_ISSUER_BLOCKED = 'true';
}

// 2. Jurisdiction restriction check
const restrictedIn: string[] = [];
for (const rule of jurisdictionRules) {
if (rule.blockedAssets.some((a) => a.toUpperCase() === assetCode.toUpperCase())) {
restrictedIn.push(rule.jurisdiction);
}
}
if (restrictedIn.length > 0) {
warnings.push({
flag: 'COMPLIANCE_JURISDICTION_RESTRICTED',
message: `Asset ${assetCode} is restricted in jurisdictions: ${restrictedIn.join(', ')}`,
});
envVars.COMPLIANCE_JURISDICTION_RESTRICTED = 'true';
envVars.COMPLIANCE_RESTRICTED_JURISDICTIONS = restrictedIn.join(',');
}

// 3. KYC flag — required whenever any compliance issue is present
if (warnings.length > 0) {
envVars.COMPLIANCE_KYC_REQUIRED = 'true';
}

return {
canDeploy: true, // never blocks deployment
warnings,
envVars,
};
}
1 change: 1 addition & 0 deletions packages/stellar/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export * from './soroban-ttl-manager';
export * from './multi-party-issuance';
export * from './fee-bump-orchestrator';
export * from './account-merge-protection';
export * from './asset-compliance';
Loading