feat: ofac auto updater script

Includes a test subfolder for doing a test using Eth Sepolia Safe multisig + testing folder in tree server.  See OFAC_AUTO_UPDATER_README for quick readme on how to test and how to use for production
This commit is contained in:
Evi Nova
2025-12-11 01:32:29 +10:00
parent 6e01b3c114
commit 6135f6fa2b
9 changed files with 2345 additions and 0 deletions

11
.gitignore vendored
View File

@@ -29,3 +29,14 @@ app/android/android-passport-nfc-reader/
contracts/out/
contracts/cache_forge/
contracts/broadcast/
# SSH keys and credentials
*.pem
infra.pem
# OFAC automation outputs (regenerated by automation)
ofacdata/
common/ofacdata/
ofacdata-test-*/
contracts/scripts/ofac/safe-tx-batch.json
contracts/scripts/ofac/update-details.json

View File

@@ -0,0 +1,122 @@
# OFAC Sanctions List Automation
Automated pipeline for updating OFAC sanctions list with ~6-7 second mismatch window.
## Prerequisites
### SSH Access
Add to `~/.ssh/config`:
```
Host self-infra-prod
HostName <PRODUCTION_IP>
User ec2-user
IdentityFile ~/.ssh/infra.pem
Host self-infra-staging
HostName 54.71.62.30
User ec2-user
IdentityFile ~/.ssh/infra.pem
```
### VPN
Connect to NordLayer VPN before running any commands.
---
## Production Deployment
### First Signer
```bash
cd /path/to/self
# Download OFAC list and build trees
yarn dlx tsx common/scripts/ofac/index.ts
# Propose and sign
cd contracts
PRIVATE_KEY=0x... \
NETWORK=celo \
yarn dlx tsx scripts/ofac/prepareMultisigUpdate.ts
```
### Final Signer (2nd of 2/5)
```bash
cd /path/to/self/contracts
PRIVATE_KEY=0x... \
NETWORK=celo \
SSH_HOST=self-infra-prod \
yarn dlx tsx scripts/ofac/signExecuteAndUpload.ts
```
This script:
1. Pre-stages trees to server `/tmp/`
2. Signs the pending transaction
3. Executes on-chain
4. Moves trees to production (~6-7s mismatch)
---
## E2E Testing (Sepolia)
Test Safe: `0x4264a631c5E685a622b5C8171b5f17BeD7FB30c6` (2/2 threshold)
### First Signer
```bash
cd /path/to/self
# Download and build trees (if not already done)
yarn dlx tsx common/scripts/ofac/index.ts
# Propose test transaction
cd contracts
PRIVATE_KEY=0x_SIGNER_1_KEY_ \
yarn dlx tsx scripts/ofac/test-e2e/testSafeProposal.ts
```
### Second Signer
```bash
cd /path/to/self/contracts
PRIVATE_KEY=0x_SIGNER_2_KEY_ \
NETWORK=sepolia \
SSH_HOST=self-infra-staging \
UPLOAD_PATH=/home/ec2-user/ofac-e2e-test \
yarn dlx tsx scripts/ofac/signExecuteAndUpload.ts
```
### Cleanup
```bash
ssh self-infra-staging "rm -rf /home/ec2-user/ofac-e2e-test /tmp/ofac-prestage-*"
```
---
## Configuration
| Environment | Safe Address | SSH Host |
|-------------|--------------|----------|
| Production (Celo) | `0x067b18e09A10Fa03d027c1D60A098CEbbE5637f0` | `self-infra-prod` |
| Staging (Celo Sepolia) | `0x067b18e09A10Fa03d027c1D60A098CEbbE5637f0` | `self-infra-staging` |
| E2E Test (Eth Sepolia) | `0x4264a631c5E685a622b5C8171b5f17BeD7FB30c6` | `self-infra-staging` |
Default RPC URLs: Celo (`forno.celo.org`), Eth Sepolia (`rpc.sepolia.org`).
Celo Sepolia requires `CELO_SEPOLIA_RPC_URL` env var.
---
## Troubleshooting
| Issue | Solution |
|-------|----------|
| `tsx: command not found` | Use `yarn dlx tsx` |
| SSH timeout | Connect to NordLayer VPN |
| Orphaned pre-staged files | `ssh <host> "rm -rf /tmp/ofac-prestage-*"` |

View File

