diff --git a/examples/fireblocks/README.md b/examples/fireblocks/README.md index e8d9849..dbee457 100644 --- a/examples/fireblocks/README.md +++ b/examples/fireblocks/README.md @@ -4,8 +4,29 @@ The Hashlock developer API is **custody-agnostic**: `POST /v1/swaps/:id/legs/:le returns *unsigned* transactions, and you sign them with your own key material. This directory shows how to sign them with an institutional custodian (Fireblocks; Copper follows the same shape). -> ⚠️ Reference only — documented pattern, not a live-tested integration. Fill in your vault / account IDs -> and asset IDs, and validate against a testnet vault before production. +> ✅ **EVM `CONTRACT_CALL` validated live** on Fireblocks Sandbox (Sepolia / `ETH_TEST5`): the unsigned +> `approve`/`createSwap` from `/v1` signs, broadcasts, and lands on-chain. TRON and Bitcoin paths remain +> documented references (Fireblocks Sandbox has Sepolia + BTC signet, but **not TRON Nile**). Fill in your +> own vault id + asset ids before running. + +## Environment (sandbox) + +```bash +FIREBLOCKS_API_KEY= +FIREBLOCKS_SECRET_KEY_PATH= +FIREBLOCKS_VAULT_ACCOUNT_ID=0 # numeric string (default sandbox vault is "0") +FIREBLOCKS_BASE_URL=https://sandbox-api.fireblocks.io # prod: https://api.fireblocks.io +``` + +Fund the vault from **outside** Fireblocks: add the `ETH_TEST5` asset to the vault, copy its **receive +address**, and send testnet ETH (gas) + your ERC-20 to it from a faucet or another wallet. A `CONTRACT_CALL` +does **not** require the ERC-20 to be a Fireblocks-recognised asset — only the on-chain balance at the vault +address plus native `ETH_TEST5` for gas. + +> **Python SDK gotcha:** `fireblocks-sdk` (Python) uses top-level string constants — `VAULT_ACCOUNT`, +> `ONE_TIME_ADDRESS`, `CONTRACT_CALL` — and `create_transaction(tx_type=…)` (not `operation=`). The +> **JavaScript** `fireblocks-sdk` uses the enums `PeerType`, `TransactionOperation`, `TransactionStatus` +> and `createTransaction({ operation, … })`. Both `sign_evm.py` / `sign_evm.ts` reflect their SDK's shape. ## Two signing models diff --git a/examples/fireblocks/sign_evm.py b/examples/fireblocks/sign_evm.py index 8fc958b..fee7989 100644 --- a/examples/fireblocks/sign_evm.py +++ b/examples/fireblocks/sign_evm.py @@ -4,29 +4,37 @@ pip install hashlock-sdk fireblocks-sdk -Not live-tested — set your vault account id + asset id and validate on a testnet vault first. +Validated live on Fireblocks Sandbox (Sepolia / ETH_TEST5) — the CONTRACT_CALL signs, broadcasts, and the +approve/createSwap lands on-chain. NOTE the Python SDK's shape: peer-type/operation are top-level string +constants (VAULT_ACCOUNT / ONE_TIME_ADDRESS / CONTRACT_CALL), and create_transaction takes `tx_type=` +(not `operation=`). The base URL for sandbox is https://sandbox-api.fireblocks.io (prod: api.fireblocks.io); +the vault account id is a small integer string (e.g. "0"). """ import os import time -from fireblocks_sdk import FireblocksSDK, TransferPeerPath, DestinationTransferPeerPath, PeerType +from fireblocks_sdk import CONTRACT_CALL, ONE_TIME_ADDRESS, VAULT_ACCOUNT, DestinationTransferPeerPath, FireblocksSDK, TransferPeerPath from hashlock import HashlockClient client = HashlockClient(api_key=os.environ["HASHLOCK_API_KEY"]) -fireblocks = FireblocksSDK(os.environ["FIREBLOCKS_API_SECRET"], os.environ["FIREBLOCKS_API_KEY"]) +fireblocks = FireblocksSDK( + private_key=open(os.environ["FIREBLOCKS_SECRET_KEY_PATH"]).read(), + api_key=os.environ["FIREBLOCKS_API_KEY"], + api_base_url=os.environ.get("FIREBLOCKS_BASE_URL", "https://sandbox-api.fireblocks.io"), +) -VAULT_ACCOUNT_ID = os.environ["FIREBLOCKS_VAULT_ID"] +VAULT_ACCOUNT_ID = os.environ["FIREBLOCKS_VAULT_ACCOUNT_ID"] # numeric string, e.g. "0" ASSET_ID = "ETH_TEST5" # Fireblocks asset id for the target chain (Sepolia here) def sign_via_fireblocks(tx: dict) -> str: """Submit one unsigned EVM tx as a Fireblocks contract call; return the on-chain hash.""" resp = fireblocks.create_transaction( - operation="CONTRACT_CALL", + tx_type=CONTRACT_CALL, asset_id=ASSET_ID, - source=TransferPeerPath(PeerType.VAULT_ACCOUNT, VAULT_ACCOUNT_ID), - destination=DestinationTransferPeerPath(PeerType.ONE_TIME_ADDRESS, one_time_address={"address": tx["to"]}), + source=TransferPeerPath(VAULT_ACCOUNT, VAULT_ACCOUNT_ID), + destination=DestinationTransferPeerPath(ONE_TIME_ADDRESS, one_time_address={"address": tx["to"]}), amount=str(int(tx.get("value") or "0")), # wei; "0" for approve / createSwap extra_parameters={"contractCallData": tx["data"]}, note="Hashlock HTLC settlement", @@ -34,10 +42,14 @@ def sign_via_fireblocks(tx: dict) -> str: tx_id = resp["id"] while True: t = fireblocks.get_transaction_by_id(tx_id) - if t.get("txHash"): + status = t.get("status") + # Return only once the tx is mined (CONFIRMING) or fully confirmed (COMPLETED) — NOT on the mere + # presence of a txHash — so the dependent createSwap isn't submitted before this approve lands + # (createSwap's transferFrom needs the approve's allowance on-chain). + if status in ("CONFIRMING", "COMPLETED") and t.get("txHash"): return t["txHash"] - if t["status"] in ("FAILED", "BLOCKED", "CANCELLED", "REJECTED"): - raise RuntimeError(f"Fireblocks tx {tx_id} {t['status']}: {t.get('subStatus')}") + if status in ("FAILED", "BLOCKED", "CANCELLED", "REJECTED"): + raise RuntimeError(f"Fireblocks tx {tx_id} {status}: {t.get('subStatus')}") time.sleep(3) diff --git a/examples/fireblocks/sign_evm.ts b/examples/fireblocks/sign_evm.ts index a91a10e..7a08c17 100644 --- a/examples/fireblocks/sign_evm.ts +++ b/examples/fireblocks/sign_evm.ts @@ -4,7 +4,9 @@ * * npm i @hashlock-tech/sdk fireblocks-sdk * - * Not live-tested — set your vault account id + asset id and validate on a testnet vault first. + * The CONTRACT_CALL flow is validated live on Fireblocks Sandbox (Sepolia); the `PeerType` / + * `TransactionOperation` / `TransactionStatus` enums below match the current `fireblocks-sdk`. Set your + * own vault account id + asset id. (Python uses different symbols — see sign_evm.py / the README.) */ import { HashlockClient, type EvmBuild } from '@hashlock-tech/sdk'; import { FireblocksSDK, PeerType, TransactionOperation, TransactionStatus } from 'fireblocks-sdk'; @@ -27,10 +29,12 @@ async function signViaFireblocks(tx: { to: string; data: string; value?: string note: 'Hashlock HTLC settlement', }); - // Poll to completion. Fireblocks broadcasts; the tx hash appears on SUBMITTED/COMPLETED. + // Return only once the tx is mined (CONFIRMING) or fully confirmed (COMPLETED) — NOT on the mere + // presence of a txHash — so the dependent createSwap isn't submitted before this approve lands + // (createSwap's transferFrom needs the approve's allowance on-chain). for (;;) { const t = await fireblocks.getTransactionById(id); - if (t.txHash) return t.txHash; + if ((t.status === TransactionStatus.CONFIRMING || t.status === TransactionStatus.COMPLETED) && t.txHash) return t.txHash; if ([TransactionStatus.FAILED, TransactionStatus.BLOCKED, TransactionStatus.CANCELLED, TransactionStatus.REJECTED].includes(t.status)) { throw new Error(`Fireblocks tx ${id} ${t.status}: ${t.subStatus}`); }