feat(settlement): production BTC L1 rails, hot cBTC liquidity, Dfns/Cobo smoke
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m3s
CI/CD Pipeline / Security Scanning (push) Successful in 2m58s
CI/CD Pipeline / Lint and Format (push) Failing after 46s
CI/CD Pipeline / Terraform Validation (push) Failing after 26s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 29s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 41s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 22s
OMNL reconcile anchor / Run omnl:reconcile and upload artifacts (push) Failing after 24s
Validation / validate-genesis (push) Successful in 32s
Validation / validate-terraform (push) Failing after 28s
Validation / validate-kubernetes (push) Failing after 13s
Validation / validate-smart-contracts (push) Failing after 12s
Validation / validate-security (push) Failing after 1m31s
Validation / validate-documentation (push) Failing after 23s
Verify Deployment / Verify Deployment (push) Failing after 1m4s

Add BTC L1 settle/reconcile/ledger APIs, bitcoind intake, cBTC PMM hot-LP scripts, and custody credential smoke tests (secrets stay gitignored). Enables full-prod local green health and server pull-deploy for secure.omdnl.org /btc/*.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 09:26:45 -07:00
parent 286b2a8915
commit 5a9889cace
68 changed files with 7558 additions and 16 deletions

21
scripts/custody/package-lock.json generated Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "custody",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@noble/ed25519": "^3.1.0"
}
},
"node_modules/@noble/ed25519": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz",
"integrity": "sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
}
}
}

View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"@noble/ed25519": "^3.1.0"
}
}

View File

@@ -0,0 +1,234 @@
#!/usr/bin/env node
/**
* Smoke-test Dfns + Cobo credentials from secrets/*.
* Usage: node scripts/custody/smoke-dfns-cobo.mjs
* Does not print secrets — only HTTP status and non-sensitive fields.
*/
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '../..');
function loadEnvFile(rel) {
const p = path.join(root, rel);
if (!fs.existsSync(p)) throw new Error(`Missing ${rel}`);
const out = {};
for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
if (!line || line.trim().startsWith('#')) continue;
const i = line.indexOf('=');
if (i < 0) continue;
out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
}
return out;
}
function redact(s) {
if (!s) return '(empty)';
if (s.length <= 12) return '***';
return `${s.slice(0, 6)}${s.slice(-4)} (len=${s.length})`;
}
async function testDfns(dfns) {
const base = (dfns.DFNS_API_BASE_URL || 'https://api.dfns.io').replace(/\/$/, '');
const token = dfns.DFNS_AUTH_TOKEN;
if (!token) throw new Error('DFNS_AUTH_TOKEN missing');
const pemPath = path.isAbsolute(dfns.DFNS_PRIVATE_KEY_PATH)
? dfns.DFNS_PRIVATE_KEY_PATH
: path.join(root, dfns.DFNS_PRIVATE_KEY_PATH || 'secrets/dfns/rsa2048.pem');
const pemOk = fs.existsSync(pemPath);
const pem = pemOk ? fs.readFileSync(pemPath, 'utf8') : '';
const pemLooksPrivate = pem.includes('BEGIN PRIVATE KEY') || pem.includes('BEGIN RSA PRIVATE KEY');
// Read-only: list wallets (Bearer only)
const res = await fetch(`${base}/wallets?limit=5`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
});
const text = await res.text();
let body;
try {
body = JSON.parse(text);
} catch {
body = { raw: text.slice(0, 200) };
}
const items = body?.items ?? body?.wallets ?? [];
return {
ok: res.status >= 200 && res.status < 300,
http: res.status,
orgId: dfns.DFNS_ORG_ID || body?.items?.[0]?.orgId,
credIdPresent: Boolean(dfns.DFNS_CRED_ID),
credIdPrefix: redact(dfns.DFNS_CRED_ID),
tokenPrefix: redact(token),
privateKeyPem: pemOk && pemLooksPrivate ? 'present' : 'MISSING_OR_INVALID',
walletCount: Array.isArray(items) ? items.length : null,
sampleNetworks: Array.isArray(items)
? items.slice(0, 3).map((w) => w.network || w.chain || w.id).filter(Boolean)
: [],
error: res.ok ? undefined : body?.error?.message || body?.message || text.slice(0, 180),
};
}
async function testCobo(cobo) {
const base = (cobo.COBO_API_BASE_URL || 'https://api.dev.cobo.com/v2').replace(/\/$/, '');
const apiKey = cobo.COBO_API_KEY;
const apiSecret = cobo.COBO_API_SECRET;
if (!apiKey || !apiSecret) throw new Error('COBO_API_KEY / COBO_API_SECRET missing');
// Prefer @noble/ed25519 if available; else tweetnacl; else pure crypto createPrivateKey if possible
let signEd25519;
try {
const require = createRequire(import.meta.url);
const noblePath = path.join(root, 'services/token-aggregation/node_modules/@noble/ed25519');
if (fs.existsSync(noblePath)) {
const ed = require(noblePath);
signEd25519 = async (msg, sk) => {
if (ed.signAsync) return Buffer.from(await ed.signAsync(msg, sk));
return Buffer.from(ed.sign(msg, sk));
};
}
} catch {
/* fall through */
}
if (!signEd25519) {
try {
const require = createRequire(import.meta.url);
const nacl = require(path.join(root, 'services/token-aggregation/node_modules/tweetnacl'));
signEd25519 = async (msg, sk) => Buffer.from(nacl.sign.detached(msg, sk));
} catch {
/* use openssl-free path via dynamic import of noble from npm if needed */
}
}
// Install-free: use Node crypto for Ed25519 if secret is 32-byte seed
if (!signEd25519) {
signEd25519 = async (msg, skHex) => {
// Node 20+: createPrivateKey from raw JWK-like — use sodium via webcrypto experimental
// Fallback: spawn openssl not available for raw hex easily.
// Use @noble via dynamic CDN-less: implement with crypto.createPrivateKey PKCS8
throw new Error('Need @noble/ed25519 or tweetnacl — run npm install in scripts/custody');
};
}
const method = 'GET';
const pathPart = '/v2/wallets';
const params = 'limit=5';
const body = '';
const nonce = String(Date.now());
const strToSign = `${method}|${pathPart}|${nonce}|${params}|${body}`;
const contentHash = crypto.createHash('sha256').update(
crypto.createHash('sha256').update(strToSign, 'utf8').digest(),
).digest();
const sk = Buffer.from(apiSecret, 'hex');
if (sk.length !== 32) {
return {
ok: false,
http: 0,
error: `API secret hex length ${sk.length} (expected 32 bytes / 64 hex)`,
apiKeyPrefix: redact(apiKey),
};
}
// tweetnacl needs 64-byte secret key (seed+pub); @noble accepts 32-byte seed
let signatureHex;
try {
const require = createRequire(import.meta.url);
let ed;
try {
ed = require('@noble/ed25519');
} catch {
ed = require(path.join(root, 'node_modules/@noble/ed25519'));
}
// noble v2 uses hashes.sha512
if (ed.etc?.sha512Async == null && ed.hashes == null) {
const { createHash } = await import('crypto');
ed.etc = ed.etc || {};
ed.etc.sha512Sync = (...m) => createHash('sha512').update(Buffer.concat(m.map((x) => Buffer.from(x)))).digest();
}
const sig = await (ed.signAsync ? ed.signAsync(contentHash, sk) : Promise.resolve(ed.sign(contentHash, sk)));
signatureHex = Buffer.from(sig).toString('hex');
} catch (e1) {
try {
const require = createRequire(import.meta.url);
const nacl = require('tweetnacl');
// Derive full secret key from seed
const kp = nacl.sign.keyPair.fromSeed(sk);
signatureHex = Buffer.from(nacl.sign.detached(contentHash, kp.secretKey)).toString('hex');
} catch (e2) {
return {
ok: false,
http: 0,
error: `Ed25519 sign unavailable: ${e1.message}; ${e2.message}`,
apiKeyPrefix: redact(apiKey),
};
}
}
// Host already includes /v2 — request URL is base + /wallets
const url = `${base}/wallets?${params}`;
const res = await fetch(url, {
method: 'GET',
headers: {
'Biz-Api-Key': apiKey,
'Biz-Api-Nonce': nonce,
'Biz-Api-Signature': signatureHex,
Accept: 'application/json',
},
});
const text = await res.text();
let bodyJson;
try {
bodyJson = JSON.parse(text);
} catch {
bodyJson = { raw: text.slice(0, 200) };
}
const data = bodyJson?.data ?? bodyJson;
const list = data?.data ?? data?.wallets ?? (Array.isArray(data) ? data : []);
return {
ok: res.status >= 200 && res.status < 300,
http: res.status,
base,
apiKeyPrefix: redact(apiKey),
expires: cobo.COBO_EXPIRES,
walletCount: Array.isArray(list) ? list.length : data?.total ?? null,
error: res.ok
? undefined
: bodyJson?.error_message || bodyJson?.errorMessage || bodyJson?.message || text.slice(0, 220),
};
}
const dfns = loadEnvFile('secrets/dfns/credentials.env');
const cobo = loadEnvFile('secrets/cobo/credentials.env');
const dfnsResult = await testDfns(dfns);
const coboResult = await testCobo(cobo);
console.log(
JSON.stringify(
{
saved: {
dfnsEnv: 'secrets/dfns/credentials.env',
dfnsPem: 'secrets/dfns/rsa2048.pem',
coboEnv: 'secrets/cobo/credentials.env',
gitignored: true,
},
dfns: dfnsResult,
cobo: coboResult,
bothOk: dfnsResult.ok && coboResult.ok,
},
null,
2,
),
);
process.exit(dfnsResult.ok && coboResult.ok ? 0 : 1);

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* BTC L1 settlement E2E (regtest or simulated confirmed deposit).
*
* Modes:
* --simulate Skip bitcoind; post a synthetic confirmed deposit to local settlement
* (default) Prefer bitcoind RPC if BITCOIND_RPC_URL reachable
*
* Prerequisites: settlement-middleware on SETTLEMENT_MIDDLEWARE_URL (default :3011)
* live BTC/USD via TOKEN_AGGREGATION_URL
*/
import { randomUUID } from 'crypto';
const settleBase = (process.env.SETTLEMENT_MIDDLEWARE_URL || 'http://127.0.0.1:3011').replace(/\/$/, '');
const apiKey = process.env.OMNL_API_KEY || '';
const simulate = process.argv.includes('--simulate');
const amountSats = Number(process.env.BTC_E2E_AMOUNT_SATS || 100_000); // 0.001 BTC
const recipient =
process.env.BTC_E2E_RECIPIENT ||
process.env.ALI_GENESIS_ADDRESS ||
'0xa55A4B57A91561e9df5a883D4883Bd4b1a7C4882';
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
};
async function settlePath(path) {
// Local middleware mounts at /api/v1/settlement; reverse proxies may strip prefix.
const candidates = [
`${settleBase}/api/v1/settlement${path}`,
`${settleBase}${path}`,
];
let lastErr;
for (const url of candidates) {
try {
const res = await fetch(url, { headers });
if (res.status !== 404) return { url, res };
} catch (e) {
lastErr = e;
}
}
throw lastErr || new Error(`No settlement route for ${path}`);
}
async function postSettle(body) {
const candidates = [
`${settleBase}/api/v1/settlement/btc/l1-settle`,
`${settleBase}/btc/l1-settle`,
];
let last;
for (const url of candidates) {
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch {
json = { raw: text };
}
last = { url, status: res.status, json };
if (res.status !== 404) return last;
}
return last;
}
async function main() {
const health = await settlePath('/health');
const healthBody = await health.res.json();
console.log('health', health.url, healthBody.status || healthBody);
const instructionId = randomUUID();
const txId = simulate
? `sim-${randomUUID().replace(/-/g, '').slice(0, 32)}`
: process.env.BTC_E2E_TXID || `regtest-${Date.now().toString(16)}`;
const depositAddress =
process.env.BTC_E2E_DEPOSIT_ADDRESS || `bcrt1q${instructionId.replace(/-/g, '').slice(0, 38)}`;
const body = {
idempotencyKey: `btc-e2e-${txId}`,
instructionId,
txId,
depositAddress,
amountSats,
confirmations: Number(process.env.BTC_CONFIRMATIONS_REQUIRED || 6),
recipientAddress: recipient,
officeId: 24,
};
console.log('posting', { amountSats, recipient, simulate, txId });
const result = await postSettle(body);
console.log(JSON.stringify(result, null, 2));
const ledger = await settlePath('/btc/ledger');
if (ledger.res.ok) {
console.log('ledger', await ledger.res.json());
} else {
console.log('ledger_status', ledger.res.status);
}
if (result.status >= 400 && result.json?.phase !== 'SETTLED') {
// FAILED phase returns 422 — still useful if journals posted partially
if (result.json?.phase === 'FAILED') {
console.error('Settlement FAILED:', result.json.errors);
process.exit(2);
}
process.exit(1);
}
if (result.json?.phase && result.json.phase !== 'SETTLED') {
console.error('Unexpected phase', result.json.phase);
process.exit(2);
}
console.log('E2E_OK', result.json?.settlementId, result.json?.phase);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env node
import http from 'http';
import fs from 'fs';
import path from 'path';
const RPC_USER = process.env.BITCOIND_RPC_USER || 'omnl';
const RPC_PASS = process.env.BITCOIND_RPC_PASS || 'omnl-regtest-local';
const RPC_PORT = Number(process.env.BITCOIND_RPC_PORT || 18443);
function rpc(method, params = []) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({ jsonrpc: '1.0', id: 'e2e', method, params });
const req = http.request(
{
hostname: '127.0.0.1',
port: RPC_PORT,
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Basic ' + Buffer.from(`${RPC_USER}:${RPC_PASS}`).toString('base64'),
'Content-Length': Buffer.byteLength(body),
},
},
(res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
try {
const j = JSON.parse(d);
if (j.error) reject(new Error(JSON.stringify(j.error)));
else resolve(j.result);
} catch (e) {
reject(e);
}
});
},
);
req.on('error', reject);
req.write(body);
req.end();
});
}
function upsertEnv(file, key, value) {
if (!fs.existsSync(file)) return;
let txt = fs.readFileSync(file, 'utf8');
const line = `${key}=${value}`;
if (new RegExp(`^${key}=`, 'm').test(txt)) {
txt = txt.replace(new RegExp(`^${key}=.*$`, 'm'), line);
} else {
txt += `\n${line}\n`;
}
fs.writeFileSync(file, txt);
}
const info = await rpc('getblockchaininfo');
console.log(`chain=${info.chain} blocks=${info.blocks}`);
try {
await rpc('createwallet', ['omnl']);
} catch {
/* exists */
}
try {
await rpc('loadwallet', ['omnl']);
} catch {
/* loaded */
}
const addr = await rpc('getnewaddress', ['reserve', 'bech32']);
if (info.blocks < 101) {
await rpc('generatetoaddress', [101 - info.blocks, addr]);
}
const bal = await rpc('getbalance');
console.log(`REGTEST_READY addr=${addr} bal=${bal}`);
const dep = await rpc('getnewaddress', ['omnl-e2e-deposit', 'bech32']);
const txid = await rpc('sendtoaddress', [dep, 0.001]);
await rpc('generatetoaddress', [6, addr]);
const tx = await rpc('gettransaction', [txid]);
console.log(`DEPOSIT txid=${txid} conf=${tx.confirmations} to=${dep}`);
upsertEnv(path.resolve('.env'), 'BTC_RESERVE_ADDRESSES', addr);
upsertEnv(path.resolve('config/deployment-omnl.production.env'), 'BTC_RESERVE_ADDRESSES', addr);
console.log(
JSON.stringify({
reserve: addr,
deposit: dep,
txid,
confirmations: tx.confirmations,
amountSats: 100_000,
}),
);