@@ -0,0 +1,366 @@
/**
* OFAC Tree Builder
*
* Orchestrates the building of all OFAC SMT trees:
* - Passport: passport_no_and_nationality, name_and_dob, name_and_yob
* - ID Card: name_and_dob_id_card, name_and_yob_id_card
* - Aadhaar: name_and_dob (Aadhaar format), name_and_yob (Aadhaar format)
* - KYC/Selfrica: name_and_dob, name_and_yob
*/
import * as fs from 'fs';
import * as path from 'path';
import { buildAadhaarSMT, buildSMT } from '../../src/utils/trees.js';
// Note: buildKycSMT not available on all branches - use buildSMT with KYC tree types
// When buildKycSMT is available, uncomment the import above
import type { OfacEntry } from './parseSdn.js';
export interface TreeBuildResult {
treeType: string;
entriesProcessed: number;
entriesAdded: number;
buildTime: number;
root: string;
exportPath?: string;
}
export interface AllTreesResult {
success: boolean;
trees: TreeBuildResult[];
totalTime: number;
error?: string;
}
/**
* Load OFAC entries from JSON file
*/
function loadEntries(inputPath: string): OfacEntry[] {
if (!fs.existsSync(inputPath)) {
throw new Error(`Input file not found: ${inputPath}`);
}
const content = fs.readFileSync(inputPath, 'utf-8');
return JSON.parse(content) as OfacEntry[];
}
/**
* Build all OFAC trees for passport verification
*/
async function buildPassportTrees(
entries: OfacEntry[],
outputDir: string
): Promise<TreeBuildResult[]> {
const results: TreeBuildResult[] = [];
// Filter entries with passport numbers for passport_no_and_nationality tree
const passportEntries = entries.filter((e) => e.Pass_No && e.Pass_Country);
// 1. Passport Number and Nationality tree
console.log('\n📦 Building passport_no_and_nationality tree...');
const [ppCount, ppTime, ppTree] = buildSMT(passportEntries, 'passport_no_and_nationality');
const ppExport = ppTree.export();
const ppPath = path.join(outputDir, 'passportNoAndNationalitySMT.json');
fs.writeFileSync(ppPath, JSON.stringify(ppExport));
results.push({
treeType: 'passport_no_and_nationality',
entriesProcessed: passportEntries.length,
entriesAdded: ppCount,
buildTime: ppTime,
root: ppTree.root.toString(),
exportPath: ppPath,
});
// 2. Name and DOB tree (passport format)
console.log('\n📦 Building name_and_dob tree...');
const [dobCount, dobTime, dobTree] = buildSMT(entries, 'name_and_dob');
const dobExport = dobTree.export();
const dobPath = path.join(outputDir, 'nameAndDobSMT.json');
fs.writeFileSync(dobPath, JSON.stringify(dobExport));
results.push({
treeType: 'name_and_dob',
entriesProcessed: entries.length,
entriesAdded: dobCount,
buildTime: dobTime,
root: dobTree.root.toString(),
exportPath: dobPath,
});
// 3. Name and YOB tree (passport format)
console.log('\n📦 Building name_and_yob tree...');
const [yobCount, yobTime, yobTree] = buildSMT(entries, 'name_and_yob');
const yobExport = yobTree.export();
const yobPath = path.join(outputDir, 'nameAndYobSMT.json');
fs.writeFileSync(yobPath, JSON.stringify(yobExport));
results.push({
treeType: 'name_and_yob',
entriesProcessed: entries.length,
entriesAdded: yobCount,
buildTime: yobTime,
root: yobTree.root.toString(),
exportPath: yobPath,
});
return results;
}
/**
* Build all OFAC trees for ID card verification
*/
async function buildIdCardTrees(
entries: OfacEntry[],
outputDir: string
): Promise<TreeBuildResult[]> {
const results: TreeBuildResult[] = [];
// 1. Name and DOB tree (ID card format)
console.log('\n📦 Building name_and_dob_id_card tree...');
const [dobCount, dobTime, dobTree] = buildSMT(entries, 'name_and_dob_id_card');
const dobExport = dobTree.export();
const dobPath = path.join(outputDir, 'nameAndDobSMT_ID.json');
fs.writeFileSync(dobPath, JSON.stringify(dobExport));
results.push({
treeType: 'name_and_dob_id_card',
entriesProcessed: entries.length,
entriesAdded: dobCount,
buildTime: dobTime,
root: dobTree.root.toString(),
exportPath: dobPath,
});
// 2. Name and YOB tree (ID card format)
console.log('\n📦 Building name_and_yob_id_card tree...');
const [yobCount, yobTime, yobTree] = buildSMT(entries, 'name_and_yob_id_card');
const yobExport = yobTree.export();
const yobPath = path.join(outputDir, 'nameAndYobSMT_ID.json');
fs.writeFileSync(yobPath, JSON.stringify(yobExport));
results.push({
treeType: 'name_and_yob_id_card',
entriesProcessed: entries.length,
entriesAdded: yobCount,
buildTime: yobTime,
root: yobTree.root.toString(),
exportPath: yobPath,
});
return results;
}
/**
* Build all OFAC trees for Aadhaar verification
*/
async function buildAadhaarTrees(
entries: OfacEntry[],
outputDir: string
): Promise<TreeBuildResult[]> {
const results: TreeBuildResult[] = [];
// 1. Name and DOB tree (Aadhaar format)
console.log('\n📦 Building Aadhaar name_and_dob tree...');
const [dobCount, dobTime, dobTree] = buildAadhaarSMT(entries, 'name_and_dob');
const dobExport = dobTree.export();
const dobPath = path.join(outputDir, 'nameAndDobSMT_AADHAAR.json');
fs.writeFileSync(dobPath, JSON.stringify(dobExport));
results.push({
treeType: 'aadhaar_name_and_dob',
entriesProcessed: entries.length,
entriesAdded: dobCount,
buildTime: dobTime,
root: dobTree.root.toString(),
exportPath: dobPath,
});
// 2. Name and YOB tree (Aadhaar format)
console.log('\n📦 Building Aadhaar name_and_yob tree...');
const [yobCount, yobTime, yobTree] = buildAadhaarSMT(entries, 'name_and_yob');
const yobExport = yobTree.export();
const yobPath = path.join(outputDir, 'nameAndYobSMT_AADHAAR.json');
fs.writeFileSync(yobPath, JSON.stringify(yobExport));
results.push({
treeType: 'aadhaar_name_and_yob',
entriesProcessed: entries.length,
entriesAdded: yobCount,
buildTime: yobTime,
root: yobTree.root.toString(),
exportPath: yobPath,
});
return results;
}
/**
* Build all OFAC trees for KYC/Selfrica verification
* NOTE: Disabled - buildKycSMT not available on dev branch
* Uncomment when buildKycSMT is added to common/src/utils/trees.ts
*/
async function buildKycTrees(
_entries: OfacEntry[],
_outputDir: string
): Promise<TreeBuildResult[]> {
console.log('\n⚠ Skipping KYC trees (buildKycSMT not available on this branch)');
return [];
/* Uncomment when buildKycSMT is available:
const results: TreeBuildResult[] = [];
// 1. Name and DOB tree (KYC format)
console.log('\n📦 Building KYC name_and_dob tree...');
const [dobCount, dobTime, dobTree] = buildKycSMT(entries, 'name_and_dob');
const dobExport = dobTree.export();
const dobPath = path.join(outputDir, 'nameAndDobKycSMT.json');
fs.writeFileSync(dobPath, JSON.stringify(dobExport));
results.push({
treeType: 'kyc_name_and_dob',
entriesProcessed: entries.length,
entriesAdded: dobCount,
buildTime: dobTime,
root: dobTree.root.toString(),
exportPath: dobPath,
});
// 2. Name and YOB tree (KYC format)
console.log('\n📦 Building KYC name_and_yob tree...');
const [yobCount, yobTime, yobTree] = buildKycSMT(entries, 'name_and_yob');
const yobExport = yobTree.export();
const yobPath = path.join(outputDir, 'nameAndYobKycSMT.json');
fs.writeFileSync(yobPath, JSON.stringify(yobExport));
results.push({
treeType: 'kyc_name_and_yob',
entriesProcessed: entries.length,
entriesAdded: yobCount,
buildTime: yobTime,
root: yobTree.root.toString(),
exportPath: yobPath,
});
return results;
*/
}
/**
* Build all OFAC trees
*/
export async function buildAllOfacTrees(
inputPath: string,
outputDir: string
): Promise<AllTreesResult> {
const startTime = performance.now();
try {
// Ensure output directory exists
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Load entries
console.log(`\nLoading entries from: ${inputPath}`);
const entries = loadEntries(inputPath);
console.log(`Loaded ${entries.length} entries`);
const allResults: TreeBuildResult[] = [];
// Build all tree types
const passportResults = await buildPassportTrees(entries, outputDir);
allResults.push(...passportResults);
const idCardResults = await buildIdCardTrees(entries, outputDir);
allResults.push(...idCardResults);
const aadhaarResults = await buildAadhaarTrees(entries, outputDir);
allResults.push(...aadhaarResults);
const kycResults = await buildKycTrees(entries, outputDir);
allResults.push(...kycResults);
const totalTime = performance.now() - startTime;
// Save roots summary
const rootsSummary: Record<string, string> = {};
for (const result of allResults) {
rootsSummary[result.treeType] = result.root;
}
const rootsPath = path.join(outputDir, 'roots.json');
fs.writeFileSync(rootsPath, JSON.stringify(rootsSummary, null, 2));
console.log(`\nSaved roots summary to: ${rootsPath}`);
return {
success: true,
trees: allResults,
totalTime,
};
} catch (error) {
return {
success: false,
trees: [],
totalTime: performance.now() - startTime,
error: (error as Error).message,
};
}
}
/**
* Print build results summary
*/
function printSummary(result: AllTreesResult): void {
console.log('\n' + '='.repeat(70));
console.log('OFAC TREE BUILD SUMMARY');
console.log('='.repeat(70));
if (!result.success) {
console.log(`\n❌ Build failed: ${result.error}`);
return;
}
console.log('\n📊 Tree Build Results:\n');
console.log(
'| Tree Type | Entries | Added | Time (ms) | Root (first 20 chars) |'
);
console.log(
'|------------------------------|---------|--------|-----------|--------------------------|'
);
for (const tree of result.trees) {
const rootPreview = tree.root.substring(0, 20) + '...';
console.log(
`| ${tree.treeType.padEnd(28)} | ${tree.entriesProcessed.toString().padStart(7)} | ${tree.entriesAdded.toString().padStart(6)} | ${tree.buildTime.toFixed(0).padStart(9)} | ${rootPreview.padEnd(24)} |`
);
}
console.log('\n' + '='.repeat(70));
console.log(`Total build time: ${(result.totalTime / 1000).toFixed(2)} seconds`);
console.log('='.repeat(70));
}
// CLI entrypoint
async function main() {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const inputPath = process.argv[2] || path.join(__dirname, '../../../ofacdata/inputs/names.json');
const outputDir = process.argv[3] || path.join(__dirname, '../../../ofacdata/outputs');
console.log('='.repeat(60));
console.log('OFAC Tree Builder');
console.log('='.repeat(60));
console.log(`Input: ${inputPath}`);
console.log(`Output: ${outputDir}`);
const result = await buildAllOfacTrees(inputPath, outputDir);
printSummary(result);
if (!result.success) {
process.exit(1);
}
console.log('\n✅ All trees built successfully!');
}
// Run if executed directly
import { fileURLToPath } from 'url';
const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
if (isMainModule) {
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
}

View File

