Wallet execution (v1)
POST /v1/deposit/build returns a deposit plan. Switch on its kind:
CONTRACT_CALL: the user’s wallet runs the actions inapprovals[], in order. You submit the hash of the action markeddeposit: true. This page covers EVM and Solana wallets.TRANSFER: the user sends a plain transfer to a deposit address. See Deposit addresses.
What a wallet action holds
Section titled “What a wallet action holds”| Field | On | Meaning |
|---|---|---|
type |
Every action | eip1193_request (EVM) or solana_sendTransaction (Solana). |
request |
EVM | An EIP-1193 request: wallet_switchEthereumChain or eth_sendTransaction. Forward it unchanged. |
transaction |
Solana | A base64-encoded transaction to sign and send. |
waitForReceipt |
EVM, optional | true on approvals. Wait for the receipt before the next action. |
deposit |
Optional | true on the one action that is the deposit. Submit its hash. |
A chain-switch action has neither waitForReceipt nor deposit. Treat a missing flag as false.
EVM: forward EIP-1193 requests
Section titled “EVM: forward EIP-1193 requests”An EVM plan is a list of EIP-1193 requests, so any EIP-1193 wallet can run it. Pass each request to provider.request as it is. A typical native-route plan is a chain switch, an approval when the allowance is short, and the deposit.
// API v1 (deprecated): run the EVM wallet actions from POST /v1/deposit/build with any EIP-1193// wallet, then submit the deposit hash. Each request is forwarded to the wallet unchanged.import { type Hex, isHex } from "viem";
const API = "https://api.hyperstream.dev";
export type Eip1193Provider = { request(args: { method: string; params?: readonly unknown[] }): Promise<unknown>;};
/** The quote and route the user chose, and the wallet that sends the deposit. */export type Selection = { quoteId: string; routeId: string; from: string };
type EvmAction = { type: "eip1193_request"; request: { method: string; params?: readonly unknown[] }; waitForReceipt?: boolean; deposit?: boolean;};type SolanaAction = { type: "solana_sendTransaction"; transaction: string; deposit?: boolean };type DepositPlan = | { kind: "CONTRACT_CALL"; approvals?: Array<EvmAction | SolanaAction>; expiresAt?: number; buildId?: string } | { kind: "TRANSFER"; depositAddress: string; amount: string; token: string; chainId: number } | { kind: "PERMIT2" };type Receipt = { status: Hex };
/** Builds, executes and submits one EVM deposit. Returns the v1 order id. */export async function depositWithEvmWallet(provider: Eip1193Provider, selection: Selection): Promise<string> { const plan = await call<DepositPlan>("POST", "/v1/deposit/build", selection); if (plan.kind !== "CONTRACT_CALL") throw new Error(`Not a wallet deposit: ${plan.kind}`);
let depositTxHash: Hex | undefined; for (const action of plan.approvals ?? []) { if (action.type !== "eip1193_request") throw new Error("A Solana action in an EVM plan"); // wallet_switchEthereumChain resolves to null; eth_sendTransaction to the transaction hash. const result = await provider.request(action.request); if (action.request.method !== "eth_sendTransaction") continue; if (typeof result !== "string" || !isHex(result)) throw new Error("The wallet returned no transaction hash"); if (action.waitForReceipt) await waitForReceipt(provider, result); if (action.deposit) depositTxHash = result; } if (!depositTxHash) throw new Error("The plan had no deposit action");
// Submit once. buildId is present only for a quote made without fromAddress. const submitted = await call<{ orderId: string; txHash: string }>("PUT", "/v1/deposit/submit", { txHash: depositTxHash, quoteId: selection.quoteId, routeId: selection.routeId, ...(plan.buildId ? { buildId: plan.buildId } : {}), }); return submitted.orderId;}
async function waitForReceipt(provider: Eip1193Provider, hash: Hex): Promise<void> { for (;;) { const receipt = (await provider.request({ method: "eth_getTransactionReceipt", params: [hash] })) as Receipt | null; if (receipt) { if (receipt.status !== "0x1") throw new Error(`Transaction ${hash} reverted`); return; } await new Promise((resolve) => setTimeout(resolve, 2_000)); }}
async function call<T>(method: "POST" | "PUT", path: string, body: unknown): Promise<T> { const res = await fetch(`${API}${path}`, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const text = await res.text(); if (!res.ok) throw new Error(`${res.status} ${path}: ${text} (x-request-id ${res.headers.get("x-request-id")})`); return JSON.parse(text) as T;}- Forward every field. The deposit’s
eth_sendTransactioncarriesvalueand oftengas. Droppingvaluemakes the deposit revert, because it pays the relay fee. - Wait where the plan says so. An approval has
waitForReceipt: true. Sending the deposit before the approval confirms makes it revert. - A chain switch can fail when the wallet does not know the chain. Ask the user to add it with
wallet_addEthereumChain, using the RPC and explorer fromGET /v1/chains, then run the plan again. - Show a stepper. Tell the user how many signatures to expect (approve, then deposit), so no prompt arrives without context.
Solana: sign base64 transactions
Section titled “Solana: sign base64 transactions”A Solana plan holds base64-encoded transactions. The example below targets an injected Solana wallet provider, such as the one a browser wallet extension exposes, whose signAndSendTransaction resolves to { signature }. It uses @solana/web3.js, which you install yourself; it is not type-checked with these docs.
import { VersionedTransaction } from "@solana/web3.js";
const API = "https://api.hyperstream.dev";
// An injected Solana wallet provider, connected to the account in `from`.type SolanaProvider = { signAndSendTransaction(tx: VersionedTransaction): Promise<{ signature: string }>;};type SolanaAction = { type: "solana_sendTransaction"; transaction: string; deposit?: boolean };type DepositPlan = | { kind: "CONTRACT_CALL"; approvals?: SolanaAction[]; buildId?: string } | { kind: "TRANSFER" | "PERMIT2" };
export async function depositWithSolanaWallet( provider: SolanaProvider, selection: { quoteId: string; routeId: string; from: string },): Promise<string> { const plan = await call<DepositPlan>("POST", "/v1/deposit/build", selection); if (plan.kind !== "CONTRACT_CALL") throw new Error(`Not a wallet deposit: ${plan.kind}`);
let depositSignature: string | undefined; for (const action of plan.approvals ?? []) { if (action.type !== "solana_sendTransaction") throw new Error("Expected Solana actions"); // Handles legacy and v0 transactions. The build refreshed the blockhash: send right away. const tx = VersionedTransaction.deserialize(base64ToBytes(action.transaction)); const { signature } = await provider.signAndSendTransaction(tx); if (action.deposit) depositSignature = signature; } if (!depositSignature) throw new Error("The plan had no deposit action");
const submitted = await call<{ orderId: string }>("PUT", "/v1/deposit/submit", { txHash: depositSignature, quoteId: selection.quoteId, routeId: selection.routeId, ...(plan.buildId ? { buildId: plan.buildId } : {}), }); return submitted.orderId;}
function base64ToBytes(base64: string): Uint8Array { return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));}
async function call<T>(method: "POST" | "PUT", path: string, body: unknown): Promise<T> { const res = await fetch(`${API}${path}`, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const text = await res.text(); if (!res.ok) throw new Error(`${res.status} ${path}: ${text}`); return JSON.parse(text) as T;}- Sign right after the build. The API puts a fresh blockhash into Solana transactions when it builds them, and a blockhash expires after roughly a minute. A plan that sat too long fails to send. Build again.
- Submit the signature as
txHash. A Solana signature is base58, 87 or 88 characters.
Deposit addresses
Section titled “Deposit addresses”A TRANSFER plan has no wallet actions. It names an address and an amount:
{ "kind": "TRANSFER", "orderId": "9f2c4e1a7b3d5f60812a4c6e8b0d2f41", "depositAddress": "0x7d2c…e41a", "amount": "10000000", "token": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", "chainId": 42161, "expiresAt": 1792443600}Send exactly amount of token to depositAddress on chainId, before expiresAt. Any wallet can send it. Then:
- Without
submitTxHashRequested(the native route on an EVM source): do not call submit. The order already exists, and the deposit address detects the transfer itself. For a quote made without a wallet, submit returns400. Track the order withGET /v1/orders/{refundTo}?orderIds={orderId}, where the address is the order’s refund address (refundTo, or elserecipient, or elsefrom).GET /v1/orders/by-id/{orderId}does not serve these 32-character ids. - With
submitTxHashRequested: true(a TRON or Bitcoin source onHyperstreamUTXO): after the transfer, callPUT /v1/deposit/submitwith its hash astxHash, written as 64 hex characters without0x. The response carries theorderIdto track.
Server-side broadcast
Section titled “Server-side broadcast”If the user’s wallet can sign but not broadcast, send the signed transaction instead of a hash, and the API broadcasts it:
{ "signedTransaction": "0x02f8…", "quoteId": "3f6c2a1e-8b4d-4e7a-9c15-2d0b7e9a4f61", "routeId": "Hyperstream" }Use 0x-prefixed hex for EVM and base64 for Solana. Send either txHash or signedTransaction, never both. A broadcast the chain rejects returns 400 BroadcastException. Check the balance and nonce, then sign again and resubmit. See Deposit for every field.