Skip to content
API v1 is deprecated. It stays supported, but new integrations should use the Khalani API. See Migrate to v2.
API v1 · Deprecated

Wallet execution (v1)

POST /v1/deposit/build returns a deposit plan. Switch on its kind:

  • CONTRACT_CALL: the user’s wallet runs the actions in approvals[], in order. You submit the hash of the action marked deposit: true. This page covers EVM and Solana wallets.
  • TRANSFER: the user sends a plain transfer to a deposit address. See Deposit addresses.
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.

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.

v1-wallet-evm.ts
// 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_sendTransaction carries value and often gas. Dropping value makes 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 from GET /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.

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.

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 returns 400. Track the order with GET /v1/orders/{refundTo}?orderIds={orderId}, where the address is the order’s refund address (refundTo, or else recipient, or else from). GET /v1/orders/by-id/{orderId} does not serve these 32-character ids.
  • With submitTxHashRequested: true (a TRON or Bitcoin source on HyperstreamUTXO): after the transfer, call PUT /v1/deposit/submit with its hash as txHash, written as 64 hex characters without 0x. The response carries the orderId to track.

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.