@@ -0,0 +1,190 @@
/**
* OFAC SDN List Downloader
*
* Downloads the Specially Designated Nationals (SDN) list from the
* U.S. Treasury's OFAC Sanctions List Service in XML format.
*
* Source: https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/SDN.XML
*/
import * as fs from 'fs';
import * as path from 'path';
// OFAC SDN XML download URL (official Treasury endpoint)
const OFAC_SDN_XML_URL =
'https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/SDN.XML';
// Alternative URLs if primary fails
const OFAC_SDN_ALTERNATE_URLS = [
'https://www.treasury.gov/ofac/downloads/sdn.xml',
'https://www.treasury.gov/ofac/downloads/sanctions/1.0/sdn_advanced.xml',
];
export interface DownloadResult {
success: boolean;
filePath?: string;
timestamp: string;
source: string;
error?: string;
}
/**
* Downloads a file with retry logic
*/
async function fetchWithRetry(
url: string,
maxRetries: number = 3,
delayMs: number = 2000
): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`Attempt ${attempt}/${maxRetries}: Fetching ${url}`);
const response = await fetch(url, {
headers: {
'User-Agent': 'Self-OFAC-Updater/1.0',
Accept: 'application/xml, text/xml, */*',
},
});
if (response.ok) {
return response;
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
} catch (error) {
lastError = error as Error;
console.warn(`Attempt ${attempt} failed: ${lastError.message}`);
if (attempt < maxRetries) {
console.log(`Waiting ${delayMs}ms before retry...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
throw lastError;
}
/**
* Downloads the OFAC SDN XML file from Treasury
*/
export async function downloadOfacSdn(outputDir: string): Promise<DownloadResult> {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const outputPath = path.join(outputDir, `sdn-${timestamp}.xml`);
const latestPath = path.join(outputDir, 'sdn-latest.xml');
// Ensure output directory exists
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Try primary URL first, then alternates
const urlsToTry = [OFAC_SDN_XML_URL, ...OFAC_SDN_ALTERNATE_URLS];
for (const url of urlsToTry) {
try {
console.log(`\nDownloading OFAC SDN list from: ${url}`);
const response = await fetchWithRetry(url);
const xmlContent = await response.text();
// Basic validation - check if it looks like valid OFAC XML
if (
!xmlContent.includes('<sdnList') &&
!xmlContent.includes('<sanctionsData') &&
!xmlContent.includes('<sdnEntry')
) {
throw new Error('Downloaded content does not appear to be valid OFAC SDN XML');
}
// Write timestamped file
fs.writeFileSync(outputPath, xmlContent, 'utf-8');
console.log(`Saved timestamped file: ${outputPath}`);
// Also write as latest
fs.writeFileSync(latestPath, xmlContent, 'utf-8');
console.log(`Updated latest file: ${latestPath}`);
const stats = fs.statSync(outputPath);
console.log(`File size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
return {
success: true,
filePath: outputPath,
timestamp,
source: url,
};
} catch (error) {
console.error(`Failed to download from ${url}: ${(error as Error).message}`);
continue;
}
}
return {
success: false,
timestamp,
source: 'none',
error: 'All download URLs failed',
};
}
/**
* Checks if an update is needed by comparing file dates
*/
export async function checkForUpdates(outputDir: string): Promise<boolean> {
const latestPath = path.join(outputDir, 'sdn-latest.xml');
if (!fs.existsSync(latestPath)) {
console.log('No existing SDN file found - update needed');
return true;
}
const stats = fs.statSync(latestPath);
const fileAge = Date.now() - stats.mtimeMs;
const oneDayMs = 24 * 60 * 60 * 1000;
if (fileAge > oneDayMs) {
console.log(`Existing file is ${(fileAge / oneDayMs).toFixed(1)} days old - update needed`);
return true;
}
console.log(`Existing file is fresh (${(fileAge / 3600000).toFixed(1)} hours old)`);
return false;
}
// CLI entrypoint
async function main() {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outputDir = process.argv[2] || path.join(__dirname, '../../../ofacdata/raw');
console.log('='.repeat(60));
console.log('OFAC SDN List Downloader');
console.log('='.repeat(60));
console.log(`Output directory: ${outputDir}`);
console.log(`Timestamp: ${new Date().toISOString()}`);
console.log('');
const result = await downloadOfacSdn(outputDir);
if (result.success) {
console.log('\n✅ Download successful!');
console.log(` File: ${result.filePath}`);
console.log(` Source: ${result.source}`);
} else {
console.error('\n❌ Download failed!');
console.error(` Error: ${result.error}`);
process.exit(1);
}
}
// Run if executed directly
import { fileURLToPath } from 'url';
const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
if (isMainModule) {
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
}

View File

@@ -0,0 +1,189 @@
/**
* OFAC Update Pipeline
*
* Main entry point for the complete OFAC update workflow:
* 1. Download latest SDN XML from Treasury
* 2. Parse XML to extract sanctioned individuals
* 3. Build all OFAC Merkle trees
* 4. Output roots for on-chain updates
*
* Usage:
* npx tsx common/scripts/ofac/index.ts [--skip-download] [--output-dir <path>]
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { buildAllOfacTrees, type AllTreesResult } from './buildAllTrees.js';
import { downloadOfacSdn } from './downloadSdn.js';
import { parseOfacSdn, saveOfacData } from './parseSdn.js';
export interface PipelineResult {
success: boolean;
downloadedFile?: string;
parsedEntries?: number;
trees?: AllTreesResult;
roots?: Record<string, string>;
error?: string;
}
export interface PipelineOptions {
skipDownload?: boolean;
rawDir?: string;
inputDir?: string;
outputDir?: string;
}
/**
* Run the complete OFAC update pipeline
*/
export async function runOfacPipeline(options: PipelineOptions = {}): Promise<PipelineResult> {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const baseDir = path.join(__dirname, '../../../ofacdata');
const rawDir = options.rawDir || path.join(baseDir, 'raw');
const inputDir = options.inputDir || path.join(baseDir, 'inputs');
const outputDir = options.outputDir || path.join(baseDir, 'outputs');
console.log('\n' + '═'.repeat(70));
console.log(' OFAC UPDATE PIPELINE');
console.log('═'.repeat(70));
console.log(`Started at: ${new Date().toISOString()}`);
console.log('');
try {
// Step 1: Download SDN XML
let xmlPath = path.join(rawDir, 'sdn-latest.xml');
if (!options.skipDownload) {
console.log('\n📥 STEP 1: Downloading OFAC SDN list...');
console.log('-'.repeat(50));
const downloadResult = await downloadOfacSdn(rawDir);
if (!downloadResult.success) {
return {
success: false,
error: `Download failed: ${downloadResult.error}`,
};
}
xmlPath = downloadResult.filePath!;
console.log(`✅ Downloaded: ${xmlPath}`);
} else {
console.log('\n📥 STEP 1: Skipping download (using existing file)');
if (!fs.existsSync(xmlPath)) {
return {
success: false,
error: `No existing XML file found at: ${xmlPath}`,
};
}
console.log(` Using: ${xmlPath}`);
}
// Step 2: Parse XML
console.log('\n📄 STEP 2: Parsing SDN XML...');
console.log('-'.repeat(50));
const parseResult = await parseOfacSdn(xmlPath);
if (!parseResult.success) {
return {
success: false,
error: `Parsing failed: ${parseResult.error}`,
};
}
saveOfacData(parseResult.entries, inputDir);
console.log(`✅ Parsed ${parseResult.entries.length} entries`);
// Step 3: Build trees
console.log('\n🌳 STEP 3: Building Merkle trees...');
console.log('-'.repeat(50));
const namesPath = path.join(inputDir, 'names.json');
const treesResult = await buildAllOfacTrees(namesPath, outputDir);
if (!treesResult.success) {
return {
success: false,
error: `Tree building failed: ${treesResult.error}`,
};
}
// Collect roots
const roots: Record<string, string> = {};
for (const tree of treesResult.trees) {
roots[tree.treeType] = tree.root;
}
// Step 4: Summary
console.log('\n📊 STEP 4: Pipeline complete!');
console.log('-'.repeat(50));
console.log('\nNew OFAC Roots:');
console.log(JSON.stringify(roots, null, 2));
// Save roots to a file for easy access
const rootsPath = path.join(outputDir, 'latest-roots.json');
fs.writeFileSync(
rootsPath,
JSON.stringify(
{
timestamp: new Date().toISOString(),
roots,
},
null,
2
)
);
console.log(`\nRoots saved to: ${rootsPath}`);
return {
success: true,
downloadedFile: xmlPath,
parsedEntries: parseResult.entries.length,
trees: treesResult,
roots,
};
} catch (error) {
return {
success: false,
error: (error as Error).message,
};
}
}
// CLI entrypoint
async function main() {
const args = process.argv.slice(2);
const skipDownload = args.includes('--skip-download');
const outputDirIndex = args.indexOf('--output-dir');
const outputDir = outputDirIndex >= 0 ? args[outputDirIndex + 1] : undefined;
const result = await runOfacPipeline({
skipDownload,
outputDir,
});
console.log('\n' + '═'.repeat(70));
if (result.success) {
console.log('✅ OFAC UPDATE PIPELINE COMPLETED SUCCESSFULLY');
console.log('');
console.log('Next steps:');
console.log(' 1. Upload trees to tree server');
console.log(' 2. Prepare multisig transaction with new roots');
console.log(' 3. Get 2/5 dev approvals on Safe');
} else {
console.log('❌ OFAC UPDATE PIPELINE FAILED');
console.log(` Error: ${result.error}`);
process.exit(1);
}
console.log('═'.repeat(70));
}
// Run if executed directly
import { fileURLToPath } from 'url';
const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
if (isMainModule) {
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
}

View File