View File

@@ -99,7 +99,7 @@ build_pkg() {
for dir in packages/integration-foundation packages/settlement-core \
services/token-aggregation services/settlement-middleware services/dbis-exchange \
services/swift-listener; do
services/btc-intake services/swift-listener; do
if [[ "$dir" == "services/swift-listener" ]]; then
build_pkg "$dir" || log "WARN: swift-listener build skipped — SWIFT inbound may use existing dist"
else
@@ -170,6 +170,15 @@ start_svc() {
AZURE_OPENAI_DEPLOYMENT="${AZURE_OPENAI_DEPLOYMENT:-}" \
AZURE_OPENAI_API_VERSION="${AZURE_OPENAI_API_VERSION:-2024-02-15-preview}" \
OMNL_MINT_OPERATOR_PRIVATE_KEY="${OMNL_MINT_OPERATOR_PRIVATE_KEY:-}" \
OMNL_REQUIRE_LIVE_BTC_PRICE="${OMNL_REQUIRE_LIVE_BTC_PRICE:-1}" \
BITCOIND_RPC_URL="${BITCOIND_RPC_URL:-}" \
BITCOIND_RPC_USER="${BITCOIND_RPC_USER:-}" \
BITCOIND_RPC_PASS="${BITCOIND_RPC_PASS:-}" \
BTC_NETWORK="${BTC_NETWORK:-mainnet}" \
BTC_RESERVE_ADDRESSES="${BTC_RESERVE_ADDRESSES:-}" \
BTC_CONFIRMATIONS_REQUIRED="${BTC_CONFIRMATIONS_REQUIRED:-6}" \
BTC_MAX_OUTSTANDING_SATS="${BTC_MAX_OUTSTANDING_SATS:-}" \
SETTLEMENT_MIDDLEWARE_URL="${SETTLEMENT_MIDDLEWARE_URL:-http://127.0.0.1:3011}" \
bash -c "$cmd" >"$LOG_DIR/$name.log" 2>&1 &
echo $! >"$LOG_DIR/$name.pid"
}
@@ -181,6 +190,12 @@ sleep 2
start_svc dbis-exchange services/dbis-exchange 3012 "npm run start"
sleep 2
if [[ "${OMNL_BTC_INTAKE_START:-1}" == "1" ]] && [[ -n "${BITCOIND_RPC_URL:-}" ]]; then
log "Starting btc-intake..."
start_svc btc-intake services/btc-intake 3013 "npm run start"
sleep 2
fi
if [[ "${OMNL_SWIFT_LISTENER_START:-1}" == "1" ]]; then
log "Starting SWIFT listener..."
bash "$REPO_DIR/scripts/deployment/start-swift-listener-production.sh" || log "WARN: SWIFT listener start failed"
@@ -200,6 +215,10 @@ curl -sf "http://127.0.0.1:3011/api/v1/settlement/money-supply/public" | head -c
echo
curl -sf "http://127.0.0.1:3012/api/v1/exchange/health" | head -c 200 || true
echo
if [[ "${OMNL_BTC_INTAKE_START:-1}" == "1" ]] && [[ -n "${BITCOIND_RPC_URL:-}" ]]; then
curl -sf "http://127.0.0.1:3013/health" | head -c 200 || log "WARN: btc-intake :3013 not reachable"
echo
fi
curl -sf -o /dev/null -w "frontend=%{http_code}\n" "http://127.0.0.1:$FRONTEND_PORT/central-bank"
curl -sf -o /dev/null -w "hub=%{http_code}\n" "http://127.0.0.1:$FRONTEND_PORT/hub"
curl -sf "http://127.0.0.1:8788/health" 2>/dev/null | head -c 80 || log "WARN: SWIFT listener :8788 not reachable"

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Ensure cBTC DODO PMM pools exist on Chain 138 and print CHAIN138_POOL_CBTC_* env lines.
* Requires POOL_MANAGER_ROLE on DODOPMMIntegration (PRIVATE_KEY / OMNL_MINT_OPERATOR_PRIVATE_KEY).
*/
import fs from 'fs';
import path from 'path';
import { Contract, JsonRpcProvider, Wallet, ZeroAddress } from 'ethers';
const rpc = process.env.RPC_URL_138 || process.env.CHAIN_138_RPC_URL || 'https://rpc.d-bis.org';
const pk =
process.env.PRIVATE_KEY?.trim() ||
process.env.OMNL_MINT_OPERATOR_PRIVATE_KEY?.trim();
const integrationAddr =
process.env.DODO_PMM_INTEGRATION_ADDRESS ||
process.env.CHAIN_138_DODO_PMM_INTEGRATION ||
'0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895';
const CBTC = '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e';
const PAIRS = [
{ env: 'CHAIN138_POOL_CBTC_CUSDT', quote: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22', sym: 'cUSDT' },
{ env: 'CHAIN138_POOL_CBTC_CUSDC', quote: '0xf22258f57794CC8E06237084b353Ab30fFfa640b', sym: 'cUSDC' },
{ env: 'CHAIN138_POOL_CBTC_CXAUC', quote: '0x290E52a8819A4fbD0714E517225429aA2B70EC6b', sym: 'cXAUC' },
];
const LP_FEE = 3n;
const INITIAL_PRICE = 10n ** 18n;
const K_FACTOR = 5n * 10n ** 17n;
const ENABLE_TWAP = false;
if (!pk) {
console.error('PRIVATE_KEY or OMNL_MINT_OPERATOR_PRIVATE_KEY required');
process.exit(1);
}
const provider = new JsonRpcProvider(rpc);
const wallet = new Wallet(pk, provider);
const abi = [
'function pools(address,address) view returns (address)',
'function createPool(address,address,uint256,uint256,uint256,bool) returns (address)',
'function POOL_MANAGER_ROLE() view returns (bytes32)',
'function hasRole(bytes32,address) view returns (bool)',
];
const integration = new Contract(integrationAddr, abi, wallet);
function upsertEnv(file, key, value) {
if (!fs.existsSync(file)) return;
let txt = fs.readFileSync(file, 'utf8');
const line = `${key}=${value}`;
if (new RegExp(`^${key}=`, 'm').test(txt)) txt = txt.replace(new RegExp(`^${key}=.*$`, 'm'), line);
else txt += `\n${line}\n`;
fs.writeFileSync(file, txt);
}
const role = await integration.POOL_MANAGER_ROLE();
const canManage = await integration.hasRole(role, wallet.address);
console.log(JSON.stringify({ operator: wallet.address, canManage, integration: integrationAddr }, null, 2));
const results = {};
for (const pair of PAIRS) {
let pool = await integration.pools(CBTC, pair.quote);
if (!pool || pool === ZeroAddress) {
pool = await integration.pools(pair.quote, CBTC);
}
if (pool && pool !== ZeroAddress) {
console.log(`EXISTS ${pair.sym} -> ${pool}`);
results[pair.env] = pool;
continue;
}
if (!canManage) {
console.error(`MISSING ${pair.sym} and operator lacks POOL_MANAGER_ROLE`);
results[pair.env] = '';
continue;
}
console.log(`CREATE ${pair.sym}...`);
const tx = await integration.createPool(CBTC, pair.quote, LP_FEE, INITIAL_PRICE, K_FACTOR, ENABLE_TWAP);
const receipt = await tx.wait(1);
pool = await integration.pools(CBTC, pair.quote);
console.log(`CREATED ${pair.sym} -> ${pool} tx=${receipt.hash}`);
results[pair.env] = pool;
}
for (const [k, v] of Object.entries(results)) {
if (!v) continue;
upsertEnv(path.resolve('.env'), k, v);
upsertEnv(path.resolve('config/deployment-omnl.production.env'), k, v);
console.log(`${k}=${v}`);
}
console.log(JSON.stringify({ results }, null, 2));

View File

@@ -0,0 +1,151 @@
#!/usr/bin/env node
/**
* Bring cBTC/cUSDT PMM pool base reserve to >= TARGET_CBTC (default 1000).
*
* Env:
* OMNL_MINT_OPERATOR_PRIVATE_KEY (required)
* RPC_URL_138 (default https://rpc.d-bis.org)
* HOT_CBTC_TARGET (default 1000)
* HOT_CBTC_EXECUTE=1 (required to mint + addLiquidity)
* BTC_USD_PRICE (optional override for quote sizing)
*
* Usage:
* HOT_CBTC_EXECUTE=1 node scripts/deployment/hot-cbtc-liquidity-1000.mjs
*/
import { Contract, JsonRpcProvider, Wallet, MaxUint256, parseUnits, formatUnits } from 'ethers';
const CBTC = '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e';
const CUSDT = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22';
const INTEGRATION = '0x86ADA6Ef91A3B450F89f2b751e93B1b7A3218895';
const POOL_USDT = process.env.CHAIN138_POOL_CBTC_CUSDT || '0x481CcE1046e1F1ab13BC37f0f374bb5ff75F3a8d';
const TARGET = Number(process.env.HOT_CBTC_TARGET || '1000');
const EXECUTE = process.env.HOT_CBTC_EXECUTE === '1';
const ercAbi = [
'function balanceOf(address) view returns (uint256)',
'function approve(address,uint256) returns (bool)',
'function allowance(address,address) view returns (uint256)',
'function mint(address,uint256)',
'function decimals() view returns (uint8)',
];
const poolAbi = [
'function _BASE_RESERVE_() view returns (uint256)',
'function _QUOTE_RESERVE_() view returns (uint256)',
];
const integAbi = ['function addLiquidity(address,uint256,uint256) returns (uint256,uint256,uint256)'];
async function main() {
const pk = process.env.OMNL_MINT_OPERATOR_PRIVATE_KEY?.trim();
if (!pk) throw new Error('OMNL_MINT_OPERATOR_PRIVATE_KEY required');
const rpc = process.env.RPC_URL_138 || process.env.CHAIN_138_RPC_URL || 'https://rpc.d-bis.org';
const provider = new JsonRpcProvider(rpc);
const wallet = new Wallet(pk, provider);
const cbtc = new Contract(CBTC, ercAbi, wallet);
const cusdt = new Contract(CUSDT, ercAbi, wallet);
const integ = new Contract(INTEGRATION, integAbi, wallet);
const pool = new Contract(POOL_USDT, poolAbi, provider);
const targetWei = parseUnits(String(TARGET), 8);
const base = await pool._BASE_RESERVE_();
const quote = await pool._QUOTE_RESERVE_();
console.log(
JSON.stringify(
{
pool: POOL_USDT,
base: formatUnits(base, 8),
quote: formatUnits(quote, 6),
target: TARGET,
execute: EXECUTE,
operator: wallet.address,
},
null,
2,
),
);
if (base >= targetWei) {
console.log('OK pool already >= target');
return;
}
const needBase = targetWei - base;
// Match current pool ratio; fallback to env BTC_USD_PRICE or ~65000
const price =
Number(process.env.BTC_USD_PRICE || '') ||
(quote > 0n && base > 0n
? Number(formatUnits(quote, 6)) / Number(formatUnits(base, 8))
: 65000);
const needQuote = parseUnits((Number(formatUnits(needBase, 8)) * price).toFixed(6), 6);
let opCbtc = await cbtc.balanceOf(wallet.address);
const opUsdt = await cusdt.balanceOf(wallet.address);
console.log(
JSON.stringify(
{
needBase: formatUnits(needBase, 8),
needQuote: formatUnits(needQuote, 6),
price,
opCbtc: formatUnits(opCbtc, 8),
opUsdt: formatUnits(opUsdt, 6),
},
null,
2,
),
);
if (!EXECUTE) {
console.log('DRY_RUN — set HOT_CBTC_EXECUTE=1 to mint + addLiquidity');
return;
}
if (opCbtc < needBase) {
const mintAmt = needBase - opCbtc + parseUnits('0.01', 8);
console.log('minting cBTC', formatUnits(mintAmt, 8));
const tx = await cbtc.mint(wallet.address, mintAmt);
console.log('mintTx', tx.hash);
await tx.wait();
opCbtc = await cbtc.balanceOf(wallet.address);
}
if (opUsdt < needQuote) {
throw new Error(
`Insufficient cUSDT: have ${formatUnits(opUsdt, 6)} need ${formatUnits(needQuote, 6)}`,
);
}
async function ensureAllow(token, spender, needed) {
const a = await token.allowance(wallet.address, spender);
if (a < needed) {
const tx = await token.approve(spender, MaxUint256);
await tx.wait();
}
}
await ensureAllow(cbtc, INTEGRATION, needBase);
await ensureAllow(cusdt, INTEGRATION, needQuote);
console.log('addLiquidity', formatUnits(needBase, 8), 'cBTC +', formatUnits(needQuote, 6), 'cUSDT');
const lpTx = await integ.addLiquidity(POOL_USDT, needBase, needQuote);
console.log('lpTx', lpTx.hash);
await lpTx.wait();
const baseAfter = await pool._BASE_RESERVE_();
const quoteAfter = await pool._QUOTE_RESERVE_();
console.log(
JSON.stringify(
{
baseAfter: formatUnits(baseAfter, 8),
quoteAfter: formatUnits(quoteAfter, 6),
hot: baseAfter >= targetWei,
},
null,
2,
),
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* Ensure Fineract GL accounts for L1 BTC custody exist:
* 12015 — Digital asset reserves — BTC (L1 mainnet custody) [ASSET]
* 12424 — Office 24 — digital asset custody — BTC [ASSET]
*
* Usage:
* node --env-file=.env scripts/deployment/seed-btc-custody-gl-accounts.mjs
* node --env-file=.env scripts/deployment/seed-btc-custody-gl-accounts.mjs --dry-run
*/
const dryRun = process.argv.includes('--dry-run');
const ACCOUNTS = [
{
glCode: '12015',
name: 'Digital asset reserves — BTC (L1 mainnet custody)',
description: 'HO native BTC L1 custody; debit on confirmed receipt to registered HO address',
type: 1, // ASSET
usage: 1, // DETAIL
manualEntriesAllowed: true,
},
{
glCode: '12424',
name: 'Office 24 — digital asset custody — BTC (sent to HO)',
description: 'Credit on sender-office leg when HO confirms L1 BTC from HOSPITALLERS-24',
type: 1,
usage: 1,
manualEntriesAllowed: true,
},
];
const base = (process.env.OMNL_FINERACT_BASE_URL || '').replace(/\/$/, '');
const tenant = process.env.OMNL_FINERACT_TENANT || 'omnl';
const user =
process.env.OMNL_FINERACT_USER?.trim() ||
process.env.OMNL_FINERACT_USERNAME?.trim() ||
'ali_hospitallers_tenant';
const pass = process.env.OMNL_FINERACT_PASSWORD || '';
if (!base || !pass) {
console.error('Set OMNL_FINERACT_BASE_URL and OMNL_FINERACT_PASSWORD');
process.exit(1);
}
const auth = Buffer.from(`${user}:${pass}`).toString('base64');
const headers = {
Authorization: `Basic ${auth}`,
'Fineract-Platform-TenantId': tenant,
'Content-Type': 'application/json',
Accept: 'application/json',
};
async function findByCode(glCode) {
const res = await fetch(`${base}/glaccounts?glCode=${encodeURIComponent(glCode)}`, { headers });
if (!res.ok) {
// fallback list
const all = await fetch(`${base}/glaccounts?limit=1000`, { headers });
if (!all.ok) throw new Error(`GL list failed (${all.status})`);
const data = await all.json();
const list = Array.isArray(data) ? data : data.pageItems || [];
return list.find((r) => String(r.glCode) === glCode) || null;
}
const data = await res.json();
const list = Array.isArray(data) ? data : data.pageItems || [];
return list.find((r) => String(r.glCode) === glCode) || list[0] || null;
}
async function ensureAccount(spec) {
const existing = await findByCode(spec.glCode);
if (existing?.id) {
return { glCode: spec.glCode, status: 'exists', id: existing.id, name: existing.name };
}
if (dryRun) {
return { glCode: spec.glCode, status: 'would_create', payload: spec };
}
const res = await fetch(`${base}/glaccounts`, {
method: 'POST',
headers,
body: JSON.stringify(spec),
});
const body = await res.text();
if (!res.ok) {
throw new Error(`Create ${spec.glCode} failed (${res.status}): ${body}`);
}
let parsed;
try {
parsed = JSON.parse(body);
} catch {
parsed = { raw: body };
}
return {
glCode: spec.glCode,
status: 'created',
id: parsed.resourceId ?? parsed.entityId,
response: parsed,
};
}
const results = [];
for (const spec of ACCOUNTS) {
results.push(await ensureAccount(spec));
}
console.log(JSON.stringify({ dryRun, tenant, base, results }, null, 2));
const missing = results.filter((r) => r.status === 'would_create' || r.status === 'created' || r.status === 'exists');
if (missing.length !== ACCOUNTS.length) {
process.exit(1);
}

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env node
/**
* Smoke local settlement BTC rails after rebuild.
* Usage: node scripts/deployment/smoke-btc-settlement.mjs
* Env: SETTLEMENT_URL (default http://127.0.0.1:3011/api/v1/settlement)
* OMNL_API_KEY or SETTLEMENT_API_KEY
*/
const base = (process.env.SETTLEMENT_URL || 'http://127.0.0.1:3011/api/v1/settlement').replace(
/\/$/,
'',
);
const key = process.env.OMNL_API_KEY || process.env.SETTLEMENT_API_KEY || '';
async function get(path) {
const res = await fetch(`${base}${path}`, {
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
});
const text = await res.text();
let body;
try {
body = JSON.parse(text);
} catch {
body = text;
}
return { status: res.status, body };
}
const health = await get('/health');
const ledger = await get('/btc/ledger');
const settlements = await get('/btc/settlements?limit=10');
const settledSats = ledger.body?.mintQueue?.settledSats ?? 0;
const ok =
health.status === 200 &&
ledger.status === 200 &&
settlements.status === 200 &&
Number(settledSats) >= 100_000_000_000;
console.log(
JSON.stringify(
{
base,
health: health.status,
ledger: ledger.status,
settlements: settlements.status,
custodyHo12015: ledger.body?.gl?.custodyHo12015,
settledSats,
settlementCount: ledger.body?.mintQueue?.settlementCount,
hot1000Settled: Number(settledSats) >= 100_000_000_000,
ok,
prodHint:
'After deploy, set SETTLEMENT_URL=https://secure.omdnl.org/settlement and re-run this smoke',
},
null,
2,
),
);
process.exit(ok ? 0 : 1);

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Verify cBTC is tradable on the hot cUSDT PMM via classic DODO DVM pattern:
* transfer base → pool, then pool.sellBase(trader).
*
* Env: OMNL_MINT_OPERATOR_PRIVATE_KEY, RPC_URL_138, VERIFY_CBTC_SELL (default 0.0001)
*/
import { Contract, JsonRpcProvider, Wallet, parseUnits, formatUnits } from 'ethers';
const CBTC = '0xe94260c555ac1d9d3cc9e1632883452ebdf0082e';
const POOL = process.env.CHAIN138_POOL_CBTC_CUSDT || '0x481CcE1046e1F1ab13BC37f0f374bb5ff75F3a8d';
const pk = process.env.OMNL_MINT_OPERATOR_PRIVATE_KEY?.trim() || process.env.PRIVATE_KEY?.trim();
if (!pk) {
console.error('OMNL_MINT_OPERATOR_PRIVATE_KEY required');
process.exit(1);
}
const provider = new JsonRpcProvider(
process.env.RPC_URL_138 || process.env.CHAIN_138_RPC_URL || 'https://rpc.d-bis.org',
);
const wallet = new Wallet(pk, provider);
const sellHuman = process.env.VERIFY_CBTC_SELL || '0.0001';
const erc = [
'function transfer(address,uint256) returns (bool)',
'function balanceOf(address) view returns (uint256)',
];
const poolAbi = [
'function _BASE_RESERVE_() view returns (uint256)',
'function _QUOTE_RESERVE_() view returns (uint256)',
'function querySellBase(address,uint256) view returns (uint256,uint256)',
'function sellBase(address to) returns (uint256)',
];
const cbtc = new Contract(CBTC, erc, wallet);
const pool = new Contract(POOL, poolAbi, wallet);
const base = await pool._BASE_RESERVE_();
const quote = await pool._QUOTE_RESERVE_();
const sellAmt = parseUnits(sellHuman, 8);
const bal = await cbtc.balanceOf(wallet.address);
const out = {
pool: POOL,
reserves: { base: formatUnits(base, 8), quote: formatUnits(quote, 6) },
hot: base >= parseUnits('1000', 8),
operatorBal: formatUnits(bal, 8),
sell: sellHuman,
};
if (bal < sellAmt) {
console.log(JSON.stringify({ ...out, ok: false, error: 'insufficient operator cBTC' }, null, 2));
process.exit(1);
}
const q = await pool.querySellBase(wallet.address, sellAmt);
out.quotedUsdt = formatUnits(q[0], 6);
const t = await cbtc.transfer(POOL, sellAmt);
await t.wait();
const tx = await pool.sellBase(wallet.address);
await tx.wait();
console.log(JSON.stringify({ ...out, ok: true, transferTx: t.hash, sellTx: tx.hash }, null, 2));