Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions examples/fireblocks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Environment (sandbox)

```bash
FIREBLOCKS_API_KEY=<API key id (UUID)>
FIREBLOCKS_SECRET_KEY_PATH=<path to your RSA private key .key/.pem>
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

Expand Down
32 changes: 22 additions & 10 deletions examples/fireblocks/sign_evm.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,52 @@

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",
)
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)


Expand Down
10 changes: 7 additions & 3 deletions examples/fireblocks/sign_evm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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}`);
}
Expand Down
Loading