Cancel and refunds
When an order refunds
Section titled “When an order refunds”Settlement pays the filled part to payoutAddr on the destination chain and refunds the rest of the deposit to refundAddr on the source chain. A refund happens when the deadline passes before the order fills completely, or when the user cancels a published order. When an order expires, Khalani settles it automatically and you don’t need to do anything.
The flat fee is kept in every case, including an order that never fills. On a partly filled order, the percentage fees apply to the filled part only. See Fees and commission.
Cancel early
Section titled “Cancel early”Once an order is published, an EOA depositor can end it early, including after a partial fill. The depositor signs an EIP-712 message in their wallet, and your backend relays it with POST /v2/orders/{orderId}/cancel. The filled part is paid out and the rest refunded.
Contract-wallet depositors cannot use this self-service path. The endpoint recovers an ECDSA signer and requires it to be the depositor or Khalani’s operator; it does not validate ERC-1271 signatures. A Safe owner’s signature does not match the depositing Safe. Wait for automatic settlement at expiry, or contact Khalani for help cancelling early.
- A
200is an acceptance, not settlement. The hub transaction and the transfers follow within minutes. Keep polling untildeliveredordelivery_failed, with a local timeout. - A cancel that races a fill is safe. Whichever lands first settles the order.
- Before
publishedthere is nothing to cancel, and the route returns409. An order the user never deposits stays atpending_deposit, and nothing needs refunding. - You can cancel only your own orders. Another integrator’s
orderIdreturns404.
Sign the cancel
Section titled “Sign the cancel”The signature comes from the depositor’s wallet, not your API key, so a cancel has two halves: your frontend asks the wallet to sign, and your backend relays the signature with its key. Build the typed data exactly as below. Any difference makes signature recovery fail.
// Cancel an order in two halves: the user's wallet signs in your frontend, and your backend relays the// signature with its API key. The API key never reaches the browser.import { bytesToHex, type Hex, type WalletClient } from "viem";
const API = "https://api.hyperstream.dev";
// Production constants for the cancel's EIP-712 domain.const HUB_CHAIN_ID = 4278608;const ORDER_DELEGATE_V2 = "0xa83d18aa3F32270762147e0E0F2CC3396A8f157A";
export type SignedCancel = { payload: { nonce: string; rootIntentId: Hex }; signature: Hex };
// Frontend: the depositor's wallet signs. rootIntentId is the order's traces.rootIntentId, set once// the order is published; GET /v2/orders/{orderId} is public, so the frontend can read it directly.export async function signCancel(wallet: WalletClient, rootIntentId: Hex): Promise<SignedCancel> { const account = wallet.account ?? (await wallet.getAddresses())[0]; if (!account) throw new Error("Connect the depositor's wallet first");
// A fresh random nonce on every attempt. It is not the build nonce, and a reused one is rejected. const nonce = BigInt(bytesToHex(crypto.getRandomValues(new Uint8Array(32))));
const signature = await wallet.signTypedData({ account, domain: { name: "Arcadia", version: "1", chainId: HUB_CHAIN_ID, verifyingContract: ORDER_DELEGATE_V2 }, types: { OrderDelegateV2Cancel: [ { name: "nonce", type: "uint256" }, { name: "rootIntentId", type: "bytes32" }, ], }, primaryType: "OrderDelegateV2Cancel", message: { nonce, rootIntentId }, }); return { payload: { nonce: nonce.toString(), rootIntentId }, signature };}
// Backend: relay the signed cancel with your API key.export async function relayCancel(orderId: Hex, cancel: SignedCancel, apiKey: string) { const res = await fetch(`${API}/v2/orders/${orderId}/cancel`, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": apiKey }, body: JSON.stringify(cancel), }); if (!res.ok) throw new Error(`${res.status}: ${((await res.json()) as { error?: string }).error}`); // Accepted, not settled: poll until delivered or delivery_failed, with a local timeout. return (await res.json()) as { medusaSubmissionId: string };}- Domain.
nameis"Arcadia"andversionis"1", exactly.chainIdandverifyingContractare the production hub chain and its order contract. - Nonce. A fresh random
uint256on every attempt, retries included. It is not the build nonce and is not tied to the order, and a reused value is rejected. - No expiry. The signature stays valid until its nonce is used, so don’t collect cancel signatures in advance.
Cancel errors
Section titled “Cancel errors”| Status | error |
What to do |
|---|---|---|
400 |
Payload rootIntentId does not match the order's published root intent |
Sign against the order’s traces.rootIntentId. |
404 |
Order not found |
Unknown orderId, or another integrator’s order. |
409 |
Order has not been published yet … |
Wait for published. |
502 |
The rejection message | Read the order and the rejection. If it has settled, keep tracking delivery. Fix a wrong signer or malformed signature before retrying; a fresh nonce cannot fix a signer mismatch. For a reused nonce or temporary failure, retry with a fresh nonce only while the order is still open. |