diff --git a/docs.json b/docs.json index 7ef6717..d21debe 100644 --- a/docs.json +++ b/docs.json @@ -160,6 +160,14 @@ "references/api/fast-fill", "references/api/get-swap-sources", "references/api/execute", + { + "group": "Withdrawals", + "pages": [ + "references/api/attest-deposit", + "references/api/request-withdrawal", + "references/api/get-withdrawal-status" + ] + }, { "group": "Deprecated", "pages": [ @@ -259,6 +267,7 @@ "pages": [ "references/protocol/guides/for-solvers", "references/protocol/guides/for-apps", + "references/protocol/guides/withdrawals", "references/protocol/guides/third-party-oracle" ] }, diff --git a/references/api/attest-deposit.mdx b/references/api/attest-deposit.mdx new file mode 100644 index 0000000..40ed971 --- /dev/null +++ b/references/api/attest-deposit.mdx @@ -0,0 +1,11 @@ +--- +title: "Attest Deposit" +description: "Attest a deposit transaction so its funds become claimable via the withdrawal flow" +openapi: post /withdrawals/attest-deposit +--- + +[How do programmatic withdrawals work?](/references/protocol/guides/withdrawals) + + +Unlike the other withdrawal endpoints, `chainId` here is the numeric Relay chain id. These endpoints power [relay.link/withdraw](https://relay.link/withdraw) and are not yet a versioned API surface. + diff --git a/references/api/get-withdrawal-status.mdx b/references/api/get-withdrawal-status.mdx new file mode 100644 index 0000000..f351843 --- /dev/null +++ b/references/api/get-withdrawal-status.mdx @@ -0,0 +1,11 @@ +--- +title: "Get Withdrawal Status" +description: "Poll the status of a user-triggered withdrawal job" +openapi: get /withdrawals/status +--- + +[How do programmatic withdrawals work?](/references/protocol/guides/withdrawals) + + +Pass the `jobId` returned by [Request Withdrawal](/references/api/request-withdrawal) as `id`. On most chains a `ready` status includes a `transaction` the owner wallet must broadcast. Status entries are retained for 24 hours. + diff --git a/references/api/request-withdrawal.mdx b/references/api/request-withdrawal.mdx new file mode 100644 index 0000000..4813c0d --- /dev/null +++ b/references/api/request-withdrawal.mdx @@ -0,0 +1,11 @@ +--- +title: "Request Withdrawal" +description: "Prepare or execute a user-triggered withdrawal from the Relay Depository" +openapi: post /withdrawals/request +--- + +[How do programmatic withdrawals work?](/references/protocol/guides/withdrawals) + + +Call this endpoint twice per withdrawal: first without `signature` to prepare (returns the `nonce`, validated `amount`, and any `additionalData` to sign), then with the `nonce` and owner `signature` to execute (returns a `jobId`). `chainId` and `ownerChainId` are protocol chain slugs, not numeric ids. These endpoints power [relay.link/withdraw](https://relay.link/withdraw) and are not yet a versioned API surface. + diff --git a/references/protocol/guides/withdrawals.mdx b/references/protocol/guides/withdrawals.mdx new file mode 100644 index 0000000..8e85d1c --- /dev/null +++ b/references/protocol/guides/withdrawals.mdx @@ -0,0 +1,181 @@ +--- +title: "Programmatic Withdrawals" +description: "Reclaim depository funds programmatically using the public withdrawal endpoints" +sidebarTitle: "Withdrawals" +--- + +import BuildWithAI from "/snippets/BuildWithAI.mdx"; + +## Overview + +Sometimes a deposit can't be filled: it was flagged by screening, sent in the wrong currency or on the wrong chain, or the order failed after the deposit landed. Those funds sit in the [Depository](/references/protocol/components/depository) until the original depositor reclaims them. [relay.link/withdraw](https://relay.link/withdraw) is a thin frontend over three public API endpoints, and your app can drive the same flow directly. + +This is useful if you want to handle recovery inside your own app instead of sending users to the self-serve UI, or run it programmatically on a server. It works especially well when you control the depositing wallet and can complete the signing flow yourself. + + +These endpoints power the Relay withdrawal UI. They are stable enough to build against, but they are not yet a versioned API surface, so request and response shapes may change without a deprecation cycle. + + +There is no API key. Authorization is cryptographic: only the wallet that made the deposit (the **`owner`**) can produce a valid signature, so anyone can call these endpoints but only the depositor can move funds. The base URL is `https://api.relay.link`. + +| Endpoint | Purpose | +| --- | --- | +| [`POST /withdrawals/attest-deposit`](/references/api/attest-deposit) | Claim a stuck or flagged deposit so its balance becomes withdrawable | +| [`POST /withdrawals/request`](/references/api/request-withdrawal) | Prepare a withdrawal (no signature), then execute it (with signature) | +| [`GET /withdrawals/status`](/references/api/get-withdrawal-status) | Poll the withdrawal job to completion | + +## Supported Chains and Signers + +User-triggered withdrawals are supported on EVM chains, Hyperliquid, Solana, Tron, and TON. Bitcoin and Lighter withdrawals are rejected with a `400`. Recovering those funds currently goes through [support](/resources/support). + +The **`owner`** must be the exact depositor address, and it must be able to sign an arbitrary message with its own key. Smart contract wallets, exchange-custodied addresses, and other signers that can't produce a raw message signature can't complete this flow, since there is no contract-signature (ERC-1271) path today. If the depositor key is inaccessible, contact [support](/resources/support). + +## Withdrawal Flow + +### Step 1: Check Eligibility + +Look up the request with the [requests API](/references/api/get-requests): + +```bash +curl "https://api.relay.link/requests/v3?id={requestId}" +``` + +To enumerate candidates for a wallet instead of checking a single request, query `GET /requests/v3?user={wallet}&status=failure` and filter on the protocol object. + +In each returned request, the `protocol` object tells you what you need: + +- **`protocol.isWithdrawable`** — `true` means the deposit is recoverable via this flow +- **`protocol.deposit.origin.depositor`** — the wallet that must sign (the `owner` below) +- **`protocol.deposit.origin`** — also carries the deposit's `chainId`, `currency`, `amount`, and `transactionId` for the next steps + +### Step 2: Attest the Deposit + +If the deposit hasn't been claimed onto the protocol hub yet, attest it: + +```bash +curl -X POST "https://api.relay.link/withdrawals/attest-deposit" \ + -H "Content-Type: application/json" \ + -d '{ "chainId": 8453, "transactionId": "0xDEPOSIT_TX_HASH" }' +``` + +Note that `chainId` here is the numeric Relay chain id of the deposit chain. This is the one exception in the flow — every other `chainId` is a protocol slug (see the next step). A successful attestation returns `{ "success": true }`. + +The call is idempotent and safe to retry. A `503` (for example "Transaction not yet finalized" or "Recovery in progress") means wait and retry. A `400` is terminal for that transaction. + +### Step 3: Prepare + +Call the request endpoint without a signature to get signing parameters: + +```bash +curl -X POST "https://api.relay.link/withdrawals/request" \ + -H "Content-Type: application/json" \ + -d '{ + "chainId": "base", + "currency": "0x0000000000000000000000000000000000000000", + "amount": "211891421", + "ownerChainId": "base", + "owner": "0xDEPOSITOR_ADDRESS", + "recipient": "0xRECIPIENT_ADDRESS" + }' +``` + +- **`chainId`** / **`ownerChainId`** — protocol chain slugs (`base`, `bnb`, `solana`), not numeric ids. Read them from `chains[].protocol.v2.chainId` in the [chains API](/references/api/get-chains). +- **`currency`** — the token address on the withdrawal chain, or the zero address for the native token +- **`amount`** — raw base units as an integer string +- **`recipient`** — where the funds go. May differ from `owner`. + +The response looks like `{ "nonce": "0x…", "amount": "211891421", "additionalData": { … } }`. Use the returned `amount` in every following step — it's validated against the available hub balance, and requesting more than is available returns a `400`. `additionalData` may be absent; when present, pass it through untouched. + +The `nonce` is deterministic per one-minute window for a given (chain, owner, currency, recipient) tuple and expires quickly, so prepare, sign, and execute in one sitting. If the job later reports `expired`, restart from this step. + +### Step 4: Sign the Digest + +Build a SHA-256 digest over the stable-stringified request and sign it with the owner wallet: + +```typescript +import stringify from "json-stable-stringify"; +import { sha256, toHex } from "viem"; + +const digest = sha256( + toHex( + stringify({ + operation: "withdrawal", + chainId, // protocol slug, as sent in the prepare step + currency, + amount, // the amount RETURNED by prepare + ownerChainId, + owner, + recipient, + nonce, // returned by prepare + additionalData, // exactly as returned by prepare; omit if absent + })! + ) +).slice(2); // hex digest, no 0x prefix +``` + +The digest includes `operation: "withdrawal"`, the `nonce`, and `additionalData` exactly as prepare returned them. Key order doesn't matter (`json-stable-stringify` sorts keys), but changing any value — including signing your original `amount` instead of the returned one — produces an invalid signature. + +Sign the digest with the owner key and submit the signature as 0x-prefixed hex: + +| Chain family | How to sign | +| --- | --- | +| EVM / Hyperliquid | `personal_sign` over the raw digest bytes — `walletClient.signMessage({ message: { raw: '0x' + digest } })` | +| Solana | `signMessage(digest)` (the digest as a UTF-8 string), then hex-encode the ed25519 signature | +| Tron | `tronWeb.trx.signMessageV2(digest)` | +| TON | TonConnect `signData({ type: "text", text: digest })`, then hex-encode the signature | + +On TON, the wallet's `signData` response also includes a `timestamp` and `domain`. Submit them in the execute call as `additionalData["ton-vm"] = { timestamp, domain }`. They are needed for verification but are not part of the signed digest above. + +### Step 5: Execute + +Repeat the same call with the `nonce`, `additionalData`, and `signature` added: + +```bash +curl -X POST "https://api.relay.link/withdrawals/request" \ + -H "Content-Type: application/json" \ + -d '{ + "chainId": "base", + "currency": "0x0000000000000000000000000000000000000000", + "amount": "211891421", + "ownerChainId": "base", + "owner": "0xDEPOSITOR_ADDRESS", + "recipient": "0xRECIPIENT_ADDRESS", + "nonce": "0xNONCE_FROM_PREPARE", + "additionalData": {}, + "signature": "0xOWNER_SIGNATURE" + }' +``` + +Success returns `{ "jobId": "…", "status": "processing" }`. Only one withdrawal per (chain, owner, currency) balance can be in flight at a time — a second request returns a `409` with code `WITHDRAWAL_IN_PROGRESS` and the `existingJobId` to poll instead. + +### Step 6: Poll for Status + +```bash +curl "https://api.relay.link/withdrawals/status?id={jobId}" +``` + +| Status | What to do | +| --- | --- | +| `processing`, `initiating`, `attesting` | Keep polling (~5s; back off to ~15s on a `429`) | +| `ready` | The response includes a `transaction` — broadcast it from the owner wallet (next step), then keep polling | +| `executed` | Done — the funds are with the recipient | +| `expired` | The nonce or job expired and the funds returned to the hub balance. Restart from the prepare step. | +| `failed` | See `reason`, then restart from the prepare step | + +Status entries are retained for 24 hours. An unknown or evicted id reports `processing`, so don't poll ids older than a day expecting a terminal state. + +### Step 7: Broadcast the Transaction + +`ready` is not done. On TON the solver broadcasts for you and the status moves to `executed` on its own, but on every other supported chain, `ready` hands you a `transaction` object that the owner wallet must sign and broadcast on-chain, paying its own gas. Depending on the chain this is an EVM transaction request, Solana instructions, or a Tron `TriggerSmartContract` payload. + +A job stuck at `ready` because the transaction was never broadcast is the most common integration mistake. After broadcasting, keep polling until the status reaches `executed`. If the owner is on a different VM than the withdrawal chain, the returned transaction is built for the `recipient` to broadcast instead. + +## Caveats + +- `chainId` is a protocol slug (`base`, not `8453`) everywhere except `attest-deposit`, which takes the numeric id. +- Sign and execute with the `amount` that prepare returned, not your original input. +- `ready` is not done — broadcast the returned transaction and keep polling. +- Move quickly between prepare, sign, and execute. The nonce is short-lived; on `expired`, re-prepare. +- One withdrawal at a time per (chain, owner, currency). A second in-flight request returns a `409` with the existing `jobId`, and the balance unlocks when the job reaches a terminal state. + + diff --git a/snippets/BuildWithAI.mdx b/snippets/BuildWithAI.mdx new file mode 100644 index 0000000..1d445ae --- /dev/null +++ b/snippets/BuildWithAI.mdx @@ -0,0 +1,6 @@ + + Building with an AI assistant? Use the **Copy page** button at the top of + this page to hand it to your agent, or see [Integrating using + AI](/resources/developing-with-ai) to connect the Relay docs MCP server and + llms.txt. +