mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
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:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -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
|
||||
|
||||
122
common/scripts/ofac/OFAC_AUTO_UPDATER_README.md
Normal file
122
common/scripts/ofac/OFAC_AUTO_UPDATER_README.md
Normal 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-*"` |
|
||||
366
common/scripts/ofac/buildAllTrees.ts
Normal file
366
common/scripts/ofac/buildAllTrees.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
190
common/scripts/ofac/downloadSdn.ts
Normal file
190
common/scripts/ofac/downloadSdn.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
189
common/scripts/ofac/index.ts
Normal file
189
common/scripts/ofac/index.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
402
common/scripts/ofac/parseSdn.ts
Normal file
402
common/scripts/ofac/parseSdn.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
429
contracts/scripts/ofac/prepareMultisigUpdate.ts
Normal file
429
contracts/scripts/ofac/prepareMultisigUpdate.ts
Normal 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);
|
||||
});
|
||||
478
contracts/scripts/ofac/signExecuteAndUpload.ts
Normal file
478
contracts/scripts/ofac/signExecuteAndUpload.ts
Normal 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);
|
||||
});
|
||||
158
contracts/scripts/ofac/test-e2e/testSafeProposal.ts
Normal file
158
contracts/scripts/ofac/test-e2e/testSafeProposal.ts
Normal 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();
|
||||
Reference in New Issue
Block a user