@@ -0,0 +1,402 @@
/**
* OFAC SDN XML Parser
*
* Parses the SDN XML file and extracts sanctioned individual data
* in the format required for building OFAC Merkle trees.
*
* Output format matches the existing names.json structure used by buildSMT()
*/
import * as fs from 'fs';
import * as path from 'path';
// Types for parsed OFAC data
export interface OfacEntry {
First_Name: string;
Last_Name: string;
day: string | null;
month: string | null;
year: string | null;
Pass_No?: string;
Pass_Country?: string;
}
export interface ParseResult {
success: boolean;
entries: OfacEntry[];
stats: {
totalEntries: number;
individualsProcessed: number;
entriesWithDob: number;
entriesWithPassport: number;
parseTime: number;
};
error?: string;
}
// Month name to number mapping
const MONTH_MAP: Record<string, string> = {
jan: '01',
january: '01',
feb: '02',
february: '02',
mar: '03',
march: '03',
apr: '04',
april: '04',
may: '05',
jun: '06',
june: '06',
jul: '07',
july: '07',
aug: '08',
august: '08',
sep: '09',
sept: '09',
september: '09',
oct: '10',
october: '10',
nov: '11',
november: '11',
dec: '12',
december: '12',
};
/**
* Parse a date string in various formats
* Returns { day, month, year } or null values if parsing fails
*/
function parseDate(dateStr: string): { day: string | null; month: string | null; year: string | null } {
if (!dateStr) {
return { day: null, month: null, year: null };
}
const trimmed = dateStr.trim();
// Format: DD Mon YYYY (e.g., "07 Oct 1954")
const dmyMatch = trimmed.match(/^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/);
if (dmyMatch) {
const monthLower = dmyMatch[2].toLowerCase();
const monthNum = MONTH_MAP[monthLower];
if (monthNum) {
return {
day: dmyMatch[1].padStart(2, '0'),
month: monthLower.slice(0, 3), // Use 3-letter abbreviation
year: dmyMatch[3],
};
}
}
// Format: YYYY-MM-DD
const isoMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (isoMatch) {
const monthNames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const monthIndex = parseInt(isoMatch[2], 10) - 1;
return {
day: isoMatch[3],
month: monthNames[monthIndex] || null,
year: isoMatch[1],
};
}
// Format: YYYY (year only)
const yearOnlyMatch = trimmed.match(/^(\d{4})$/);
if (yearOnlyMatch) {
return {
day: null,
month: null,
year: yearOnlyMatch[1],
};
}
// Format: Mon YYYY (e.g., "Oct 1954")
const myMatch = trimmed.match(/^([A-Za-z]+)\s+(\d{4})$/);
if (myMatch) {
const monthLower = myMatch[1].toLowerCase();
const monthAbbrev = MONTH_MAP[monthLower] ? monthLower.slice(0, 3) : null;
return {
day: null,
month: monthAbbrev,
year: myMatch[2],
};
}
return { day: null, month: null, year: null };
}
/**
* Simple XML tag content extractor (works without external parser)
*/
function extractTagContent(xml: string, tagName: string): string | null {
const regex = new RegExp(`<${tagName}[^>]*>([^<]*)</${tagName}>`, 'i');
const match = xml.match(regex);
return match ? match[1].trim() : null;
}
/**
* Extract all occurrences of a tag
*/
function extractAllTags(xml: string, tagName: string): string[] {
const regex = new RegExp(`<${tagName}[^>]*>([^<]*)</${tagName}>`, 'gi');
const matches: string[] = [];
let match;
while ((match = regex.exec(xml)) !== null) {
matches.push(match[1].trim());
}
return matches;
}
/**
* Extract blocks of XML between opening and closing tags
*/
function extractBlocks(xml: string, tagName: string): string[] {
const regex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)</${tagName}>`, 'gi');
const matches: string[] = [];
let match;
while ((match = regex.exec(xml)) !== null) {
matches.push(match[1]);
}
return matches;
}
/**
* Parse the OFAC SDN XML file
*/
export async function parseOfacSdn(xmlPath: string): Promise<ParseResult> {
const startTime = performance.now();
if (!fs.existsSync(xmlPath)) {
return {
success: false,
entries: [],
stats: {
totalEntries: 0,
individualsProcessed: 0,
entriesWithDob: 0,
entriesWithPassport: 0,
parseTime: 0,
},
error: `File not found: ${xmlPath}`,
};
}
console.log(`Reading XML file: ${xmlPath}`);
const xmlContent = fs.readFileSync(xmlPath, 'utf-8');
console.log(`File size: ${(xmlContent.length / 1024 / 1024).toFixed(2)} MB`);
const entries: OfacEntry[] = [];
let totalEntries = 0;
let individualsProcessed = 0;
let entriesWithDob = 0;
let entriesWithPassport = 0;
// Extract all SDN entries
const sdnEntries = extractBlocks(xmlContent, 'sdnEntry');
console.log(`Found ${sdnEntries.length} SDN entries`);
for (const entry of sdnEntries) {
totalEntries++;
// Check if this is an individual (not an entity/vessel)
const sdnType = extractTagContent(entry, 'sdnType');
if (sdnType !== 'Individual') {
continue;
}
individualsProcessed++;
// Extract name parts
const firstName = extractTagContent(entry, 'firstName') || '';
const lastName = extractTagContent(entry, 'lastName') || '';
if (!firstName && !lastName) {
continue;
}
// Extract date of birth
const dobList = extractBlocks(entry, 'dateOfBirthItem');
let day: string | null = null;
let month: string | null = null;
let year: string | null = null;
for (const dobItem of dobList) {
const dateStr = extractTagContent(dobItem, 'dateOfBirth');
if (dateStr) {
const parsed = parseDate(dateStr);
if (parsed.year) {
day = parsed.day;
month = parsed.month;
year = parsed.year;
break;
}
}
}
// Also check alternate DOB locations
if (!year) {
const dateOfBirthList = extractBlocks(entry, 'dateOfBirthList');
for (const dobBlock of dateOfBirthList) {
const dateStr = extractTagContent(dobBlock, 'dateOfBirth');
if (dateStr) {
const parsed = parseDate(dateStr);
if (parsed.year) {
day = parsed.day;
month = parsed.month;
year = parsed.year;
break;
}
}
}
}
// Extract passport/ID documents
const idList = extractBlocks(entry, 'id');
let passNo: string | undefined;
let passCountry: string | undefined;
for (const idBlock of idList) {
const idType = extractTagContent(idBlock, 'idType');
if (idType && idType.toLowerCase().includes('passport')) {
passNo = extractTagContent(idBlock, 'idNumber') || undefined;
passCountry = extractTagContent(idBlock, 'idCountry') || undefined;
if (passNo) break;
}
}
// Create entry
const ofacEntry: OfacEntry = {
First_Name: firstName.toUpperCase(),
Last_Name: lastName.toUpperCase(),
day,
month,
year,
};
if (passNo) {
ofacEntry.Pass_No = passNo.toUpperCase();
entriesWithPassport++;
}
if (passCountry) {
ofacEntry.Pass_Country = passCountry;
}
if (year) {
entriesWithDob++;
}
entries.push(ofacEntry);
// Also add aliases
const akaList = extractBlocks(entry, 'aka');
for (const aka of akaList) {
const akaFirstName = extractTagContent(aka, 'firstName') || '';
const akaLastName = extractTagContent(aka, 'lastName') || '';
if (akaFirstName || akaLastName) {
const aliasEntry: OfacEntry = {
First_Name: akaFirstName.toUpperCase() || firstName.toUpperCase(),
Last_Name: akaLastName.toUpperCase() || lastName.toUpperCase(),
day,
month,
year,
};
if (passNo) {
aliasEntry.Pass_No = passNo.toUpperCase();
}
if (passCountry) {
aliasEntry.Pass_Country = passCountry;
}
entries.push(aliasEntry);
}
}
// Progress logging
if (individualsProcessed % 500 === 0) {
console.log(`Processed ${individualsProcessed} individuals...`);
}
}
const parseTime = performance.now() - startTime;
console.log('\n--- Parsing Summary ---');
console.log(`Total SDN entries: ${totalEntries}`);
console.log(`Individuals processed: ${individualsProcessed}`);
console.log(`Entries created (including aliases): ${entries.length}`);
console.log(`Entries with DOB: ${entriesWithDob}`);
console.log(`Entries with passport: ${entriesWithPassport}`);
console.log(`Parse time: ${parseTime.toFixed(2)}ms`);
return {
success: true,
entries,
stats: {
totalEntries,
individualsProcessed,
entriesWithDob,
entriesWithPassport,
parseTime,
},
};
}
/**
* Save parsed entries to JSON files
*/
export function saveOfacData(
entries: OfacEntry[],
outputDir: string
): { namesPath: string; passportsPath: string } {
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const namesPath = path.join(outputDir, 'names.json');
const passportsPath = path.join(outputDir, 'passports.json');
// Save all entries (for name-based trees)
fs.writeFileSync(namesPath, JSON.stringify(entries, null, 2), 'utf-8');
console.log(`Saved ${entries.length} entries to: ${namesPath}`);
// Save only entries with passport numbers
const passportEntries = entries.filter((e) => e.Pass_No);
fs.writeFileSync(passportsPath, JSON.stringify(passportEntries, null, 2), 'utf-8');
console.log(`Saved ${passportEntries.length} passport entries to: ${passportsPath}`);
return { namesPath, passportsPath };
}
// CLI entrypoint
async function main() {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const xmlPath = process.argv[2] || path.join(__dirname, '../../../ofacdata/raw/sdn-latest.xml');
const outputDir = process.argv[3] || path.join(__dirname, '../../../ofacdata/inputs');
console.log('='.repeat(60));
console.log('OFAC SDN XML Parser');
console.log('='.repeat(60));
console.log(`Input: ${xmlPath}`);
console.log(`Output: ${outputDir}`);
console.log('');
const result = await parseOfacSdn(xmlPath);
if (result.success) {
console.log('\n✅ Parsing successful!');
saveOfacData(result.entries, outputDir);
} else {
console.error('\n❌ Parsing failed!');
console.error(` Error: ${result.error}`);
process.exit(1);
}
}
// Run if executed directly
import { fileURLToPath } from 'url';
const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
if (isMainModule) {
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
}

View File

@@ -0,0 +1,429 @@
/**
* OFAC Multisig Update Preparation
*
* Prepares Safe multisig transactions for updating OFAC roots across all registries.
*
* Features:
* - Reads new roots from the OFAC pipeline output
* - Compares with current on-chain roots
* - Generates batched transaction data for Safe
* - Uses registry.json for deployed addresses
*
* Usage:
* NETWORK=celo npx tsx contracts/scripts/ofac/prepareMultisigUpdate.ts
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { ethers } from 'ethers';
import * as dotenv from 'dotenv';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 2/5 Operations Multisig on Celo Mainnet
const CELO_MAINNET_SAFE = '0x067b18e09A10Fa03d027c1D60A098CEbbE5637f0';
// Hardcoded registry addresses (Celo Mainnet)
const CELO_REGISTRY_ADDRESSES: Record<string, string> = {
IdentityRegistry: '0x37F5CB8cB1f6B00aa768D8aA99F1A9289802A968',
IdentityRegistryIdCard: '0xeAD1E6Ec29c1f3D33a0662f253a3a94D189566E1',
IdentityRegistryAadhaar: '0xd603Fa8C8f4694E8DD1DcE1f27C0C3fc91e32Ac4',
};
// Registry configuration
interface RegistryConfig {
name: string;
registryKey: string;
hasPassportNo: boolean;
hasNameAndDob: boolean;
hasNameAndYob: boolean;
rootTreePrefix: string;
}
const REGISTRY_CONFIGS: RegistryConfig[] = [
{
name: 'Passport Registry',
registryKey: 'IdentityRegistry',
hasPassportNo: true,
hasNameAndDob: true,
hasNameAndYob: true,
rootTreePrefix: '',
},
{
name: 'ID Card Registry',
registryKey: 'IdentityRegistryIdCard',
hasPassportNo: false,
hasNameAndDob: true,
hasNameAndYob: true,
rootTreePrefix: '_id_card',
},
{
name: 'Aadhaar Registry',
registryKey: 'IdentityRegistryAadhaar',
hasPassportNo: false,
hasNameAndDob: true,
hasNameAndYob: true,
rootTreePrefix: '_aadhaar',
},
];
// Minimal ABI for OFAC root functions
const REGISTRY_ABI = [
'function getPassportNoOfacRoot() view returns (uint256)',
'function getNameAndDobOfacRoot() view returns (uint256)',
'function getNameAndYobOfacRoot() view returns (uint256)',
'function updatePassportNoOfacRoot(uint256 root)',
'function updateNameAndDobOfacRoot(uint256 root)',
'function updateNameAndYobOfacRoot(uint256 root)',
];
// Transaction data structure for Safe
interface SafeTransaction {
to: string;
value: string;
data: string;
operation: number;
}
interface UpdateResult {
registry: string;
address: string;
updates: {
function: string;
oldRoot: string;
newRoot: string;
changed: boolean;
}[];
transactions: SafeTransaction[];
}
interface PrepareResult {
success: boolean;
network: string;
timestamp: string;
registryUpdates: UpdateResult[];
batchedTransactions: SafeTransaction[];
totalChanges: number;
error?: string;
}
/**
* Get registry address for a network
*/
function getRegistryAddress(registryKey: string, network: string): string | null {
if (network === 'celo') {
return CELO_REGISTRY_ADDRESSES[registryKey] || null;
}
// Add other networks as needed
return null;
}
function loadNewRoots(rootsPath: string): Record<string, string> {
if (!fs.existsSync(rootsPath)) {
throw new Error('Roots file not found: ' + rootsPath);
}
const data = JSON.parse(fs.readFileSync(rootsPath, 'utf-8'));
return data.roots || data;
}
function getRootForRegistry(
roots: Record<string, string>,
config: RegistryConfig,
rootType: 'passportNo' | 'nameAndDob' | 'nameAndYob'
): string | null {
let key: string;
switch (rootType) {
case 'passportNo':
key = 'passport_no_and_nationality';
break;
case 'nameAndDob':
if (config.rootTreePrefix === '_aadhaar') key = 'aadhaar_name_and_dob';
else if (config.rootTreePrefix === '_kyc') key = 'kyc_name_and_dob';
else if (config.rootTreePrefix === '_id_card') key = 'name_and_dob_id_card';
else key = 'name_and_dob';
break;
case 'nameAndYob':
if (config.rootTreePrefix === '_aadhaar') key = 'aadhaar_name_and_yob';
else if (config.rootTreePrefix === '_kyc') key = 'kyc_name_and_yob';
else if (config.rootTreePrefix === '_id_card') key = 'name_and_yob_id_card';
else key = 'name_and_yob';
break;
}
return roots[key] || null;
}
async function getCurrentRoot(
contract: ethers.Contract,
rootType: 'passportNo' | 'nameAndDob' | 'nameAndYob'
): Promise<string> {
try {
switch (rootType) {
case 'passportNo':
return (await contract.getPassportNoOfacRoot()).toString();
case 'nameAndDob':
return (await contract.getNameAndDobOfacRoot()).toString();
case 'nameAndYob':
return (await contract.getNameAndYobOfacRoot()).toString();
}
} catch {
return '0';
}
}
function generateCalldata(
contract: ethers.Contract,
rootType: 'passportNo' | 'nameAndDob' | 'nameAndYob',
newRoot: string
): string {
switch (rootType) {
case 'passportNo':
return contract.interface.encodeFunctionData('updatePassportNoOfacRoot', [newRoot]);
case 'nameAndDob':
return contract.interface.encodeFunctionData('updateNameAndDobOfacRoot', [newRoot]);
case 'nameAndYob':
return contract.interface.encodeFunctionData('updateNameAndYobOfacRoot', [newRoot]);
}
}
async function prepareRegistryUpdates(
config: RegistryConfig,
registryAddress: string,
provider: ethers.Provider,
newRoots: Record<string, string>
): Promise<UpdateResult> {
const contract = new ethers.Contract(registryAddress, REGISTRY_ABI, provider);
const updates: UpdateResult['updates'] = [];
const transactions: SafeTransaction[] = [];
if (config.hasPassportNo) {
const newRoot = getRootForRegistry(newRoots, config, 'passportNo');
if (newRoot) {
const oldRoot = await getCurrentRoot(contract, 'passportNo');
const changed = oldRoot !== newRoot;
updates.push({ function: 'updatePassportNoOfacRoot', oldRoot, newRoot, changed });
if (changed) {
transactions.push({
to: registryAddress,
value: '0',
data: generateCalldata(contract, 'passportNo', newRoot),
operation: 0,
});
}
}
}
if (config.hasNameAndDob) {
const newRoot = getRootForRegistry(newRoots, config, 'nameAndDob');
if (newRoot) {
const oldRoot = await getCurrentRoot(contract, 'nameAndDob');
const changed = oldRoot !== newRoot;
updates.push({ function: 'updateNameAndDobOfacRoot', oldRoot, newRoot, changed });
if (changed) {
transactions.push({
to: registryAddress,
value: '0',
data: generateCalldata(contract, 'nameAndDob', newRoot),
operation: 0,
});
}
}
}
if (config.hasNameAndYob) {
const newRoot = getRootForRegistry(newRoots, config, 'nameAndYob');
if (newRoot) {
const oldRoot = await getCurrentRoot(contract, 'nameAndYob');
const changed = oldRoot !== newRoot;
updates.push({ function: 'updateNameAndYobOfacRoot', oldRoot, newRoot, changed });
if (changed) {
transactions.push({
to: registryAddress,
value: '0',
data: generateCalldata(contract, 'nameAndYob', newRoot),
operation: 0,
});
}
}
}
return { registry: config.name, address: registryAddress, updates, transactions };
}
// Default RPC URLs (public endpoints)
const DEFAULT_RPC_URLS: Record<string, string> = {
'celo': 'https://forno.celo.org',
'celo-sepolia': 'https://celo-sepolia.drpc.org',
'sepolia': 'https://rpc.sepolia.org',
};
function getRpcUrl(network: string): string | undefined {
switch (network) {
case 'celo': return process.env.CELO_RPC_URL || DEFAULT_RPC_URLS['celo'];
case 'celo-sepolia': return process.env.CELO_SEPOLIA_RPC_URL || DEFAULT_RPC_URLS['celo-sepolia'];
case 'sepolia': return process.env.SEPOLIA_RPC_URL || DEFAULT_RPC_URLS['sepolia'];
default: return process.env.RPC_URL;
}
}
export async function prepareOfacMultisigUpdate(
rootsPath: string,
network?: string
): Promise<PrepareResult> {
const timestamp = new Date().toISOString();
const targetNetwork = network || process.env.NETWORK || 'celo';
const rpcUrl = getRpcUrl(targetNetwork);
if (!rpcUrl) {
return {
success: false,
network: targetNetwork,
timestamp,
registryUpdates: [],
batchedTransactions: [],
totalChanges: 0,
error: 'No RPC URL found for network: ' + targetNetwork,
};
}
try {
console.log('Loading new roots from: ' + rootsPath);
const newRoots = loadNewRoots(rootsPath);
console.log('Loaded ' + Object.keys(newRoots).length + ' root values');
const provider = new ethers.JsonRpcProvider(rpcUrl);
console.log('Connected to network: ' + targetNetwork);
const registryUpdates: UpdateResult[] = [];
const batchedTransactions: SafeTransaction[] = [];
for (const config of REGISTRY_CONFIGS) {
const address = getRegistryAddress(config.registryKey, targetNetwork);
if (!address) {
console.log('Registry not configured for network: ' + config.name);
continue;
}
console.log('\nProcessing ' + config.name + ' at ' + address);
const result = await prepareRegistryUpdates(config, address, provider, newRoots);
registryUpdates.push(result);
batchedTransactions.push(...result.transactions);
}
return {
success: true,
network: targetNetwork,
timestamp,
registryUpdates,
batchedTransactions,
totalChanges: batchedTransactions.length,
};
} catch (error) {
return {
success: false,
network: targetNetwork,
timestamp,
registryUpdates: [],
batchedTransactions: [],
totalChanges: 0,
error: (error as Error).message,
};
}
}
function generateSafeTransactionBuilderJson(result: PrepareResult, safeAddress: string): object {
const chainIdMap: Record<string, string> = {
celo: '42220',
'celo-sepolia': '11142220',
sepolia: '11155111',
};
return {
version: '1.0',
chainId: chainIdMap[result.network] || '42220',
createdAt: Date.now(),
meta: {
name: 'OFAC Root Update',
description: 'Update OFAC roots across ' + result.registryUpdates.length + ' registries',
txBuilderVersion: '1.16.3',
createdFromSafeAddress: safeAddress,
},
transactions: result.batchedTransactions.map((tx) => ({
to: tx.to,
value: tx.value,
data: tx.data,
})),
};
}
function printSummary(result: PrepareResult): void {
console.log('\n' + '='.repeat(70));
console.log('OFAC MULTISIG UPDATE PREPARATION');
console.log('='.repeat(70));
console.log('Network: ' + result.network);
console.log('Timestamp: ' + result.timestamp);
if (!result.success) {
console.log('FAILED: ' + result.error);
return;
}
for (const registry of result.registryUpdates) {
console.log('\n' + registry.registry);
console.log(' Address: ' + registry.address);
for (const update of registry.updates) {
const status = update.changed ? 'CHANGED' : 'UNCHANGED';
console.log(' ' + update.function + ': ' + status);
if (update.changed) {
console.log(' Old: ' + update.oldRoot.substring(0, 30) + '...');
console.log(' New: ' + update.newRoot.substring(0, 30) + '...');
}
}
}
console.log('\n' + '-'.repeat(70));
console.log('Total transactions: ' + result.totalChanges);
if (result.totalChanges === 0) {
console.log('\nNo changes needed - all roots are up to date!');
} else {
console.log('\nTransactions need to be submitted to Safe multisig for approval');
}
}
async function main() {
const rootsPath = process.argv[2] || path.join(__dirname, '../../../common/ofacdata/outputs/latest-roots.json');
const safeAddress = process.env.OFAC_SAFE_ADDRESS || CELO_MAINNET_SAFE;
console.log('='.repeat(60));
console.log('OFAC Multisig Update Preparation');
console.log('='.repeat(60));
console.log('Roots file: ' + rootsPath);
console.log('Safe address: ' + safeAddress);
const result = await prepareOfacMultisigUpdate(rootsPath);
printSummary(result);
if (result.success && result.totalChanges > 0) {
const txBuilderJson = generateSafeTransactionBuilderJson(result, safeAddress);
const outputPath = path.join(__dirname, 'safe-tx-batch.json');
fs.writeFileSync(outputPath, JSON.stringify(txBuilderJson, null, 2));
console.log('\nSafe Transaction Builder JSON saved to: ' + outputPath);
const detailPath = path.join(__dirname, 'update-details.json');
fs.writeFileSync(detailPath, JSON.stringify(result, null, 2));
console.log('Detailed update info saved to: ' + detailPath);
}
if (!result.success) {
process.exit(1);
}
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,478 @@
/**
* OFAC Update: Sign, Execute, and Upload (Pre-staged for minimal mismatch)
*
* This script uses PRE-STAGING to minimize the mismatch window to ~1 second:
*
* 1. Upload trees to temp directory (before execution - no impact)
* 2. Sign the pending Safe transaction
* 3. Execute the transaction on-chain
* 4. ATOMICALLY move trees from temp to production (~1 second)
*
* Usage:
* PRIVATE_KEY=0x... NETWORK=celo npx tsx scripts/ofac/signExecuteAndUpload.ts
*
* Required env:
* PRIVATE_KEY - Private key of the final signer (must be Safe owner)
* NETWORK - Network (celo, celo-sepolia, sepolia)
*
* Optional env:
* SAFE_ADDRESS - Override default Safe address
* TREES_DIR - Path to generated trees (default: ../common/ofacdata/outputs)
* SSH_HOST - SSH host for upload (default: self-infra-staging)
* UPLOAD_PATH - Remote path for trees
* DRY_RUN - Set to 'true' to skip actual execution/upload
* SKIP_PRESTAGE - Set to 'true' if files already pre-staged
*/
import Safe from '@safe-global/protocol-kit';
import SafeApiKit from '@safe-global/api-kit';
import { ethers } from 'ethers';
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Default RPC URLs (public endpoints)
const DEFAULT_RPC_URLS: Record<string, string> = {
'celo': 'https://forno.celo.org',
'celo-sepolia': 'https://celo-sepolia.drpc.org',
'sepolia': 'https://rpc.sepolia.org',
};
// Network configurations
const NETWORK_CONFIG: Record<string, {
chainId: bigint;
txServiceUrl: string;
rpcEnvVar: string;
defaultSafe: string;
uploadPath: string;
}> = {
'celo': {
chainId: 42220n,
txServiceUrl: 'https://safe-transaction-celo.safe.global',
rpcEnvVar: 'CELO_RPC_URL',
defaultSafe: '0x067b18e09A10Fa03d027c1D60A098CEbbE5637f0',
uploadPath: '/home/ec2-user/self-infra/merkle-tree-reader/common/constants/ofac',
},
'celo-sepolia': {
chainId: 11142220n,
txServiceUrl: 'https://safe-transaction-celo.safe.global',
rpcEnvVar: 'CELO_SEPOLIA_RPC_URL',
defaultSafe: '0x067b18e09A10Fa03d027c1D60A098CEbbE5637f0', // Same 2/5 multisig
uploadPath: '/home/ec2-user/self-infra-staging/merkle-tree-reader/common/constants/ofac',
},
'sepolia': {
chainId: 11155111n,
txServiceUrl: 'https://safe-transaction-sepolia.safe.global/api',
rpcEnvVar: 'SEPOLIA_RPC_URL',
defaultSafe: '0x4264a631c5E685a622b5C8171b5f17BeD7FB30c6', // Test Safe (2/2)
uploadPath: '/home/ec2-user/ofac-e2e-test', // Test directory
},
};
// Tree files to upload
const TREE_FILES = [
'passportNoAndNationalitySMT.json',
'nameAndDobSMT.json',
'nameAndYobSMT.json',
'nameAndDobSMT_ID.json',
'nameAndYobSMT_ID.json',
'nameAndDobSMT_AADHAAR.json',
'nameAndYobSMT_AADHAAR.json',
'roots.json',
];
function log(msg: string) {
const timestamp = new Date().toISOString().slice(11, 23);
console.log(`[${timestamp}] ${msg}`);
}
/**
* Pre-stage trees to temporary directory on server
*/
function prestageFiles(
treesDir: string,
sshHost: string,
stagingPath: string,
dryRun: boolean
): boolean {
log(`📤 PRE-STAGING: Uploading trees to ${sshHost}:${stagingPath}`);
const filesToUpload = TREE_FILES
.map(f => path.join(treesDir, f))
.filter(f => fs.existsSync(f));
if (filesToUpload.length === 0) {
log('❌ No tree files found to upload!');
return false;
}
log(` Found ${filesToUpload.length} files`);
if (dryRun) {
log(' [DRY RUN] Would upload:');
filesToUpload.forEach(f => log(` - ${path.basename(f)}`));
return true;
}
try {
// Create staging directory
execSync(`ssh ${sshHost} "mkdir -p ${stagingPath}"`, { stdio: 'pipe' });
// Upload all files to staging
for (const file of filesToUpload) {
const basename = path.basename(file);
process.stdout.write(` Uploading ${basename}...`);
execSync(`scp "${file}" "${sshHost}:${stagingPath}/"`, { stdio: 'pipe' });
console.log(' ✓');
}
log(`✅ Pre-staged ${filesToUpload.length} files`);
return true;
} catch (error) {
log(`❌ Pre-staging failed: ${error}`);
return false;
}
}
/**
* Atomically move files from staging to production
* This is the critical ~1 second operation
*/
function atomicMove(
sshHost: string,
stagingPath: string,
productionPath: string,
dryRun: boolean
): { success: boolean; durationMs: number } {
log(`⚡ ATOMIC MOVE: ${stagingPath}${productionPath}`);
if (dryRun) {
log(' [DRY RUN] Would move files');
return { success: true, durationMs: 0 };
}
const startTime = Date.now();
try {
// Ensure production directory exists
execSync(`ssh ${sshHost} "mkdir -p ${productionPath}"`, { stdio: 'pipe' });
// Atomic move (mv is atomic on same filesystem)
// Use cp + rm for cross-filesystem safety, but mv is faster
const moveCmd = `ssh ${sshHost} "mv ${stagingPath}/*.json ${productionPath}/ && rm -rf ${stagingPath}"`;
execSync(moveCmd, { stdio: 'pipe' });
const durationMs = Date.now() - startTime;
log(`✅ Atomic move completed in ${durationMs}ms`);
return { success: true, durationMs };
} catch (error) {
const durationMs = Date.now() - startTime;
log(`❌ Atomic move failed after ${durationMs}ms: ${error}`);
return { success: false, durationMs };
}
}
/**
* Verify files exist in production
*/
function verifyProduction(sshHost: string, productionPath: string): void {
log('📋 Verifying production files...');
try {
const result = execSync(
`ssh ${sshHost} "ls -la ${productionPath}/*.json 2>/dev/null | tail -10"`,
{ encoding: 'utf-8' }
);
console.log(result);
} catch {
log('⚠️ Could not verify (may still be successful)');
}
}
async function main() {
console.log('');
console.log('═'.repeat(70));
console.log(' OFAC UPDATE: SIGN, EXECUTE & UPLOAD (PRE-STAGED)');
console.log('═'.repeat(70));
console.log('');
console.log(' This script minimizes mismatch window to ~1 second by:');
console.log(' 1. Pre-staging files to temp directory (before execution)');
console.log(' 2. Executing multisig on-chain');
console.log(' 3. Atomically moving files to production');
console.log('');
// Parse configuration
const network = process.env.NETWORK || 'sepolia';
const config = NETWORK_CONFIG[network];
if (!config) {
console.error(`❌ Unknown network: ${network}`);
console.error(` Supported: ${Object.keys(NETWORK_CONFIG).join(', ')}`);
process.exit(1);
}
const privateKey = process.env.PRIVATE_KEY;
if (!privateKey) {
console.error('❌ PRIVATE_KEY environment variable required');
process.exit(1);
}
const rpcUrl = process.env.RPC_URL || process.env[config.rpcEnvVar] || DEFAULT_RPC_URLS[network];
if (!rpcUrl) {
console.error(`❌ RPC URL required (set RPC_URL or ${config.rpcEnvVar})`);
process.exit(1);
}
const safeAddress = process.env.SAFE_ADDRESS || config.defaultSafe;
const treesDir = process.env.TREES_DIR || path.join(__dirname, '../../..', 'common/ofacdata/outputs');
const sshHost = process.env.SSH_HOST || 'self-infra-staging';
const productionPath = process.env.UPLOAD_PATH || config.uploadPath;
const dryRun = process.env.DRY_RUN === 'true';
const skipPrestage = process.env.SKIP_PRESTAGE === 'true';
// Generate unique staging path
const timestamp = Date.now();
const stagingPath = `/tmp/ofac-prestage-${timestamp}`;
console.log('Configuration:');
log(`Network: ${network} (chainId: ${config.chainId})`);
log(`Safe: ${safeAddress}`);
log(`Trees: ${treesDir}`);
log(`SSH Host: ${sshHost}`);
log(`Staging: ${stagingPath}`);
log(`Production: ${productionPath}`);
log(`Dry Run: ${dryRun}`);
console.log('');
// Verify trees exist
const existingTrees = TREE_FILES.filter(f => fs.existsSync(path.join(treesDir, f)));
if (existingTrees.length === 0) {
console.error('❌ No tree files found in:', treesDir);
console.error(' Run the tree builder first:');
console.error(' npx tsx common/scripts/ofac/index.ts');
process.exit(1);
}
log(`✅ Found ${existingTrees.length} tree files locally`);
// Get signer address
const wallet = new ethers.Wallet(privateKey);
const signerAddress = wallet.address;
log(`Signer: ${signerAddress}`);
// ═══════════════════════════════════════════════════════════════════
// PHASE 1: PRE-STAGE FILES (before any on-chain action)
// ═══════════════════════════════════════════════════════════════════
console.log('');
console.log('─'.repeat(70));
console.log(' PHASE 1: PRE-STAGE FILES');
console.log('─'.repeat(70));
console.log('');
if (skipPrestage) {
log('⏭️ Skipping pre-stage (SKIP_PRESTAGE=true)');
} else {
const prestageSuccess = prestageFiles(treesDir, sshHost, stagingPath, dryRun);
if (!prestageSuccess && !dryRun) {
console.error('');
console.error('❌ Pre-staging failed. Aborting before any on-chain action.');
console.error(' Fix SSH access and try again.');
process.exit(1);
}
}
// ═══════════════════════════════════════════════════════════════════
// PHASE 2: SAFE TRANSACTION
// ═══════════════════════════════════════════════════════════════════
console.log('');
console.log('─'.repeat(70));
console.log(' PHASE 2: SAFE TRANSACTION');
console.log('─'.repeat(70));
console.log('');
// Initialize Safe API Kit
log('🔗 Connecting to Safe Transaction Service...');
const apiKit = new SafeApiKit({
chainId: config.chainId,
txServiceUrl: config.txServiceUrl,
});
// Check if signer is owner
const safeInfo = await apiKit.getSafeInfo(safeAddress);
if (!safeInfo.owners.map(o => o.toLowerCase()).includes(signerAddress.toLowerCase())) {
console.error(`${signerAddress} is not an owner of Safe ${safeAddress}`);
console.error(` Owners: ${safeInfo.owners.join(', ')}`);
process.exit(1);
}
log(`✅ Signer is owner (${safeInfo.threshold}/${safeInfo.owners.length} threshold)`);
// Get pending transactions
log('📋 Fetching pending transactions...');
const pendingTxs = await apiKit.getPendingTransactions(safeAddress);
if (pendingTxs.count === 0) {
console.error('❌ No pending transactions found');
console.error(' First, propose a transaction using prepareMultisigUpdate.ts');
process.exit(1);
}
// Get the most recent pending transaction
const pendingTx = pendingTxs.results[0];
const safeTxHash = pendingTx.safeTxHash;
const existingSignatures = pendingTx.confirmations?.length || 0;
log(`Found ${pendingTxs.count} pending transaction(s)`);
log(`Using most recent: ${safeTxHash.slice(0, 18)}...`);
log(`Current signatures: ${existingSignatures}/${safeInfo.threshold}`);
// Check if we already signed
const alreadySigned = pendingTx.confirmations?.some(
c => c.owner.toLowerCase() === signerAddress.toLowerCase()
);
if (alreadySigned) {
log(`⚠️ You have already signed this transaction`);
}
// Initialize Protocol Kit
log('🔐 Initializing Safe Protocol Kit...');
const protocolKit = await Safe.init({
provider: rpcUrl,
signer: privateKey,
safeAddress,
});
// Sign if not already signed
if (!alreadySigned) {
log('✍️ Signing transaction...');
if (dryRun) {
log('[DRY RUN] Would sign transaction');
} else {
const signature = await protocolKit.signHash(safeTxHash);
await apiKit.confirmTransaction(safeTxHash, signature.data);
log('✅ Transaction signed');
}
}
// Check if we can execute
const updatedTx = await apiKit.getTransaction(safeTxHash);
const totalSignatures = updatedTx.confirmations?.length || 0;
const canExecute = totalSignatures >= safeInfo.threshold;
log(`Total signatures: ${totalSignatures}/${safeInfo.threshold}`);
if (!canExecute) {
console.log('');
console.log('─'.repeat(70));
log('⏳ Not enough signatures to execute yet');
log(` Need ${safeInfo.threshold - totalSignatures} more signature(s)`);
console.log('');
log('Files are pre-staged. Run this script again after more signatures.');
log(`Staging path: ${stagingPath}`);
process.exit(0);
}
// ═══════════════════════════════════════════════════════════════════
// PHASE 3: EXECUTE ON-CHAIN
// ═══════════════════════════════════════════════════════════════════
console.log('');
console.log('─'.repeat(70));
console.log(' PHASE 3: EXECUTE ON-CHAIN');
console.log('─'.repeat(70));
console.log('');
let executionSuccess = false;
if (dryRun) {
log('[DRY RUN] Would execute transaction');
executionSuccess = true;
} else {
log('🚀 Executing Safe transaction...');
try {
// Build the executable transaction with all signatures
const safeTransaction = await apiKit.getTransaction(safeTxHash);
const safeTx = await protocolKit.toSafeTransactionType(safeTransaction);
// Add all confirmations as signatures
for (const confirmation of updatedTx.confirmations || []) {
safeTx.addSignature({
signer: confirmation.owner,
data: confirmation.signature,
isContractSignature: false,
} as any);
}
const executeTxResponse = await protocolKit.executeTransaction(safeTx);
log(`📝 TX Hash: ${executeTxResponse.hash}`);
log('⏳ Waiting for confirmation...');
// Wait for the transaction to be mined
const provider = new ethers.JsonRpcProvider(rpcUrl);
const receipt = await provider.waitForTransaction(executeTxResponse.hash, 1, 120000);
if (receipt?.status === 1) {
log('✅ Transaction executed successfully!');
log(` Block: ${receipt.blockNumber}`);
executionSuccess = true;
} else {
throw new Error('Transaction failed on-chain');
}
} catch (error) {
console.error('❌ Execution failed:', error);
console.error('');
console.error('⚠️ Pre-staged files remain at:', stagingPath);
console.error(' You can retry execution or clean up manually.');
process.exit(1);
}
}
// ═══════════════════════════════════════════════════════════════════
// PHASE 4: ATOMIC MOVE (the critical ~1 second operation)
// ═══════════════════════════════════════════════════════════════════
console.log('');
console.log('─'.repeat(70));
console.log(' PHASE 4: ATOMIC MOVE (~1 second mismatch window)');
console.log('─'.repeat(70));
console.log('');
if (executionSuccess) {
const moveResult = atomicMove(sshHost, stagingPath, productionPath, dryRun);
if (moveResult.success) {
verifyProduction(sshHost, productionPath);
console.log('');
console.log('═'.repeat(70));
console.log(' ✅ OFAC UPDATE COMPLETE');
console.log('═'.repeat(70));
console.log('');
log(`On-chain update: ✅ Complete`);
log(`Tree deployment: ✅ Complete`);
log(`Mismatch window: ${moveResult.durationMs}ms (~${(moveResult.durationMs / 1000).toFixed(1)}s)`);
console.log('');
if (moveResult.durationMs < 2000) {
console.log(' 🎉 Minimal mismatch achieved!');
}
console.log('');
} else {
console.error('');
console.error('⚠️ CRITICAL: On-chain update succeeded but move failed!');
console.error(' Manual move required IMMEDIATELY:');
console.error(` ssh ${sshHost} "mv ${stagingPath}/*.json ${productionPath}/"`);
process.exit(1);
}
}
}
main().catch(error => {
console.error('❌ Fatal error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,158 @@
/**
* Test Safe Proposal Script
*
* Tests the full Safe proposal flow using a test Safe.
* This script will propose a dummy transaction to verify the mechanics work.
*
* Usage:
* PRIVATE_KEY=0x... npx tsx contracts/scripts/ofac/testSafeProposal.ts
*
* Requirements:
* - PRIVATE_KEY must be one of the Safe owners
* - SEPOLIA_RPC_URL in .env
*/
import { ethers } from 'ethers';
import * as dotenv from 'dotenv';
import * as path from 'path';
import { fileURLToPath } from 'url';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Test Safe on Sepolia (2/2 multisig)
const TEST_SAFE_ADDRESS = '0x4264a631c5E685a622b5C8171b5f17BeD7FB30c6';
const SEPOLIA_CHAIN_ID = 11155111;
async function main() {
console.log('='.repeat(60));
console.log('Safe Proposal Test');
console.log('='.repeat(60));
console.log(`Safe: ${TEST_SAFE_ADDRESS}`);
console.log(`Network: Sepolia (${SEPOLIA_CHAIN_ID})`);
console.log('');
// Check for private key
const privateKey = process.env.PRIVATE_KEY;
if (!privateKey) {
console.log('❌ PRIVATE_KEY environment variable not set');
console.log('');
console.log('To test, run with your private key:');
console.log(' PRIVATE_KEY=0x... npx tsx contracts/scripts/ofac/testSafeProposal.ts');
console.log('');
console.log('The private key must be one of the Safe owners:');
console.log(' - 0x846F1cF04ec494303e4B90440b130bb01913E703');
console.log(' - 0xD886Cd4c6A33c0C56c4fe0d7b597c69b98E28625');
process.exit(1);
}
const rpcUrl = process.env.SEPOLIA_RPC_URL || 'https://rpc.sepolia.org';
console.log(`Using RPC: ${rpcUrl.includes('alchemy') ? 'Alchemy' : 'Public'}`);
console.log('');
try {
// Setup provider and signer
const provider = new ethers.JsonRpcProvider(rpcUrl);
const signer = new ethers.Wallet(privateKey, provider);
const signerAddress = await signer.getAddress();
console.log(`Signer address: ${signerAddress}`);
// Check if signer is owner
const safeOwners = [
'0x846F1cF04ec494303e4B90440b130bb01913E703'.toLowerCase(),
'0xD886Cd4c6A33c0C56c4fe0d7b597c69b98E28625'.toLowerCase(),
];
if (!safeOwners.includes(signerAddress.toLowerCase())) {
console.log('❌ Signer is not a Safe owner');
console.log(' Your address:', signerAddress);
console.log(' Required owners:', safeOwners);
process.exit(1);
}
console.log('✅ Signer is a Safe owner');
console.log('');
// Import Safe SDK dynamically
console.log('Loading Safe SDK...');
const SafeApiKit = (await import('@safe-global/api-kit')).default;
const Safe = (await import('@safe-global/protocol-kit')).default;
// Initialize Safe SDK with explicit txServiceUrl (no API key needed for public endpoints)
const apiKit = new SafeApiKit({
chainId: BigInt(SEPOLIA_CHAIN_ID),
txServiceUrl: 'https://safe-transaction-sepolia.safe.global/api',
});
// Initialize Protocol Kit
const protocolKit = await Safe.init({
provider: rpcUrl,
signer: privateKey,
safeAddress: TEST_SAFE_ADDRESS,
});
console.log('✅ Safe SDK initialized');
console.log('');
// Create a dummy transaction (send 0 ETH to self)
const safeTransactionData = {
to: TEST_SAFE_ADDRESS,
value: '0',
data: '0x',
};
console.log('Creating test transaction...');
console.log(` To: ${safeTransactionData.to}`);
console.log(` Value: ${safeTransactionData.value}`);
console.log(` Data: ${safeTransactionData.data}`);
console.log('');
// Create Safe transaction
const safeTransaction = await protocolKit.createTransaction({
transactions: [safeTransactionData],
});
// Get transaction hash
const safeTxHash = await protocolKit.getTransactionHash(safeTransaction);
console.log(`Safe TX Hash: ${safeTxHash}`);
// Sign the transaction
console.log('Signing transaction...');
const signature = await protocolKit.signHash(safeTxHash);
// Propose to Safe Transaction Service
console.log('Proposing to Safe Transaction Service...');
await apiKit.proposeTransaction({
safeAddress: TEST_SAFE_ADDRESS,
safeTransactionData: safeTransaction.data,
safeTxHash,
senderAddress: signerAddress,
senderSignature: signature.data,
});
console.log('');
console.log('✅ Transaction proposed successfully!');
console.log('');
console.log('='.repeat(60));
console.log('NEXT STEPS:');
console.log('='.repeat(60));
console.log('1. Go to Safe UI to see the pending transaction:');
console.log(` https://app.safe.global/transactions/queue?safe=sep:${TEST_SAFE_ADDRESS}`);
console.log('');
console.log('2. Get the 2nd signature from the other owner');
console.log('');
console.log('3. Execute the transaction');
console.log('');
console.log('4. The watcher would detect the execution (if watching this Safe)');
console.log('='.repeat(60));
} catch (error) {
console.error('❌ Error:', error);
process.exit(1);
}
}
main();