diff --git a/bank_bridge.py b/bank_bridge.py new file mode 100644 index 0000000..c754cb8 --- /dev/null +++ b/bank_bridge.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +""" +Vector Zulu — Bank Bridge H2H Submission +Submits BankBridgeDocumentRequest to KoreNet's banking platform. +Targets korebank.korenet.cloud (primary) with fnb.korenet.cloud fallback. + +Usage: + python bank_bridge.py --dry-run # validate and print payload only + python bank_bridge.py --register-nostro # register VZ nostro at ABN AMRO + python bank_bridge.py --register-corridor # register VZ → ABN AMRO corridor + python bank_bridge.py --submit # submit BankBridgeDocumentRequest + python bank_bridge.py --status {uetr} # check instruction status +""" +import os, sys, json, time, uuid, hmac, hashlib, logging, argparse, requests, urllib3 +from datetime import datetime, timezone + +urllib3.disable_warnings() +logging.basicConfig(level=logging.INFO, + format='%(asctime)s [BANK-BRIDGE] %(levelname)s %(message)s') +log = logging.getLogger(__name__) + +# ── Config ──────────────────────────────────────────────────────────────────── + +# KoreNet banking platform — primary +KOREBANK_PRIMARY = os.environ.get('KOREBANK_URL', 'https://korebank.korenet.cloud') +# FNB node — fallback (confirmed accepting JWT for vault reads) +KOREBANK_FALLBACK = os.environ.get('KOREBANK_FALLBACK', 'https://fnb.korenet.cloud') + +# VZ identity +TENANT_ID = 'vector-zulu' +OPERATOR_ID = 'KORE-ENT-EFA1FD85-9173' + +# VZ bank relationship — Wells Fargo / SM Legacy Trust (beneficiary) +VZ_BENEFICIARY = { + 'name': 'SM Legacy Trust', + 'account': os.environ.get('WF_ACCOUNT', '8618159373'), + 'bic': 'WFBIUS6S', + 'routing': '121000248', + 'bankName': 'Wells Fargo Bank N.A.', + 'country': 'US', +} + +# ABN AMRO — H2H counterparty (sandbox details pending Bryan) +ABN_AMRO = { + 'bic': os.environ.get('ABN_AMRO_BIC', 'ABNANL2A'), + 'account': os.environ.get('ABN_AMRO_ACCOUNT', ''), # PENDING BRYAN + 'bankName': 'ABN AMRO Bank N.V.', + 'country': 'NL', + 'environment': os.environ.get('ABN_AMRO_ENV', 'SANDBOX'), +} + +# Active proposal +PROPOSAL = { + 'transferId': 'VZ-PROP-a77a1edcbe7d435eb1907aa0', + 'koreId': 'KID-TRF-PAY-20260716-9cd6806e01a556a2', + 'uetr': 'd13a4cb3-518d-4eeb-84d6-b117c198159d', + 'amount': '1000.00', + 'currency': 'USD', +} + +# ── Auth ────────────────────────────────────────────────────────────────────── + +def load_secrets(): + for path in ['/app/.secrets', + os.path.expanduser('~/.korenet/vector-zulu/.secrets')]: + if os.path.exists(path): + jwt = lick_key = '' + with open(path) as f: + for line in f: + if 'VECTOR_ZULU_JWT=' in line: + jwt = line.split('=',1)[1].strip().strip('"').strip("'") + if 'VECTOR_ZULU_LICK_KEY=' in line: + lick_key = line.split('=',1)[1].strip().strip('"').strip("'") + if jwt: + return jwt, lick_key + raise RuntimeError("Could not load secrets") + +def lick_headers(jwt, lick_key, body_bytes): + KEY = lick_key.encode('utf-8') + ts = int(time.time()) + sig = hmac.new(KEY, body_bytes + b'\n' + str(ts).encode(), + hashlib.sha256).hexdigest() + return { + 'Authorization': f'Bearer {jwt}', + 'X-Tenant-Id': TENANT_ID, + 'Content-Type': 'application/json', + 'x-lick-ts': str(ts), + 'x-lick-sig': sig, + 'x-lick-nonce': str(uuid.uuid4()), + 'x-lick-signer': TENANT_ID, + 'x-lick-variant': 'canonical', + 'Idempotency-Key': str(uuid.uuid4()), + 'X-Correlation-ID': str(uuid.uuid4()), + } + +def post(jwt, lick_key, path, payload, dry_run=False): + body_bytes = json.dumps(payload, separators=(',',':')).encode('utf-8') + headers = lick_headers(jwt, lick_key, body_bytes) + + if dry_run: + log.info(f"[DRY RUN] POST {path}") + log.info(f"Payload: {json.dumps(payload, indent=2)}") + log.info(f"x-lick-sig: {headers['x-lick-sig']}") + return {'dryRun': True, 'path': path, 'payload': payload} + + for base in [KOREBANK_PRIMARY, KOREBANK_FALLBACK]: + try: + r = requests.post(f'{base}{path}', data=body_bytes, + headers=headers, verify=False, timeout=30) + log.info(f"POST {base}{path} → HTTP {r.status_code}") + if r.status_code in (200, 201, 202): + return r.json() + elif r.status_code == 401: + log.warning(f"{base}: 401 — {r.text[:100]}") + # Try fallback + continue + else: + log.error(f"{base}: {r.status_code} — {r.text[:200]}") + return {'error': r.text, 'status': r.status_code} + except Exception as e: + log.warning(f"{base}: connection error — {e}") + continue + + return {'error': 'All endpoints failed'} + +def get(jwt, path): + headers = { + 'Authorization': f'Bearer {jwt}', + 'X-Tenant-Id': TENANT_ID, + } + for base in [KOREBANK_PRIMARY, KOREBANK_FALLBACK]: + try: + r = requests.get(f'{base}{path}', headers=headers, + verify=False, timeout=15) + if r.status_code == 200: + return r.json() + log.warning(f"{base}{path}: {r.status_code} — {r.text[:100]}") + except Exception as e: + log.warning(f"{base}: {e}") + return None + +# ── Operations ──────────────────────────────────────────────────────────────── + +def register_corridor(jwt, lick_key, dry_run=False): + """Register VZ → ABN AMRO corridor on KoreNet.""" + log.info("Registering VZ → ABN AMRO corridor...") + + payload = { + 'source_country': 'KN', # St Kitts & Nevis — VZ domicile + 'destination_country': 'NL', # Netherlands — ABN AMRO + 'name': 'VZ-ABNAMRO-USD-CORRIDOR', + 'supported_rails': ['SWIFT_GPI', 'H2H'], + 'settlement_currencies': ['USD', 'GBP', 'EUR'], + } + + # PENDING BRYAN: confirm correct endpoint path on korebank + # Candidate paths based on swagger schemas found: + for path in [ + '/api/v1/corridor/register', + '/api/corridor/register', + '/api/v1/BankRegistration/corridor', + '/api/BankRegistration/corridor', + ]: + result = post(jwt, lick_key, path, payload, dry_run) + if dry_run or result.get('error') != 'All endpoints failed': + return result + return result + +def register_nostro(jwt, lick_key, dry_run=False): + """Register VZ nostro account at ABN AMRO.""" + log.info("Registering VZ nostro at ABN AMRO...") + + if not ABN_AMRO['account'] and not dry_run: + log.error("ABN_AMRO_ACCOUNT not set — cannot register nostro") + log.error("Set ABN_AMRO_ACCOUNT env var with sandbox account from Bryan") + return {'error': 'ABN_AMRO_ACCOUNT not configured'} + + payload = { + 'holderBic': 'KNCOZAJJ', # VZ BIC + 'counterpartyBic': ABN_AMRO['bic'], + 'currency': 'USD', + 'balanceMinor': 0, # Initial balance — ABN AMRO will confirm actual + } + + for path in [ + '/api/v1/nostro/register', + '/api/nostro/register', + '/api/v1/BankRegistration/nostro', + '/api/BankRegistration/nostro', + ]: + result = post(jwt, lick_key, path, payload, dry_run) + if dry_run or result.get('error') != 'All endpoints failed': + return result + return result + +def register_bank_account(jwt, lick_key, dry_run=False): + """Register VZ bank account (Wells Fargo beneficiary) with KoreNet.""" + log.info("Registering VZ Wells Fargo beneficiary account...") + + payload = { + 'accountNumber': VZ_BENEFICIARY['account'], + 'authorizedSigners': [TENANT_ID], + 'bankName': VZ_BENEFICIARY['bankName'], + 'iban': None, + 'jurisdiction': VZ_BENEFICIARY['country'], + 'routingNumber': VZ_BENEFICIARY['routing'], + 'swiftCode': VZ_BENEFICIARY['bic'], + 'vaultId': 'VZ-USD-VAULT', + } + + result = post(jwt, lick_key, + '/api/BankRegistration/register-bank-account', payload, dry_run) + return result + +def submit_bridge_document(jwt, lick_key, dry_run=False): + """Submit BankBridgeDocumentRequest for H2H transfer.""" + log.info("Submitting BankBridgeDocumentRequest...") + + if not ABN_AMRO['account'] and not dry_run: + log.error("ABN_AMRO_ACCOUNT not set — cannot submit bridge document") + log.error("Set ABN_AMRO_ACCOUNT env var with sandbox account from Bryan") + return {'error': 'ABN_AMRO_ACCOUNT not configured'} + + payload = { + # Identifiers + 'bankBrand': 'ABN_AMRO', + 'transactionId': PROPOSAL['transferId'], + 'koreId': PROPOSAL['koreId'], + 'uetr': PROPOSAL['uetr'], + 'transactionReference': f"VZ-H2H-{int(time.time())}", + 'transactionRef': PROPOSAL['transferId'], + 'reference': PROPOSAL['transferId'], + + # Amount + 'amount': float(PROPOSAL['amount']), + 'currency': PROPOSAL['currency'], + 'amountInWords': 'One Thousand United States Dollars', + + # Source (KoreNet/ABN AMRO side) + 'sourceName': 'Vector Zulu LLC', + 'sourceAccount': 'VZ-USD-VAULT', + 'sourceSwift': 'KNCOZAJJ', + 'senderName': 'Vector Zulu LLC', + 'senderAccount': 'VZ-USD-VAULT', + 'senderBic': 'KNCOZAJJ', + + # Beneficiary (Wells Fargo via ABN AMRO correspondent) + 'beneficiaryName': VZ_BENEFICIARY['name'], + 'beneficiaryAccount': VZ_BENEFICIARY['account'], + 'beneficiarySwift': VZ_BENEFICIARY['bic'], + 'beneficiaryCountry': VZ_BENEFICIARY['country'], + 'receiverBic': VZ_BENEFICIARY['bic'], + + # Settlement + 'settlementType': 'SWIFT_GPI', + 'purpose': 'VZ H2H treasury transfer — first milestone validation', + 'bankSide': 'DEBIT', + + # Balance placeholders — ABN AMRO will populate + 'balanceBefore': 0, + 'balanceAfter': 0, + + # Bank officer — PENDING BRYAN + 'bankOfficer': os.environ.get('ABN_AMRO_OFFICER', ''), + } + + # Try candidate submission paths + for path in [ + '/api/BankBridge/submit', + '/api/v1/BankBridge/submit', + '/api/BankRegistration/bridge/submit', + '/api/v1/bank-bridge/submit', + '/api/v1/payments/bridge', + ]: + result = post(jwt, lick_key, path, payload, dry_run) + if dry_run or (result and result.get('error') != 'All endpoints failed'): + return result + + return {'error': 'No valid submission endpoint found — ask Bryan for correct path'} + +def check_status(jwt, uetr): + """Check instruction status by UETR.""" + log.info(f"Checking status for UETR: {uetr}") + + for path in [ + f'/api/v1/payment-mirror/{uetr}/status', + f'/api/ull/status/{uetr}', + f'/api/v1/rails/status/{uetr}', + f'/api/BankBridge/status/{uetr}', + ]: + result = get(jwt, path) + if result: + log.info(f"Found status at {path}") + return result + + return {'error': 'Status not found on any endpoint'} + +def health_check(jwt): + """Check korebank health.""" + for base in [KOREBANK_PRIMARY, KOREBANK_FALLBACK]: + try: + r = requests.get(f'{base}/health', timeout=8, verify=False) + log.info(f"{base}/health: {r.status_code} — {r.text[:100]}") + except Exception as e: + log.warning(f"{base}: {e}") + +# ── CLI ─────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description='VZ Bank Bridge H2H Submission') + parser.add_argument('--dry-run', action='store_true', + help='Print payload without submitting') + parser.add_argument('--health', action='store_true', + help='Check korebank health') + parser.add_argument('--register-corridor', action='store_true', + help='Register VZ → ABN AMRO corridor') + parser.add_argument('--register-nostro', action='store_true', + help='Register VZ nostro at ABN AMRO') + parser.add_argument('--register-bank', action='store_true', + help='Register VZ Wells Fargo beneficiary account') + parser.add_argument('--submit', action='store_true', + help='Submit BankBridgeDocumentRequest') + parser.add_argument('--status', type=str, metavar='UETR', + help='Check instruction status by UETR') + parser.add_argument('--all', action='store_true', + help='Run full registration sequence then submit') + args = parser.parse_args() + + jwt, lick_key = load_secrets() + log.info(f"Loaded JWT and LICK key") + log.info(f"Primary: {KOREBANK_PRIMARY}") + log.info(f"Fallback: {KOREBANK_FALLBACK}") + + if args.health: + health_check(jwt) + + elif args.register_corridor: + result = register_corridor(jwt, lick_key, args.dry_run) + print(json.dumps(result, indent=2)) + + elif args.register_nostro: + result = register_nostro(jwt, lick_key, args.dry_run) + print(json.dumps(result, indent=2)) + + elif args.register_bank: + result = register_bank_account(jwt, lick_key, args.dry_run) + print(json.dumps(result, indent=2)) + + elif args.submit: + result = submit_bridge_document(jwt, lick_key, args.dry_run) + print(json.dumps(result, indent=2)) + + elif args.status: + result = check_status(jwt, args.status) + print(json.dumps(result, indent=2)) + + elif args.all: + log.info("=== Full H2H registration sequence ===") + log.info("Step 1: Register corridor") + r1 = register_corridor(jwt, lick_key, args.dry_run) + print(json.dumps(r1, indent=2)) + + log.info("Step 2: Register nostro") + r2 = register_nostro(jwt, lick_key, args.dry_run) + print(json.dumps(r2, indent=2)) + + log.info("Step 3: Register bank account") + r3 = register_bank_account(jwt, lick_key, args.dry_run) + print(json.dumps(r3, indent=2)) + + log.info("Step 4: Submit bridge document") + r4 = submit_bridge_document(jwt, lick_key, args.dry_run) + print(json.dumps(r4, indent=2)) + + else: + parser.print_help() + +if __name__ == '__main__': + main()