259 lines
9.3 KiB
Python
259 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Vector Zulu — Chain API
|
|
Serves Amitis L1 + KoreAnchor V1.0 data to KoreNet adapter.
|
|
Port 8767
|
|
"""
|
|
import os, json, time, hmac, hashlib, uuid, logging, requests, urllib3
|
|
from datetime import datetime, timezone
|
|
from flask import Flask, jsonify, request, abort
|
|
import redis, psycopg2
|
|
|
|
urllib3.disable_warnings()
|
|
logging.basicConfig(level=logging.INFO,
|
|
format='%(asctime)s [CHAIN-API] %(levelname)s %(message)s')
|
|
log = logging.getLogger(__name__)
|
|
|
|
app = Flask(__name__)
|
|
|
|
ALCHEMY_URL = os.environ.get('ALCHEMY_URL',
|
|
'https://eth-mainnet.g.alchemy.com/v2/nRVT_IneMUDF3Pokubawx')
|
|
KORE_ANCHOR = '0x8AaA785c7dC116b834E3A1766866078051fC0313'
|
|
VZ_POR = '0x508DA1733567d3361988CD554038301c0E61e260'
|
|
LICK_KEY = os.environ.get('VECTOR_ZULU_LICK_KEY', 'NotMyCircu$_NotMyMonkey$').encode()
|
|
FINALITY_BLOCKS = 12
|
|
|
|
def get_redis():
|
|
return redis.Redis(host=os.environ.get('REDIS_HOST','localhost'),
|
|
port=6379, decode_responses=True)
|
|
|
|
def get_pg():
|
|
return psycopg2.connect(
|
|
host=os.environ.get('POSTGRES_HOST','localhost'),
|
|
dbname=os.environ.get('POSTGRES_DB','vz_oracle'),
|
|
user=os.environ.get('POSTGRES_USER','vz_oracle'),
|
|
password=os.environ.get('POSTGRES_PASSWORD',''),
|
|
connect_timeout=10)
|
|
|
|
def alchemy_call(method, params):
|
|
r = requests.post(ALCHEMY_URL, json={
|
|
'jsonrpc':'2.0','method':method,'params':params,'id':1
|
|
}, timeout=10)
|
|
d = r.json()
|
|
if 'error' in d:
|
|
raise Exception(d['error']['message'])
|
|
return d['result']
|
|
|
|
def sign_response(payload):
|
|
ts = int(time.time())
|
|
body = json.dumps(payload, separators=(',',':')).encode()
|
|
sig = hmac.new(LICK_KEY, body + b'\n' + str(ts).encode(),
|
|
hashlib.sha256).hexdigest()
|
|
return {
|
|
'signedBy': 'vector-zulu-chain-api-v1',
|
|
'timestamp': datetime.now(timezone.utc).isoformat(),
|
|
'signature': sig,
|
|
'payloadHash': hashlib.sha256(body).hexdigest()
|
|
}
|
|
|
|
def current_block():
|
|
result = alchemy_call('eth_blockNumber', [])
|
|
return int(result, 16)
|
|
|
|
def get_tx_receipt(tx_hash):
|
|
return alchemy_call('eth_getTransactionReceipt', [tx_hash])
|
|
|
|
def init_schema():
|
|
try:
|
|
pg = get_pg()
|
|
with pg.cursor() as cur:
|
|
cur.execute("""
|
|
CREATE TABLE IF NOT EXISTS chain_webhooks (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
url TEXT NOT NULL,
|
|
event_types TEXT[] NOT NULL,
|
|
signing_key TEXT NOT NULL,
|
|
max_retries INT DEFAULT 3,
|
|
timeout_seconds INT DEFAULT 30,
|
|
active BOOLEAN DEFAULT true,
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
""")
|
|
pg.commit()
|
|
pg.close()
|
|
log.info("Chain API schema ready")
|
|
except Exception as e:
|
|
log.error(f"Schema init error: {e}")
|
|
|
|
# ── Routes ────────────────────────────────────────────────────
|
|
|
|
@app.route('/health')
|
|
def health():
|
|
try:
|
|
block = current_block()
|
|
r = get_redis()
|
|
last_poll = r.get('vault:last_successful_poll')
|
|
return jsonify({
|
|
'status': 'OPERATIONAL',
|
|
'network': 'ETHEREUM_MAINNET',
|
|
'blockHeight': block,
|
|
'timestamp': datetime.now(timezone.utc).isoformat(),
|
|
'vaultCacheLastPoll': last_poll,
|
|
})
|
|
except Exception as e:
|
|
return jsonify({'status': 'DEGRADED', 'error': str(e)}), 503
|
|
|
|
@app.route('/v1/time')
|
|
def server_time():
|
|
ts = int(time.time())
|
|
body = str(ts).encode()
|
|
sig = hmac.new(LICK_KEY, body, hashlib.sha256).hexdigest()
|
|
return jsonify({
|
|
'timestamp': datetime.now(timezone.utc).isoformat(),
|
|
'epoch': ts,
|
|
'signature': sig
|
|
})
|
|
|
|
@app.route('/v1/transactions/<tx_hash>')
|
|
def get_transaction(tx_hash):
|
|
try:
|
|
receipt = get_tx_receipt(tx_hash)
|
|
if not receipt:
|
|
return jsonify({'code':'NOT_FOUND','message':'Transaction not found'}), 404
|
|
block_num = int(receipt['blockNumber'], 16)
|
|
current = current_block()
|
|
confirmations = current - block_num
|
|
status = 'CONFIRMED' if int(receipt['status'],16) == 1 else 'FAILED'
|
|
if confirmations >= FINALITY_BLOCKS:
|
|
status = 'FINALIZED'
|
|
payload = {
|
|
'txHash': tx_hash,
|
|
'status': status,
|
|
'blockNumber': block_num,
|
|
'confirmations': confirmations,
|
|
'finalized': confirmations >= FINALITY_BLOCKS,
|
|
'gasUsed': int(receipt['gasUsed'],16),
|
|
'network': 'ETHEREUM_MAINNET',
|
|
'etherscan': f'https://etherscan.io/tx/{tx_hash}',
|
|
'timestamp': datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
payload['provenance'] = sign_response(payload)
|
|
return jsonify(payload)
|
|
except Exception as e:
|
|
return jsonify({'code':'ERROR','message':str(e)}), 500
|
|
|
|
@app.route('/v1/transactions/koreid/<kore_id>')
|
|
def get_transaction_by_koreid(kore_id):
|
|
try:
|
|
pg = get_pg()
|
|
with pg.cursor() as cur:
|
|
cur.execute("""
|
|
SELECT koreid, event_type, status, tx_hash, amount, currency, posted_at
|
|
FROM kore_outbound_events WHERE koreid = %s
|
|
""", (kore_id,))
|
|
row = cur.fetchone()
|
|
pg.close()
|
|
if not row:
|
|
return jsonify({'code':'NOT_FOUND','message':'No record for KoreID'}), 404
|
|
koreid, event_type, status, tx_hash, amount, currency, posted_at = row
|
|
payload = {
|
|
'koreId': koreid,
|
|
'eventType': event_type,
|
|
'status': status,
|
|
'txHash': tx_hash,
|
|
'amount': str(amount),
|
|
'currency': currency,
|
|
'postedAt': posted_at.isoformat() if posted_at else None,
|
|
'network': 'ETHEREUM_MAINNET',
|
|
'etherscan': f'https://etherscan.io/tx/{tx_hash}' if tx_hash else None,
|
|
}
|
|
payload['provenance'] = sign_response(payload)
|
|
return jsonify(payload)
|
|
except Exception as e:
|
|
return jsonify({'code':'ERROR','message':str(e)}), 500
|
|
|
|
@app.route('/v1/transactions/uetr/<uetr>')
|
|
def get_transaction_by_uetr(uetr):
|
|
try:
|
|
pg = get_pg()
|
|
with pg.cursor() as cur:
|
|
cur.execute("""
|
|
SELECT koreid, event_type, status, tx_hash, amount, currency, posted_at
|
|
FROM kore_outbound_events WHERE guid::text = %s
|
|
OR koreid LIKE %s
|
|
""", (uetr, f'%{uetr[-8:]}%'))
|
|
row = cur.fetchone()
|
|
pg.close()
|
|
if not row:
|
|
return jsonify({'code':'NOT_FOUND','message':'No record for UETR'}), 404
|
|
koreid, event_type, status, tx_hash, amount, currency, posted_at = row
|
|
payload = {
|
|
'uetr': uetr,
|
|
'koreId': koreid,
|
|
'eventType': event_type,
|
|
'status': status,
|
|
'txHash': tx_hash,
|
|
'amount': str(amount),
|
|
'currency': currency,
|
|
'postedAt': posted_at.isoformat() if posted_at else None,
|
|
}
|
|
payload['provenance'] = sign_response(payload)
|
|
return jsonify(payload)
|
|
except Exception as e:
|
|
return jsonify({'code':'ERROR','message':str(e)}), 500
|
|
|
|
@app.route('/v1/blocks/<int:block_height>')
|
|
def get_block(block_height):
|
|
try:
|
|
current = current_block()
|
|
confirmations = current - block_height
|
|
payload = {
|
|
'blockHeight': block_height,
|
|
'currentBlock': current,
|
|
'confirmations': max(0, confirmations),
|
|
'finalized': confirmations >= FINALITY_BLOCKS,
|
|
'network': 'ETHEREUM_MAINNET',
|
|
'timestamp': datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
return jsonify(payload)
|
|
except Exception as e:
|
|
return jsonify({'code':'ERROR','message':str(e)}), 500
|
|
|
|
@app.route('/v1/webhooks', methods=['POST'])
|
|
def register_webhook():
|
|
data = request.get_json()
|
|
if not data or not data.get('url') or not data.get('eventTypes'):
|
|
return jsonify({'code':'INVALID','message':'url and eventTypes required'}), 400
|
|
try:
|
|
pg = get_pg()
|
|
webhook_id = str(uuid.uuid4())
|
|
with pg.cursor() as cur:
|
|
cur.execute("""
|
|
INSERT INTO chain_webhooks (id, url, event_types, signing_key, max_retries, timeout_seconds)
|
|
VALUES (%s, %s, %s, %s, %s, %s)
|
|
""", (webhook_id, data['url'], data['eventTypes'],
|
|
data.get('signingKey', str(uuid.uuid4())),
|
|
data.get('maxRetries', 3), data.get('timeoutSeconds', 30)))
|
|
pg.commit()
|
|
pg.close()
|
|
return jsonify({'webhookId': webhook_id, 'status': 'REGISTERED'}), 201
|
|
except Exception as e:
|
|
return jsonify({'code':'ERROR','message':str(e)}), 500
|
|
|
|
@app.route('/v1/webhooks/<webhook_id>', methods=['DELETE'])
|
|
def delete_webhook(webhook_id):
|
|
try:
|
|
pg = get_pg()
|
|
with pg.cursor() as cur:
|
|
cur.execute("UPDATE chain_webhooks SET active=false WHERE id=%s", (webhook_id,))
|
|
pg.commit()
|
|
pg.close()
|
|
return '', 204
|
|
except Exception as e:
|
|
return jsonify({'code':'ERROR','message':str(e)}), 500
|
|
|
|
if __name__ == '__main__':
|
|
init_schema()
|
|
log.info("Chain API starting on port 8767")
|
|
app.run(host='0.0.0.0', port=8767)
|