# drip.tools developer docs — full text > Drip Tools (https://drip.tools) swaps real mainnet tokens for testnet funds — one way only. A swap is four steps against the public REST API at https://drip.tools/api (JSON over HTTPS, no auth): discover chains with GET /api/chains, get a backend-signed quote from POST /api/quote, make one payment on the FROM chain that carries the quoteId (a call to the depository contract on an EVM mainnet, or a transfer with the quoteId as memo / comment on Solana and TON), then track one order per recipient with GET /api/order until each is completed. Every page of https://docs.drip.tools, in reading order. Index: https://docs.drip.tools/llms.txt --- # Drip Tools https://docs.drip.tools/ **Swap mainnet tokens for testnet funds.** Move real assets into any testnet — from the web app, or fully programmatically via the API. - [Get Started](https://docs.drip.tools/guide/introduction.md) - [Quick Start](https://docs.drip.tools/guide/quickstart.md) - [Open the app](https://drip.tools) ## Features - **Swap programmatically** — Discover chains, request a signed quote, pay on-chain, and track delivery — all over a simple REST API. No browser needed. - **Pay from EVM, Solana or TON** — Pay with ETH, BNB, POL, AVAX, MON, HYPE, SOL, Toncoin or a stablecoin on ten EVM mainnets, Solana and TON. One payment per swap, however many recipients. - **One payment, many recipients** — Fund a single wallet or fan out to thousands in one transaction. Recipients and amounts live in a backend-signed quote. - **Any testnet** — Deliver test ETH, MON, GRAM, SOL, BTC and more across EVM, TON, Solana, and Bitcoin testnets. - **Built for agents** — llms.txt, an OpenAPI 3.1 spec, every page as markdown and runnable examples — an LLM agent can read and use the API without a browser. ([For agents](https://docs.drip.tools/guide/agents.md)) ## Built for developers & airdrop hunters Drip Tools bridges the gap between production and sandbox environments. Whether you're funding QA wallets, testing decentralized apps, or topping up faucets that have run dry, you can move real mainnet assets into a testnet in seconds — and script the whole thing. Start with the **[Introduction](https://docs.drip.tools/guide/introduction.md)** for how the flow works, jump straight to the **[Quick Start](https://docs.drip.tools/guide/quickstart.md)** for a runnable script, or browse the **[API reference](https://docs.drip.tools/api/discovery.md)**. --- # At a glance https://docs.drip.tools/guide/cheatsheet Swap real **mainnet** tokens for **testnet** funds in four API calls. Base URL `https://drip.tools/api`, JSON over HTTPS, no auth, open CORS. Pay from ten EVM mainnets, Solana or TON; receive on 30+ EVM, Solana, TON and Bitcoin testnets. ## The flow | # | Step | Call | You get | | --- | --- | --- | --- | | 1 | **Discover** | `GET /api/chains?unwrap=true` | FROM chains (`isTestnet: false`) with their **pay target** `contracts.depository`; TO chains (`isTestnet: true`; skip `isEnabled: false`) and tokens. | | 2 | **Quote** | `POST /api/quote` `{ chainId, fromToken, sender, toChainId, toToken, recipients[], toAmounts[] }` | `quoteId`, `vault` (= pay target), `fromTotal`, `deadline`, `signature`. Free, non-binding, valid 180 s. | | 3 | **Pay** | one transaction on the FROM chain, **exactly `fromTotal`**, before `deadline` | EVM: `pay(quoteId, deadline)` on `vault` with `msg.value = fromTotal` (or `payWithToken` / `payWithTokenPermit`). Solana: transfer to `vault` + SPL memo = `quoteId`. TON: non-bounceable transfer to `vault` + comment = `quoteId`. | | 4 | **Track** | `GET /api/order?from_tx_hash=` | one order per recipient; `completed` once `to.txHash` is set. `ref` = EVM tx hash, Solana signature, or the `quoteId` on TON. | Ready-to-run scripts for every variant, in JavaScript and Python: [Examples](https://docs.drip.tools/swap/examples.md). ## Endpoints | Endpoint | Purpose | | --- | --- | | [`GET /api/chains`](https://docs.drip.tools/api/discovery.md#get-api-chains) | Chains, tokens, pay targets. | | [`GET /api/quotes`](https://docs.drip.tools/api/discovery.md#get-api-quotes-rates-liquidity) | TO-token rates and dispensable liquidity (not the quote endpoint). | | [`GET /api/prices`](https://docs.drip.tools/api/prices.md) · [`/prices/at`](https://docs.drip.tools/api/prices.md#get-api-prices-at) | Live and historical FROM-token prices. | | [`POST /api/quote`](https://docs.drip.tools/api/quote.md) | Price and sign a swap. | | [`GET /api/order`](https://docs.drip.tools/api/orders.md#get-api-order) | Orders of one payment (`from_tx_hash`) or one order (`id`); `&paginate=1` for big batches. | | [`GET /api/orders`](https://docs.drip.tools/api/orders.md#get-api-orders) | A payer's history, `view=batches` for one row per payment. | | [`POST /api/orders/status`](https://docs.drip.tools/api/orders.md#post-api-orders-status) | Bulk status by order id (≤ 500). | | [`GET /api/orders/load`](https://docs.drip.tools/api/orders.md#get-api-orders-load) | Queue depth and your position (ETA). | | [`GET /api/orders/stream`](https://docs.drip.tools/api/orders.md#get-api-orders-stream) | Server-Sent Events when your orders change. | ## Numbers to remember | What | Value | | --- | --- | | Minimum order | **$1** single recipient · **$5** multisend | | TO amount precision | 4 decimal places (whole sats on Bitcoin); ≥ 0.001 SOL per recipient on Solana | | Quote lifetime | **180 s** (`deadline` = `expiry`) | | Recipients per quote | up to **10 000**, one payment | | Rate limits | 600 requests / min per IP · 10 quotes / min per IP · 30 quotes / min per `sender` | | Amounts | base-unit integer strings (`toAmounts`, `fromTotal`); order history amounts are human decimals | | Native coin | token address `0x0000000000000000000000000000000000000000` on every chain | | Non-EVM FROM ids | Solana `1399811149` · TON `1667471768` (Solana testnet `1399811150`, devnet `1399811151`, TON testnet `1667471769`) | | Price | TO amount × listed rate, converted at the live Chainlink price; no extra fee. You pay the FROM chain's gas. | ## Payment rules Same payer as `sender` · same token as `fromToken` · **exactly** `fromTotal` · before `deadline` · one `quoteId` per payment. Anything else does not become an order — see [Payment rules](https://docs.drip.tools/reference/lifecycle.md#payment-rules). ## When something goes wrong | You see | Meaning | Do | | --- | --- | --- | | `400 BELOW_MIN_TOTAL` | order under $1 / $5 | raise `toAmounts` | | `400 NO_LIQUIDITY` / `503 NO_TO_PRICE` | that TO token can't be dispensed now | check [`GET /api/quotes`](https://docs.drip.tools/api/discovery.md#get-api-quotes-rates-liquidity), try later or another token | | `429 RATE_LIMITED` | too many requests / quotes | sleep `Retry-After` seconds | | `503 WARMING_UP` | backend restarting | retry in `retryAfterSec` | | revert `Expired` (EVM) | paid after `deadline` | request a new quote | | order `failed` | payout failed for that recipient | the FROM slice is refunded — see the `refund` field of the `GET /api/order` response | | no order after paying | paid after `deadline`, or no usable `quoteId` in the memo / comment | a refund is recorded — `refund` on `GET /api/order`, or your history | | no order, no refund | wrong payer / token / amount, or the same quote paid twice | [Errors → held for review](https://docs.drip.tools/reference/errors.md#payments-held-for-review) | Every code with its fix: [Errors](https://docs.drip.tools/reference/errors.md). --- # Introduction https://docs.drip.tools/guide/introduction **Drip Tools** lets you swap real **mainnet** tokens for **testnet** tokens. You pay a small amount of a live asset (ETH, USDC, USDT, SOL, TON, …) on a mainnet chain, and one or more recipients receive test tokens on the testnet of your choice — for funding QA wallets, airdrop farming, or filling faucets that have run dry. This section is for developers who want to run swaps **programmatically** — from a script, a backend, or a bot — without opening the web app. In a hurry? [At a glance](https://docs.drip.tools/guide/cheatsheet.md) fits on one screen; the [Quick Start](https://docs.drip.tools/guide/quickstart.md) is one runnable script. ## How a swap works Drip Tools uses an **off-chain order** built around a **backend-signed quote**. You never encode recipients or amounts on-chain: the whole order lives in the quote, and your wallet makes a single payment that references it. *Diagram: Your script requests a quote from the API, pays once on the FROM chain, and the backend delivers to every recipient on the testnet.* In four steps: 1. **Discover** — [`GET /api/chains`](https://docs.drip.tools/api/discovery.md) lists the FROM (mainnet) chains, the TO (testnet) chains, their tokens, and each FROM chain's **pay target** (`contracts.depository`). 2. **Quote** — [`POST /api/quote`](https://docs.drip.tools/api/quote.md) with the recipients and the exact TO amounts you want each to receive. You get a `quoteId`, the total cost (`fromTotal`), a `deadline`, and a `signature`. A quote is free and commits you to nothing. 3. **Pay** — send **one** transaction on the FROM chain that carries the `quoteId`: a call to the depository contract on an EVM chain, or a plain transfer to the treasury with the `quoteId` as the memo on Solana / TON. See [Pay on-chain](https://docs.drip.tools/swap/flow.md#step-3-pay-on-chain). 4. **Track** — the backend sees the payment, expands the quote into one order per recipient, and sends the testnet tokens. Poll [`GET /api/order`](https://docs.drip.tools/api/orders.md) until every recipient is `completed`. ## Key concepts **One direction only.** Swaps always go **mainnet → testnet**. You spend a real asset; recipients get test tokens. There is no reverse. **You choose the TO amount; the API prices the FROM side.** You say "recipient X should get `1.5` testnet ETH" (as base units); the API tells you exactly how much mainnet token that costs (`fromTotal`). You pay that — exactly that. **What you pay.** The testnet tokens have a listed USD rate ([`GET /api/quotes`](https://docs.drip.tools/api/discovery.md#get-api-quotes-rates-liquidity)); your cost is that rate converted at the live Chainlink price of the token you pay with. There is no additional fee or spread. Gas on the FROM chain is yours; gas for the testnet payouts is ours. **One payment, many recipients.** Whether you have one recipient or thousands, you make a single payment. All recipients and amounts are in the signed quote, and the backend fans out the deliveries. Minimum order value is **$1** for a single recipient and **$5** for a multisend. **Two payment models, one API.** On the ten EVM FROM chains you call the depository contract. On Solana and TON there is no contract: you transfer to the treasury with the `quoteId` in the memo (Solana) or the text comment (TON). Quote and tracking calls are identical — only the payment transaction differs. See [Paying from Solana & TON](https://docs.drip.tools/swap/memo.md). **The chain only sees the payment.** It records that `payer` paid `fromAmount` for `quoteId`; the backend matches that against the quote (same payer, same token, same amount, before the deadline) before delivering. That is why the on-chain part is tiny and identical for single and batch swaps — and why you must pay exactly what was quoted. See [Payment rules](https://docs.drip.tools/reference/lifecycle.md#payment-rules). ## Orders Every recipient becomes one **order**: `processing` while the payout is queued, then `completed` (with the payout transaction in `to.txHash`) or `failed`. A multisend collapses into one aggregate status — `processing`, `completed`, `failed` or `partial` — and failed slices are refunded. See [Order lifecycle & refunds](https://docs.drip.tools/reference/lifecycle.md). > **Tip: Where to pay** > > Always read `contracts.depository` from [`GET /api/chains`](https://docs.drip.tools/api/discovery.md) for your FROM chain > at runtime. On an EVM chain it is the depository contract; on Solana and TON it is the treasury > account. If it is absent, programmatic swaps aren't enabled on that chain. ## Next steps - **[At a glance](https://docs.drip.tools/guide/cheatsheet.md)** — the whole API on one screen. - **[Quick Start](https://docs.drip.tools/guide/quickstart.md)** — a minimal end-to-end script in JavaScript or Python. - **[Examples](https://docs.drip.tools/swap/examples.md)** — every payment variant, ready to run. - **[Swap flow](https://docs.drip.tools/swap/flow.md)** — the four steps in detail. - **[API reference](https://docs.drip.tools/api/discovery.md)** and **[Errors](https://docs.drip.tools/reference/errors.md)**. --- # Quick Start https://docs.drip.tools/guide/quickstart From nothing to a **completed swap** in one script: pay native ETH on **Base** (a mainnet chain) and deliver 5 testnet ETH on **Arbitrum Sepolia** to a recipient. Pick your language below — the three scripts do exactly the same thing. ## What you need - A wallet on Base holding a few dollars of ETH. Its private key goes in `PRIVATE_KEY`. - The recipient address on the testnet — your own test wallet. Put it in `RECIPIENT`. - Node 18+ with `viem` or `ethers`, or Python 3.10+ with `web3` and `requests`. > **Warning** > > You are spending a **real mainnet asset**. The swap is one-directional and cannot be reversed. > Start near the **$1 minimum**; this script costs about **$1.25 plus gas** (a fraction of a cent > on Base). ## The script **JavaScript · viem** — `examples/js/evm-native.mjs` ```js // Drip Tools — single swap, native token, EVM FROM chain (viem). // Run: PRIVATE_KEY=0x… node evm-native.mjs // Dry run: DRY_RUN=1 node evm-native.mjs (real quote, builds the call, sends nothing) import { createPublicClient, createWalletClient, http, fallback, parseAbi, encodeFunctionData } from 'viem' import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' const API = 'https://drip.tools/api' const FROM_CHAIN_ID = Number(process.env.FROM_CHAIN_ID ?? 8453) // Base (mainnet) const TO_CHAIN_ID = Number(process.env.TO_CHAIN_ID ?? 421614) // Arbitrum Sepolia (testnet) const RECIPIENT = process.env.RECIPIENT ?? '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A' // ← YOUR testnet wallet const TO_AMOUNT = process.env.TO_AMOUNT ?? '5000000000000000000' // 5 testnet ETH, in wei const NATIVE = '0x0000000000000000000000000000000000000000' const DRY_RUN = process.env.DRY_RUN === '1' if (!DRY_RUN && RECIPIENT === '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A') throw new Error('Set RECIPIENT to your own testnet wallet') if (!DRY_RUN && !process.env.PRIVATE_KEY) throw new Error('Set PRIVATE_KEY') const account = privateKeyToAccount(process.env.PRIVATE_KEY ?? generatePrivateKey()) // 1. Discover: the FROM chain's RPC list and its pay target const chains = await fetch(`${API}/chains?unwrap=true`).then(r => r.json()) const fromChain = chains.find(c => String(c.chainId) === String(FROM_CHAIN_ID)) if (!fromChain?.contracts?.depository) throw new Error('Swaps are not enabled on this chain') // 2. Quote: you name the TO amount, the API prices the FROM side and signs the order const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: NATIVE, sender: account.address, toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) console.log(`Cost ${quote.fromTotal} wei (~$${quote.usdTotal}) → pay target ${quote.vault}`) // 3. Pay: one call, pay(quoteId, deadline), with msg.value = fromTotal — exactly const abi = parseAbi(['function pay(bytes32 quoteId, uint256 deadline) payable']) const args = [quote.quoteId, BigInt(quote.deadline)] if (DRY_RUN) { console.log('DRY_RUN — would send to', quote.vault, 'calldata', encodeFunctionData({ abi, functionName: 'pay', args })) process.exit(0) } const transport = fallback(fromChain.rpc.map((url) => http(url))) // rpc[0] first, the rest are fallbacks const publicClient = createPublicClient({ transport }) const wallet = createWalletClient({ account, transport }) const hash = await wallet.writeContract({ address: quote.vault, abi, functionName: 'pay', args, value: BigInt(quote.fromTotal), chain: null, }) console.log('Paid', hash) try { await publicClient.waitForTransactionReceipt({ hash }) } // optional: the order API below is the source of truth catch (e) { console.warn('receipt wait failed, tracking via the API instead:', e.shortMessage ?? e.message) } // 4. Track: one order per recipient — poll until every one is completed or failed for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${hash}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { for (const o of data) console.log(`${o.to.address}: ${o.status} ${o.to.txHash ?? ''}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **JavaScript · ethers** — `examples/js/evm-native-ethers.mjs` ```js // Drip Tools — single swap, native token, EVM FROM chain (ethers v6). // Run: PRIVATE_KEY=0x… node evm-native-ethers.mjs // Dry run: DRY_RUN=1 node evm-native-ethers.mjs (real quote, builds the call, sends nothing) import { ethers } from 'ethers' const API = 'https://drip.tools/api' const FROM_CHAIN_ID = Number(process.env.FROM_CHAIN_ID ?? 8453) // Base (mainnet) const TO_CHAIN_ID = Number(process.env.TO_CHAIN_ID ?? 421614) // Arbitrum Sepolia (testnet) const RECIPIENT = process.env.RECIPIENT ?? '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A' // ← YOUR testnet wallet const TO_AMOUNT = process.env.TO_AMOUNT ?? '5000000000000000000' // 5 testnet ETH, in wei const NATIVE = '0x0000000000000000000000000000000000000000' const DRY_RUN = process.env.DRY_RUN === '1' if (!DRY_RUN && RECIPIENT === '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A') throw new Error('Set RECIPIENT to your own testnet wallet') // 1. Discover: the FROM chain's RPC list and its pay target const chains = await fetch(`${API}/chains?unwrap=true`).then(r => r.json()) const fromChain = chains.find(c => String(c.chainId) === String(FROM_CHAIN_ID)) if (!fromChain?.contracts?.depository) throw new Error('Swaps are not enabled on this chain') const provider = await (async () => { // rpc[0] first, the rest are fallbacks for (const url of fromChain.rpc) { try { const p = new ethers.JsonRpcProvider(url); await p.getBlockNumber(); return p } catch {} } throw new Error('No working RPC for the FROM chain') })() if (!DRY_RUN && !process.env.PRIVATE_KEY) throw new Error('Set PRIVATE_KEY') const signer = process.env.PRIVATE_KEY ? new ethers.Wallet(process.env.PRIVATE_KEY, provider) : ethers.Wallet.createRandom(provider) // 2. Quote const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: NATIVE, sender: signer.address, toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) console.log(`Cost ${quote.fromTotal} wei (~$${quote.usdTotal}) → pay target ${quote.vault}`) // 3. Pay: pay(quoteId, deadline) with value = fromTotal — exactly const payTarget = new ethers.Contract(quote.vault, [ 'function pay(bytes32 quoteId, uint256 deadline) payable', ], signer) if (DRY_RUN) { console.log('DRY_RUN — calldata', payTarget.interface.encodeFunctionData('pay', [quote.quoteId, quote.deadline])) process.exit(0) } const tx = await payTarget.pay(quote.quoteId, quote.deadline, { value: quote.fromTotal }) try { await tx.wait() } // optional: the order API below is the source of truth catch (e) { console.warn('receipt wait failed, tracking via the API instead:', e.shortMessage ?? e.message) } console.log('Paid', tx.hash) // 4. Track for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${tx.hash}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { for (const o of data) console.log(`${o.to.address}: ${o.status} ${o.to.txHash ?? ''}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **Python · web3.py** — `examples/python/evm_native.py` ```python """Drip Tools — single swap, native token, EVM FROM chain (web3.py). Run: PRIVATE_KEY=0x… python evm_native.py Dry run: DRY_RUN=1 python evm_native.py (real quote, builds the call, sends nothing) """ import os import time import requests from eth_account import Account from web3 import Web3 from web3.middleware import ExtraDataToPOAMiddleware API = "https://drip.tools/api" FROM_CHAIN_ID = int(os.environ.get("FROM_CHAIN_ID", 8453)) # Base (mainnet) TO_CHAIN_ID = int(os.environ.get("TO_CHAIN_ID", 421614)) # Arbitrum Sepolia (testnet) RECIPIENT = os.environ.get("RECIPIENT", "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A") # ← YOUR testnet wallet TO_AMOUNT = os.environ.get("TO_AMOUNT", "5000000000000000000") # 5 testnet ETH, in wei NATIVE = "0x0000000000000000000000000000000000000000" DRY_RUN = os.environ.get("DRY_RUN") == "1" if not DRY_RUN and RECIPIENT == "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A": raise SystemExit("Set RECIPIENT to your own testnet wallet") account = Account.from_key(os.environ["PRIVATE_KEY"]) if "PRIVATE_KEY" in os.environ else Account.create() if not DRY_RUN and "PRIVATE_KEY" not in os.environ: raise SystemExit("Set PRIVATE_KEY") # 1. Discover: the FROM chain's RPC list and its pay target chains = requests.get(f"{API}/chains", params={"unwrap": "true"}, timeout=30).json() from_chain = next(c for c in chains if str(c["chainId"]) == str(FROM_CHAIN_ID)) if not from_chain.get("contracts", {}).get("depository"): raise SystemExit("Swaps are not enabled on this chain") # 2. Quote: you name the TO amount, the API prices the FROM side and signs the order quote = requests.post(f"{API}/quote", json={ "chainId": FROM_CHAIN_ID, "fromToken": NATIVE, "sender": account.address, "toChainId": TO_CHAIN_ID, "toToken": NATIVE, "recipients": [RECIPIENT], "toAmounts": [TO_AMOUNT], }, timeout=30).json() if "error" in quote: raise SystemExit(f"{quote.get('code', 'ERROR')}: {quote['error']}") print(f"Cost {quote['fromTotal']} wei (~${quote['usdTotal']}) → pay target {quote['vault']}") # 3. Pay: one call, pay(quoteId, deadline), with value = fromTotal — exactly def connect(rpcs): """rpc[0] first, the rest are fallbacks — public endpoints do go down or rate-limit.""" for url in rpcs: w3 = Web3(Web3.HTTPProvider(url, request_kwargs={"timeout": 20, "headers": {"User-Agent": "drip-tools-example/1.0"}})) w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0) # BNB, Polygon and other POA-style chains pad extraData try: if w3.eth.chain_id == FROM_CHAIN_ID: return w3 except Exception: continue raise SystemExit("No working RPC for the FROM chain") w3 = connect(from_chain["rpc"]) abi = [{"name": "pay", "type": "function", "stateMutability": "payable", "inputs": [{"name": "quoteId", "type": "bytes32"}, {"name": "deadline", "type": "uint256"}], "outputs": []}] pay_target = w3.eth.contract(address=Web3.to_checksum_address(quote["vault"]), abi=abi) call = pay_target.functions.pay(bytes.fromhex(quote["quoteId"][2:]), int(quote["deadline"])) if DRY_RUN: print("DRY_RUN — would send to", quote["vault"], "calldata", pay_target.encode_abi("pay", args=call.args)) raise SystemExit(0) tx = call.build_transaction({ "from": account.address, "value": int(quote["fromTotal"]), "nonce": w3.eth.get_transaction_count(account.address), }) signed = w3.eth.account.sign_transaction(tx, account.key) tx_hash = w3.to_hex(w3.eth.send_raw_transaction(signed.raw_transaction)) print("Paid", tx_hash) try: w3.eth.wait_for_transaction_receipt(tx_hash) # optional: the order API below is the source of truth except Exception as e: # noqa: BLE001 — a flaky public RPC must not strand a sent payment print("receipt wait failed, tracking via the API instead:", e) # 4. Track: one order per recipient — poll until every one is completed or failed while True: data = requests.get(f"{API}/order", params={"from_tx_hash": tx_hash}, timeout=30).json()["data"] if data and all(o["status"] in ("completed", "failed") for o in data): for o in data: print(f"{o['to']['address']}: {o['status']} {o['to']['txHash'] or ''}") break time.sleep(5) ``` Try it first with `DRY_RUN=1`: it requests a real quote and builds the call, but sends nothing. ## What just happened The same script, one step at a time. Pick a language once — every block on the page follows. ### 1. Discover the FROM chain `GET /api/chains` gives you the FROM chain's RPC list and its **pay target** (`contracts.depository`) — the contract you pay on an EVM chain. **JavaScript · viem** — excerpt from `examples/js/evm-native.mjs` ```js // 1. Discover: the FROM chain's RPC list and its pay target const chains = await fetch(`${API}/chains?unwrap=true`).then(r => r.json()) const fromChain = chains.find(c => String(c.chainId) === String(FROM_CHAIN_ID)) if (!fromChain?.contracts?.depository) throw new Error('Swaps are not enabled on this chain') ``` **JavaScript · ethers** — excerpt from `examples/js/evm-native-ethers.mjs` ```js // 1. Discover: the FROM chain's RPC list and its pay target const chains = await fetch(`${API}/chains?unwrap=true`).then(r => r.json()) const fromChain = chains.find(c => String(c.chainId) === String(FROM_CHAIN_ID)) if (!fromChain?.contracts?.depository) throw new Error('Swaps are not enabled on this chain') const provider = await (async () => { // rpc[0] first, the rest are fallbacks for (const url of fromChain.rpc) { try { const p = new ethers.JsonRpcProvider(url); await p.getBlockNumber(); return p } catch {} } throw new Error('No working RPC for the FROM chain') })() if (!DRY_RUN && !process.env.PRIVATE_KEY) throw new Error('Set PRIVATE_KEY') const signer = process.env.PRIVATE_KEY ? new ethers.Wallet(process.env.PRIVATE_KEY, provider) : ethers.Wallet.createRandom(provider) ``` **Python · web3.py** — excerpt from `examples/python/evm_native.py` ```python # 1. Discover: the FROM chain's RPC list and its pay target chains = requests.get(f"{API}/chains", params={"unwrap": "true"}, timeout=30).json() from_chain = next(c for c in chains if str(c["chainId"]) == str(FROM_CHAIN_ID)) if not from_chain.get("contracts", {}).get("depository"): raise SystemExit("Swaps are not enabled on this chain") ``` ### 2. Get a signed quote `POST /api/quote` returns a `quoteId`, the exact cost (`fromTotal`), a `deadline` and a `signature`. A quote is free, commits you to nothing, and is valid for 180 s. **JavaScript · viem** — excerpt from `examples/js/evm-native.mjs` ```js // 2. Quote: you name the TO amount, the API prices the FROM side and signs the order const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: NATIVE, sender: account.address, toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) console.log(`Cost ${quote.fromTotal} wei (~$${quote.usdTotal}) → pay target ${quote.vault}`) ``` **JavaScript · ethers** — excerpt from `examples/js/evm-native-ethers.mjs` ```js // 2. Quote const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: NATIVE, sender: signer.address, toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) console.log(`Cost ${quote.fromTotal} wei (~$${quote.usdTotal}) → pay target ${quote.vault}`) ``` **Python · web3.py** — excerpt from `examples/python/evm_native.py` ```python # 2. Quote: you name the TO amount, the API prices the FROM side and signs the order quote = requests.post(f"{API}/quote", json={ "chainId": FROM_CHAIN_ID, "fromToken": NATIVE, "sender": account.address, "toChainId": TO_CHAIN_ID, "toToken": NATIVE, "recipients": [RECIPIENT], "toAmounts": [TO_AMOUNT], }, timeout=30).json() if "error" in quote: raise SystemExit(f"{quote.get('code', 'ERROR')}: {quote['error']}") print(f"Cost {quote['fromTotal']} wei (~${quote['usdTotal']}) → pay target {quote['vault']}") ``` ### 3. Pay One call: `pay(quoteId, deadline)` on the pay target, with `value` = `fromTotal` — exactly. The recipient and the amount are **not** in this call; they live in the signed quote. The backend matches the payment against the quote: same payer, same token, exactly `fromTotal`. **JavaScript · viem** — excerpt from `examples/js/evm-native.mjs` ```js // 3. Pay: one call, pay(quoteId, deadline), with msg.value = fromTotal — exactly const abi = parseAbi(['function pay(bytes32 quoteId, uint256 deadline) payable']) const args = [quote.quoteId, BigInt(quote.deadline)] if (DRY_RUN) { console.log('DRY_RUN — would send to', quote.vault, 'calldata', encodeFunctionData({ abi, functionName: 'pay', args })) process.exit(0) } const transport = fallback(fromChain.rpc.map((url) => http(url))) // rpc[0] first, the rest are fallbacks const publicClient = createPublicClient({ transport }) const wallet = createWalletClient({ account, transport }) const hash = await wallet.writeContract({ address: quote.vault, abi, functionName: 'pay', args, value: BigInt(quote.fromTotal), chain: null, }) console.log('Paid', hash) try { await publicClient.waitForTransactionReceipt({ hash }) } // optional: the order API below is the source of truth catch (e) { console.warn('receipt wait failed, tracking via the API instead:', e.shortMessage ?? e.message) } ``` **JavaScript · ethers** — excerpt from `examples/js/evm-native-ethers.mjs` ```js // 3. Pay: pay(quoteId, deadline) with value = fromTotal — exactly const payTarget = new ethers.Contract(quote.vault, [ 'function pay(bytes32 quoteId, uint256 deadline) payable', ], signer) if (DRY_RUN) { console.log('DRY_RUN — calldata', payTarget.interface.encodeFunctionData('pay', [quote.quoteId, quote.deadline])) process.exit(0) } const tx = await payTarget.pay(quote.quoteId, quote.deadline, { value: quote.fromTotal }) try { await tx.wait() } // optional: the order API below is the source of truth catch (e) { console.warn('receipt wait failed, tracking via the API instead:', e.shortMessage ?? e.message) } console.log('Paid', tx.hash) ``` **Python · web3.py** — excerpt from `examples/python/evm_native.py` ```python # 3. Pay: one call, pay(quoteId, deadline), with value = fromTotal — exactly def connect(rpcs): """rpc[0] first, the rest are fallbacks — public endpoints do go down or rate-limit.""" for url in rpcs: w3 = Web3(Web3.HTTPProvider(url, request_kwargs={"timeout": 20, "headers": {"User-Agent": "drip-tools-example/1.0"}})) w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0) # BNB, Polygon and other POA-style chains pad extraData try: if w3.eth.chain_id == FROM_CHAIN_ID: return w3 except Exception: continue raise SystemExit("No working RPC for the FROM chain") w3 = connect(from_chain["rpc"]) abi = [{"name": "pay", "type": "function", "stateMutability": "payable", "inputs": [{"name": "quoteId", "type": "bytes32"}, {"name": "deadline", "type": "uint256"}], "outputs": []}] pay_target = w3.eth.contract(address=Web3.to_checksum_address(quote["vault"]), abi=abi) call = pay_target.functions.pay(bytes.fromhex(quote["quoteId"][2:]), int(quote["deadline"])) if DRY_RUN: print("DRY_RUN — would send to", quote["vault"], "calldata", pay_target.encode_abi("pay", args=call.args)) raise SystemExit(0) tx = call.build_transaction({ "from": account.address, "value": int(quote["fromTotal"]), "nonce": w3.eth.get_transaction_count(account.address), }) signed = w3.eth.account.sign_transaction(tx, account.key) tx_hash = w3.to_hex(w3.eth.send_raw_transaction(signed.raw_transaction)) print("Paid", tx_hash) try: w3.eth.wait_for_transaction_receipt(tx_hash) # optional: the order API below is the source of truth except Exception as e: # noqa: BLE001 — a flaky public RPC must not strand a sent payment print("receipt wait failed, tracking via the API instead:", e) ``` ### 4. Track the orders `GET /api/order?from_tx_hash=…` returns one order per recipient. Once the testnet tokens are sent, each flips to `completed` with the payout `to.txHash`. **JavaScript · viem** — excerpt from `examples/js/evm-native.mjs` ```js // 4. Track: one order per recipient — poll until every one is completed or failed for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${hash}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { for (const o of data) console.log(`${o.to.address}: ${o.status} ${o.to.txHash ?? ''}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **JavaScript · ethers** — excerpt from `examples/js/evm-native-ethers.mjs` ```js // 4. Track for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${tx.hash}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { for (const o of data) console.log(`${o.to.address}: ${o.status} ${o.to.txHash ?? ''}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **Python · web3.py** — excerpt from `examples/python/evm_native.py` ```python # 4. Track: one order per recipient — poll until every one is completed or failed while True: data = requests.get(f"{API}/order", params={"from_tx_hash": tx_hash}, timeout=30).json()["data"] if data and all(o["status"] in ("completed", "failed") for o in data): for o in data: print(f"{o['to']['address']}: {o['status']} {o['to']['txHash'] or ''}") break time.sleep(5) ``` ## How long it takes The payout is sent as soon as the payment is indexed — measured over two weeks in October 2026, EVM payments were paid out a median **3 seconds** after the payment was recorded (95% within 11 s). End to end, including your transaction's confirmation, count on well under a minute. Paying from Solana adds 15–30 s for finality. ## Next - Pay with **USDC / USDT**, **many recipients** at once, or from **Solana / TON**: [Examples](https://docs.drip.tools/swap/examples.md). - Every endpoint, field and error: [API reference](https://docs.drip.tools/api/discovery.md), [Errors](https://docs.drip.tools/reference/errors.md). - The whole flow explained: [Swap flow](https://docs.drip.tools/swap/flow.md). --- # For agents https://docs.drip.tools/guide/agents This page is written for a model or an automated client that swaps on behalf of a user. It is terse on purpose: every rule here is a contract, not advice. ## What the service does Drip Tools sells testnet tokens for real mainnet tokens, one way only. You make **one payment** on a mainnet FROM chain (ten EVM chains, Solana or TON) and the service pays the quoted testnet amounts to up to 10 000 recipients on one TO chain (EVM, Solana, TON or Bitcoin testnets). Base URL `https://drip.tools/api`, JSON over HTTPS, no auth, open CORS. Every swap is four steps: **discover** (`GET /api/chains`) → **quote** (`POST /api/quote`) → **pay** (one transaction on the FROM chain, exactly `fromTotal`) → **track** (`GET /api/order?from_tx_hash=…`). The payment is real money and cannot be reversed; the price is the TO amount × the listed rate, converted at the live Chainlink price of the FROM token, with no fee or spread. FROM-chain gas is yours. ## 1. Discover `GET https://drip.tools/api/chains?unwrap=true` → a flat array of chain objects. The fields you use: ```json { "chainId": "42161", "isTestnet": false, "isEnabled": true, "rpc": ["https://arbitrum.drpc.org", "https://arbitrum-one-rpc.publicnode.com"], "tokens": [ { "symbol": "ETH", "address": "0x0000000000000000000000000000000000000000", "decimals": 18 }, { "symbol": "USDC", "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", "decimals": 6 } ], "contracts": { "depository": "0x52361c9412DceC6A83980fb065D0276ec001fACE" } } ``` Optional fields: `isPaused` (present and `true` only while paused), `family` (`"solana"`, `"ton"`, `"bitcoin"`; absent = EVM), `cluster` (Solana). | Field | Rule | | --- | --- | | `chainId` | A **string** here. Solana FROM `1399811149`, TON FROM `1667471768`; EVM chains use their EVM id. | | `isTestnet` | `false` = FROM side (you pay there). `true` = TO side (recipients receive there). | | `isEnabled` / `isPaused` | Usable only when `isEnabled: true` and `isPaused` is absent or `false`. Otherwise skip the chain. | | `family` | Absent = EVM. `"solana"`, `"ton"` or `"bitcoin"` otherwise. Selects the payment shape. | | `rpc` | Ordered: `rpc[0]` primary, the rest fallbacks. | | `tokens[]` | `address` `0x0000000000000000000000000000000000000000` = native coin on every chain; SPL tokens use the base58 mint. `decimals` converts amounts. Bitcoin testnet tokens add `quoteDecimals` and `minTo`. | | `contracts.depository` | FROM chains only: the pay target (EVM contract, Solana / TON treasury). Absent → not payable. | Optional before quoting: `GET /api/quotes?chainId=&symbol=` → `{ "price": 0.25, "balance": "", "available": true }`. `available: false` or a `balance` below your total means the quote will fail with `NO_TO_PRICE` / `NO_LIQUIDITY`. ## 2. Quote `POST https://drip.tools/api/quote` with `Content-Type: application/json`. Every field is required: ```json { "chainId": 8453, "fromToken": "0x0000000000000000000000000000000000000000", "sender": "0x9246ef7F2b7fEcE8AfD8a72271A76Cc2A8d8A3E5", "toChainId": 421614, "toToken": "0x0000000000000000000000000000000000000000", "recipients": ["0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A"], "toAmounts": ["5000000000000000000"] } ``` | Field | Rule | | --- | --- | | `chainId`, `toChainId` | **Numbers**. FROM must be an enabled mainnet, TO an enabled testnet. | | `fromToken`, `toToken` | An `address` from that chain's `tokens[]`. | | `sender` | The wallet that will sign and pay. EVM `0x…` any case; Solana base58; TON any form. | | `recipients` | 1 to 10 000 addresses valid on the TO chain. | | `toAmounts` | Base-unit **integer strings** of the TO token, one per recipient. No decimals, hex or exponents. | Response `200` — the fields you must use: ```json { "quoteId": "0x1f5215a4577ef4548b550313d772594473e237c67514943751634818fdff9615", "vault": "0x52361c9412dcec6a83980fb065d0276ec001face", "fromToken": "0x0000000000000000000000000000000000000000", "fromTotal": "517554316856097", "deadline": 1784121105, "signature": "0x23fbc0…1b", "signer": "0x5C0917e114e49F3Cf266D6467ac129FEe42d77c5", "usdTotal": "1.25" } ``` | Field | Use | | --- | --- | | `quoteId` | The only reference carried on-chain: `pay` argument, Solana memo, or TON comment. | | `vault` | Pay target, canonical form (EVM lowercased, Solana base58, TON `UQ…`). Equals `contracts.depository`. | | `fromToken` | The token to pay with, canonical form. | | `fromTotal` | The exact amount to pay, base units (wei, token units, lamports, nanotons). | | `deadline` | Epoch seconds. The payment must land at or before it. Equals `expiry`. | | `signature` | EIP-712 signature by `signer`. Verifying it is optional: [Verifying the quote signature](https://docs.drip.tools/swap/contract.md#verifying-the-quote-signature-optional). | The response also carries `issuedAt`, `expiry`, `fromAmounts` and `toAmounts` (bookkeeping). Errors are `{ "error": "", "code": "" }`. Decide by HTTP status and code: | Response | Action | | --- | --- | | `400` + code | Fix the request; never resend it unchanged. `BELOW_MIN_TOTAL` raise amounts · `TO_TOO_PRECISE` / `TO_TOO_SMALL` / `BELOW_SOL_RENT_MIN` fix the amount · `NO_LIQUIDITY` lower amounts or pick another TO token · `BAD_TO_CHAIN` / `BAD_FROM_CHAIN` pick another chain · `TOO_MANY` split the batch. | | `429` JSON `RATE_LIMITED` | Sleep `Retry-After` seconds, then retry. | | `429` empty body | Edge flood guard: sleep a few seconds, then retry. | | `503` `WARMING_UP` | Sleep `retryAfterSec`, then retry. | | `503` `NO_FROM_PRICE` / `NO_TO_PRICE` / `SIGNING_DISABLED`, `500` `NO_VAULT` | Not now: another token or chain, or try much later. | Every code: [Errors](https://docs.drip.tools/reference/errors.md). ## 3. Pay One transaction on the FROM chain. Recipients and amounts are **not** in it; they live in the signed quote. Pick the shape by the FROM chain's `family` and the token. ### EVM, native coin ```json { "to": "", "function": "pay(bytes32 quoteId, uint256 deadline)", "args": ["", ""], "value": "" } ``` ### EVM, ERC-20 (approve, then pay) ```json [ { "to": "", "function": "approve(address spender, uint256 amount)", "args": ["", ""], "onlyIf": "allowance(sender, quote.vault) < fromTotal" }, { "to": "", "function": "payWithToken(address token, uint256 amount, bytes32 quoteId, uint256 deadline)", "args": ["", "", "", ""], "value": "0" } ] ``` Ethereum-mainnet USDT: if the allowance is non-zero but too small, `approve(vault, 0)` first. ### EVM, ERC-20 with EIP-2612 permit (one transaction) ```json { "to": "", "function": "payWithTokenPermit(address token, uint256 amount, bytes32 quoteId, uint256 deadline, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s)", "args": ["", "", "", "", "", "", "", ""], "permit": { "owner": "", "spender": "", "value": "" } } ``` Only for tokens that implement `permit` (native USDC does, Ethereum USDT does not). Full example: [Examples → ERC-20 with permit](https://docs.drip.tools/swap/examples.md#erc-20-with-permit). ABI for all EVM variants: ```js const depositoryAbi = parseAbi([ 'function pay(bytes32 quoteId, uint256 deadline) payable', 'function payWithToken(address token, uint256 amount, bytes32 quoteId, uint256 deadline)', 'function payWithTokenPermit(address token, uint256 amount, bytes32 quoteId, uint256 deadline, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s)', ]) ``` Reverts: `Expired` (after `deadline`), `MissingQuote`, `AmountTooLow` / `ZeroAmount`, `TokenTransferMismatch`, `TokenNotAllowed`, `DirectPaymentNotAllowed` (plain transfer, no call), `EnforcedPause`. A reverted transaction pays nothing (gas aside) and does not consume the quote. ### Solana (`family: "solana"`) ```json { "feePayer": "", "instructions": [ "SPL only: ATA CreateIdempotent(payer = sender, owner = quote.vault, mint = quote.fromToken)", "native SOL: SystemProgram.transfer(from = sender, to = quote.vault, lamports = quote.fromTotal)", "SPL token: TransferChecked(sender ATA -> quote.vault ATA, mint = quote.fromToken, amount = quote.fromTotal, decimals)", "Memo(program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr, data = UTF-8 quote.quoteId, no signer accounts)" ], "trackRef": "" } ``` SPL tokens use the classic Token program. Keep about 0.003 SOL for the fee and the treasury's token-account rent. Payments are indexed at `finalized`, typically 15–30 s after confirmation. ### TON (`family: "ton"`) ```json { "from": "", "message": { "to": "", "value": " nanotons", "bounce": false, "body": "text comment = (op 0 + UTF-8, one cell)" }, "sendMode": 3, "validUntil": "", "trackRef": "" } ``` Mode 3 = `PAY_GAS_SEPARATELY | IGNORE_ERRORS` (mode 1 also works). Mode 0 takes the forward fee out of the value, the treasury receives less than `fromTotal`, and the payment is held for review. `@ton/ton` does not set it for you: pass `sendMode` explicitly. One mnemonic yields a different address per wallet version (W5 / v4r2); quote the address you actually pay from. Payments are indexed 10–20 s after they land. Full shape and rules: [Paying from Solana & TON](https://docs.drip.tools/swap/memo.md). ## 4. Track `GET https://drip.tools/api/order?from_tx_hash=`, where `ref` is the EVM transaction hash, the Solana transaction signature, or the **`quoteId`** on TON. ```json { "data": [ { "status": "completed", "id": "abc123…", "from": { "chainId": "8453", "symbol": "ETH", "amount": "0.00051755", "address": "0x", "txHash": "0x" }, "to": { "chainId": "421614", "symbol": "ETH", "amount": "5.00000000", "address": "0x", "txHash": "0x", "ts": 1784120990 } } ], "refund": null } ``` | Field | Meaning | | --- | --- | | `data` | One order per recipient; `[]` until the payment is indexed. | | `status` | `processing` → `completed` \| `failed`. `pending` is reserved and never produced. | | `to.txHash` | The testnet payout transaction; `null` until dispensed. Proof of delivery. | | `refund` | `null`, or `{ "status": "refund_pending" \| "refunded", "reason", "from", "refundTxHash", "refundedAt" }`. | ## Invariants > **Warning: Real money** > > A payment cannot be cancelled. A payment that breaks invariants 1–4 does not become an order and > is not refunded automatically. 1. Pay exactly `fromTotal` base units of `quote.fromToken` to `quote.vault`, from the wallet you quoted as `sender`, before `deadline`; never round, pad or convert the amount. 2. One payment carries exactly one `quoteId`, and one `quoteId` is paid by exactly one payment. 3. Never pay a quote twice: once a payment transaction is broadcast, only track it, even if the receipt wait failed or timed out. 4. A payment with the wrong payer, token or amount, or a second payment of an honoured quote, is held for operator review with no automatic refund; recover it only via [support](https://docs.drip.tools/reference/errors.md#payments-held-for-review) with the transaction hash. 5. A quote is free and non-binding and expires 180 s after issue, so request it immediately before paying and request a new one if fewer than 20 s of it remain. 6. A late payment reverts with `Expired` on EVM (nothing paid) and, on Solana / TON, lands and is refunded with reason `expired` instead of becoming an order. 7. The minimum order is $1 for one recipient and $5 for a multisend (more than one recipient), else `BELOW_MIN_TOTAL`. 8. One quote holds up to 10 000 recipients paid by one transaction; split larger batches into several quotes. 9. TO amounts sit on a 4-decimal grid of the TO token (whole sats on Bitcoin), and every recipient on a Solana TO chain gets at least 0.001 SOL. 10. Skip chains with `isEnabled: false` or `isPaused: true`, and FROM chains without `contracts.depository`. 11. Rate limits are 600 requests / min per IP on every route, 10 quotes / min per IP and 30 quotes / min per `sender`, and a `429` answers `{ "error": "RATE_LIMITED", "retryAfterSec": n }` with a `Retry-After` header to sleep on. 12. `chainId` is a string in `/api/chains` and in order objects but a number in `POST /api/quote`, so coerce before comparing. 13. EVM addresses compare case-insensitively (quotes return lowercase, orders checksummed), Solana base58 keys are case-sensitive and never re-cased, and a TON payer is normalized to the non-bounceable `UQ…` form. 14. Quote amounts are base-unit integer strings while order amounts are human decimals with varying trailing zeros, so parse them as decimals and never compare them as strings. 15. Read the pay target at runtime (`contracts.depository` or `quote.vault`) and never send funds to it without a quote. To build a TO amount on the grid: `BigInt(Math.round(x * 1e4)) * 10n ** BigInt(decimals - 4)` for a token with 4 or more decimals. ## When to stop polling - Poll every 5 s. EVM payouts land a median 3 s after the payment is indexed; Solana adds 15–30 s of finality, TON 10–20 s. - **Stop** when `data.length > 0` and every order is `completed` or `failed`. A `failed` order's FROM slice is refunded (`reason: "failed_order"`, recorded within a few minutes; for a partial batch capped so the delivered part still cost at least $1). - **Stop** when `data` is empty and `refund` is set: the payment was refused (`expired`, `unknown_quote`, `unattributed`, `ambiguous_memo`, `bad_signature`) and no order will appear. - **Stop** when the EVM receipt says `reverted`: nothing was paid; quote again if needed. - **Stop and report** when `data` and `refund` stay empty well past the deadline (10 minutes is generous): the payment was most likely held for review (invariant 4). - TON: `from_tx_hash=` resolves only once the payment is honoured (afterwards the order's `from.txHash`, 64 hex characters, works too). A refused TON payment never resolves by `quoteId`; find its refund in `GET /api/orders?address=&view=batches` (rows with `isRefund: true`). `GET /api/orders/load` does not resolve a `quoteId`; pass `data[0].id`. - Large batches: `&paginate=1&limit=0` returns only `summary: { recipients, statusCounts, fromTotal, toTotal, completedTs, lastPayout }`; stop when `summary.recipients > 0` and `summary.completedTs` is non-null. - Many payments: `POST /api/orders/status` with `{ "ids": [...] }` (500 ids at most) instead of one lookup per payment. Order ids are case-sensitive. - Optional: `GET /api/orders/load?ref=` for a queue ETA, and `GET /api/orders/stream?address=` (Server-Sent Events, not rate-limited) whose `orders-changed` event lists FROM tx hashes to re-fetch. If the stream answers `503`, poll. Details: [Orders](https://docs.drip.tools/api/orders.md), [Order lifecycle & refunds](https://docs.drip.tools/reference/lifecycle.md). ## Machine-readable resources | Resource | Content | | --- | --- | | [`llms.txt`](https://docs.drip.tools/llms.txt) | Index of every page, one line each. | | [`llms-full.txt`](https://docs.drip.tools/llms-full.txt) | Every page in one markdown file. | | [`openapi.json`](https://docs.drip.tools/openapi.json) | The whole API as OpenAPI 3.1. | | Any page + `.md` | The page as markdown, e.g. `https://docs.drip.tools/guide/agents.md`. | | [Examples](https://docs.drip.tools/swap/examples.md) | Runnable JavaScript and Python scripts for every payment shape; `DRY_RUN=1` requests a real quote and builds the payment but sends nothing. | ## Recipe: one swap, EVM native coin JavaScript with `viem`. Pays ETH on Base, delivers 5 testnet ETH on Arbitrum Sepolia. Replace the four constants; everything else is the contract above. ```js import { createPublicClient, createWalletClient, http, fallback, parseAbi } from 'viem' import { privateKeyToAccount } from 'viem/accounts' const API = 'https://drip.tools/api' const NATIVE = '0x0000000000000000000000000000000000000000' const FROM_CHAIN_ID = 8453 // Base (number in POST /quote) const TO_CHAIN_ID = 421614 // Arbitrum Sepolia const RECIPIENT = '0x…' // the user's testnet wallet const TO_AMOUNT = '5000000000000000000' // 5 ETH in wei, on the 4-decimal grid const account = privateKeyToAccount(process.env.PRIVATE_KEY) // the payer = `sender` const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) async function api(path, init) { for (let attempt = 0; attempt < 5; attempt++) { const res = await fetch(`${API}${path}`, init) const body = await res.json().catch(() => null) // the edge 429 has no JSON body if (res.status === 429 || body?.error === 'WARMING_UP') { await sleep(1000 * Number(res.headers.get('retry-after') ?? 2)) continue } if (!res.ok) throw new Error(`${res.status} ${body?.code ?? body?.error}`) return body } throw new Error('still rate limited') } // 1. Discover const chains = await api('/chains?unwrap=true') const from = chains.find((c) => c.chainId === String(FROM_CHAIN_ID)) const to = chains.find((c) => c.chainId === String(TO_CHAIN_ID)) if (!from?.contracts?.depository || !from.isEnabled || from.isPaused) throw new Error('FROM chain not payable') if (!to?.isTestnet || !to.isEnabled || to.isPaused) throw new Error('TO chain not available') // 2. Quote, right before paying const quote = await api('/quote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: NATIVE, sender: account.address, toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }) if (quote.deadline - Date.now() / 1000 < 20) throw new Error('quote nearly expired: quote again') // 3. Pay once, exactly fromTotal const transport = fallback(from.rpc.map((url) => http(url))) // rpc[0] first const wallet = createWalletClient({ account, transport }) const hash = await wallet.writeContract({ address: quote.vault, abi: parseAbi(['function pay(bytes32 quoteId, uint256 deadline) payable']), functionName: 'pay', args: [quote.quoteId, BigInt(quote.deadline)], value: BigInt(quote.fromTotal), chain: null, }) // From here on this quote is spent: never pay it again, only track `hash`. const receipt = await createPublicClient({ transport }) .waitForTransactionReceipt({ hash }).catch(() => null) // optional; the API is the source of truth if (receipt?.status === 'reverted') throw new Error('reverted: nothing was paid') // 4. Track until every order is terminal const terminal = (o) => o.status === 'completed' || o.status === 'failed' const stopAt = Date.now() + 10 * 60_000 // then report: likely held for review while (Date.now() < stopAt) { const { data, refund } = await api(`/order?from_tx_hash=${hash}`) if (data.length && data.every(terminal)) { for (const o of data) console.log(o.to.address, o.status, o.to.txHash) if (refund) console.log('refund', refund.status, refund.reason) // failed slices break } if (!data.length && refund) { console.log('refused', refund.reason); break } await sleep(5000) } ``` For ERC-20, Solana and TON, swap step 3 for the matching shape above; the other steps are identical except the track `ref` (Solana signature, TON `quoteId`). --- # Networks & pricing https://docs.drip.tools/networks Drip Tools delivers testnet tokens for a payment in real mainnet tokens. The rate is a fixed USD price per testnet token; what you pay is that amount in the mainnet token of your choice at its live price, with no fee or spread. The minimum order is $1 ($5 when a quote pays several recipients), and a quote is valid for 180 s. Rates and the network list are read from the live API at build time; this page reflects 2026-10-21. The current list is always at https://drip.tools/api/chains and https://drip.tools/api/quotes. ## Testnets you can receive | Testnet | Chain id | Token | Rate | For $1 | | --- | --- | --- | --- | --- | | [0G Galileo Testnet](https://docs.drip.tools/networks/0g-galileo-testnet.md) | 16602 | OG | $0.25 | 4 OG | | [Arbitrum Sepolia](https://docs.drip.tools/networks/arbitrum-sepolia.md) | 421614 | ETH | $0.25 | 4 ETH | | | | USDC | $0.01 | 100 USDC | | [Avalanche Fuji Testnet](https://docs.drip.tools/networks/avalanche-fuji-testnet.md) | 43113 | AVAX | $0.05 | 20 AVAX | | [Base Sepolia](https://docs.drip.tools/networks/base-sepolia.md) | 84532 | ETH | $0.25 | 4 ETH | | | | USDC | $0.01 | 100 USDC | | [Berachain Bepolia](https://docs.drip.tools/networks/berachain-bepolia.md) | 80069 | BERA | $0.025 | 40 BERA | | [Bitcoin Signet](https://docs.drip.tools/networks/bitcoin-signet.md) | 38332 | BTC | $10.00 | 0.1 BTC | | [Bitcoin Testnet3](https://docs.drip.tools/networks/bitcoin-testnet3.md) | 18332 | BTC | $10.00 | 0.1 BTC | | [Bitcoin Testnet4](https://docs.drip.tools/networks/bitcoin-testnet4.md) | 48332 | BTC | $10.00 | 0.1 BTC | | [BNB Smart Chain Testnet](https://docs.drip.tools/networks/bsc-testnet.md) | 97 | BNB | $0.25 | 4 BNB | | [Citrea Testnet](https://docs.drip.tools/networks/citrea-testnet.md) | 5115 | cBTC | $10.00 | 0.1 cBTC | | [Ethereum Hoodi](https://docs.drip.tools/networks/ethereum-hoodi.md) | 560048 | ETH | $0.1 | 10 ETH | | [Ethereum Sepolia](https://docs.drip.tools/networks/ethereum-sepolia.md) | 11155111 | ETH | $0.25 | 4 ETH | | | | USDC | $0.01 | 100 USDC | | [Fluent Testnet](https://docs.drip.tools/networks/fluent-testnet.md) | 20994 | ETH | $0.25 | 4 ETH | | [Gensyn Testnet](https://docs.drip.tools/networks/gensyn-testnet.md) | 685685 | ETH | $0.25 | 4 ETH | | [Giwa Sepolia](https://docs.drip.tools/networks/giwa-sepolia.md) | 91342 | ETH | $0.25 | 4 ETH | | [Humanity Testnet](https://docs.drip.tools/networks/humanity-testnet.md) | 7080969 | tHP | $0.25 | 4 tHP | | [Irys Testnet](https://docs.drip.tools/networks/irys-testnet.md) | 1270 | IRYS | $0.05 | 20 IRYS | | [KiteAI Testnet](https://docs.drip.tools/networks/kiteai-testnet.md) | 2368 | KITE | $0.1 | 10 KITE | | [MegaETH Testnet v2](https://docs.drip.tools/networks/megaeth-testnet-v2.md) | 6343 | ETH | $0.25 | 4 ETH | | [Monad Testnet](https://docs.drip.tools/networks/monad-testnet.md) | 10143 | MON | $0.005 | 200 MON | | [Neura Testnet](https://docs.drip.tools/networks/neura-testnet.md) | 267 | ANKR | $0.025 | 40 ANKR | | [OPN Testnet](https://docs.drip.tools/networks/opn-testnet.md) | 984 | OPN | $0.01 | 100 OPN | | [Optimism Sepolia](https://docs.drip.tools/networks/optimism-sepolia.md) | 11155420 | ETH | $0.25 | 4 ETH | | [Pharos Atlantic Testnet](https://docs.drip.tools/networks/pharos-atlantic-testnet.md) | 688689 | PHRS | $0.5 | 2 PHRS | | [Polygon Amoy Testnet](https://docs.drip.tools/networks/polygon-amoy-testnet.md) | 80002 | POL | $0.05 | 20 POL | | [Rise Testnet](https://docs.drip.tools/networks/rise-testnet.md) | 11155931 | ETH | $0.25 | 4 ETH | | [Robinhood Testnet](https://docs.drip.tools/networks/robinhood-testnet.md) | 46630 | ETH | $0.25 | 4 ETH | | [Solana Devnet](https://docs.drip.tools/networks/solana-devnet.md) | 1399811151 | SOL | $0.25 | 4 SOL | | [Solana Testnet](https://docs.drip.tools/networks/solana-testnet.md) | 1399811150 | SOL | $0.25 | 4 SOL | | [Somnia Shannon Testnet](https://docs.drip.tools/networks/somnia-shannon-testnet.md) | 50312 | STT | $0.05 | 20 STT | | [Stable Testnet](https://docs.drip.tools/networks/stable-testnet.md) | 2201 | gUSDT | $0.5 | 2 gUSDT | | [Story Aeneid Testnet](https://docs.drip.tools/networks/story-aeneid-testnet.md) | 1315 | IP | $0.05 | 20 IP | | [TON Testnet](https://docs.drip.tools/networks/ton-testnet.md) | 1667471769 | GRAM | $0.05 | 20 GRAM | ## Mainnets you can pay from | Chain | Chain id | Family | Tokens | | --- | --- | --- | --- | | Arbitrum | 42161 | EVM | ETH, USDT, USDC | | Avalanche | 43114 | EVM | AVAX, USDT, USDC | | Base | 8453 | EVM | ETH, USDT, USDC | | BNB Smart Chain | 56 | EVM | BNB, USDT, USDC | | Ethereum | 1 | EVM | ETH, USDT, USDC | | Hyperliquid | 999 | EVM | HYPE, USDC, USDT0 | | Monad | 143 | EVM | MON, USDC, USDT0 | | Optimism | 10 | EVM | ETH, USDT, USDC | | Polygon | 137 | EVM | POL, USDT, USDC | | Robinhood | 4663 | EVM | ETH, USDG | | Solana | 1399811149 | Solana | SOL, USDC, USDT | | TON | 1667471768 | TON | GRAM | ## How a price is computed cost in the mainnet token = Σ(testnet amount × rate) ÷ live mainnet-token price (Chainlink), rounded up to the token's base unit. Details: https://docs.drip.tools/api/quote.md (pricing model), https://docs.drip.tools/guide/quickstart.md (a runnable script). --- # The Swap Flow https://docs.drip.tools/swap/flow A programmatic swap is four steps: **discover → quote → pay → track**. This page covers each in depth, including every EVM payment variant (native, ERC-20 approve, ERC-20 permit), multisend, and where the Solana / TON payment differs. All HTTP calls hit the base URL `https://drip.tools/api`. Full endpoint references live under [API reference](https://docs.drip.tools/api/discovery.md); the EVM contract is documented in [Depository contract](https://docs.drip.tools/swap/contract.md) and the Solana / TON transfer in [Paying from Solana & TON](https://docs.drip.tools/swap/memo.md). ## Step 1: Discover chains & tokens Call [`GET /api/chains`](https://docs.drip.tools/api/discovery.md#get-api-chains) to learn what you can swap. Pass `?unwrap=true` to get a single flat array instead of `{ testnet, mainnet }`. **cURL** ```bash curl 'https://drip.tools/api/chains?unwrap=true' ``` Each chain object tells you everything you need: **Response** ```json { "chainId": "42161", "name": "arbitrum", "displayName": "Arbitrum", "explorer": "https://arbiscan.io", "isTestnet": false, // false = FROM (mainnet) side "isEnabled": true, "rpc": ["https://arbitrum.drpc.org", "https://arbitrum-one-rpc.publicnode.com", "…"], "tokens": [ { "symbol": "ETH", "address": "0x0000000000000000000000000000000000000000", "decimals": 18 }, { "symbol": "USDC", "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", "decimals": 6 } ], "contracts": { "depository": "0x52361c94…" // ← the pay target (FROM chains only) } } ``` - **FROM chains** have `isTestnet: false` and expose `contracts.depository` — where you pay. On an EVM chain it is the depository contract; on Solana and TON (`family: "solana"` / `"ton"`) it is the **treasury account** you transfer to. If `depository` is missing, that chain isn't enabled for programmatic swaps. - **TO chains** have `isTestnet: true`. Skip the ones with `isEnabled: false` — they are listed, but `POST /api/quote` answers `BAD_TO_CHAIN`. There is nothing to call on a TO chain — our relayers send your recipients their testnet tokens; the payout tx hash comes back on the order. - `rpc` is an **ordered list**: `rpc[0]` is the primary endpoint, the rest are fallbacks. - Token `address` `0x0000…0000` is the chain's **native** coin (also on Solana and TON). An SPL token on Solana is identified by its base58 mint address. `decimals` is the base-unit exponent — you'll need it for every amount. To see the testnet token's USD rate and whether the relayer currently has liquidity, call [`GET /api/quotes`](https://docs.drip.tools/api/discovery.md#get-api-quotes-rates-liquidity): **cURL** ```bash curl 'https://drip.tools/api/quotes?chainId=421614&symbol=ETH' # { "symbol":"ETH", "price":0.25, "balance":"12513664307…", "available":true, … } ``` `available: false` (or a `balance` smaller than your order) means that TO token can't be dispensed right now — the quote step will reject it with `NO_TO_PRICE` / `NO_LIQUIDITY`. ## Step 2: Request a signed quote Post the recipients and the **exact TO amounts** you want each to receive to [`POST /api/quote`](https://docs.drip.tools/api/quote.md). Amounts are **base-unit integer strings** of the TO token. **cURL** ```bash curl -X POST 'https://drip.tools/api/quote' \ -H 'Content-Type: application/json' \ -d '{ "chainId": 8453, "fromToken": "0x0000000000000000000000000000000000000000", "sender": "0x9246ef7F2b7fEcE8AfD8a72271A76Cc2A8d8A3E5", "toChainId": 421614, "toToken": "0x0000000000000000000000000000000000000000", "recipients": ["0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A"], "toAmounts": ["5000000000000000000"] }' ``` Response: ```json { "quoteId": "0x1f5215a4577ef4548b550313d772594473e237c67514943751634818fdff9615", "signer": "0x5C0917e114e49F3Cf266D6467ac129FEe42d77c5", "vault": "0x52361c9412dcec6a83980fb065d0276ec001face", "issuedAt": 1784120925, "expiry": 1784121105, "deadline": 1784121105, "fromToken": "0x0000000000000000000000000000000000000000", "fromTotal": "517554316856097", "fromAmounts": ["517554316856097"], "toAmounts": ["5000000000000000000"], "usdTotal": "1.25", "signature": "0x23fbc0…1b" } ``` What matters: | Field | Use | | --- | --- | | `quoteId` | The only reference carried on-chain. Pass it to the `pay` call, or put it in the Solana memo / TON comment. | | `vault` | The pay target — equal to the FROM chain's `contracts.depository` (contract on EVM, treasury on Solana / TON); lowercased on EVM, so compare case-insensitively. | | `fromTotal` | The exact FROM amount to pay (base units). `msg.value` for native, `amount` for ERC-20, lamports / nanotons on Solana / TON. | | `deadline` | Pass to the `pay` call. The tx reverts (`Expired`) after this. Equal to `expiry`. | | `fromAmounts` | Per-recipient FROM breakdown (bookkeeping; the backend expands the batch by these). | | `signature` | EIP-712 signature over the quote. Optional to verify client-side — see below. | ### You pick TO, the API prices FROM You never specify how much mainnet token to spend. You say "recipient gets `5` testnet ETH", and the API multiplies by the live FROM price (Chainlink) and the TO rate to return `fromTotal`. The quote **locks** those TO amounts — the backend delivers exactly `toAmounts`, regardless of price movement between quoting and payment. ### Minimums & expiry - **$1** minimum for a single recipient, **$5** for a multisend (>1 recipient). Below that: `BELOW_MIN_TOTAL`. - TO amounts sit on a **4-decimal grid** (whole sats on Bitcoin): more precision → `TO_TOO_PRECISE`, less than one grid step → `TO_TOO_SMALL`. On a Solana TO chain each recipient needs at least **0.001 SOL** (`BELOW_SOL_RENT_MIN`). - A quote is valid for **180 seconds**. Submit your on-chain tx before `deadline`; otherwise request a fresh quote. - Requesting a quote is free and non-binding — nothing happens until you pay. Quote right before you are ready to send. ### Pay exactly what was quoted The payment is matched against the quote **off-chain**: same payer as `sender`, same token as `fromToken`, an amount **equal** to `fromTotal`, before `deadline`. A payment that differs does not become an order — see [Payment rules](https://docs.drip.tools/reference/lifecycle.md#payment-rules). Never round `fromTotal`, and never pay from a different wallet than the one you quoted for. ### Verify the signature (optional) The quote carries an EIP-712 signature from `signer`. Checking it before you pay is optional; a worked example is in [Verifying the quote signature](https://docs.drip.tools/swap/contract.md#verifying-the-quote-signature-optional). ## Step 3: Pay on-chain Send **one** transaction on the FROM chain. The recipient list and amounts are **not** in it — only the `quoteId` (and, on EVM, the `deadline`). Pick the variant that matches your FROM chain and token. ### Solana and TON There is no contract call: transfer exactly `fromTotal` to the treasury (`quote.vault`) with the `quoteId` as the memo (Solana) or the text comment (TON). The transaction shape is a protocol — read [Paying from Solana & TON](https://docs.drip.tools/swap/memo.md) before sending anything. The rest of this step is EVM only. ### Native token (ETH, BNB, POL, AVAX, …) `msg.value` must equal `fromTotal`. **JavaScript · viem** ```js import { parseAbi } from 'viem' const abi = parseAbi(['function pay(bytes32 quoteId, uint256 deadline) payable']) const hash = await wallet.writeContract({ address: quote.vault, abi, functionName: 'pay', args: [quote.quoteId, BigInt(quote.deadline)], value: BigInt(quote.fromTotal), }) ``` ### ERC-20 token — approve + payWithToken First approve the pay target (`quote.vault`) for at least `fromTotal`, then call `payWithToken`. `amount` must equal `fromTotal`. **JavaScript · viem** ```js const erc20 = parseAbi(['function approve(address spender, uint256 amount) returns (bool)']) const abi = parseAbi([ 'function payWithToken(address token, uint256 amount, bytes32 quoteId, uint256 deadline)', ]) // 1. approve (skip if the current allowance already covers fromTotal) const approveHash = await wallet.writeContract({ address: quote.fromToken, abi: erc20, functionName: 'approve', args: [quote.vault, BigInt(quote.fromTotal)], }) await publicClient.waitForTransactionReceipt({ hash: approveHash }) // 2. pay const hash = await wallet.writeContract({ address: quote.vault, abi, functionName: 'payWithToken', args: [quote.fromToken, BigInt(quote.fromTotal), quote.quoteId, BigInt(quote.deadline)], }) ``` > **Warning: Ethereum-mainnet USDT** > > USDT on Ethereum requires resetting the allowance to `0` before setting a new non-zero value. If a > prior non-zero allowance exists, send `approve(spender, 0)` first, then `approve(spender, fromTotal)`. ### ERC-20 token — gasless approve with EIP-2612 permit If the FROM token supports [EIP-2612](https://eips.ethereum.org/EIPS/eip-2612) (`permit`), you can skip the approve transaction entirely: sign a permit off-chain and pass it to `payWithTokenPermit` in a single transaction. **JavaScript · viem** ```js const abi = parseAbi([ 'function payWithTokenPermit(address token, uint256 amount, bytes32 quoteId, uint256 deadline, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s)', ]) // Sign an EIP-2612 permit for spender = depository, value = fromTotal. // (Build domain/types from the token's name/version/nonces — see Examples.) const { v, r, s, permitDeadline } = await signErc2612Permit(/* … */) const hash = await wallet.writeContract({ address: quote.vault, abi, functionName: 'payWithTokenPermit', args: [ quote.fromToken, BigInt(quote.fromTotal), quote.quoteId, BigInt(quote.deadline), BigInt(permitDeadline), v, r, s, ], }) ``` A full permit example (building the EIP-2612 signature) is in [Examples → ERC-20 with permit](https://docs.drip.tools/swap/examples.md#erc-20-with-permit). Tokens with a Uniswap **Permit2** allowance can use `payWithPermit2(...)` instead — see the [contract reference](https://docs.drip.tools/swap/contract.md#functions). ### Multisend is the same call A batch is not a different function. You requested many recipients in Step 2, so the quote's `fromTotal` already covers all of them. You still make **one** `pay` / `payWithToken` call (or one Solana / TON transfer) with `msg.value` / `amount = fromTotal`. The backend reads the recipients from the signed quote and delivers to each. This is the whole point of the off-chain model. ## Step 4: Track your order After the payment confirms, poll by your FROM tx hash. One payment expands into **one order per recipient**. **cURL** ```bash curl 'https://drip.tools/api/order?from_tx_hash=0x' ``` **Response** ```json { "data": [ { "status": "completed", "id": "abc123…", "from": { "chainId": "8453", "symbol": "ETH", "amount": "0.00051755", "txHash": "0x…" }, "to": { "chainId": "421614", "symbol": "ETH", "amount": "5.00000000", "address": "0x2f4e…f63a", "txHash": "0x", "ts": 1784120990 } } ], "refund": null } ``` - Each order's `status` starts as `processing` and flips to `completed` (or `failed`). - `to.txHash` is the payout transaction on the testnet — proof the recipient was funded. - If some recipients failed, their FROM slice is refunded and surfaces in `refund` — see [Order lifecycle & refunds](https://docs.drip.tools/reference/lifecycle.md). - **What to pass as `from_tx_hash`:** the EVM transaction hash, the Solana transaction signature, or — on TON, where your wallet never learns the treasury-side hash — the **`quoteId`** itself. See [Orders](https://docs.drip.tools/api/orders.md#get-api-order). Other tracking options: - **By order id** — [`GET /api/order?id=`](https://docs.drip.tools/api/orders.md#get-api-order). - **Large batches** — [`GET /api/order?from_tx_hash=…&paginate=1`](https://docs.drip.tools/api/orders.md#paginated-recipients-paginate-1) returns an aggregate summary plus a page of recipients instead of the whole array. - **Bulk** — [`POST /api/orders/status`](https://docs.drip.tools/api/orders.md#post-api-orders-status) with `{ ids: [...] }`. - **History** — [`GET /api/orders?address=&view=batches`](https://docs.drip.tools/api/orders.md#get-api-orders). - **Wait estimate** — [`GET /api/orders/load?ref=`](https://docs.drip.tools/api/orders.md#get-api-orders-load) returns your position in the dispense queue. - **Push instead of poll** — [`GET /api/orders/stream`](https://docs.drip.tools/api/orders.md#get-api-orders-stream) is a Server-Sent Events feed that fires whenever one of your orders changes. Ready-to-run scripts for every variant are in [Examples](https://docs.drip.tools/swap/examples.md). --- # Depository Contract https://docs.drip.tools/swap/contract The **DripToolsDepository** is the on-chain half of a swap on the **EVM** FROM chains. It records that a payer paid a certain amount for a `quoteId` and emits a `Paid` event; everything else — recipients, TO chain, TO amounts — lives off-chain in the [signed quote](https://docs.drip.tools/api/quote.md). This keeps the on-chain call tiny and identical whether you swap to one recipient or thousands. > **Info: Solana and TON have no contract** > > On the Solana and TON FROM chains the payment is a plain transfer to a treasury account with the > `quoteId` as memo / comment. See [Paying from Solana & TON](https://docs.drip.tools/swap/memo.md). The > [quote signature](https://docs.drip.tools/swap/contract.md#verifying-the-quote-signature-optional) described below is the same on every > FROM chain. ## Addresses There is **one pay target per FROM (mainnet) chain**. Never hardcode it — read it at runtime: - From [`GET /api/chains`](https://docs.drip.tools/api/discovery.md#get-api-chains): `chain.contracts.depository`. - From the [quote response](https://docs.drip.tools/api/quote.md): the `vault` field is the same address, lowercased — compare case-insensitively. If a FROM chain has no `contracts.depository`, programmatic swaps aren't enabled there yet. ## Functions All four payment functions take the `quoteId` and `deadline` from your quote. The paid amount must equal the quote's `fromTotal`, and the transaction must land before `deadline`. | Function | For | Notes | | --- | --- | --- | | `pay(bytes32 quoteId, uint256 deadline)` | Native FROM token | `payable`; `msg.value == fromTotal`. | | `payWithToken(address token, uint256 amount, bytes32 quoteId, uint256 deadline)` | ERC-20 | Requires a prior `approve(payTarget, amount)`; `amount == fromTotal`. | | `payWithTokenPermit(address token, uint256 amount, bytes32 quoteId, uint256 deadline, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s)` | ERC-20 with EIP-2612 | No approve tx — the permit signature authorizes the pull in the same call. A permit that fails is ignored; the pull then relies on an existing allowance. | | `payWithPermit2(address token, uint256 amount, bytes32 quoteId, uint256 deadline, uint256 permit2Nonce, uint256 permit2Deadline, bytes permit2Signature)` | Any ERC-20 via Uniswap Permit2 | Uses a Permit2 allowance signature instead of EIP-2612. | `token` must be the same FROM token you quoted (`quote.fromToken`). A fee-on-transfer token that delivers less than `amount` reverts with `TokenTransferMismatch`; an expired `deadline` reverts with `Expired`. > **Warning: The contract does not know `fromTotal`** > > The contract only checks a per-token minimum. Paying a different amount than the quote's > `fromTotal` succeeds on-chain but is **not** honoured off-chain — the payment is held for > operator review instead of becoming an order. Always pass exactly `fromTotal`. ## ABI Minimal ABI for the payment path (viem `parseAbi` form): **JavaScript · viem** ```js const depositoryAbi = parseAbi([ 'function pay(bytes32 quoteId, uint256 deadline) payable', 'function payWithToken(address token, uint256 amount, bytes32 quoteId, uint256 deadline)', 'function payWithTokenPermit(address token, uint256 amount, bytes32 quoteId, uint256 deadline, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s)', 'function payWithPermit2(address token, uint256 amount, bytes32 quoteId, uint256 deadline, uint256 permit2Nonce, uint256 permit2Deadline, bytes permit2Signature)', 'event Paid(address indexed payer, address indexed fromToken, uint256 fromAmount, bytes32 indexed quoteId)', ]) ``` ## Reverts you can hit The contract uses custom errors — decode a revert against this list: | Error | Meaning | | --- | --- | | `Expired` | The transaction landed after `deadline`. Request a fresh quote. | | `MissingQuote` | `quoteId` is zero — you passed an empty quote id. | | `AmountTooLow` / `ZeroAmount` | The paid amount is zero or below the contract's per-token floor. | | `TokenTransferMismatch` | The token amount received ≠ `amount` (fee-on-transfer / rebasing tokens are not supported). | | `TokenNotAllowed` | The FROM token isn't accepted by this contract — use one listed in `/api/chains`. | | `DirectPaymentNotAllowed` | A plain ETH transfer to the contract (no `pay` call) was rejected. | | `EnforcedPause` | The contract is paused by the operator. Try later. | ## Verifying the quote signature (optional) The quote returned by [`POST /api/quote`](https://docs.drip.tools/api/quote.md) is signed with EIP-712, scheme version **3**, by the address in `signer`. You don't have to check it — the backend does — but you can, to make sure a quote really came from Drip Tools before spending. **Domain** **EIP-712 domain** ```js { name: 'drip.tools quote', version: '3', chainId: , // number — the cross-chain replay separator } ``` There is **no `verifyingContract`** in the domain: the pay target is signed as the `depository` message field instead, because a Solana or TON treasury does not fit a 20-byte address slot. The binding is the same — a quote is tied to exactly one (FROM chain, depository) pair and cannot be replayed against another chain or deployment. **Types** **EIP-712 types** ```js Quote: [ { name: 'quoteId', type: 'bytes32' }, { name: 'depository', type: 'string' }, // == quote.vault { name: 'sender', type: 'string' }, { name: 'fromToken', type: 'string' }, { name: 'fromTotal', type: 'uint256' }, { name: 'toChainId', type: 'uint256' }, { name: 'toToken', type: 'string' }, { name: 'recipients', type: 'string[]' }, { name: 'toAmounts', type: 'uint256[]' }, { name: 'issuedAt', type: 'uint256' }, { name: 'expiry', type: 'uint256' }, ] ``` **Canonical string forms.** Every `string` field is signed in the form the API derived from your request, so reproduce it exactly when verifying: | Field | Canonical form | | --- | --- | | `depository`, `sender`, `fromToken` | Per the **FROM** chain's family. EVM: lowercased `0x…`. Solana: the base58 key exactly as sent. TON: the non-bounceable, url-safe `UQ…` form. The native sentinel `0x000…0` stays as is on every family. | | `toToken` | Lowercased `0x…` (every TO chain; `0x000…0` for native). | | `recipients[]` | Per the **TO** chain's family: EVM lowercased; TON, Solana and Bitcoin addresses exactly as sent (trimmed). | `depository` and `fromToken` come back in canonical form on the quote (`vault`, `fromToken`); `sender` and `recipients` are yours to canonicalize. **Worked example** (viem): **JavaScript · viem** ```js import { verifyTypedData } from 'viem' const ok = await verifyTypedData({ address: quote.signer, domain: { name: 'drip.tools quote', version: '3', chainId: FROM_CHAIN_ID }, types: { Quote: [ { name: 'quoteId', type: 'bytes32' }, { name: 'depository', type: 'string' }, { name: 'sender', type: 'string' }, { name: 'fromToken', type: 'string' }, { name: 'fromTotal', type: 'uint256' }, { name: 'toChainId', type: 'uint256' }, { name: 'toToken', type: 'string' }, { name: 'recipients', type: 'string[]' }, { name: 'toAmounts', type: 'uint256[]' }, { name: 'issuedAt', type: 'uint256' }, { name: 'expiry', type: 'uint256' }, ], }, primaryType: 'Quote', message: { quoteId: quote.quoteId, depository: quote.vault, // as returned sender: account.address.toLowerCase(), // EVM: lowercased; Solana: as sent; TON: UQ… form fromToken: quote.fromToken, // as returned fromTotal: BigInt(quote.fromTotal), toChainId: BigInt(TO_CHAIN_ID), toToken: TO_TOKEN.toLowerCase(), recipients: [RECIPIENT.toLowerCase()], // EVM recipients lowercased; TON/Solana/BTC as sent toAmounts: quote.toAmounts.map(BigInt), issuedAt: BigInt(quote.issuedAt), expiry: BigInt(quote.expiry), }, signature: quote.signature, }) if (!ok) throw new Error('Quote signature does not verify — do not pay') ``` --- # Paying from Solana & TON https://docs.drip.tools/swap/memo On the Solana and TON FROM chains there is **no contract**. A swap is still the same four steps — discover, quote, pay, track — but the payment is a **plain transfer to the treasury that carries the `quoteId`**: in an SPL Memo instruction on Solana, as the text comment on TON. The backend reads the transfer straight from the chain and matches it against the signed quote. Because nothing on-chain validates the payment, the **transaction shape is the protocol**. Build it exactly as described here, or run the scripts below as they are. ## The rules in short - The transfer is sent by the wallet you quoted as `sender` (on Solana: the fee payer). - The treasury receives **exactly `fromTotal`** of the quoted token. Nothing else moves to it. - The memo / comment is the `quoteId` verbatim (`0x` + 64 hex), once. - It lands before the quote's `deadline`. Nothing on-chain stops a late transfer — it becomes a refund case instead of an order. A payment that breaks the payer / token / amount rule is held for operator review and is **not refunded automatically**. The full table, with every outcome, is on [Payment rules](https://docs.drip.tools/reference/lifecycle.md#payment-rules). > **Tip: Timing** > > A quote lives 180 s. Request it right before you send; if fewer than ~20 s remain when you are > about to broadcast, request a fresh one. On TON, set the message's `validUntil` to the quote > `deadline` so the network drops a late broadcast for you. ## Solana **FROM chain id `1399811149`** (`family: "solana"`). Tokens: native SOL (`0x000…0`), USDC and USDT (base58 mints — read them from `/api/chains`). The treasury is `contracts.depository` on that chain. **Transaction shape** - **Native SOL:** one `SystemProgram.transfer` from your wallet to the treasury for exactly `fromTotal` lamports. - **SPL token (USDC / USDT):** a `TransferChecked` from your associated token account to the **treasury's associated token account** for exactly `fromTotal` units of the mint, preceded by an Associated Token Account **`CreateIdempotent`** for the treasury's account (a no-op when it exists; otherwise you fund its rent, ~0.002 SOL). Classic Token program only. - **Memo:** an SPL Memo instruction (program `MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr`; the v1 program is accepted too) whose data is the `quoteId` as UTF-8 text. No signer accounts are needed on the memo. - Your wallet is the **fee payer**. Failed transactions are ignored. Payments are indexed at `finalized` commitment, typically 15–30 s after confirmation. ### Native SOL Two instructions in one transaction — the transfer and the memo: **JavaScript · @solana/web3.js** — excerpt from `examples/js/solana-native.mjs` ```js // 3. Pay: transfer EXACTLY fromTotal to the treasury, plus the quoteId as a memo. // Your wallet is the fee payer — it must be the `sender` you quoted. const tx = new Transaction().add( SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: treasury, lamports: BigInt(quote.fromTotal) }), new TransactionInstruction({ programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from(quote.quoteId, 'utf8') }), ) if (DRY_RUN) { tx.feePayer = payer.publicKey tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash tx.sign(payer) console.log('DRY_RUN — signed tx', tx.serialize().length, 'bytes, signature', bs58.encode(tx.signature)) process.exit(0) } const signature = await sendAndConfirmTransaction(connection, tx, [payer]) console.log('Paid', signature) ``` **Python · solana-py** — excerpt from `examples/python/solana_native.py` ```python # 3. Pay: transfer EXACTLY fromTotal to the treasury + the quoteId as a memo. # Your wallet is the fee payer — it must be the `sender` you quoted. async with AsyncClient(solana["rpc"][0]) as client: blockhash = (await client.get_latest_blockhash()).value.blockhash instructions = [ transfer(TransferParams(from_pubkey=payer.pubkey(), to_pubkey=treasury, lamports=int(quote["fromTotal"]))), Instruction(MEMO_PROGRAM_ID, quote["quoteId"].encode(), []), ] message = Message.new_with_blockhash(instructions, payer.pubkey(), blockhash) tx = VersionedTransaction(message, [payer]) if DRY_RUN: print("DRY_RUN — signed tx", len(bytes(tx)), "bytes, signature", tx.signatures[0]) return signature = (await client.send_transaction(tx)).value await client.confirm_transaction(signature, "confirmed") print("Paid", signature) ``` **Full script — discover, quote, pay, track** **JavaScript · @solana/web3.js** — `examples/js/solana-native.mjs` ```js // Drip Tools — pay from Solana with native SOL (@solana/web3.js). // A plain transfer to the treasury + the quoteId in an SPL Memo instruction. No program call. // Run: SOLANA_SECRET_KEY= node solana-native.mjs // Dry run: DRY_RUN=1 node solana-native.mjs (real quote, builds + signs, sends nothing) import { Connection, Keypair, PublicKey, SystemProgram, Transaction, TransactionInstruction, sendAndConfirmTransaction, } from '@solana/web3.js' import bs58 from 'bs58' const API = 'https://drip.tools/api' const MEMO_PROGRAM_ID = new PublicKey('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr') const FROM_CHAIN_ID = 1399811149 // Solana mainnet const TO_CHAIN_ID = Number(process.env.TO_CHAIN_ID ?? 421614) // Arbitrum Sepolia const RECIPIENT = process.env.RECIPIENT ?? '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A' // ← YOUR testnet wallet const TO_AMOUNT = process.env.TO_AMOUNT ?? '5000000000000000000' // 5 testnet ETH (wei) const NATIVE = '0x0000000000000000000000000000000000000000' const DRY_RUN = process.env.DRY_RUN === '1' if (!DRY_RUN && RECIPIENT === '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A') throw new Error('Set RECIPIENT to your own testnet wallet') if (!DRY_RUN && !process.env.SOLANA_SECRET_KEY) throw new Error('Set SOLANA_SECRET_KEY') const payer = process.env.SOLANA_SECRET_KEY ? Keypair.fromSecretKey(bs58.decode(process.env.SOLANA_SECRET_KEY)) : Keypair.generate() // 1. Discover: RPC + the treasury (the pay target on Solana) const chains = await fetch(`${API}/chains?unwrap=true`).then(r => r.json()) const solana = chains.find(c => c.chainId === String(FROM_CHAIN_ID)) const connection = new Connection(solana.rpc[0], 'confirmed') const treasury = new PublicKey(solana.contracts.depository) // 2. Quote — `sender` is your base58 public key, exactly as the wallet reports it const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: NATIVE, sender: payer.publicKey.toBase58(), toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) console.log(`Cost ${quote.fromTotal} lamports (~$${quote.usdTotal}) → treasury ${quote.vault}`) // 3. Pay: transfer EXACTLY fromTotal to the treasury, plus the quoteId as a memo. // Your wallet is the fee payer — it must be the `sender` you quoted. const tx = new Transaction().add( SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: treasury, lamports: BigInt(quote.fromTotal) }), new TransactionInstruction({ programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from(quote.quoteId, 'utf8') }), ) if (DRY_RUN) { tx.feePayer = payer.publicKey tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash tx.sign(payer) console.log('DRY_RUN — signed tx', tx.serialize().length, 'bytes, signature', bs58.encode(tx.signature)) process.exit(0) } const signature = await sendAndConfirmTransaction(connection, tx, [payer]) console.log('Paid', signature) // 4. Track by the transaction signature (indexed at `finalized`, usually 15–30 s) for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${signature}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { for (const o of data) console.log(`${o.to.address}: ${o.status} ${o.to.txHash ?? ''}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **Python · solana-py** — `examples/python/solana_native.py` ```python """Drip Tools — pay from Solana with native SOL (solana-py + solders). A plain transfer to the treasury + the quoteId in an SPL Memo instruction. No program call. Run: SOLANA_SECRET_KEY= python solana_native.py Dry run: DRY_RUN=1 python solana_native.py (real quote, builds + signs, sends nothing) """ import asyncio import os import requests from solana.rpc.async_api import AsyncClient from solders.instruction import Instruction from solders.keypair import Keypair from solders.message import Message from solders.pubkey import Pubkey from solders.system_program import TransferParams, transfer from solders.transaction import VersionedTransaction from spl.memo.constants import MEMO_PROGRAM_ID API = "https://drip.tools/api" FROM_CHAIN_ID = 1399811149 # Solana mainnet TO_CHAIN_ID = int(os.environ.get("TO_CHAIN_ID", 421614)) # Arbitrum Sepolia RECIPIENT = os.environ.get("RECIPIENT", "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A") # ← YOUR testnet wallet TO_AMOUNT = os.environ.get("TO_AMOUNT", "5000000000000000000") # 5 testnet ETH (wei) NATIVE = "0x0000000000000000000000000000000000000000" DRY_RUN = os.environ.get("DRY_RUN") == "1" if not DRY_RUN and RECIPIENT == "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A": raise SystemExit("Set RECIPIENT to your own testnet wallet") payer = Keypair.from_base58_string(os.environ["SOLANA_SECRET_KEY"]) if "SOLANA_SECRET_KEY" in os.environ else Keypair() if not DRY_RUN and "SOLANA_SECRET_KEY" not in os.environ: raise SystemExit("Set SOLANA_SECRET_KEY") async def main(): # 1. Discover: RPC + the treasury (the pay target on Solana) chains = requests.get(f"{API}/chains", params={"unwrap": "true"}, timeout=30).json() solana = next(c for c in chains if c["chainId"] == str(FROM_CHAIN_ID)) treasury = Pubkey.from_string(solana["contracts"]["depository"]) # 2. Quote — `sender` is your base58 public key quote = requests.post(f"{API}/quote", json={ "chainId": FROM_CHAIN_ID, "fromToken": NATIVE, "sender": str(payer.pubkey()), "toChainId": TO_CHAIN_ID, "toToken": NATIVE, "recipients": [RECIPIENT], "toAmounts": [TO_AMOUNT], }, timeout=30).json() if "error" in quote: raise SystemExit(f"{quote.get('code', 'ERROR')}: {quote['error']}") print(f"Cost {quote['fromTotal']} lamports (~${quote['usdTotal']}) → treasury {quote['vault']}") # 3. Pay: transfer EXACTLY fromTotal to the treasury + the quoteId as a memo. # Your wallet is the fee payer — it must be the `sender` you quoted. async with AsyncClient(solana["rpc"][0]) as client: blockhash = (await client.get_latest_blockhash()).value.blockhash instructions = [ transfer(TransferParams(from_pubkey=payer.pubkey(), to_pubkey=treasury, lamports=int(quote["fromTotal"]))), Instruction(MEMO_PROGRAM_ID, quote["quoteId"].encode(), []), ] message = Message.new_with_blockhash(instructions, payer.pubkey(), blockhash) tx = VersionedTransaction(message, [payer]) if DRY_RUN: print("DRY_RUN — signed tx", len(bytes(tx)), "bytes, signature", tx.signatures[0]) return signature = (await client.send_transaction(tx)).value await client.confirm_transaction(signature, "confirmed") print("Paid", signature) # 4. Track by the transaction signature (indexed at `finalized`, usually 15–30 s) while True: data = requests.get(f"{API}/order", params={"from_tx_hash": str(signature)}, timeout=30).json()["data"] if data and all(o["status"] in ("completed", "failed") for o in data): for o in data: print(f"{o['to']['address']}: {o['status']} {o['to']['txHash'] or ''}") break await asyncio.sleep(5) asyncio.run(main()) ``` ### SPL token (USDC / USDT) `fromToken` is the mint address from `/api/chains`; `quote.fromToken` echoes it. Keep a little SOL in the paying wallet for the fee and the possible rent of the treasury's token account (~0.003 SOL covers both). Three instructions: create the treasury's token account if missing, `TransferChecked`, memo: **JavaScript · @solana/spl-token** — excerpt from `examples/js/solana-spl.mjs` ```js // 3. Pay: create the treasury's token account if missing (no-op otherwise), transfer EXACTLY // fromTotal with TransferChecked, and add the quoteId memo. Keep ~0.003 SOL for fee + rent. const mint = new PublicKey(quote.fromToken) const payerAta = getAssociatedTokenAddressSync(mint, payer.publicKey) const treasuryAta = getAssociatedTokenAddressSync(mint, treasury) const tx = new Transaction().add( createAssociatedTokenAccountIdempotentInstruction(payer.publicKey, treasuryAta, treasury, mint), createTransferCheckedInstruction(payerAta, mint, treasuryAta, payer.publicKey, BigInt(quote.fromTotal), token.decimals), new TransactionInstruction({ programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from(quote.quoteId, 'utf8') }), ) if (DRY_RUN) { tx.feePayer = payer.publicKey tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash tx.sign(payer) console.log('DRY_RUN — signed tx', tx.serialize().length, 'bytes, signature', bs58.encode(tx.signature)) process.exit(0) } const signature = await sendAndConfirmTransaction(connection, tx, [payer]) console.log('Paid', signature) ``` **Python · solana-py** — excerpt from `examples/python/solana_spl.py` ```python # 3. Pay: create the treasury's token account if missing (no-op otherwise), transfer EXACTLY # fromTotal with TransferChecked, and add the quoteId memo. Keep ~0.003 SOL for fee + rent. mint = Pubkey.from_string(quote["fromToken"]) payer_ata = get_associated_token_address(payer.pubkey(), mint) treasury_ata = get_associated_token_address(treasury, mint) async with AsyncClient(solana["rpc"][0]) as client: blockhash = (await client.get_latest_blockhash()).value.blockhash instructions = [ create_idempotent_associated_token_account(payer.pubkey(), treasury, mint), transfer_checked(TransferCheckedParams( program_id=TOKEN_PROGRAM_ID, source=payer_ata, mint=mint, dest=treasury_ata, owner=payer.pubkey(), amount=int(quote["fromTotal"]), decimals=token["decimals"], )), Instruction(MEMO_PROGRAM_ID, quote["quoteId"].encode(), []), ] message = Message.new_with_blockhash(instructions, payer.pubkey(), blockhash) tx = VersionedTransaction(message, [payer]) if DRY_RUN: print("DRY_RUN — signed tx", len(bytes(tx)), "bytes, signature", tx.signatures[0]) return signature = (await client.send_transaction(tx)).value await client.confirm_transaction(signature, "confirmed") print("Paid", signature) ``` **Full script — discover, quote, pay, track** **JavaScript · @solana/spl-token** — `examples/js/solana-spl.mjs` ```js // Drip Tools — pay from Solana with an SPL token (USDC / USDT), @solana/web3.js + @solana/spl-token. // TransferChecked to the treasury's associated token account + the quoteId in an SPL Memo. // Run: SOLANA_SECRET_KEY= node solana-spl.mjs // Dry run: DRY_RUN=1 node solana-spl.mjs import { Connection, Keypair, PublicKey, Transaction, TransactionInstruction, sendAndConfirmTransaction, } from '@solana/web3.js' import { getAssociatedTokenAddressSync, createAssociatedTokenAccountIdempotentInstruction, createTransferCheckedInstruction, } from '@solana/spl-token' import bs58 from 'bs58' const API = 'https://drip.tools/api' const MEMO_PROGRAM_ID = new PublicKey('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr') const FROM_CHAIN_ID = 1399811149 // Solana mainnet const FROM_SYMBOL = process.env.FROM_SYMBOL ?? 'USDC' // USDC or USDT const TO_CHAIN_ID = Number(process.env.TO_CHAIN_ID ?? 421614) // Arbitrum Sepolia const RECIPIENT = process.env.RECIPIENT ?? '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A' // ← YOUR testnet wallet const TO_AMOUNT = process.env.TO_AMOUNT ?? '5000000000000000000' // 5 testnet ETH (wei) const NATIVE = '0x0000000000000000000000000000000000000000' const DRY_RUN = process.env.DRY_RUN === '1' if (!DRY_RUN && RECIPIENT === '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A') throw new Error('Set RECIPIENT to your own testnet wallet') if (!DRY_RUN && !process.env.SOLANA_SECRET_KEY) throw new Error('Set SOLANA_SECRET_KEY') const payer = process.env.SOLANA_SECRET_KEY ? Keypair.fromSecretKey(bs58.decode(process.env.SOLANA_SECRET_KEY)) : Keypair.generate() // 1. Discover: RPC, treasury, and the mint of the token you pay with const chains = await fetch(`${API}/chains?unwrap=true`).then(r => r.json()) const solana = chains.find(c => c.chainId === String(FROM_CHAIN_ID)) const token = solana.tokens.find(t => t.symbol === FROM_SYMBOL) // { address: , decimals } const connection = new Connection(solana.rpc[0], 'confirmed') const treasury = new PublicKey(solana.contracts.depository) // 2. Quote — fromToken is the mint address from /api/chains const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: token.address, sender: payer.publicKey.toBase58(), toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) console.log(`Cost ${quote.fromTotal} ${FROM_SYMBOL} base units (~$${quote.usdTotal})`) // 3. Pay: create the treasury's token account if missing (no-op otherwise), transfer EXACTLY // fromTotal with TransferChecked, and add the quoteId memo. Keep ~0.003 SOL for fee + rent. const mint = new PublicKey(quote.fromToken) const payerAta = getAssociatedTokenAddressSync(mint, payer.publicKey) const treasuryAta = getAssociatedTokenAddressSync(mint, treasury) const tx = new Transaction().add( createAssociatedTokenAccountIdempotentInstruction(payer.publicKey, treasuryAta, treasury, mint), createTransferCheckedInstruction(payerAta, mint, treasuryAta, payer.publicKey, BigInt(quote.fromTotal), token.decimals), new TransactionInstruction({ programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from(quote.quoteId, 'utf8') }), ) if (DRY_RUN) { tx.feePayer = payer.publicKey tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash tx.sign(payer) console.log('DRY_RUN — signed tx', tx.serialize().length, 'bytes, signature', bs58.encode(tx.signature)) process.exit(0) } const signature = await sendAndConfirmTransaction(connection, tx, [payer]) console.log('Paid', signature) // 4. Track by the transaction signature for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${signature}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { for (const o of data) console.log(`${o.to.address}: ${o.status} ${o.to.txHash ?? ''}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **Python · solana-py** — `examples/python/solana_spl.py` ```python """Drip Tools — pay from Solana with an SPL token (USDC / USDT), solana-py + solders. TransferChecked to the treasury's associated token account + the quoteId in an SPL Memo. Run: SOLANA_SECRET_KEY= python solana_spl.py Dry run: DRY_RUN=1 python solana_spl.py """ import asyncio import os import requests from solana.rpc.async_api import AsyncClient from solders.instruction import Instruction from solders.keypair import Keypair from solders.message import Message from solders.pubkey import Pubkey from solders.transaction import VersionedTransaction from spl.memo.constants import MEMO_PROGRAM_ID from spl.token.constants import TOKEN_PROGRAM_ID from spl.token.instructions import ( create_idempotent_associated_token_account, get_associated_token_address, transfer_checked, ) from spl.token.models import TransferCheckedParams API = "https://drip.tools/api" FROM_CHAIN_ID = 1399811149 # Solana mainnet FROM_SYMBOL = os.environ.get("FROM_SYMBOL", "USDC") # USDC or USDT TO_CHAIN_ID = int(os.environ.get("TO_CHAIN_ID", 421614)) # Arbitrum Sepolia RECIPIENT = os.environ.get("RECIPIENT", "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A") # ← YOUR testnet wallet TO_AMOUNT = os.environ.get("TO_AMOUNT", "5000000000000000000") # 5 testnet ETH (wei) NATIVE = "0x0000000000000000000000000000000000000000" DRY_RUN = os.environ.get("DRY_RUN") == "1" if not DRY_RUN and RECIPIENT == "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A": raise SystemExit("Set RECIPIENT to your own testnet wallet") payer = Keypair.from_base58_string(os.environ["SOLANA_SECRET_KEY"]) if "SOLANA_SECRET_KEY" in os.environ else Keypair() if not DRY_RUN and "SOLANA_SECRET_KEY" not in os.environ: raise SystemExit("Set SOLANA_SECRET_KEY") async def main(): # 1. Discover: RPC, treasury, and the mint of the token you pay with chains = requests.get(f"{API}/chains", params={"unwrap": "true"}, timeout=30).json() solana = next(c for c in chains if c["chainId"] == str(FROM_CHAIN_ID)) token = next(t for t in solana["tokens"] if t["symbol"] == FROM_SYMBOL) # address = mint, decimals treasury = Pubkey.from_string(solana["contracts"]["depository"]) # 2. Quote — fromToken is the mint address from /api/chains quote = requests.post(f"{API}/quote", json={ "chainId": FROM_CHAIN_ID, "fromToken": token["address"], "sender": str(payer.pubkey()), "toChainId": TO_CHAIN_ID, "toToken": NATIVE, "recipients": [RECIPIENT], "toAmounts": [TO_AMOUNT], }, timeout=30).json() if "error" in quote: raise SystemExit(f"{quote.get('code', 'ERROR')}: {quote['error']}") print(f"Cost {quote['fromTotal']} {FROM_SYMBOL} base units (~${quote['usdTotal']})") # 3. Pay: create the treasury's token account if missing (no-op otherwise), transfer EXACTLY # fromTotal with TransferChecked, and add the quoteId memo. Keep ~0.003 SOL for fee + rent. mint = Pubkey.from_string(quote["fromToken"]) payer_ata = get_associated_token_address(payer.pubkey(), mint) treasury_ata = get_associated_token_address(treasury, mint) async with AsyncClient(solana["rpc"][0]) as client: blockhash = (await client.get_latest_blockhash()).value.blockhash instructions = [ create_idempotent_associated_token_account(payer.pubkey(), treasury, mint), transfer_checked(TransferCheckedParams( program_id=TOKEN_PROGRAM_ID, source=payer_ata, mint=mint, dest=treasury_ata, owner=payer.pubkey(), amount=int(quote["fromTotal"]), decimals=token["decimals"], )), Instruction(MEMO_PROGRAM_ID, quote["quoteId"].encode(), []), ] message = Message.new_with_blockhash(instructions, payer.pubkey(), blockhash) tx = VersionedTransaction(message, [payer]) if DRY_RUN: print("DRY_RUN — signed tx", len(bytes(tx)), "bytes, signature", tx.signatures[0]) return signature = (await client.send_transaction(tx)).value await client.confirm_transaction(signature, "confirmed") print("Paid", signature) # 4. Track by the transaction signature while True: data = requests.get(f"{API}/order", params={"from_tx_hash": str(signature)}, timeout=30).json()["data"] if data and all(o["status"] in ("completed", "failed") for o in data): for o in data: print(f"{o['to']['address']}: {o['status']} {o['to']['txHash'] or ''}") break await asyncio.sleep(5) asyncio.run(main()) ``` ### Browser wallets With a Wallet Standard wallet (Phantom, Solflare, …) build the same `Transaction` with the wallet's public key as `feePayer`, then `signAndSendTransaction`. The wallet must be the one you quoted as `sender`. ## TON **FROM chain id `1667471768`** (`family: "ton"`). Token: native Toncoin only (`GRAM`, address `0x000…0`, 9 decimals). The treasury is `contracts.depository` on that chain, given in the non-bounceable `UQ…` form. **Transaction shape** - One **internal transfer** from your wallet to the treasury for exactly `fromTotal` **nanotons**. - **Non-bounceable** (`bounce: false`). The treasury is a plain wallet; use the `UQ…` address exactly as returned. - **Text comment** = the `quoteId` (`0x` + 64 hex). A standard comment cell: 32-bit op `0` followed by the UTF-8 text, in a single cell — every wallet's "comment" / "memo" field produces exactly this. - Set the message's **`validUntil`** to the quote `deadline` (or earlier) so the network refuses a late broadcast. - Send with **mode 1 or 3** (fees paid separately, `SendMode.PAY_GAS_SEPARATELY`). With mode 0 the forward fee comes out of the value, the treasury receives less than `fromTotal`, and the payment is held for review, not refunded. Wallets and TON Connect do this by default; `@ton/ton` does not — pass `sendMode` explicitly, as the script below does. - Payments are indexed from the treasury's transactions, typically 10–20 s after they land. `sender` in the quote request can be any form of your address (`EQ…`, `UQ…`, raw `0:…`); the API normalizes it to the `UQ…` form, which is also how the payer appears on the order. > **Tip: Wallet versions** > > One mnemonic gives a different address per wallet contract version. The JavaScript script uses > **W5 (v5r1)**, the default of today's wallets, and prints the address it is about to pay from; > set `TON_WALLET=v4r2` for a v4 wallet. The Python script (pytoniq) supports v4r2 only. Fund the > address the script prints, not the one your wallet app shows, if they differ. ### The message One `internal` message: non-bounceable, exactly `fromTotal` nanotons, the `quoteId` as the text comment, valid until the quote `deadline`: **JavaScript · @ton/ton** — excerpt from `examples/js/ton.mjs` ```js // 3. Pay: ONE non-bounceable transfer of EXACTLY fromTotal, text comment = quoteId, // valid until the quote deadline so the network drops a late message. const messages = [ internal({ to: Address.parse(treasury), value: BigInt(quote.fromTotal), // nanotons bounce: false, // the treasury is a plain wallet body: quote.quoteId, // a string body becomes the text comment }), ] if (DRY_RUN) { const cell = wallet.createTransfer({ seqno: 1, secretKey: keyPair.secretKey, timeout: quote.deadline, sendMode: SEND_MODE, messages }) console.log('DRY_RUN — signed external message', cell.toBoc().toString('base64').length, 'chars (base64)') process.exit(0) } // toncenter allows ~1 request/s without a key: retry on 429 (or set TONCENTER_API_KEY) const retry = async (fn, tries = 6) => { for (let i = 0; ; i++) { try { return await fn() } catch (e) { if (i >= tries - 1 || e?.response?.status !== 429) throw e await new Promise(r => setTimeout(r, 1500 * (i + 1))) } } } const client = new TonClient({ endpoint: 'https://toncenter.com/api/v2/jsonRPC', apiKey: process.env.TONCENTER_API_KEY, // optional; raises the rate limit }) const contract = client.open(wallet) const seqno = await retry(() => contract.getSeqno()) await retry(() => contract.sendTransfer({ seqno, secretKey: keyPair.secretKey, timeout: quote.deadline, sendMode: SEND_MODE, messages })) console.log('Sent; tracking by quoteId', quote.quoteId) ``` **Python · pytoniq** — excerpt from `examples/python/ton.py` ```python # 3. Pay: ONE non-bounceable transfer of EXACTLY fromTotal, text comment = quoteId, # valid until the quote deadline so the network drops a late message. comment = begin_cell().store_uint(0, 32).store_snake_string(quote["quoteId"]).end_cell() message = WalletV4R2.create_wallet_internal_message( destination=Address(treasury), value=int(quote["fromTotal"]), body=comment, bounce=False, send_mode=3, # fees paid separately — mode 0 would deliver less than fromTotal and the payment would be held ) if DRY_RUN: body = WalletV4R2.raw_create_transfer_msg(private_key=private_key, seqno=1, wallet_id=wallet_id, messages=[message], valid_until=int(quote["deadline"])) print("DRY_RUN — signed external message", len(body.to_boc()), "bytes, from", sender) return provider = LiteBalancer.from_mainnet_config(trust_level=2) await provider.start_up() wallet = await WalletV4R2.from_data(provider, public_key=public_key, private_key=private_key) seqno = await wallet.get_seqno() body = WalletV4R2.raw_create_transfer_msg(private_key=private_key, seqno=seqno, wallet_id=wallet_id, messages=[message], valid_until=int(quote["deadline"])) await wallet.send_external(body=body) await provider.close_all() print("Sent; tracking by quoteId", quote["quoteId"]) ``` **TON Connect (browser)** — `examples/js/ton-connect.js` ```js // Drip Tools — pay from TON in the browser with TON Connect. // Same message as ton.mjs: one non-bounceable transfer, comment = quoteId, valid until the deadline. import { beginCell } from '@ton/core' // quote: the response of POST /api/quote, requested with sender = the connected wallet's address // treasury: contracts.depository of the TON chain from GET /api/chains (UQ… form) export async function payWithTonConnect(tonConnectUI, quote, treasury) { const body = beginCell().storeUint(0, 32).storeStringTail(quote.quoteId).endCell() await tonConnectUI.sendTransaction({ validUntil: quote.deadline, messages: [{ address: treasury, // UQ… → the wallet sends it non-bounceable amount: quote.fromTotal, // nanotons, as a string payload: body.toBoc().toString('base64'), }], }) // then poll GET /api/order?from_tx_hash= } ``` **Full script — discover, quote, pay, track** **JavaScript · @ton/ton** — `examples/js/ton.mjs` ```js // Drip Tools — pay from TON with native Toncoin (@ton/ton). // A non-bounceable transfer to the treasury with the quoteId as the text comment. No contract. // Run: TON_MNEMONIC="word1 … word24" TONCENTER_API_KEY=… node ton.mjs // Wallet: W5 (v5r1, the default of today's wallets) unless TON_WALLET=v4r2 — the address differs per version // Dry run: DRY_RUN=1 node ton.mjs (real quote, builds + signs the message offline, sends nothing) import { TonClient, WalletContractV4, WalletContractV5R1, internal } from '@ton/ton' import { mnemonicToPrivateKey, mnemonicNew } from '@ton/crypto' import { Address, SendMode } from '@ton/core' const API = 'https://drip.tools/api' const FROM_CHAIN_ID = 1667471768 // TON mainnet const TO_CHAIN_ID = Number(process.env.TO_CHAIN_ID ?? 421614) // Arbitrum Sepolia const RECIPIENT = process.env.RECIPIENT ?? '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A' // ← YOUR testnet wallet const TO_AMOUNT = process.env.TO_AMOUNT ?? '5000000000000000000' // 5 testnet ETH (wei) const NATIVE = '0x0000000000000000000000000000000000000000' const DRY_RUN = process.env.DRY_RUN === '1' // Fees on top of the value: with the default mode 0 the forward fee comes OUT of the value, the // treasury receives less than fromTotal, and the payment is held for review instead of honoured. const SEND_MODE = SendMode.PAY_GAS_SEPARATELY | SendMode.IGNORE_ERRORS if (!DRY_RUN && RECIPIENT === '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A') throw new Error('Set RECIPIENT to your own testnet wallet') if (!DRY_RUN && !process.env.TON_MNEMONIC) throw new Error('Set TON_MNEMONIC') const mnemonic = process.env.TON_MNEMONIC ? process.env.TON_MNEMONIC.split(' ') : await mnemonicNew() const keyPair = await mnemonicToPrivateKey(mnemonic) const WALLET = process.env.TON_WALLET ?? 'v5r1' const wallet = WALLET === 'v4r2' ? WalletContractV4.create({ workchain: 0, publicKey: keyPair.publicKey }) : WalletContractV5R1.create({ workchain: 0, publicKey: keyPair.publicKey }) // 1. Discover the treasury (the pay target on TON, given in the non-bounceable UQ… form) const chains = await fetch(`${API}/chains?unwrap=true`).then(r => r.json()) const ton = chains.find(c => c.chainId === String(FROM_CHAIN_ID)) const treasury = ton.contracts.depository // 2. Quote — `sender` is your wallet address in any form (shown here as UQ…) const sender = wallet.address.toString({ bounceable: false, urlSafe: true }) console.log(`Wallet ${sender} (${WALLET})`) const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: NATIVE, sender, toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) console.log(`Cost ${quote.fromTotal} nanotons (~$${quote.usdTotal}) → treasury ${treasury}`) // 3. Pay: ONE non-bounceable transfer of EXACTLY fromTotal, text comment = quoteId, // valid until the quote deadline so the network drops a late message. const messages = [ internal({ to: Address.parse(treasury), value: BigInt(quote.fromTotal), // nanotons bounce: false, // the treasury is a plain wallet body: quote.quoteId, // a string body becomes the text comment }), ] if (DRY_RUN) { const cell = wallet.createTransfer({ seqno: 1, secretKey: keyPair.secretKey, timeout: quote.deadline, sendMode: SEND_MODE, messages }) console.log('DRY_RUN — signed external message', cell.toBoc().toString('base64').length, 'chars (base64)') process.exit(0) } // toncenter allows ~1 request/s without a key: retry on 429 (or set TONCENTER_API_KEY) const retry = async (fn, tries = 6) => { for (let i = 0; ; i++) { try { return await fn() } catch (e) { if (i >= tries - 1 || e?.response?.status !== 429) throw e await new Promise(r => setTimeout(r, 1500 * (i + 1))) } } } const client = new TonClient({ endpoint: 'https://toncenter.com/api/v2/jsonRPC', apiKey: process.env.TONCENTER_API_KEY, // optional; raises the rate limit }) const contract = client.open(wallet) const seqno = await retry(() => contract.getSeqno()) await retry(() => contract.sendTransfer({ seqno, secretKey: keyPair.secretKey, timeout: quote.deadline, sendMode: SEND_MODE, messages })) console.log('Sent; tracking by quoteId', quote.quoteId) // 4. Track by the quoteId — your wallet does not learn the treasury-side tx hash for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${quote.quoteId}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { for (const o of data) console.log(`${o.to.address}: ${o.status} ${o.to.txHash ?? ''}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **Python · pytoniq** — `examples/python/ton.py` ```python """Drip Tools — pay from TON with native Toncoin (pytoniq). A non-bounceable transfer to the treasury with the quoteId as the text comment. No contract. Run: TON_MNEMONIC="word1 … word24" python ton.py Dry run: DRY_RUN=1 python ton.py (real quote, builds the message offline, sends nothing) """ import asyncio import os import time import requests from pytoniq import LiteBalancer, WalletV4R2 from pytoniq.contract.wallets.wallet import WALLET_V4_R2_CODE from pytoniq_core import Address, StateInit, begin_cell from pytoniq_core.crypto.keys import mnemonic_new, mnemonic_to_private_key API = "https://drip.tools/api" FROM_CHAIN_ID = 1667471768 # TON mainnet TO_CHAIN_ID = int(os.environ.get("TO_CHAIN_ID", 421614)) # Arbitrum Sepolia RECIPIENT = os.environ.get("RECIPIENT", "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A") # ← YOUR testnet wallet TO_AMOUNT = os.environ.get("TO_AMOUNT", "5000000000000000000") # 5 testnet ETH (wei) NATIVE = "0x0000000000000000000000000000000000000000" DRY_RUN = os.environ.get("DRY_RUN") == "1" if not DRY_RUN and RECIPIENT == "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A": raise SystemExit("Set RECIPIENT to your own testnet wallet") mnemonic = os.environ["TON_MNEMONIC"].split() if "TON_MNEMONIC" in os.environ else mnemonic_new(24) if not DRY_RUN and "TON_MNEMONIC" not in os.environ: raise SystemExit("Set TON_MNEMONIC") async def main(): # 1. Discover the treasury (the pay target on TON, given in the non-bounceable UQ… form) chains = requests.get(f"{API}/chains", params={"unwrap": "true"}, timeout=30).json() ton = next(c for c in chains if c["chainId"] == str(FROM_CHAIN_ID)) treasury = ton["contracts"]["depository"] # Your wallet (v4r2). The address is derived offline; a lite-client is only needed to send. public_key, private_key = mnemonic_to_private_key(mnemonic) wallet_id = 698983191 state_init = StateInit(code=WALLET_V4_R2_CODE, data=WalletV4R2.create_data_cell(public_key, wallet_id)) address = Address((0, state_init.serialize().hash)) sender = address.to_str(is_bounceable=False) # UQ… form (any form is accepted) # 2. Quote quote = requests.post(f"{API}/quote", json={ "chainId": FROM_CHAIN_ID, "fromToken": NATIVE, "sender": sender, "toChainId": TO_CHAIN_ID, "toToken": NATIVE, "recipients": [RECIPIENT], "toAmounts": [TO_AMOUNT], }, timeout=30).json() if "error" in quote: raise SystemExit(f"{quote.get('code', 'ERROR')}: {quote['error']}") print(f"Cost {quote['fromTotal']} nanotons (~${quote['usdTotal']}) → treasury {treasury}") # 3. Pay: ONE non-bounceable transfer of EXACTLY fromTotal, text comment = quoteId, # valid until the quote deadline so the network drops a late message. comment = begin_cell().store_uint(0, 32).store_snake_string(quote["quoteId"]).end_cell() message = WalletV4R2.create_wallet_internal_message( destination=Address(treasury), value=int(quote["fromTotal"]), body=comment, bounce=False, send_mode=3, # fees paid separately — mode 0 would deliver less than fromTotal and the payment would be held ) if DRY_RUN: body = WalletV4R2.raw_create_transfer_msg(private_key=private_key, seqno=1, wallet_id=wallet_id, messages=[message], valid_until=int(quote["deadline"])) print("DRY_RUN — signed external message", len(body.to_boc()), "bytes, from", sender) return provider = LiteBalancer.from_mainnet_config(trust_level=2) await provider.start_up() wallet = await WalletV4R2.from_data(provider, public_key=public_key, private_key=private_key) seqno = await wallet.get_seqno() body = WalletV4R2.raw_create_transfer_msg(private_key=private_key, seqno=seqno, wallet_id=wallet_id, messages=[message], valid_until=int(quote["deadline"])) await wallet.send_external(body=body) await provider.close_all() print("Sent; tracking by quoteId", quote["quoteId"]) # 4. Track by the quoteId — your wallet does not learn the treasury-side tx hash while True: data = requests.get(f"{API}/order", params={"from_tx_hash": quote["quoteId"]}, timeout=30).json()["data"] if data and all(o["status"] in ("completed", "failed") for o in data): for o in data: print(f"{o['to']['address']}: {o['status']} {o['to']['txHash'] or ''}") break time.sleep(5) asyncio.run(main()) ``` ### Tracking on TON Your wallet returns only the hash of the **external** message it broadcast, which is not the hash of the treasury-side transaction the backend records. Pass the **`quoteId`** as `from_tx_hash` instead — [`GET /api/order?from_tx_hash=`](https://docs.drip.tools/api/orders.md#get-api-order) resolves it once the payment is honoured. The order's `from.txHash` is then the treasury-side transaction hash (64 hex characters), which also works for every later lookup. A payment that was **refused** (expired, no readable comment) never consumes its quote, so the `quoteId` lookup stays empty — its refund shows up in the payer's history, `GET /api/orders?address=&view=batches`. **JavaScript · @ton/ton** — excerpt from `examples/js/ton.mjs` ```js // 4. Track by the quoteId — your wallet does not learn the treasury-side tx hash for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${quote.quoteId}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { for (const o of data) console.log(`${o.to.address}: ${o.status} ${o.to.txHash ?? ''}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **Python · pytoniq** — excerpt from `examples/python/ton.py` ```python # 4. Track by the quoteId — your wallet does not learn the treasury-side tx hash while True: data = requests.get(f"{API}/order", params={"from_tx_hash": quote["quoteId"]}, timeout=30).json()["data"] if data and all(o["status"] in ("completed", "failed") for o in data): for o in data: print(f"{o['to']['address']}: {o['status']} {o['to']['txHash'] or ''}") break time.sleep(5) ``` ## Checklist - [ ] `sender` in the quote = the wallet that signs and pays the fee. - [ ] Amount = `fromTotal`, in base units, exactly. - [ ] `quoteId` in the memo / comment, nothing else, once. - [ ] Only the quoted token moves to the treasury; no other transfers in the transaction. - [ ] Broadcast with plenty of the 180 s left; on TON, `validUntil = deadline`. - [ ] Track by the Solana signature, or by the `quoteId` on TON. --- # Examples https://docs.drip.tools/swap/examples Complete, ready-to-run scripts for every way to pay, in **JavaScript** and **Python**. Each one is self-contained and follows the same four steps — discover, quote, pay, track. Set the constants at the top (or override them with environment variables) and run. **JavaScript** ```bash pnpm add viem ethers @solana/web3.js @solana/spl-token bs58 @ton/ton @ton/crypto @ton/core PRIVATE_KEY=0x… node evm-native.mjs ``` **Python** ```bash pip install requests web3 solana solders pytoniq PRIVATE_KEY=0x… python evm_native.py ``` > **Tip: Try it without spending** > > `DRY_RUN=1` requests a real quote and builds the payment (Solana / TON also sign it), but sends nothing: > `DRY_RUN=1 node solana-spl.mjs`. Every script also refuses to pay the example recipient — put > **your** testnet wallet in `RECIPIENT` first. Keys: `PRIVATE_KEY` (EVM, `0x`-prefixed), `SOLANA_SECRET_KEY` (base58), `TON_MNEMONIC` (24 words; `TONCENTER_API_KEY` is optional — without it toncenter allows about one request per second and the script retries on `429`). Route overrides: `FROM_CHAIN_ID`, `TO_CHAIN_ID`, `RECIPIENT`, `TO_AMOUNT` (base units of the TO token), `FROM_TOKEN` / `FROM_SYMBOL` for token payments. ## Single swap: native token (EVM) Pay ETH on Base, deliver 5 testnet ETH on Arbitrum Sepolia (≈ $1.25 + gas). **JavaScript · viem** — `examples/js/evm-native.mjs` ```js // Drip Tools — single swap, native token, EVM FROM chain (viem). // Run: PRIVATE_KEY=0x… node evm-native.mjs // Dry run: DRY_RUN=1 node evm-native.mjs (real quote, builds the call, sends nothing) import { createPublicClient, createWalletClient, http, fallback, parseAbi, encodeFunctionData } from 'viem' import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' const API = 'https://drip.tools/api' const FROM_CHAIN_ID = Number(process.env.FROM_CHAIN_ID ?? 8453) // Base (mainnet) const TO_CHAIN_ID = Number(process.env.TO_CHAIN_ID ?? 421614) // Arbitrum Sepolia (testnet) const RECIPIENT = process.env.RECIPIENT ?? '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A' // ← YOUR testnet wallet const TO_AMOUNT = process.env.TO_AMOUNT ?? '5000000000000000000' // 5 testnet ETH, in wei const NATIVE = '0x0000000000000000000000000000000000000000' const DRY_RUN = process.env.DRY_RUN === '1' if (!DRY_RUN && RECIPIENT === '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A') throw new Error('Set RECIPIENT to your own testnet wallet') if (!DRY_RUN && !process.env.PRIVATE_KEY) throw new Error('Set PRIVATE_KEY') const account = privateKeyToAccount(process.env.PRIVATE_KEY ?? generatePrivateKey()) // 1. Discover: the FROM chain's RPC list and its pay target const chains = await fetch(`${API}/chains?unwrap=true`).then(r => r.json()) const fromChain = chains.find(c => String(c.chainId) === String(FROM_CHAIN_ID)) if (!fromChain?.contracts?.depository) throw new Error('Swaps are not enabled on this chain') // 2. Quote: you name the TO amount, the API prices the FROM side and signs the order const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: NATIVE, sender: account.address, toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) console.log(`Cost ${quote.fromTotal} wei (~$${quote.usdTotal}) → pay target ${quote.vault}`) // 3. Pay: one call, pay(quoteId, deadline), with msg.value = fromTotal — exactly const abi = parseAbi(['function pay(bytes32 quoteId, uint256 deadline) payable']) const args = [quote.quoteId, BigInt(quote.deadline)] if (DRY_RUN) { console.log('DRY_RUN — would send to', quote.vault, 'calldata', encodeFunctionData({ abi, functionName: 'pay', args })) process.exit(0) } const transport = fallback(fromChain.rpc.map((url) => http(url))) // rpc[0] first, the rest are fallbacks const publicClient = createPublicClient({ transport }) const wallet = createWalletClient({ account, transport }) const hash = await wallet.writeContract({ address: quote.vault, abi, functionName: 'pay', args, value: BigInt(quote.fromTotal), chain: null, }) console.log('Paid', hash) try { await publicClient.waitForTransactionReceipt({ hash }) } // optional: the order API below is the source of truth catch (e) { console.warn('receipt wait failed, tracking via the API instead:', e.shortMessage ?? e.message) } // 4. Track: one order per recipient — poll until every one is completed or failed for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${hash}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { for (const o of data) console.log(`${o.to.address}: ${o.status} ${o.to.txHash ?? ''}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **JavaScript · ethers** — `examples/js/evm-native-ethers.mjs` ```js // Drip Tools — single swap, native token, EVM FROM chain (ethers v6). // Run: PRIVATE_KEY=0x… node evm-native-ethers.mjs // Dry run: DRY_RUN=1 node evm-native-ethers.mjs (real quote, builds the call, sends nothing) import { ethers } from 'ethers' const API = 'https://drip.tools/api' const FROM_CHAIN_ID = Number(process.env.FROM_CHAIN_ID ?? 8453) // Base (mainnet) const TO_CHAIN_ID = Number(process.env.TO_CHAIN_ID ?? 421614) // Arbitrum Sepolia (testnet) const RECIPIENT = process.env.RECIPIENT ?? '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A' // ← YOUR testnet wallet const TO_AMOUNT = process.env.TO_AMOUNT ?? '5000000000000000000' // 5 testnet ETH, in wei const NATIVE = '0x0000000000000000000000000000000000000000' const DRY_RUN = process.env.DRY_RUN === '1' if (!DRY_RUN && RECIPIENT === '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A') throw new Error('Set RECIPIENT to your own testnet wallet') // 1. Discover: the FROM chain's RPC list and its pay target const chains = await fetch(`${API}/chains?unwrap=true`).then(r => r.json()) const fromChain = chains.find(c => String(c.chainId) === String(FROM_CHAIN_ID)) if (!fromChain?.contracts?.depository) throw new Error('Swaps are not enabled on this chain') const provider = await (async () => { // rpc[0] first, the rest are fallbacks for (const url of fromChain.rpc) { try { const p = new ethers.JsonRpcProvider(url); await p.getBlockNumber(); return p } catch {} } throw new Error('No working RPC for the FROM chain') })() if (!DRY_RUN && !process.env.PRIVATE_KEY) throw new Error('Set PRIVATE_KEY') const signer = process.env.PRIVATE_KEY ? new ethers.Wallet(process.env.PRIVATE_KEY, provider) : ethers.Wallet.createRandom(provider) // 2. Quote const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: NATIVE, sender: signer.address, toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) console.log(`Cost ${quote.fromTotal} wei (~$${quote.usdTotal}) → pay target ${quote.vault}`) // 3. Pay: pay(quoteId, deadline) with value = fromTotal — exactly const payTarget = new ethers.Contract(quote.vault, [ 'function pay(bytes32 quoteId, uint256 deadline) payable', ], signer) if (DRY_RUN) { console.log('DRY_RUN — calldata', payTarget.interface.encodeFunctionData('pay', [quote.quoteId, quote.deadline])) process.exit(0) } const tx = await payTarget.pay(quote.quoteId, quote.deadline, { value: quote.fromTotal }) try { await tx.wait() } // optional: the order API below is the source of truth catch (e) { console.warn('receipt wait failed, tracking via the API instead:', e.shortMessage ?? e.message) } console.log('Paid', tx.hash) // 4. Track for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${tx.hash}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { for (const o of data) console.log(`${o.to.address}: ${o.status} ${o.to.txHash ?? ''}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **Python · web3.py** — `examples/python/evm_native.py` ```python """Drip Tools — single swap, native token, EVM FROM chain (web3.py). Run: PRIVATE_KEY=0x… python evm_native.py Dry run: DRY_RUN=1 python evm_native.py (real quote, builds the call, sends nothing) """ import os import time import requests from eth_account import Account from web3 import Web3 from web3.middleware import ExtraDataToPOAMiddleware API = "https://drip.tools/api" FROM_CHAIN_ID = int(os.environ.get("FROM_CHAIN_ID", 8453)) # Base (mainnet) TO_CHAIN_ID = int(os.environ.get("TO_CHAIN_ID", 421614)) # Arbitrum Sepolia (testnet) RECIPIENT = os.environ.get("RECIPIENT", "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A") # ← YOUR testnet wallet TO_AMOUNT = os.environ.get("TO_AMOUNT", "5000000000000000000") # 5 testnet ETH, in wei NATIVE = "0x0000000000000000000000000000000000000000" DRY_RUN = os.environ.get("DRY_RUN") == "1" if not DRY_RUN and RECIPIENT == "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A": raise SystemExit("Set RECIPIENT to your own testnet wallet") account = Account.from_key(os.environ["PRIVATE_KEY"]) if "PRIVATE_KEY" in os.environ else Account.create() if not DRY_RUN and "PRIVATE_KEY" not in os.environ: raise SystemExit("Set PRIVATE_KEY") # 1. Discover: the FROM chain's RPC list and its pay target chains = requests.get(f"{API}/chains", params={"unwrap": "true"}, timeout=30).json() from_chain = next(c for c in chains if str(c["chainId"]) == str(FROM_CHAIN_ID)) if not from_chain.get("contracts", {}).get("depository"): raise SystemExit("Swaps are not enabled on this chain") # 2. Quote: you name the TO amount, the API prices the FROM side and signs the order quote = requests.post(f"{API}/quote", json={ "chainId": FROM_CHAIN_ID, "fromToken": NATIVE, "sender": account.address, "toChainId": TO_CHAIN_ID, "toToken": NATIVE, "recipients": [RECIPIENT], "toAmounts": [TO_AMOUNT], }, timeout=30).json() if "error" in quote: raise SystemExit(f"{quote.get('code', 'ERROR')}: {quote['error']}") print(f"Cost {quote['fromTotal']} wei (~${quote['usdTotal']}) → pay target {quote['vault']}") # 3. Pay: one call, pay(quoteId, deadline), with value = fromTotal — exactly def connect(rpcs): """rpc[0] first, the rest are fallbacks — public endpoints do go down or rate-limit.""" for url in rpcs: w3 = Web3(Web3.HTTPProvider(url, request_kwargs={"timeout": 20, "headers": {"User-Agent": "drip-tools-example/1.0"}})) w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0) # BNB, Polygon and other POA-style chains pad extraData try: if w3.eth.chain_id == FROM_CHAIN_ID: return w3 except Exception: continue raise SystemExit("No working RPC for the FROM chain") w3 = connect(from_chain["rpc"]) abi = [{"name": "pay", "type": "function", "stateMutability": "payable", "inputs": [{"name": "quoteId", "type": "bytes32"}, {"name": "deadline", "type": "uint256"}], "outputs": []}] pay_target = w3.eth.contract(address=Web3.to_checksum_address(quote["vault"]), abi=abi) call = pay_target.functions.pay(bytes.fromhex(quote["quoteId"][2:]), int(quote["deadline"])) if DRY_RUN: print("DRY_RUN — would send to", quote["vault"], "calldata", pay_target.encode_abi("pay", args=call.args)) raise SystemExit(0) tx = call.build_transaction({ "from": account.address, "value": int(quote["fromTotal"]), "nonce": w3.eth.get_transaction_count(account.address), }) signed = w3.eth.account.sign_transaction(tx, account.key) tx_hash = w3.to_hex(w3.eth.send_raw_transaction(signed.raw_transaction)) print("Paid", tx_hash) try: w3.eth.wait_for_transaction_receipt(tx_hash) # optional: the order API below is the source of truth except Exception as e: # noqa: BLE001 — a flaky public RPC must not strand a sent payment print("receipt wait failed, tracking via the API instead:", e) # 4. Track: one order per recipient — poll until every one is completed or failed while True: data = requests.get(f"{API}/order", params={"from_tx_hash": tx_hash}, timeout=30).json()["data"] if data and all(o["status"] in ("completed", "failed") for o in data): for o in data: print(f"{o['to']['address']}: {o['status']} {o['to']['txHash'] or ''}") break time.sleep(5) ``` ## ERC-20 with approve Pay USDC on Base: `approve` the pay target for `fromTotal` (skipped when the allowance already covers it), then `payWithToken`. Ethereum-mainnet USDT needs the allowance reset to `0` before a new non-zero value. **JavaScript · viem** — `examples/js/evm-erc20-approve.mjs` ```js // Drip Tools — single swap paid with an ERC-20 (approve + payWithToken), viem. // Run: PRIVATE_KEY=0x… node evm-erc20-approve.mjs // Dry run: DRY_RUN=1 node evm-erc20-approve.mjs import { createPublicClient, createWalletClient, http, fallback, parseAbi, encodeFunctionData } from 'viem' import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' const API = 'https://drip.tools/api' const FROM_CHAIN_ID = Number(process.env.FROM_CHAIN_ID ?? 8453) // Base const FROM_SYMBOL = process.env.FROM_SYMBOL ?? 'USDC' // symbol on the FROM chain, see /api/chains const TO_CHAIN_ID = Number(process.env.TO_CHAIN_ID ?? 10143) // Monad Testnet const RECIPIENT = process.env.RECIPIENT ?? '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A' // ← YOUR testnet wallet const TO_AMOUNT = process.env.TO_AMOUNT ?? '300000000000000000000' // 300 testnet MON (18 decimals) ≈ $1.50 const NATIVE = '0x0000000000000000000000000000000000000000' const DRY_RUN = process.env.DRY_RUN === '1' if (!DRY_RUN && RECIPIENT === '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A') throw new Error('Set RECIPIENT to your own testnet wallet') if (!DRY_RUN && !process.env.PRIVATE_KEY) throw new Error('Set PRIVATE_KEY') const account = privateKeyToAccount(process.env.PRIVATE_KEY ?? generatePrivateKey()) // 1. Discover const chains = await fetch(`${API}/chains?unwrap=true`).then(r => r.json()) const fromChain = chains.find(c => String(c.chainId) === String(FROM_CHAIN_ID)) if (!fromChain?.contracts?.depository) throw new Error('Swaps are not enabled on this chain') const FROM_TOKEN = process.env.FROM_TOKEN ?? fromChain.tokens.find(t => t.symbol === FROM_SYMBOL)?.address if (!FROM_TOKEN) throw new Error(`${FROM_SYMBOL} is not listed on chain ${FROM_CHAIN_ID}`) const transport = fallback(fromChain.rpc.map((url) => http(url))) // rpc[0] first, the rest are fallbacks const publicClient = createPublicClient({ transport }) const wallet = createWalletClient({ account, transport }) // 2. Quote — fromToken is the ERC-20 you pay with (must be listed for the chain) const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: FROM_TOKEN, sender: account.address, toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) const amount = BigInt(quote.fromTotal) console.log(`Cost ${amount} base units of ${FROM_TOKEN} (~$${quote.usdTotal})`) // 3. Pay: approve the pay target for fromTotal (if needed), then payWithToken const erc20 = parseAbi([ 'function approve(address spender, uint256 amount) returns (bool)', 'function allowance(address owner, address spender) view returns (uint256)', ]) const abi = parseAbi(['function payWithToken(address token, uint256 amount, bytes32 quoteId, uint256 deadline)']) const args = [quote.fromToken, amount, quote.quoteId, BigInt(quote.deadline)] if (DRY_RUN) { console.log('DRY_RUN — calldata', encodeFunctionData({ abi, functionName: 'payWithToken', args })) process.exit(0) } const allowance = await publicClient.readContract({ address: quote.fromToken, abi: erc20, functionName: 'allowance', args: [account.address, quote.vault], }) if (allowance < amount) { if (allowance > 0n) { // Ethereum-mainnet USDT refuses a non-zero → non-zero change: reset to 0 first const resetHash = await wallet.writeContract({ address: quote.fromToken, abi: erc20, functionName: 'approve', args: [quote.vault, 0n], chain: null, }) await publicClient.waitForTransactionReceipt({ hash: resetHash }) } const approveHash = await wallet.writeContract({ address: quote.fromToken, abi: erc20, functionName: 'approve', args: [quote.vault, amount], chain: null, }) await publicClient.waitForTransactionReceipt({ hash: approveHash }) } const hash = await wallet.writeContract({ address: quote.vault, abi, functionName: 'payWithToken', args, chain: null }) console.log('Paid', hash) try { await publicClient.waitForTransactionReceipt({ hash }) } // optional: the order API below is the source of truth catch (e) { console.warn('receipt wait failed, tracking via the API instead:', e.shortMessage ?? e.message) } // 4. Track for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${hash}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { for (const o of data) console.log(`${o.to.address}: ${o.status} ${o.to.txHash ?? ''}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **Python · web3.py** — `examples/python/evm_erc20_approve.py` ```python """Drip Tools — single swap paid with an ERC-20 (approve + payWithToken), web3.py. Run: PRIVATE_KEY=0x… python evm_erc20_approve.py Dry run: DRY_RUN=1 python evm_erc20_approve.py """ import os import time import requests from eth_account import Account from web3 import Web3 from web3.middleware import ExtraDataToPOAMiddleware API = "https://drip.tools/api" FROM_CHAIN_ID = int(os.environ.get("FROM_CHAIN_ID", 8453)) # Base FROM_SYMBOL = os.environ.get("FROM_SYMBOL", "USDC") # symbol on the FROM chain, see /api/chains TO_CHAIN_ID = int(os.environ.get("TO_CHAIN_ID", 10143)) # Monad Testnet RECIPIENT = os.environ.get("RECIPIENT", "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A") # ← YOUR testnet wallet TO_AMOUNT = os.environ.get("TO_AMOUNT", "300000000000000000000") # 300 testnet MON (18 decimals) ≈ $1.50 NATIVE = "0x0000000000000000000000000000000000000000" DRY_RUN = os.environ.get("DRY_RUN") == "1" if not DRY_RUN and RECIPIENT == "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A": raise SystemExit("Set RECIPIENT to your own testnet wallet") account = Account.from_key(os.environ["PRIVATE_KEY"]) if "PRIVATE_KEY" in os.environ else Account.create() if not DRY_RUN and "PRIVATE_KEY" not in os.environ: raise SystemExit("Set PRIVATE_KEY") # 1. Discover chains = requests.get(f"{API}/chains", params={"unwrap": "true"}, timeout=30).json() from_chain = next(c for c in chains if str(c["chainId"]) == str(FROM_CHAIN_ID)) if not from_chain.get("contracts", {}).get("depository"): raise SystemExit("Swaps are not enabled on this chain") FROM_TOKEN = os.environ.get("FROM_TOKEN") or next((t["address"] for t in from_chain["tokens"] if t["symbol"] == FROM_SYMBOL), None) if not FROM_TOKEN: raise SystemExit(f"{FROM_SYMBOL} is not listed on chain {FROM_CHAIN_ID}") def connect(rpcs): """rpc[0] first, the rest are fallbacks — public endpoints do go down or rate-limit.""" for url in rpcs: w3 = Web3(Web3.HTTPProvider(url, request_kwargs={"timeout": 20, "headers": {"User-Agent": "drip-tools-example/1.0"}})) w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0) # BNB, Polygon and other POA-style chains pad extraData try: if w3.eth.chain_id == FROM_CHAIN_ID: return w3 except Exception: continue raise SystemExit("No working RPC for the FROM chain") w3 = connect(from_chain["rpc"]) # 2. Quote — fromToken is the ERC-20 you pay with (must be listed for the chain) quote = requests.post(f"{API}/quote", json={ "chainId": FROM_CHAIN_ID, "fromToken": FROM_TOKEN, "sender": account.address, "toChainId": TO_CHAIN_ID, "toToken": NATIVE, "recipients": [RECIPIENT], "toAmounts": [TO_AMOUNT], }, timeout=30).json() if "error" in quote: raise SystemExit(f"{quote.get('code', 'ERROR')}: {quote['error']}") amount = int(quote["fromTotal"]) print(f"Cost {amount} base units of {FROM_TOKEN} (~${quote['usdTotal']})") # 3. Pay: approve the pay target for fromTotal (if needed), then payWithToken erc20_abi = [ {"name": "approve", "type": "function", "stateMutability": "nonpayable", "inputs": [{"name": "spender", "type": "address"}, {"name": "amount", "type": "uint256"}], "outputs": [{"name": "", "type": "bool"}]}, {"name": "allowance", "type": "function", "stateMutability": "view", "inputs": [{"name": "owner", "type": "address"}, {"name": "spender", "type": "address"}], "outputs": [{"name": "", "type": "uint256"}]}, ] pay_abi = [{"name": "payWithToken", "type": "function", "stateMutability": "nonpayable", "inputs": [{"name": "token", "type": "address"}, {"name": "amount", "type": "uint256"}, {"name": "quoteId", "type": "bytes32"}, {"name": "deadline", "type": "uint256"}], "outputs": []}] token = w3.eth.contract(address=Web3.to_checksum_address(quote["fromToken"]), abi=erc20_abi) pay_target = w3.eth.contract(address=Web3.to_checksum_address(quote["vault"]), abi=pay_abi) pay_args = [token.address, amount, bytes.fromhex(quote["quoteId"][2:]), int(quote["deadline"])] if DRY_RUN: print("DRY_RUN — calldata", pay_target.encode_abi("payWithToken", args=pay_args)) raise SystemExit(0) def send(fn_call, value=0): tx = fn_call.build_transaction({ "from": account.address, "value": value, "nonce": w3.eth.get_transaction_count(account.address), }) signed = w3.eth.account.sign_transaction(tx, account.key) tx_hash = w3.to_hex(w3.eth.send_raw_transaction(signed.raw_transaction)) try: w3.eth.wait_for_transaction_receipt(tx_hash) # nonces keep approve → pay in order even if this fails except Exception as e: # noqa: BLE001 — a flaky public RPC must not strand a sent payment print("receipt wait failed, continuing:", e) return tx_hash allowance = token.functions.allowance(account.address, pay_target.address).call() if allowance < amount: if allowance > 0: # Ethereum-mainnet USDT refuses a non-zero → non-zero change: reset to 0 first send(token.functions.approve(pay_target.address, 0)) send(token.functions.approve(pay_target.address, amount)) tx_hash = send(pay_target.functions.payWithToken(*pay_args)) print("Paid", tx_hash) # 4. Track while True: data = requests.get(f"{API}/order", params={"from_tx_hash": tx_hash}, timeout=30).json()["data"] if data and all(o["status"] in ("completed", "failed") for o in data): for o in data: print(f"{o['to']['address']}: {o['status']} {o['to']['txHash'] or ''}") break time.sleep(5) ``` ## ERC-20 with permit Skip the approve transaction with an [EIP-2612](https://eips.ethereum.org/EIPS/eip-2612) permit signature and pay in one call with `payWithTokenPermit`. Works for tokens that implement `permit` (native USDC does; Ethereum USDT does not — use approve for it). The permit `version` and domain vary by token: USDC is `'2'`, many tokens are `'1'`; with a wrong domain the permit is ignored and the call reverts on the missing allowance (an allowance that already exists is used instead) — fall back to approve. **JavaScript · viem** — `examples/js/evm-erc20-permit.mjs` ```js // Drip Tools — single swap paid with an ERC-20 via EIP-2612 permit (no approve tx), viem. // Works for tokens that implement permit (native USDC does; Ethereum USDT does not). // Run: PRIVATE_KEY=0x… node evm-erc20-permit.mjs // Dry run: DRY_RUN=1 node evm-erc20-permit.mjs import { createPublicClient, createWalletClient, http, fallback, parseAbi, encodeFunctionData } from 'viem' import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' const API = 'https://drip.tools/api' const FROM_CHAIN_ID = Number(process.env.FROM_CHAIN_ID ?? 8453) // Base const FROM_SYMBOL = process.env.FROM_SYMBOL ?? 'USDC' // symbol on the FROM chain, see /api/chains const PERMIT_VERSION = process.env.PERMIT_VERSION ?? '2' // USDC: '2'; many tokens: '1' const TO_CHAIN_ID = Number(process.env.TO_CHAIN_ID ?? 10143) // Monad Testnet const RECIPIENT = process.env.RECIPIENT ?? '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A' // ← YOUR testnet wallet const TO_AMOUNT = process.env.TO_AMOUNT ?? '300000000000000000000' // 300 testnet MON ≈ $1.50 const NATIVE = '0x0000000000000000000000000000000000000000' const DRY_RUN = process.env.DRY_RUN === '1' if (!DRY_RUN && RECIPIENT === '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A') throw new Error('Set RECIPIENT to your own testnet wallet') if (!DRY_RUN && !process.env.PRIVATE_KEY) throw new Error('Set PRIVATE_KEY') const account = privateKeyToAccount(process.env.PRIVATE_KEY ?? generatePrivateKey()) // 1. Discover const chains = await fetch(`${API}/chains?unwrap=true`).then(r => r.json()) const fromChain = chains.find(c => String(c.chainId) === String(FROM_CHAIN_ID)) if (!fromChain?.contracts?.depository) throw new Error('Swaps are not enabled on this chain') const FROM_TOKEN = process.env.FROM_TOKEN ?? fromChain.tokens.find(t => t.symbol === FROM_SYMBOL)?.address if (!FROM_TOKEN) throw new Error(`${FROM_SYMBOL} is not listed on chain ${FROM_CHAIN_ID}`) const transport = fallback(fromChain.rpc.map((url) => http(url))) // rpc[0] first, the rest are fallbacks const publicClient = createPublicClient({ transport }) const wallet = createWalletClient({ account, transport }) // 2. Quote const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: FROM_TOKEN, sender: account.address, toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) const amount = BigInt(quote.fromTotal) console.log(`Cost ${amount} base units (~$${quote.usdTotal})`) // 3. Sign an EIP-2612 permit (owner = you, spender = pay target, value = fromTotal) … const erc20 = parseAbi([ 'function name() view returns (string)', 'function nonces(address owner) view returns (uint256)', ]) const [name, nonce] = await Promise.all([ publicClient.readContract({ address: quote.fromToken, abi: erc20, functionName: 'name' }), publicClient.readContract({ address: quote.fromToken, abi: erc20, functionName: 'nonces', args: [account.address] }), ]) const permitDeadline = BigInt(Math.floor(Date.now() / 1000) + 3600) const signature = await wallet.signTypedData({ domain: { name, version: PERMIT_VERSION, chainId: FROM_CHAIN_ID, verifyingContract: quote.fromToken }, types: { Permit: [ { name: 'owner', type: 'address' }, { name: 'spender', type: 'address' }, { name: 'value', type: 'uint256' }, { name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint256' }, ], }, primaryType: 'Permit', message: { owner: account.address, spender: quote.vault, value: amount, nonce, deadline: permitDeadline }, }) const r = signature.slice(0, 66) const s = `0x${signature.slice(66, 130)}` const v = parseInt(signature.slice(130, 132), 16) // … and pay in ONE transaction: payWithTokenPermit const abi = parseAbi([ 'function payWithTokenPermit(address token, uint256 amount, bytes32 quoteId, uint256 deadline, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s)', ]) const args = [quote.fromToken, amount, quote.quoteId, BigInt(quote.deadline), permitDeadline, v, r, s] if (DRY_RUN) { console.log('DRY_RUN — calldata', encodeFunctionData({ abi, functionName: 'payWithTokenPermit', args })) process.exit(0) } const hash = await wallet.writeContract({ address: quote.vault, abi, functionName: 'payWithTokenPermit', args, chain: null }) console.log('Paid', hash) try { await publicClient.waitForTransactionReceipt({ hash }) } // optional: the order API below is the source of truth catch (e) { console.warn('receipt wait failed, tracking via the API instead:', e.shortMessage ?? e.message) } // 4. Track for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${hash}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { for (const o of data) console.log(`${o.to.address}: ${o.status} ${o.to.txHash ?? ''}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` ## Multisend Many recipients, **one** payment: the whole list goes into the quote, and `pay()` is the same call as for a single swap. Minimum order value for a multisend is **$5**; up to 10 000 recipients per quote. For batches of hundreds or thousands, poll the [paginated form](https://docs.drip.tools/api/orders.md#paginated-recipients-paginate-1) instead of the full array. **JavaScript · viem** — `examples/js/evm-multisend.mjs` ```js // Drip Tools — multisend: many recipients, ONE payment (viem). Same call as a single swap. // Run: PRIVATE_KEY=0x… node evm-multisend.mjs // Dry run: DRY_RUN=1 node evm-multisend.mjs import { createPublicClient, createWalletClient, http, fallback, parseAbi, encodeFunctionData } from 'viem' import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts' const API = 'https://drip.tools/api' const FROM_CHAIN_ID = Number(process.env.FROM_CHAIN_ID ?? 8453) // Base const TO_CHAIN_ID = Number(process.env.TO_CHAIN_ID ?? 421614) // Arbitrum Sepolia const NATIVE = '0x0000000000000000000000000000000000000000' const DRY_RUN = process.env.DRY_RUN === '1' if (!DRY_RUN && !process.env.RECIPIENTS) throw new Error('Set RECIPIENTS to your own testnet wallets') // Up to 10 000 recipients per quote; the order total must be at least $5 for a multisend. const recipients = (process.env.RECIPIENTS ?? '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A,0x9246ef7F2b7fEcE8AfD8a72271A76Cc2A8d8A3E5').split(',') const toAmounts = recipients.map(() => process.env.TO_AMOUNT ?? '25000000000000000000') // 25 testnet ETH each if (!DRY_RUN && !process.env.PRIVATE_KEY) throw new Error('Set PRIVATE_KEY') const account = privateKeyToAccount(process.env.PRIVATE_KEY ?? generatePrivateKey()) // 1. Discover const chains = await fetch(`${API}/chains?unwrap=true`).then(r => r.json()) const fromChain = chains.find(c => String(c.chainId) === String(FROM_CHAIN_ID)) if (!fromChain?.contracts?.depository) throw new Error('Swaps are not enabled on this chain') // 2. Quote — the whole recipient list goes into one signed quote const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: NATIVE, sender: account.address, toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients, toAmounts, }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) console.log(`Total ${quote.fromTotal} wei (~$${quote.usdTotal}) for ${recipients.length} recipients`) // 3. Pay — ONE pay() call covers every recipient const abi = parseAbi(['function pay(bytes32 quoteId, uint256 deadline) payable']) const args = [quote.quoteId, BigInt(quote.deadline)] if (DRY_RUN) { console.log('DRY_RUN — calldata', encodeFunctionData({ abi, functionName: 'pay', args })) process.exit(0) } const transport = fallback(fromChain.rpc.map((url) => http(url))) // rpc[0] first, the rest are fallbacks const publicClient = createPublicClient({ transport }) const wallet = createWalletClient({ account, transport }) const hash = await wallet.writeContract({ address: quote.vault, abi, functionName: 'pay', args, value: BigInt(quote.fromTotal), chain: null, }) console.log('Paid', hash) try { await publicClient.waitForTransactionReceipt({ hash }) } // optional: the order API below is the source of truth catch (e) { console.warn('receipt wait failed, tracking via the API instead:', e.shortMessage ?? e.message) } // 4. Track — one order per recipient; for thousands, use the paginated form (&paginate=1) for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${hash}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { const done = data.filter(o => o.status === 'completed').length console.log(`${done}/${data.length} delivered`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **Python · web3.py** — `examples/python/evm_multisend.py` ```python """Drip Tools — multisend: many recipients, ONE payment (web3.py). Same call as a single swap. Run: PRIVATE_KEY=0x… python evm_multisend.py Dry run: DRY_RUN=1 python evm_multisend.py """ import os import time import requests from eth_account import Account from web3 import Web3 from web3.middleware import ExtraDataToPOAMiddleware API = "https://drip.tools/api" FROM_CHAIN_ID = int(os.environ.get("FROM_CHAIN_ID", 8453)) # Base TO_CHAIN_ID = int(os.environ.get("TO_CHAIN_ID", 421614)) # Arbitrum Sepolia NATIVE = "0x0000000000000000000000000000000000000000" DRY_RUN = os.environ.get("DRY_RUN") == "1" if not DRY_RUN and "RECIPIENTS" not in os.environ: raise SystemExit("Set RECIPIENTS to your own testnet wallets") # Up to 10 000 recipients per quote; the order total must be at least $5 for a multisend. recipients = os.environ.get( "RECIPIENTS", "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A,0x9246ef7F2b7fEcE8AfD8a72271A76Cc2A8d8A3E5" ).split(",") to_amounts = [os.environ.get("TO_AMOUNT", "25000000000000000000")] * len(recipients) # 25 testnet ETH each account = Account.from_key(os.environ["PRIVATE_KEY"]) if "PRIVATE_KEY" in os.environ else Account.create() if not DRY_RUN and "PRIVATE_KEY" not in os.environ: raise SystemExit("Set PRIVATE_KEY") # 1. Discover chains = requests.get(f"{API}/chains", params={"unwrap": "true"}, timeout=30).json() from_chain = next(c for c in chains if str(c["chainId"]) == str(FROM_CHAIN_ID)) if not from_chain.get("contracts", {}).get("depository"): raise SystemExit("Swaps are not enabled on this chain") # 2. Quote — the whole recipient list goes into one signed quote quote = requests.post(f"{API}/quote", json={ "chainId": FROM_CHAIN_ID, "fromToken": NATIVE, "sender": account.address, "toChainId": TO_CHAIN_ID, "toToken": NATIVE, "recipients": recipients, "toAmounts": to_amounts, }, timeout=30).json() if "error" in quote: raise SystemExit(f"{quote.get('code', 'ERROR')}: {quote['error']}") print(f"Total {quote['fromTotal']} wei (~${quote['usdTotal']}) for {len(recipients)} recipients") # 3. Pay — ONE pay() call covers every recipient def connect(rpcs): """rpc[0] first, the rest are fallbacks — public endpoints do go down or rate-limit.""" for url in rpcs: w3 = Web3(Web3.HTTPProvider(url, request_kwargs={"timeout": 20, "headers": {"User-Agent": "drip-tools-example/1.0"}})) w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0) # BNB, Polygon and other POA-style chains pad extraData try: if w3.eth.chain_id == FROM_CHAIN_ID: return w3 except Exception: continue raise SystemExit("No working RPC for the FROM chain") w3 = connect(from_chain["rpc"]) abi = [{"name": "pay", "type": "function", "stateMutability": "payable", "inputs": [{"name": "quoteId", "type": "bytes32"}, {"name": "deadline", "type": "uint256"}], "outputs": []}] pay_target = w3.eth.contract(address=Web3.to_checksum_address(quote["vault"]), abi=abi) call = pay_target.functions.pay(bytes.fromhex(quote["quoteId"][2:]), int(quote["deadline"])) if DRY_RUN: print("DRY_RUN — calldata", pay_target.encode_abi("pay", args=call.args)) raise SystemExit(0) tx = call.build_transaction({ "from": account.address, "value": int(quote["fromTotal"]), "nonce": w3.eth.get_transaction_count(account.address), }) signed = w3.eth.account.sign_transaction(tx, account.key) tx_hash = w3.to_hex(w3.eth.send_raw_transaction(signed.raw_transaction)) print("Paid", tx_hash) try: w3.eth.wait_for_transaction_receipt(tx_hash) # optional: the order API below is the source of truth except Exception as e: # noqa: BLE001 — a flaky public RPC must not strand a sent payment print("receipt wait failed, tracking via the API instead:", e) # 4. Track — one order per recipient; for thousands, use the paginated form (&paginate=1) while True: data = requests.get(f"{API}/order", params={"from_tx_hash": tx_hash}, timeout=30).json()["data"] if data and all(o["status"] in ("completed", "failed") for o in data): done = sum(o["status"] == "completed" for o in data) print(f"{done}/{len(data)} delivered") break time.sleep(5) ``` ## Solana: native SOL A plain transfer to the treasury plus the `quoteId` in an SPL Memo instruction. Read [Paying from Solana & TON](https://docs.drip.tools/swap/memo.md) for the rules. **JavaScript · @solana/web3.js** — `examples/js/solana-native.mjs` ```js // Drip Tools — pay from Solana with native SOL (@solana/web3.js). // A plain transfer to the treasury + the quoteId in an SPL Memo instruction. No program call. // Run: SOLANA_SECRET_KEY= node solana-native.mjs // Dry run: DRY_RUN=1 node solana-native.mjs (real quote, builds + signs, sends nothing) import { Connection, Keypair, PublicKey, SystemProgram, Transaction, TransactionInstruction, sendAndConfirmTransaction, } from '@solana/web3.js' import bs58 from 'bs58' const API = 'https://drip.tools/api' const MEMO_PROGRAM_ID = new PublicKey('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr') const FROM_CHAIN_ID = 1399811149 // Solana mainnet const TO_CHAIN_ID = Number(process.env.TO_CHAIN_ID ?? 421614) // Arbitrum Sepolia const RECIPIENT = process.env.RECIPIENT ?? '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A' // ← YOUR testnet wallet const TO_AMOUNT = process.env.TO_AMOUNT ?? '5000000000000000000' // 5 testnet ETH (wei) const NATIVE = '0x0000000000000000000000000000000000000000' const DRY_RUN = process.env.DRY_RUN === '1' if (!DRY_RUN && RECIPIENT === '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A') throw new Error('Set RECIPIENT to your own testnet wallet') if (!DRY_RUN && !process.env.SOLANA_SECRET_KEY) throw new Error('Set SOLANA_SECRET_KEY') const payer = process.env.SOLANA_SECRET_KEY ? Keypair.fromSecretKey(bs58.decode(process.env.SOLANA_SECRET_KEY)) : Keypair.generate() // 1. Discover: RPC + the treasury (the pay target on Solana) const chains = await fetch(`${API}/chains?unwrap=true`).then(r => r.json()) const solana = chains.find(c => c.chainId === String(FROM_CHAIN_ID)) const connection = new Connection(solana.rpc[0], 'confirmed') const treasury = new PublicKey(solana.contracts.depository) // 2. Quote — `sender` is your base58 public key, exactly as the wallet reports it const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: NATIVE, sender: payer.publicKey.toBase58(), toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) console.log(`Cost ${quote.fromTotal} lamports (~$${quote.usdTotal}) → treasury ${quote.vault}`) // 3. Pay: transfer EXACTLY fromTotal to the treasury, plus the quoteId as a memo. // Your wallet is the fee payer — it must be the `sender` you quoted. const tx = new Transaction().add( SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: treasury, lamports: BigInt(quote.fromTotal) }), new TransactionInstruction({ programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from(quote.quoteId, 'utf8') }), ) if (DRY_RUN) { tx.feePayer = payer.publicKey tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash tx.sign(payer) console.log('DRY_RUN — signed tx', tx.serialize().length, 'bytes, signature', bs58.encode(tx.signature)) process.exit(0) } const signature = await sendAndConfirmTransaction(connection, tx, [payer]) console.log('Paid', signature) // 4. Track by the transaction signature (indexed at `finalized`, usually 15–30 s) for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${signature}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { for (const o of data) console.log(`${o.to.address}: ${o.status} ${o.to.txHash ?? ''}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **Python · solana-py** — `examples/python/solana_native.py` ```python """Drip Tools — pay from Solana with native SOL (solana-py + solders). A plain transfer to the treasury + the quoteId in an SPL Memo instruction. No program call. Run: SOLANA_SECRET_KEY= python solana_native.py Dry run: DRY_RUN=1 python solana_native.py (real quote, builds + signs, sends nothing) """ import asyncio import os import requests from solana.rpc.async_api import AsyncClient from solders.instruction import Instruction from solders.keypair import Keypair from solders.message import Message from solders.pubkey import Pubkey from solders.system_program import TransferParams, transfer from solders.transaction import VersionedTransaction from spl.memo.constants import MEMO_PROGRAM_ID API = "https://drip.tools/api" FROM_CHAIN_ID = 1399811149 # Solana mainnet TO_CHAIN_ID = int(os.environ.get("TO_CHAIN_ID", 421614)) # Arbitrum Sepolia RECIPIENT = os.environ.get("RECIPIENT", "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A") # ← YOUR testnet wallet TO_AMOUNT = os.environ.get("TO_AMOUNT", "5000000000000000000") # 5 testnet ETH (wei) NATIVE = "0x0000000000000000000000000000000000000000" DRY_RUN = os.environ.get("DRY_RUN") == "1" if not DRY_RUN and RECIPIENT == "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A": raise SystemExit("Set RECIPIENT to your own testnet wallet") payer = Keypair.from_base58_string(os.environ["SOLANA_SECRET_KEY"]) if "SOLANA_SECRET_KEY" in os.environ else Keypair() if not DRY_RUN and "SOLANA_SECRET_KEY" not in os.environ: raise SystemExit("Set SOLANA_SECRET_KEY") async def main(): # 1. Discover: RPC + the treasury (the pay target on Solana) chains = requests.get(f"{API}/chains", params={"unwrap": "true"}, timeout=30).json() solana = next(c for c in chains if c["chainId"] == str(FROM_CHAIN_ID)) treasury = Pubkey.from_string(solana["contracts"]["depository"]) # 2. Quote — `sender` is your base58 public key quote = requests.post(f"{API}/quote", json={ "chainId": FROM_CHAIN_ID, "fromToken": NATIVE, "sender": str(payer.pubkey()), "toChainId": TO_CHAIN_ID, "toToken": NATIVE, "recipients": [RECIPIENT], "toAmounts": [TO_AMOUNT], }, timeout=30).json() if "error" in quote: raise SystemExit(f"{quote.get('code', 'ERROR')}: {quote['error']}") print(f"Cost {quote['fromTotal']} lamports (~${quote['usdTotal']}) → treasury {quote['vault']}") # 3. Pay: transfer EXACTLY fromTotal to the treasury + the quoteId as a memo. # Your wallet is the fee payer — it must be the `sender` you quoted. async with AsyncClient(solana["rpc"][0]) as client: blockhash = (await client.get_latest_blockhash()).value.blockhash instructions = [ transfer(TransferParams(from_pubkey=payer.pubkey(), to_pubkey=treasury, lamports=int(quote["fromTotal"]))), Instruction(MEMO_PROGRAM_ID, quote["quoteId"].encode(), []), ] message = Message.new_with_blockhash(instructions, payer.pubkey(), blockhash) tx = VersionedTransaction(message, [payer]) if DRY_RUN: print("DRY_RUN — signed tx", len(bytes(tx)), "bytes, signature", tx.signatures[0]) return signature = (await client.send_transaction(tx)).value await client.confirm_transaction(signature, "confirmed") print("Paid", signature) # 4. Track by the transaction signature (indexed at `finalized`, usually 15–30 s) while True: data = requests.get(f"{API}/order", params={"from_tx_hash": str(signature)}, timeout=30).json()["data"] if data and all(o["status"] in ("completed", "failed") for o in data): for o in data: print(f"{o['to']['address']}: {o['status']} {o['to']['txHash'] or ''}") break await asyncio.sleep(5) asyncio.run(main()) ``` ## Solana: USDC / USDT `TransferChecked` to the treasury's associated token account (created idempotently), plus the memo. **JavaScript · @solana/spl-token** — `examples/js/solana-spl.mjs` ```js // Drip Tools — pay from Solana with an SPL token (USDC / USDT), @solana/web3.js + @solana/spl-token. // TransferChecked to the treasury's associated token account + the quoteId in an SPL Memo. // Run: SOLANA_SECRET_KEY= node solana-spl.mjs // Dry run: DRY_RUN=1 node solana-spl.mjs import { Connection, Keypair, PublicKey, Transaction, TransactionInstruction, sendAndConfirmTransaction, } from '@solana/web3.js' import { getAssociatedTokenAddressSync, createAssociatedTokenAccountIdempotentInstruction, createTransferCheckedInstruction, } from '@solana/spl-token' import bs58 from 'bs58' const API = 'https://drip.tools/api' const MEMO_PROGRAM_ID = new PublicKey('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr') const FROM_CHAIN_ID = 1399811149 // Solana mainnet const FROM_SYMBOL = process.env.FROM_SYMBOL ?? 'USDC' // USDC or USDT const TO_CHAIN_ID = Number(process.env.TO_CHAIN_ID ?? 421614) // Arbitrum Sepolia const RECIPIENT = process.env.RECIPIENT ?? '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A' // ← YOUR testnet wallet const TO_AMOUNT = process.env.TO_AMOUNT ?? '5000000000000000000' // 5 testnet ETH (wei) const NATIVE = '0x0000000000000000000000000000000000000000' const DRY_RUN = process.env.DRY_RUN === '1' if (!DRY_RUN && RECIPIENT === '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A') throw new Error('Set RECIPIENT to your own testnet wallet') if (!DRY_RUN && !process.env.SOLANA_SECRET_KEY) throw new Error('Set SOLANA_SECRET_KEY') const payer = process.env.SOLANA_SECRET_KEY ? Keypair.fromSecretKey(bs58.decode(process.env.SOLANA_SECRET_KEY)) : Keypair.generate() // 1. Discover: RPC, treasury, and the mint of the token you pay with const chains = await fetch(`${API}/chains?unwrap=true`).then(r => r.json()) const solana = chains.find(c => c.chainId === String(FROM_CHAIN_ID)) const token = solana.tokens.find(t => t.symbol === FROM_SYMBOL) // { address: , decimals } const connection = new Connection(solana.rpc[0], 'confirmed') const treasury = new PublicKey(solana.contracts.depository) // 2. Quote — fromToken is the mint address from /api/chains const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: token.address, sender: payer.publicKey.toBase58(), toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) console.log(`Cost ${quote.fromTotal} ${FROM_SYMBOL} base units (~$${quote.usdTotal})`) // 3. Pay: create the treasury's token account if missing (no-op otherwise), transfer EXACTLY // fromTotal with TransferChecked, and add the quoteId memo. Keep ~0.003 SOL for fee + rent. const mint = new PublicKey(quote.fromToken) const payerAta = getAssociatedTokenAddressSync(mint, payer.publicKey) const treasuryAta = getAssociatedTokenAddressSync(mint, treasury) const tx = new Transaction().add( createAssociatedTokenAccountIdempotentInstruction(payer.publicKey, treasuryAta, treasury, mint), createTransferCheckedInstruction(payerAta, mint, treasuryAta, payer.publicKey, BigInt(quote.fromTotal), token.decimals), new TransactionInstruction({ programId: MEMO_PROGRAM_ID, keys: [], data: Buffer.from(quote.quoteId, 'utf8') }), ) if (DRY_RUN) { tx.feePayer = payer.publicKey tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash tx.sign(payer) console.log('DRY_RUN — signed tx', tx.serialize().length, 'bytes, signature', bs58.encode(tx.signature)) process.exit(0) } const signature = await sendAndConfirmTransaction(connection, tx, [payer]) console.log('Paid', signature) // 4. Track by the transaction signature for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${signature}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { for (const o of data) console.log(`${o.to.address}: ${o.status} ${o.to.txHash ?? ''}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **Python · solana-py** — `examples/python/solana_spl.py` ```python """Drip Tools — pay from Solana with an SPL token (USDC / USDT), solana-py + solders. TransferChecked to the treasury's associated token account + the quoteId in an SPL Memo. Run: SOLANA_SECRET_KEY= python solana_spl.py Dry run: DRY_RUN=1 python solana_spl.py """ import asyncio import os import requests from solana.rpc.async_api import AsyncClient from solders.instruction import Instruction from solders.keypair import Keypair from solders.message import Message from solders.pubkey import Pubkey from solders.transaction import VersionedTransaction from spl.memo.constants import MEMO_PROGRAM_ID from spl.token.constants import TOKEN_PROGRAM_ID from spl.token.instructions import ( create_idempotent_associated_token_account, get_associated_token_address, transfer_checked, ) from spl.token.models import TransferCheckedParams API = "https://drip.tools/api" FROM_CHAIN_ID = 1399811149 # Solana mainnet FROM_SYMBOL = os.environ.get("FROM_SYMBOL", "USDC") # USDC or USDT TO_CHAIN_ID = int(os.environ.get("TO_CHAIN_ID", 421614)) # Arbitrum Sepolia RECIPIENT = os.environ.get("RECIPIENT", "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A") # ← YOUR testnet wallet TO_AMOUNT = os.environ.get("TO_AMOUNT", "5000000000000000000") # 5 testnet ETH (wei) NATIVE = "0x0000000000000000000000000000000000000000" DRY_RUN = os.environ.get("DRY_RUN") == "1" if not DRY_RUN and RECIPIENT == "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A": raise SystemExit("Set RECIPIENT to your own testnet wallet") payer = Keypair.from_base58_string(os.environ["SOLANA_SECRET_KEY"]) if "SOLANA_SECRET_KEY" in os.environ else Keypair() if not DRY_RUN and "SOLANA_SECRET_KEY" not in os.environ: raise SystemExit("Set SOLANA_SECRET_KEY") async def main(): # 1. Discover: RPC, treasury, and the mint of the token you pay with chains = requests.get(f"{API}/chains", params={"unwrap": "true"}, timeout=30).json() solana = next(c for c in chains if c["chainId"] == str(FROM_CHAIN_ID)) token = next(t for t in solana["tokens"] if t["symbol"] == FROM_SYMBOL) # address = mint, decimals treasury = Pubkey.from_string(solana["contracts"]["depository"]) # 2. Quote — fromToken is the mint address from /api/chains quote = requests.post(f"{API}/quote", json={ "chainId": FROM_CHAIN_ID, "fromToken": token["address"], "sender": str(payer.pubkey()), "toChainId": TO_CHAIN_ID, "toToken": NATIVE, "recipients": [RECIPIENT], "toAmounts": [TO_AMOUNT], }, timeout=30).json() if "error" in quote: raise SystemExit(f"{quote.get('code', 'ERROR')}: {quote['error']}") print(f"Cost {quote['fromTotal']} {FROM_SYMBOL} base units (~${quote['usdTotal']})") # 3. Pay: create the treasury's token account if missing (no-op otherwise), transfer EXACTLY # fromTotal with TransferChecked, and add the quoteId memo. Keep ~0.003 SOL for fee + rent. mint = Pubkey.from_string(quote["fromToken"]) payer_ata = get_associated_token_address(payer.pubkey(), mint) treasury_ata = get_associated_token_address(treasury, mint) async with AsyncClient(solana["rpc"][0]) as client: blockhash = (await client.get_latest_blockhash()).value.blockhash instructions = [ create_idempotent_associated_token_account(payer.pubkey(), treasury, mint), transfer_checked(TransferCheckedParams( program_id=TOKEN_PROGRAM_ID, source=payer_ata, mint=mint, dest=treasury_ata, owner=payer.pubkey(), amount=int(quote["fromTotal"]), decimals=token["decimals"], )), Instruction(MEMO_PROGRAM_ID, quote["quoteId"].encode(), []), ] message = Message.new_with_blockhash(instructions, payer.pubkey(), blockhash) tx = VersionedTransaction(message, [payer]) if DRY_RUN: print("DRY_RUN — signed tx", len(bytes(tx)), "bytes, signature", tx.signatures[0]) return signature = (await client.send_transaction(tx)).value await client.confirm_transaction(signature, "confirmed") print("Paid", signature) # 4. Track by the transaction signature while True: data = requests.get(f"{API}/order", params={"from_tx_hash": str(signature)}, timeout=30).json()["data"] if data and all(o["status"] in ("completed", "failed") for o in data): for o in data: print(f"{o['to']['address']}: {o['status']} {o['to']['txHash'] or ''}") break await asyncio.sleep(5) asyncio.run(main()) ``` ## TON One non-bounceable transfer to the treasury with the `quoteId` as the text comment, valid until the quote deadline. Track by the `quoteId`. **JavaScript · @ton/ton** — `examples/js/ton.mjs` ```js // Drip Tools — pay from TON with native Toncoin (@ton/ton). // A non-bounceable transfer to the treasury with the quoteId as the text comment. No contract. // Run: TON_MNEMONIC="word1 … word24" TONCENTER_API_KEY=… node ton.mjs // Wallet: W5 (v5r1, the default of today's wallets) unless TON_WALLET=v4r2 — the address differs per version // Dry run: DRY_RUN=1 node ton.mjs (real quote, builds + signs the message offline, sends nothing) import { TonClient, WalletContractV4, WalletContractV5R1, internal } from '@ton/ton' import { mnemonicToPrivateKey, mnemonicNew } from '@ton/crypto' import { Address, SendMode } from '@ton/core' const API = 'https://drip.tools/api' const FROM_CHAIN_ID = 1667471768 // TON mainnet const TO_CHAIN_ID = Number(process.env.TO_CHAIN_ID ?? 421614) // Arbitrum Sepolia const RECIPIENT = process.env.RECIPIENT ?? '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A' // ← YOUR testnet wallet const TO_AMOUNT = process.env.TO_AMOUNT ?? '5000000000000000000' // 5 testnet ETH (wei) const NATIVE = '0x0000000000000000000000000000000000000000' const DRY_RUN = process.env.DRY_RUN === '1' // Fees on top of the value: with the default mode 0 the forward fee comes OUT of the value, the // treasury receives less than fromTotal, and the payment is held for review instead of honoured. const SEND_MODE = SendMode.PAY_GAS_SEPARATELY | SendMode.IGNORE_ERRORS if (!DRY_RUN && RECIPIENT === '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A') throw new Error('Set RECIPIENT to your own testnet wallet') if (!DRY_RUN && !process.env.TON_MNEMONIC) throw new Error('Set TON_MNEMONIC') const mnemonic = process.env.TON_MNEMONIC ? process.env.TON_MNEMONIC.split(' ') : await mnemonicNew() const keyPair = await mnemonicToPrivateKey(mnemonic) const WALLET = process.env.TON_WALLET ?? 'v5r1' const wallet = WALLET === 'v4r2' ? WalletContractV4.create({ workchain: 0, publicKey: keyPair.publicKey }) : WalletContractV5R1.create({ workchain: 0, publicKey: keyPair.publicKey }) // 1. Discover the treasury (the pay target on TON, given in the non-bounceable UQ… form) const chains = await fetch(`${API}/chains?unwrap=true`).then(r => r.json()) const ton = chains.find(c => c.chainId === String(FROM_CHAIN_ID)) const treasury = ton.contracts.depository // 2. Quote — `sender` is your wallet address in any form (shown here as UQ…) const sender = wallet.address.toString({ bounceable: false, urlSafe: true }) console.log(`Wallet ${sender} (${WALLET})`) const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: NATIVE, sender, toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) console.log(`Cost ${quote.fromTotal} nanotons (~$${quote.usdTotal}) → treasury ${treasury}`) // 3. Pay: ONE non-bounceable transfer of EXACTLY fromTotal, text comment = quoteId, // valid until the quote deadline so the network drops a late message. const messages = [ internal({ to: Address.parse(treasury), value: BigInt(quote.fromTotal), // nanotons bounce: false, // the treasury is a plain wallet body: quote.quoteId, // a string body becomes the text comment }), ] if (DRY_RUN) { const cell = wallet.createTransfer({ seqno: 1, secretKey: keyPair.secretKey, timeout: quote.deadline, sendMode: SEND_MODE, messages }) console.log('DRY_RUN — signed external message', cell.toBoc().toString('base64').length, 'chars (base64)') process.exit(0) } // toncenter allows ~1 request/s without a key: retry on 429 (or set TONCENTER_API_KEY) const retry = async (fn, tries = 6) => { for (let i = 0; ; i++) { try { return await fn() } catch (e) { if (i >= tries - 1 || e?.response?.status !== 429) throw e await new Promise(r => setTimeout(r, 1500 * (i + 1))) } } } const client = new TonClient({ endpoint: 'https://toncenter.com/api/v2/jsonRPC', apiKey: process.env.TONCENTER_API_KEY, // optional; raises the rate limit }) const contract = client.open(wallet) const seqno = await retry(() => contract.getSeqno()) await retry(() => contract.sendTransfer({ seqno, secretKey: keyPair.secretKey, timeout: quote.deadline, sendMode: SEND_MODE, messages })) console.log('Sent; tracking by quoteId', quote.quoteId) // 4. Track by the quoteId — your wallet does not learn the treasury-side tx hash for (;;) { const { data } = await fetch(`${API}/order?from_tx_hash=${quote.quoteId}`).then(r => r.json()) if (data.length && data.every(o => o.status === 'completed' || o.status === 'failed')) { for (const o of data) console.log(`${o.to.address}: ${o.status} ${o.to.txHash ?? ''}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **Python · pytoniq** — `examples/python/ton.py` ```python """Drip Tools — pay from TON with native Toncoin (pytoniq). A non-bounceable transfer to the treasury with the quoteId as the text comment. No contract. Run: TON_MNEMONIC="word1 … word24" python ton.py Dry run: DRY_RUN=1 python ton.py (real quote, builds the message offline, sends nothing) """ import asyncio import os import time import requests from pytoniq import LiteBalancer, WalletV4R2 from pytoniq.contract.wallets.wallet import WALLET_V4_R2_CODE from pytoniq_core import Address, StateInit, begin_cell from pytoniq_core.crypto.keys import mnemonic_new, mnemonic_to_private_key API = "https://drip.tools/api" FROM_CHAIN_ID = 1667471768 # TON mainnet TO_CHAIN_ID = int(os.environ.get("TO_CHAIN_ID", 421614)) # Arbitrum Sepolia RECIPIENT = os.environ.get("RECIPIENT", "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A") # ← YOUR testnet wallet TO_AMOUNT = os.environ.get("TO_AMOUNT", "5000000000000000000") # 5 testnet ETH (wei) NATIVE = "0x0000000000000000000000000000000000000000" DRY_RUN = os.environ.get("DRY_RUN") == "1" if not DRY_RUN and RECIPIENT == "0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A": raise SystemExit("Set RECIPIENT to your own testnet wallet") mnemonic = os.environ["TON_MNEMONIC"].split() if "TON_MNEMONIC" in os.environ else mnemonic_new(24) if not DRY_RUN and "TON_MNEMONIC" not in os.environ: raise SystemExit("Set TON_MNEMONIC") async def main(): # 1. Discover the treasury (the pay target on TON, given in the non-bounceable UQ… form) chains = requests.get(f"{API}/chains", params={"unwrap": "true"}, timeout=30).json() ton = next(c for c in chains if c["chainId"] == str(FROM_CHAIN_ID)) treasury = ton["contracts"]["depository"] # Your wallet (v4r2). The address is derived offline; a lite-client is only needed to send. public_key, private_key = mnemonic_to_private_key(mnemonic) wallet_id = 698983191 state_init = StateInit(code=WALLET_V4_R2_CODE, data=WalletV4R2.create_data_cell(public_key, wallet_id)) address = Address((0, state_init.serialize().hash)) sender = address.to_str(is_bounceable=False) # UQ… form (any form is accepted) # 2. Quote quote = requests.post(f"{API}/quote", json={ "chainId": FROM_CHAIN_ID, "fromToken": NATIVE, "sender": sender, "toChainId": TO_CHAIN_ID, "toToken": NATIVE, "recipients": [RECIPIENT], "toAmounts": [TO_AMOUNT], }, timeout=30).json() if "error" in quote: raise SystemExit(f"{quote.get('code', 'ERROR')}: {quote['error']}") print(f"Cost {quote['fromTotal']} nanotons (~${quote['usdTotal']}) → treasury {treasury}") # 3. Pay: ONE non-bounceable transfer of EXACTLY fromTotal, text comment = quoteId, # valid until the quote deadline so the network drops a late message. comment = begin_cell().store_uint(0, 32).store_snake_string(quote["quoteId"]).end_cell() message = WalletV4R2.create_wallet_internal_message( destination=Address(treasury), value=int(quote["fromTotal"]), body=comment, bounce=False, send_mode=3, # fees paid separately — mode 0 would deliver less than fromTotal and the payment would be held ) if DRY_RUN: body = WalletV4R2.raw_create_transfer_msg(private_key=private_key, seqno=1, wallet_id=wallet_id, messages=[message], valid_until=int(quote["deadline"])) print("DRY_RUN — signed external message", len(body.to_boc()), "bytes, from", sender) return provider = LiteBalancer.from_mainnet_config(trust_level=2) await provider.start_up() wallet = await WalletV4R2.from_data(provider, public_key=public_key, private_key=private_key) seqno = await wallet.get_seqno() body = WalletV4R2.raw_create_transfer_msg(private_key=private_key, seqno=seqno, wallet_id=wallet_id, messages=[message], valid_until=int(quote["deadline"])) await wallet.send_external(body=body) await provider.close_all() print("Sent; tracking by quoteId", quote["quoteId"]) # 4. Track by the quoteId — your wallet does not learn the treasury-side tx hash while True: data = requests.get(f"{API}/order", params={"from_tx_hash": quote["quoteId"]}, timeout=30).json()["data"] if data and all(o["status"] in ("completed", "failed") for o in data): for o in data: print(f"{o['to']['address']}: {o['status']} {o['to']['txHash'] or ''}") break time.sleep(5) asyncio.run(main()) ``` **TON Connect (browser)** — `examples/js/ton-connect.js` ```js // Drip Tools — pay from TON in the browser with TON Connect. // Same message as ton.mjs: one non-bounceable transfer, comment = quoteId, valid until the deadline. import { beginCell } from '@ton/core' // quote: the response of POST /api/quote, requested with sender = the connected wallet's address // treasury: contracts.depository of the TON chain from GET /api/chains (UQ… form) export async function payWithTonConnect(tonConnectUI, quote, treasury) { const body = beginCell().storeUint(0, 32).storeStringTail(quote.quoteId).endCell() await tonConnectUI.sendTransaction({ validUntil: quote.deadline, messages: [{ address: treasury, // UQ… → the wallet sends it non-bounceable amount: quote.fromTotal, // nanotons, as a string payload: body.toBoc().toString('base64'), }], }) // then poll GET /api/order?from_tx_hash= } ``` ## Verify a quote's signature Optional: check that a quote was really signed by Drip Tools before paying. Needs no keys. **JavaScript · viem** — `examples/js/verify-quote.mjs` ```js // Drip Tools — check a quote's EIP-712 signature before paying (viem). Needs no keys. // Run: node verify-quote.mjs import { verifyTypedData } from 'viem' const API = 'https://drip.tools/api' const FROM_CHAIN_ID = Number(process.env.FROM_CHAIN_ID ?? 8453) const SENDER = process.env.SENDER ?? '0x9246ef7F2b7fEcE8AfD8a72271A76Cc2A8d8A3E5' const TO_CHAIN_ID = Number(process.env.TO_CHAIN_ID ?? 421614) const RECIPIENT = process.env.RECIPIENT ?? '0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A' const TO_AMOUNT = process.env.TO_AMOUNT ?? '5000000000000000000' const NATIVE = '0x0000000000000000000000000000000000000000' const quote = await fetch(`${API}/quote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chainId: FROM_CHAIN_ID, fromToken: NATIVE, sender: SENDER, toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT], }), }).then(r => r.json()) if (quote.error) throw new Error(`${quote.code ?? 'ERROR'}: ${quote.error}`) // Scheme v3: the domain has no verifyingContract; the pay target is signed as `depository`. // Every address is a string in its canonical form (EVM lowercased; Solana as sent; TON UQ…). const ok = await verifyTypedData({ address: quote.signer, domain: { name: 'drip.tools quote', version: '3', chainId: FROM_CHAIN_ID }, types: { Quote: [ { name: 'quoteId', type: 'bytes32' }, { name: 'depository', type: 'string' }, { name: 'sender', type: 'string' }, { name: 'fromToken', type: 'string' }, { name: 'fromTotal', type: 'uint256' }, { name: 'toChainId', type: 'uint256' }, { name: 'toToken', type: 'string' }, { name: 'recipients', type: 'string[]' }, { name: 'toAmounts', type: 'uint256[]' }, { name: 'issuedAt', type: 'uint256' }, { name: 'expiry', type: 'uint256' }, ], }, primaryType: 'Quote', message: { quoteId: quote.quoteId, depository: quote.vault, // as returned sender: SENDER.toLowerCase(), // EVM sender: lowercased fromToken: quote.fromToken, // as returned fromTotal: BigInt(quote.fromTotal), toChainId: BigInt(TO_CHAIN_ID), toToken: NATIVE, recipients: [RECIPIENT.toLowerCase()], // EVM recipients lowercased; TON/Solana/BTC as sent toAmounts: quote.toAmounts.map(BigInt), issuedAt: BigInt(quote.issuedAt), expiry: BigInt(quote.expiry), }, signature: quote.signature, }) console.log(ok ? `Signature OK — signed by ${quote.signer}` : 'Signature does NOT verify — do not pay') if (!ok) process.exit(1) ``` --- # API Reference — Discovery https://docs.drip.tools/api/discovery The Drip Tools REST API is served at: ``` https://drip.tools/api ``` ## Conventions - **Transport:** JSON over HTTPS. `Content-Type: application/json` for POST bodies. CORS is open. - **No auth.** Endpoints are public. Requests are rate-limited per IP, `POST /api/quote` more tightly — see [Rate limits](https://docs.drip.tools/api/rate-limits.md). - **Amounts.** All token amounts in quotes (`toAmounts` in, `fromTotal` / `fromAmounts` / `toAmounts` out) are **base-unit integer strings** — no decimals, no scientific notation. Convert with the token's `decimals`. Order-history amounts (`from.amount`, `to.amount`) are instead human-readable decimal strings. - **Native token sentinel.** A token `address` of `0x0000000000000000000000000000000000000000` means the chain's native coin (ETH, BNB, POL, SOL, TON, BTC, …) — on every chain family. - **Identities are strings, per family.** EVM addresses are `0x…` and compared case-insensitively; quote fields (`sender`, `fromToken`, `vault`) come back lowercased, order objects return the checksummed form — never compare EVM strings byte for byte. Solana keys are base58 and **case-sensitive**. A TON *payer* (`sender`) is accepted in any form and normalized to the non-bounceable `UQ…` form; TON *recipients* are kept exactly as sent. Never change the case of a non-EVM identity. - **`chainId` types vary by endpoint.** `GET /chains`, `GET /quotes`, and order objects return `chainId` as a **string**; `POST /quote` and `GET /prices` use a **number**. Coerce defensively. - **Timestamps** are epoch **seconds**. - **Machine-readable.** The whole API is described in [`/openapi.json`](https://docs.drip.tools/openapi.json) (OpenAPI 3.1, validated against live responses by the docs checks). The docs themselves are available as markdown: [`/llms.txt`](https://docs.drip.tools/llms.txt) (index), [`/llms-full.txt`](https://docs.drip.tools/llms-full.txt) (everything in one file), or any page URL with `.md` appended. See [For agents](https://docs.drip.tools/guide/agents.md). - **Warming up.** For about a minute after a backend restart, `GET /api/quotes`, `GET /api/prices` and `POST /api/quote` can answer `503 { "error": "WARMING_UP", "retryAfterSec": 5, "waitingFor": [...] }` with a `Retry-After` header. Wait and retry; it is not a rate limit. ## GET /api/chains Lists every supported chain, its tokens, and where to pay. This is your entry point: FROM chains (mainnet) expose `contracts.depository` — the depository contract on EVM, the treasury account on Solana and TON. TO chains (testnet) carry no contract you interact with — payouts are sent to your recipients by our relayers. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `unwrap` | `'true'` | no | Return one flat array `[...testnet, ...mainnet]` instead of `{ testnet, mainnet }`. | **Response** — `{ testnet: [...], mainnet: [...] }`, or a flat array with `unwrap=true`. Each chain: **Response** ```json { "chainId": "42161", "name": "arbitrum", "displayName": "Arbitrum", "explorer": "https://arbiscan.io", "isTestnet": false, "isEnabled": true, "rpc": ["https://arbitrum.drpc.org", "https://arbitrum-one-rpc.publicnode.com"], "tokens": [ { "symbol": "USDC", "displayName": "USD Coin", "decimals": 6, "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", "priceFeed": "0x50834F3163758fcC1Df9973b6e91f0F0F0434aD3" } ], "contracts": { "depository": "0x52361c9412DceC6A83980fb065D0276ec001fACE" } } ``` A non-EVM FROM chain looks like this: **Response** ```json { "chainId": "1399811149", "name": "solana_mainnet", "displayName": "Solana", "explorer": "https://explorer.solana.com", "family": "solana", "cluster": "mainnet-beta", "isTestnet": false, "isEnabled": true, "rpc": ["https://…"], "tokens": [ { "symbol": "SOL", "displayName": "SOL", "decimals": 9, "address": "0x0000000000000000000000000000000000000000", "priceFeed": { "chainId": 1, "address": "0x4ffC43a60e009B551865A93d232E33Fce9f01507" } }, { "symbol": "USDC", "displayName": "USDC", "decimals": 6, "address": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "priceFeed": { "chainId": 1, "address": "0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6" } } ], "contracts": { "depository": "94BkmkTF6ZFASfLfqEv4HV61GcWj3dykKZP67C2HTQnU" } } ``` The TON FROM chain (`1667471768`, `family: "ton"`, token `GRAM`) has the same shape; its `contracts.depository` is the treasury wallet `UQCpSpH8mxHJ02uj2QEcyujXf9a1XIUY_ROgpcmPnz_Cp0VK`, given in the non-bounceable `UQ…` form. | Field | Meaning | | --- | --- | | `chainId` | Chain id, **as a string**. Non-EVM chains use synthetic ids (Solana mainnet `1399811149`, TON mainnet `1667471768`). | | `name` | Internal slug (`arbitrum`, `solana_mainnet`, …). Use `displayName` for labels. | | `displayName` | Human-readable chain name. | | `isTestnet` | `false` = FROM (mainnet) side; `true` = TO (testnet) side. | | `isEnabled` | Disabled chains are still listed but not swappable. | | `isPaused` | Present, and `true`, only while the chain is temporarily paused: still listed, but `POST /api/quote` refuses it. Comes with `pausedReason` (free text or `null`). | | `family` | Present only on non-EVM chains: `solana` \| `ton` \| `bitcoin`. Absent = EVM. | | `cluster` | Solana only: `mainnet-beta` (FROM), `testnet` \| `devnet` (TO). | | `network` | Bitcoin only: `testnet` \| `testnet4` \| `signet`. | | `explorer` | Block-explorer base URL for the chain. | | `rpc` | `string[]` — ordered public RPC endpoints: `rpc[0]` is the primary, the rest are fallbacks to try in order. | | `tokens[]` | `symbol`, `displayName`, `decimals`, `address` (`0x0…0` = native; a base58 mint on Solana), `priceFeed` (informational: the oracle feed the price comes from — a feed address, `{ chainId, address }` when the feed lives on another chain, or `0x` on TO tokens, which are not priced). Bitcoin testnet tokens add `quoteDecimals` (how many decimal places a TO amount may have) and `minTo` (per-recipient minimum, in whole tokens). | | `contracts.depository` | **FROM chains** — the pay target: the depository contract (EVM) or the treasury account (Solana `base58`, TON `UQ…`). Absent → swaps not enabled on that chain. | ## GET /api/quotes (rates & liquidity) The listed USD rate of each TO token and how much of it can be dispensed right now. Use it to estimate cost and check availability before quoting. > **Warning: Not the quote endpoint** > > `GET /api/quotes` (plural) is a read-only price list. The swap itself starts with > [`POST /api/quote`](https://docs.drip.tools/api/quote.md) (singular), which prices and signs your order. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chainId` | string/number | no* | TO chain id. | | `symbol` | string | no* | TO token symbol. | \* With **neither**, returns the full array of enabled TO tokens. With **both**, returns the single matching object. With only one → `400 Missing chainId or symbol`. Both but no match → `404 No quote found for symbol `. **Response** ```json { "chainName": "Arbitrum Sepolia", "chainId": "421614", "symbol": "ETH", "address": "0x0000000000000000000000000000000000000000", "decimals": 18, "price": 0.25, "balance": "12513664307780914212000", "available": true } ``` | Field | Meaning | | --- | --- | | `price` | USD rate of the TO token (a plain number) — the one `POST /api/quote` prices with. | | `balance` | What can be dispensed right now, **base units**. Orders exceeding this are rejected `NO_LIQUIDITY`. | | `available` | `false` when that token can't be dispensed right now (`NO_TO_PRICE` at quote time). | The response carries a few more bookkeeping fields; the ones above are all you need. `503 WARMING_UP` while the first balance pass after a restart is running. ## OpenAPI specification Every endpoint on these pages is also described in an [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) document at [`https://docs.drip.tools/openapi.json`](https://docs.drip.tools/openapi.json): query parameters, request and response schemas (conditional fields are optional), every error body with its `code` and HTTP status, and the rate-limit headers. Import it into Postman, Insomnia or Swagger UI, or generate a typed client from it. It is checked against the live API, like the field tables here. The docs themselves are available as markdown — see [For agents](https://docs.drip.tools/guide/agents.md). ## Next - Price your swap and lock the amounts: [POST /api/quote](https://docs.drip.tools/api/quote.md). - Live FROM-token prices: [Prices](https://docs.drip.tools/api/prices.md). - Track the resulting orders: [Orders](https://docs.drip.tools/api/orders.md). --- # API Reference — POST /api/quote https://docs.drip.tools/api/quote Prices a swap, locks the exact TO amounts, and returns a backend-signed quote. This is the core of a programmatic swap: you send recipients and the amounts they should receive; the API returns the FROM cost and a `quoteId` you carry on-chain. **Endpoint** ```txt POST https://drip.tools/api/quote Content-Type: application/json ``` ## Request body **Request body** ```json { "chainId": 8453, "fromToken": "0x0000000000000000000000000000000000000000", "sender": "0x9246ef7F2b7fEcE8AfD8a72271A76Cc2A8d8A3E5", "toChainId": 421614, "toToken": "0x0000000000000000000000000000000000000000", "recipients": ["0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A"], "toAmounts": ["5000000000000000000"] } ``` | Field | Type | Description | | --- | --- | --- | | `chainId` | integer | FROM (mainnet) chain id. Must be an enabled FROM chain — an EVM id, or `1399811149` (Solana) / `1667471768` (TON). | | `fromToken` | string | FROM token as listed in `/api/chains`: an `0x…` ERC-20 address, a base58 SPL mint on Solana, or `0x0…0` for the native coin on any chain. | | `sender` | string | The wallet that will send the payment. Bound into the signature — the payer **must** match. EVM `0x…` (any case), Solana base58, TON any form. | | `toChainId` | integer | TO (testnet) chain id. Must be an enabled TO chain. | | `toToken` | address | TO token as listed in `/api/chains`. `0x0…0` = native (also for Solana / TON / Bitcoin testnets). | | `recipients` | string[] | Recipient addresses on the TO chain. 1 = single swap; >1 = multisend. Up to 10 000. | | `toAmounts` | string[] | Base-unit **integer strings** of the TO token, one per recipient (same length as `recipients`). No decimals/hex/exponents. | **Rules** - `recipients` and `toAmounts` must be non-empty and the same length. - Recipients are validated per the TO chain: EVM addresses in any case; TON, Solana and Bitcoin addresses are checked and delivered **exactly as sent**. - The payment must come from `sender` — a different paying wallet is not honoured. - Each `toAmounts[i]` must sit on the TO token's precision grid and be at or above its per-recipient minimum (see error codes below). ## Response `200` **Response** ```json { "quoteId": "0x1f5215a4577ef4548b550313d772594473e237c67514943751634818fdff9615", "signer": "0x5C0917e114e49F3Cf266D6467ac129FEe42d77c5", "vault": "0x52361c9412dcec6a83980fb065d0276ec001face", "issuedAt": 1784120925, "expiry": 1784121105, "deadline": 1784121105, "fromToken": "0x0000000000000000000000000000000000000000", "fromTotal": "517554316856097", "fromAmounts": ["517554316856097"], "toAmounts": ["5000000000000000000"], "usdTotal": "1.25", "signature": "0x23fbc0…1b" } ``` | Field | Meaning | | --- | --- | | `quoteId` | 32 random bytes — the only quote reference carried on-chain. Pass to the `pay` call, or put in the Solana memo / TON comment. | | `signer` | Address that produced the EIP-712 signature (verify against it). | | `vault` | Pay target = the FROM chain's `contracts.depository`, in canonical form: the depository contract (EVM) or the treasury account (Solana base58, TON `UQ…`). | | `issuedAt` / `expiry` | Quote validity window (epoch s). | | `deadline` | Passed to the `pay` call; the tx reverts `Expired` after it. On Solana / TON a payment landing after it is not honoured. Equals `expiry`. | | `fromToken` | The FROM token in canonical form. | | `fromTotal` | **Total FROM to pay**, base units. `msg.value` (native) or `amount` (ERC-20) on EVM; lamports / SPL units / nanotons on Solana / TON. Pay **exactly** this. | | `fromAmounts` | Per-recipient FROM breakdown, base units (the backend expands the batch by these). | | `toAmounts` | Echoes the requested TO amounts. Delivered exactly, regardless of later price moves. | | `usdTotal` | Human USD total, 2 decimals. | | `signature` | 65-byte EIP-712 signature. Optional to check — see [Verifying the quote signature](https://docs.drip.tools/swap/contract.md#verifying-the-quote-signature-optional). | ## Pricing model You specify the **TO** amounts; the API computes the **FROM** cost using the live FROM-token price (Chainlink, see [Prices](https://docs.drip.tools/api/prices.md)) and the TO token's USD rate ([`GET /api/quotes`](https://docs.drip.tools/api/discovery.md#get-api-quotes-rates-liquidity)): **Formula** ```txt fromAmount_i = floor( toAmount_i × toRateUsd × 10^fromDecimals / (10^toDecimals × fromPriceUsd) ) fromTotal = Σ fromAmount_i ``` The quote **locks** `toAmounts` — the backend delivers exactly those, even if prices move between quoting and payment. **What you pay** is the listed rate of the testnet tokens converted at the live price of the token you pay with — nothing more. There is no additional fee or spread. Gas on the FROM chain is yours; gas for the testnet payouts is ours. Requesting a quote is free and commits you to nothing. ## Minimums | Order | Minimum USD value | | --- | --- | | Single recipient | **$1** | | Multisend (>1 recipient) | **$5** | Compared at cent precision (round half up), so a feed wobble like $4.9998 → $5.00 is not rejected. ## Validity window A quote expires **180 seconds** after issue, on every chain. Submit the on-chain payment before `deadline`, or request a fresh quote. ## Errors All errors are `{ "error": "", "code": "" }`, except the rate-limit and warming-up shapes, which carry `retryAfterSec` and a `Retry-After` header instead of `code`. | | Codes | | --- | --- | | Your request | `BAD_FROM_CHAIN` · `BAD_TO_CHAIN` · `BAD_ADDRESS` · `BAD_FROM_TOKEN` · `BAD_TO_TOKEN` · `BAD_ARRAYS` · `EMPTY` · `LENGTH_MISMATCH` · `TOO_MANY` · `BAD_RECIPIENT` · `BAD_TO_AMOUNT` (all `400`) | | Your amounts | `TO_TOO_SMALL` · `TO_TOO_PRECISE` · `FROM_ROUNDS_TO_ZERO` · `BELOW_SOL_RENT_MIN` · `BELOW_MIN_TOTAL` · `NO_LIQUIDITY` (all `400`) | | Availability | `NO_FROM_PRICE` · `NO_TO_PRICE` · `SIGNING_DISABLED` · `WARMING_UP` (`503`) · `NO_VAULT` (`500`) | | Rate limit | `RATE_LIMITED` (`429`, with `Retry-After`) | Every code with its meaning and fix: [Errors → POST /api/quote](https://docs.drip.tools/reference/errors.md#post-api-quote). Once you have a quote, [pay on-chain](https://docs.drip.tools/swap/flow.md#step-3-pay-on-chain) and then [track the order](https://docs.drip.tools/api/orders.md). --- # API Reference — Prices https://docs.drip.tools/api/prices Live USD prices of the FROM (mainnet) tokens, as the backend reads them from Chainlink, plus the price history. `POST /api/quote` prices with the same feed, so these endpoints let you **estimate** a swap before quoting and **audit** what the rate was when you paid. Prices are raw feed answers: an integer string `price` with `priceDecimals` (8 for every feed today). USD value = `price / 10^priceDecimals`. ## GET /api/prices The latest price of every enabled FROM token, or a filtered subset. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chainId` | number | no | Only tokens of this FROM chain. | | `address` | string | no | Only this token (`0x…`, base58 for an SPL mint, `0x0…0` for native). | **cURL** ```bash curl 'https://drip.tools/api/prices?chainId=8453' ``` **Response** — an array: **Response** ```json [ { "chainId": 8453, "chainName": "Base", "symbol": "ETH", "address": "0x0000000000000000000000000000000000000000", "tokenDecimals": 18, "price": "241316450380", "priceDecimals": 8, "updatedAt": 1792324273, "observedAt": 1792324279, "available": true } ] ``` | Field | Meaning | | --- | --- | | `chainId` | FROM chain id, **as a number**. | | `address` | Token address in canonical form (lowercased `0x…`, or a base58 mint). | | `tokenDecimals` | The token's own decimals (for converting `fromTotal`). | | `price` / `priceDecimals` | The feed's latest answer and its scale. USD = `price / 10^priceDecimals`. | | `updatedAt` / `observedAt` | When the feed last updated, and when the backend read it (epoch s; refreshed about every minute). | | `available` | `false` only for a token that has never been priced. A **stale** feed keeps `available: true` and its last price, with an old `updatedAt`; `POST /api/quote` refuses it with `NO_FROM_PRICE` once `updatedAt` is older than 26 hours — treat an old `updatedAt` the same way. A never-priced token has `price`, `priceDecimals`, `updatedAt` and `observedAt` as `null`. | An unknown `chainId` or a token that isn't listed returns `[]`. A malformed `address` → `400 Invalid address`. `503 WARMING_UP` while the first oracle pass after a restart is running. Responses are cacheable for 5 s. ## GET /api/prices/at The price the feed showed at a given moment — the latest feed round at or before `ts`. Use it to reconcile an order's `from.amount` against the rate at payment time. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chainId` | number | yes | FROM chain id. | | `address` | string | yes | Token address (`0x0…0` for native). | | `ts` | number | yes | Epoch seconds. | **cURL** ```bash curl 'https://drip.tools/api/prices/at?chainId=1&address=0x0000000000000000000000000000000000000000&ts=1792318200' ``` **Response** ```json { "chainId": 1, "address": "0x0000000000000000000000000000000000000000", "price": "242105416592", "priceDecimals": 8, "updatedAt": 1792318127, "observedAt": 1792318148 } ``` **Errors:** `400 chainId, address and ts are required`; `400 Invalid address`; `400 Invalid ts`; `404 No price recorded at or before ts` (history starts when a token was listed). Responses are cacheable for 60 s. ## How a quote uses these **Formula** ```txt fromAmount_i = floor( toAmount_i × toRateUsd × 10^fromDecimals / (10^toDecimals × fromPriceUsd) ) ``` `toRateUsd` is `price` from [`GET /api/quotes`](https://docs.drip.tools/api/discovery.md#get-api-quotes-rates-liquidity); `fromPriceUsd` is `price / 10^priceDecimals` from this page. The quote locks `toAmounts`, so the FROM side you pay is fixed at quote time — see [POST /api/quote](https://docs.drip.tools/api/quote.md#pricing-model). --- # API Reference — Orders https://docs.drip.tools/api/orders After you pay, one payment becomes **one order per recipient**. These endpoints track them. - [`GET /api/order`](https://docs.drip.tools/api/orders.md#get-api-order) — a single order, or all orders of one payment. - [`GET /api/orders`](https://docs.drip.tools/api/orders.md#get-api-orders) — a payer's history. - [`POST /api/orders/status`](https://docs.drip.tools/api/orders.md#post-api-orders-status) — bulk status by order id. - [`GET /api/orders/load`](https://docs.drip.tools/api/orders.md#get-api-orders-load) — dispense-queue position / ETA. - [`GET /api/orders/stream`](https://docs.drip.tools/api/orders.md#get-api-orders-stream) — Server-Sent Events when your orders change. Order status values: `processing` → `completed` (or `failed`); `pending` is reserved and not produced by the current flow. See [Order lifecycle](https://docs.drip.tools/reference/lifecycle.md). ## GET /api/order Two modes — pass **one** of `id` or `from_tx_hash`. ### By payment: `?from_tx_hash=` The most common tracking call — one payment can emit several orders (multisend). `ref` is: | FROM chain | What to pass | | --- | --- | | EVM | The payment transaction hash (`0x…`). | | Solana | The transaction signature (base58). | | TON | The **`quoteId`** — your wallet does not learn the treasury-side transaction hash. Resolved once the payment is honoured (afterwards the order's `from.txHash`, 64 hex characters, works too). A **refused** payment never resolves here — find its refund in the payer's history, `GET /api/orders?address=&view=batches`. | **cURL** ```bash curl 'https://drip.tools/api/order?from_tx_hash=0x' ``` **Response** ```json { "data": [ { "status": "completed", "id": "abc123…", "from": { "chainId": "8453", "displayName": "Base", "symbol": "ETH", "amount": "0.00051755", "address": "0xpayer", "tokenAddress": "0x0000000000000000000000000000000000000000", "txHash": "0x", "ts": 1784120925, "explorer": "https://basescan.org", "family": null, "cluster": null }, "to": { "chainId": "421614", "displayName": "Arbitrum Sepolia", "symbol": "ETH", "amount": "5.00000000", "address": "0xrecipient", "tokenAddress": "0x0000000000000000000000000000000000000000", "txHash": "0x", "ts": 1784120990, "explorer": "https://sepolia.arbiscan.io", "family": null, "cluster": null } } ], "refund": null } ``` - `data` is one [order object](https://docs.drip.tools/api/orders.md#order-object) per recipient (empty array until the payment is indexed — keep polling). - `refund` is `null` unless some/all of the payment is being returned (a payment that could not become an order, or the failed slices of a partial batch) — see [refund object](https://docs.drip.tools/api/orders.md#refund-object). ### Paginated recipients: `&paginate=1` For large batches, ask for an aggregate **summary** plus a keyset page of recipients instead of the whole array: **cURL** ```bash curl 'https://drip.tools/api/order?from_tx_hash=0x&paginate=1&limit=100' ``` | Parameter | Description | | --- | --- | | `limit` | Page size, default 100, max 1000. `limit=all` returns the whole remainder in one response; `limit=0` returns the summary only. | | `cursor` | `next_cursor` from the previous page. | **Response** ```json { "summary": { "recipients": 250, "statusCounts": { "completed": 240, "processing": 10, "failed": 0 }, "fromTotal": "0.12500000", "toTotal": "1250.00000000", "completedTs": null, "lastPayout": null }, "data": [ /* order objects, in recipient order */ ], "pagination": { "has_next_page": true, "next_cursor": "184233" }, "refund": null } ``` `completedTs` is filled once nothing is left `processing`; `lastPayout` (`{ txHash, ts }` of the last payout) additionally needs at least one `completed` order — an all-failed batch has `completedTs` but `lastPayout: null`. Until the payment is indexed, `summary.recipients` is `0` and `fromTotal` / `toTotal` are `null`. ### By id: `?id=` Returns a single [order object](https://docs.drip.tools/api/orders.md#order-object), or `null` (with status 200) if the order isn't indexed yet — keep polling. Order ids are **case-sensitive**. **cURL** ```bash curl 'https://drip.tools/api/order?id=abc123…' ``` Passing neither parameter → `400 Provide order id or from_tx_hash`. ### Order object | Field | Meaning | | --- | --- | | `status` | `processing` \| `completed` \| `failed` (`pending` is reserved, never produced today). | | `id` | The order id (use with `/order?id=` and `/orders/status`). | | `from` | The payment side: `chainId`, `displayName`, `symbol`, `amount` (human), `address` (payer), `tokenAddress`, `txHash`, `ts`, plus `explorer` / `family` / `cluster` for building links. | | `to` | The payout side: same fields for the recipient; `txHash` is the payout tx (`null` until dispensed), `ts` the completion time. | Identity forms on the `from` side follow the chain: an EVM payer is `0x…` and `txHash` is the transaction hash; a Solana payer is base58 and `txHash` the transaction signature (`family: "solana"`, `cluster: "mainnet-beta"`); a TON payer is the `UQ…` address and `txHash` the treasury-side transaction hash (64 hex characters, `family: "ton"`). ### Refund object | Field | Meaning | | --- | --- | | `status` | `refund_pending` (recorded, not yet returned) \| `refunded` (return tx sent). | | `reason` | `unknown_quote` \| `unattributed` \| `ambiguous_memo` \| `expired` \| `bad_signature` \| `failed_order` — see [Refund reasons](https://docs.drip.tools/reference/errors.md#refund-reasons). | | `from` | The returned amount + payer + original payment tx. | | `refundTxHash` | The return transaction, or `null`. | | `refundedAt` | Epoch seconds, or `null`. | ## GET /api/orders A payer's order history, keyset-paginated. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `address` | string | yes | The payer identity, or up to 10 identities comma-separated (`0x…`, Solana base58, TON address). EVM addresses match case-insensitively; Solana and TON identities must be exact (`UQ…` form for TON). | | `view` | string | no | `batches` = one row per payment (multisend collapsed). Otherwise one row per order. | | `limit` | number | no | 1–100, default 20. | | `cursor` | string | no | `next_cursor` from a prior response — the next (older) page. | | `since_cursor` | string | no | Poll for rows newer than this cursor. | `view=batches` also accepts filters (resend them with every page): `status` (`processing`\|`partial`\|`completed`\|`failed`\|`refund`), `to_chain_id`, and `q` (prefix search over order ids / FROM tx hashes, at least 2 characters). **Response** ```json { "data": [ /* order or batch rows */ ], "pagination": { "has_next_page": true, "next_cursor": "eyJ0cyI6…", "latest_item_cursor": "eyJ0cyI6…" } } ``` A **batch row** (`view=batches`) aggregates a payment: **Response** ```json { "status": "partial", "statusCounts": { "completed": 3, "processing": 0, "failed": 1 }, "refund": { "status": "refund_pending", "reason": "failed_order" }, "recipients": 4, "id": "firstOrderId", "from": { "chainId": "8453", "displayName": "Base", "symbol": "ETH", "amount": "0.16", "address": "0x", "txHash": "0x…", "ts": 1784120925 }, "to": { "chainId": "421614", "displayName": "Arbitrum Sepolia", "symbol": "ETH", "amount": "16.0", "address": null, "txHash": null, "ts": null } } ``` Aggregate `status` is `processing` \| `completed` \| `failed` \| `partial` (some paid, some failed). Amounts are decimal strings whose trailing zeros vary (a batch row's `from.amount` is padded to 18 places, other amounts to 8) — parse them as decimals, never compare them as strings. Payments that could **not** become an order (unknown or missing quote, expired) appear in the unfiltered batches view as rows with `status: "refund_pending"` / `"refunded"`, `isRefund: true`, `reason`, `recipients: 0`, `id: null` (no order id — look the payment up by its FROM tx hash) and `to: null`; `status=refund` lists only those. **Errors:** `400 Incorrect address`; `400 status must be one of: processing, partial, completed, failed, refund`. ## POST /api/orders/status Lightweight bulk status for order ids you already hold. **cURL** ```bash curl -X POST 'https://drip.tools/api/orders/status' \ -H 'Content-Type: application/json' \ -d '{ "ids": ["orderId1", "orderId2"] }' ``` **Response** ```json { "data": [ { "status": "completed", "id": "orderId1", "to": { "txHash": "0x", "ts": 1784120990 } } ] } ``` Missing ids are simply omitted. Empty `ids: []` → `{ "data": [] }`. Non-array `ids` → `400 ids must be an array and is required`. More than 500 ids → `400 too many ids (max 500)`. ## GET /api/orders/load Dispense-queue depth and drain rate, for estimating how long a payout will take. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `ref` | string | no | An `order_id` or a FROM tx hash (or the TON treasury-side hash) — adds *your* position in line. | **Response** ```json { "updatedAt": 1784120100, "ratePerSec": 25, "minEtaSec": 10, "chains": { "421614": { "depth": 42, "rate": 28.5, "measured": true, "stalled": false, "minEtaSec": null } }, "ref": { "chainId": "421614", "depth": 17, "statusCounts": { "processing": 2, "completed": 15, "failed": 0 } } } ``` - `chains[toChainId].depth` — recipients still awaiting dispense on that TO chain; `rate` — measured drain rate (recipients/sec). Rough ETA ≈ `max(minEtaSec, depth / rate)`. An idle chain is simply absent from `chains`. - `ref.depth` — recipients ahead of (and including) your batch on its TO chain; only shrinks as the queue reaches you. `ref` is `null` if the ref isn't indexed yet — fall back to the chain-wide depth. ## GET /api/orders/stream A [Server-Sent Events](https://developer.mozilla.org/docs/Web/API/Server-sent_events) feed that fires whenever an order or refund belonging to the given payer identities changes. It is a **notification only** — re-fetch with `GET /api/order` or `GET /api/orders` when it fires. **cURL** ```bash curl -N 'https://drip.tools/api/orders/stream?address=0xpayer' ``` **Event stream** ```txt : connected event: orders-changed data: {"txs":["0x"]} : ping ``` - `address` — same as [`GET /api/orders`](https://docs.drip.tools/api/orders.md#get-api-orders): one or more identities, comma-separated. - `orders-changed` — `data.txs` lists the FROM tx hashes whose orders changed. Re-fetch those. - A `: ping` comment every 25 s keeps the connection alive. The stream is not rate-limited; a malformed `address` gets `400 Incorrect address`, and if it answers `503`, fall back to polling. --- # API Reference — Rate limits https://docs.drip.tools/api/rate-limits The API is public and needs no key. Two things are metered so that one script cannot starve everyone else: **how many requests an IP sends**, and — more tightly — **how many signed quotes it asks for**. ## Limits | Scope | Limit | Key | | --- | --- | --- | | Every `/api/*` route | 600 requests / minute | client IP | | `POST /api/quote` | 10 quotes / minute | client IP | | `POST /api/quote` | 30 quotes / minute | `sender` address (canonical form) | Limits are **token buckets**: the allowance refills continuously (600/min ≈ one request every 100 ms, with the full 600 available as a burst from idle). CORS preflight (`OPTIONS`) and the long-lived `GET /api/orders/stream` connection are not counted. There is also a coarser per-IP cap at the edge (nginx), meant for floods: about 30 requests/s on `/api/*` with a burst of 20, and 2 requests/s on `POST /api/quote` with a burst of 5. It answers `429` with a non-JSON body and no `Retry-After` — pause for a second or two and retry. ## What a rejection looks like Every metered response carries the allowance of the **last bucket checked**: the per-IP bucket on most routes; on `POST /api/quote` with a `sender` in the body, the per-sender bucket (30) — the 10/min per-IP bucket only shows up in its own `429`. **Response headers** ```txt X-RateLimit-Scope: anonymous X-RateLimit-Limit: 30 X-RateLimit-Remaining: 29 ``` When you are over the limit you get **`429 Too Many Requests`**: **HTTP response** ```txt HTTP/1.1 429 Too Many Requests Retry-After: 6 Content-Type: application/json { "error": "RATE_LIMITED", "retryAfterSec": 6 } ``` `Retry-After` (seconds, whole) is the earliest moment the next request will pass. Sleep for it, then retry — do not tight-loop; every rejected attempt is itself a request. > **Tip: Quote once, pay once** > > A quote is valid for 180 s — you do not need to re-quote while a quote is still live, and you > should not request a quote before you are ready to send the transaction. Batches (multisend) are > one quote regardless of the recipient count, so 10 quotes/min is 10 *swaps*, not 10 recipients. > **Tip: Running at volume?** > > If the anonymous allowance is too small for what you are building, reach out on > [Telegram](https://t.me/drip_tools) or [Discord](https://discord.gg/Zn3M3gag2W). ## Not rate limits Two other `5xx` answers look similar but mean something else: | Status | Body | Meaning | | --- | --- | --- | | `503` | `{ "error": "WARMING_UP", "retryAfterSec": 5, "waitingFor": [...] }` | The backend just restarted and is loading prices / balances (`/api/quotes`, `/api/prices`, `POST /api/quote`). Retry after `Retry-After`. | | `503` | `{ "error": "Quoting is disabled", "code": "SIGNING_DISABLED" }` | Quoting is paused by the operator — see [POST /api/quote](https://docs.drip.tools/api/quote.md). | ## Summary of error shapes | Status | Body | Meaning | | --- | --- | --- | | `429` | `{ "error": "RATE_LIMITED", "retryAfterSec": n }` | per-minute allowance exhausted (IP or sender) | | `429` | *(empty)* | edge flood protection — back off harder | | `503` | `{ "error": "WARMING_UP", "retryAfterSec": n, "waitingFor": [...] }` | backend warming up — retry | --- # Order Lifecycle & Refunds https://docs.drip.tools/reference/lifecycle What has to be true for a payment to become orders, the states an order moves through, how a multisend aggregates them, and when funds come back. ## Payment rules The backend indexes your payment (the `Paid` event on EVM, the treasury transfer on Solana / TON), loads the quote named by its `quoteId`, and checks the payment against it. **All** of these must hold: | Rule | Detail | | --- | --- | | **Same payer** | Sent by the wallet you quoted as `sender`. On Solana that wallet is the fee payer (first signer). | | **Same token** | The `fromToken` you quoted: the native coin, the ERC-20, or the exact SPL mint. | | **Exact amount** | The pay target receives **exactly `fromTotal`** base units — not more, not less. | | **Before the deadline** | The transaction's block time is at or before `deadline`. On EVM the contract reverts a late call; on Solana / TON nothing stops a late transfer — it lands and becomes a refund case. | | **One `quoteId`** | EVM: the `quoteId` argument. Solana: exactly one memo carrying the id, and only one asset credited to the treasury in that transaction. TON: the text comment. | | **Not a self-transfer** | Solana / TON: a transfer from the treasury to itself is ignored. | | **Once** | Each quote is honoured once. | When a rule is broken: | What went wrong | What happens | | --- | --- | | Different payer, token or amount | **Held for review.** No order, no automatic refund; the payment is flagged to the operator. Contact support with the transaction hash — see [Errors](https://docs.drip.tools/reference/errors.md#payments-held-for-review). | | Landed after `deadline` | EVM: the contract reverts, nothing is paid. Solana / TON: no order; a refund is recorded with reason `expired`. | | Unknown `quoteId`, or no readable memo / comment | No order; a refund is recorded with reason `unknown_quote` (an id the backend never issued) or `unattributed` (no readable id at all — Solana / TON). | | Same `quoteId` paid twice | The first payment is honoured; the second is **held for review** — which payment to return is the operator's call, nothing is refunded automatically. | ## Order states Once the payment passes the checks, the quote is expanded into **one order per recipient**: | Status | Meaning | | --- | --- | | `pending` | Reserved — the current flow never produces it; an order appears directly as `processing`. | | `processing` | The testnet payout is queued or in flight. | | `completed` | The recipient received their tokens. `to.txHash` is the payout transaction. | | `failed` | The payout could not be delivered to that recipient. | ``` payment indexed → checked against the quote → order(s) → pending / processing → completed ↘ failed → refund ``` You observe these on each [order object](https://docs.drip.tools/api/orders.md#order-object) via `status`, and the payout transaction via `to.txHash` once `completed`. Payouts usually land within seconds of the payment being indexed. ## Multisend: aggregate status A payment with many recipients produces many orders. The [batches view](https://docs.drip.tools/api/orders.md#get-api-orders) and the [order-by-tx lookup](https://docs.drip.tools/api/orders.md#by-payment-from-tx-hash-ref) collapse them into one aggregate `status`: | Aggregate | When | | --- | --- | | `processing` | Any recipient is still pending/processing. | | `completed` | Every recipient was delivered. | | `failed` | Every recipient failed. | | `partial` | Some recipients were delivered and some failed. | For a fully-failed batch the whole payment is refunded on the FROM chain. For a partial batch the **failed slices are refunded**, capped so that the delivered part still cost at least **$1** at the quote's locked price (otherwise the multisend minimum could be bypassed). A refund the cap would reduce to zero is not sent automatically — it is held for the operator. ## Refunds A refund returns FROM funds to the payer when a payment can't be (fully) honoured: | Reason | Cause | | --- | --- | | `failed_order` | The failed recipients of a settled batch — their FROM slice is returned (capped for a partial batch, see above). | | `unknown_quote` | The payment referenced a `quoteId` the backend never issued. | | `unattributed` | A transfer to the treasury with no readable `quoteId` at all (Solana / TON), above a small dust threshold — smaller amounts are ignored. | | `ambiguous_memo` | One Solana transaction carrying several different `quoteId`s — it cannot be split between them. | | `expired` | The payment landed after the quote's `deadline` (Solana / TON). | | `bad_signature` | The stored quote's signature did not verify against a trusted signer key — an operator-side issue, not something a payer can cause. | Refund states are `refund_pending` (recorded, return not yet sent) and `refunded` (return tx sent). Refunds are issued by the operator from the receiving account. The record for a refused payment (`expired`, `unknown_quote`, `unattributed`, `ambiguous_memo`, `bad_signature`) is written when the payment is indexed; a `failed_order` record appears within a few minutes of the batch settling. The return transaction follows once it is sent. Track them via the `refund` field on [`GET /api/order?from_tx_hash=`](https://docs.drip.tools/api/orders.md#by-payment-from-tx-hash-ref) (EVM tx hash or Solana signature), or in the payer's history ([`GET /api/orders?view=batches`](https://docs.drip.tools/api/orders.md#get-api-orders), `status=refund` for refunds only). On TON use the history: a refused payment never consumed its quote, so `from_tx_hash=` does not find it: **Response** ```json { "data": [ /* … the delivered/failed orders … */ ], "refund": { "status": "refunded", "reason": "failed_order", "from": { "symbol": "ETH", "amount": "0.0003", "address": "0xpayer", "txHash": "0x" }, "refundTxHash": "0x", "refundedAt": 1784120300 } } ``` > **Tip: Avoid refunds** > > The avoidable reasons are `expired` (submit your payment well before the quote `deadline`), > `unattributed` and `ambiguous_memo` (always send exactly one `quoteId` in the memo / comment). > Pay the exact `fromTotal`, from the quoted wallet, before the deadline, and never retry a payment > whose transaction already confirmed — track it instead. ## Tracking to completion A robust client polls [`GET /api/order?from_tx_hash=`](https://docs.drip.tools/api/orders.md#by-payment-from-tx-hash-ref) until every order is terminal: **JavaScript** ```js const terminal = o => o.status === 'completed' || o.status === 'failed' for (;;) { const { data, refund } = await fetch(`${API}/order?from_tx_hash=${ref}`).then(r => r.json()) if (data.length && data.every(terminal)) { const ok = data.filter(o => o.status === 'completed').length const failed = data.filter(o => o.status === 'failed').length console.log(`${ok} delivered, ${failed} failed`) if (refund) console.log(`refund ${refund.status}: ${refund.from.amount} ${refund.from.symbol}`) break } await new Promise(r => setTimeout(r, 5000)) } ``` **Python** ```python terminal = lambda o: o["status"] in ("completed", "failed") while True: r = requests.get(f"{API}/order", params={"from_tx_hash": ref}, timeout=30).json() data, refund = r["data"], r["refund"] if data and all(map(terminal, data)): ok = sum(o["status"] == "completed" for o in data) print(f"{ok} delivered, {len(data) - ok} failed") if refund: print(f"refund {refund['status']}: {refund['from']['amount']} {refund['from']['symbol']}") break time.sleep(5) ``` `ref` is the EVM tx hash, the Solana signature, or the `quoteId` on TON. For a wait estimate while orders are still `processing`, call [`GET /api/orders/load?ref=`](https://docs.drip.tools/api/orders.md#get-api-orders-load) with the tx hash, the signature or an order id — on TON pass `data[0].id` from the lookup above, `/orders/load` does not resolve a `quoteId`; to be notified instead of polling, subscribe to [`GET /api/orders/stream`](https://docs.drip.tools/api/orders.md#get-api-orders-stream). --- # Errors https://docs.drip.tools/reference/errors Every error the API, the contract or the order flow can hand you, in one place, with what to do about it. ## Error shape API errors are JSON: **Response** ```json { "error": "Order total is below the $1 minimum", "code": "BELOW_MIN_TOTAL" } ``` The two refusals that ask you to wait — `429 RATE_LIMITED` and `503 WARMING_UP` — carry `retryAfterSec` and a `Retry-After` header instead of `code`. Order endpoints answer `400 { "error": "" }` without a code. ## POST /api/quote | HTTP | Code | Meaning | What to do | | --- | --- | --- | --- | | 400 | *(no code)* | The body is not valid JSON — rejected before validation, body `{ "error": "Unexpected token …" }`. A missing body counts as `{}` and fails with `BAD_FROM_CHAIN`. | Send `Content-Type: application/json` and a JSON object. | | 400 | `BAD_FROM_CHAIN` | `chainId` is not an enabled FROM chain, or the chain is paused. | Pick a chain with `isTestnet: false`, `isEnabled: true` and no `isPaused` in `/api/chains`. | | 400 | `BAD_TO_CHAIN` | `toChainId` is not an enabled TO chain, or paused. | Same check on the TO side. | | 400 | `BAD_ADDRESS` | `fromToken`, `sender` or `toToken` is empty or malformed for its chain. | EVM `0x…`; Solana base58; TON any form. | | 400 | `BAD_FROM_TOKEN` | `fromToken` is not listed for that FROM chain. | Use a token from `/api/chains`. | | 400 | `BAD_TO_TOKEN` | `toToken` is not listed for that TO chain. | Use a token from `/api/chains` (`0x0…0` = native). | | 400 | `BAD_ARRAYS` / `EMPTY` / `LENGTH_MISMATCH` | `recipients` / `toAmounts` not arrays, empty, or different lengths. | One amount per recipient. | | 400 | `TOO_MANY` | More than 10 000 recipients. | Split into several quotes. | | 400 | `BAD_RECIPIENT` | A recipient fails the TO chain's validation. | Check the address family (EVM / Solana / TON / Bitcoin testnet). | | 400 | `BAD_TO_AMOUNT` | An amount is not a positive base-unit integer string. | `"5000000000000000000"`, not `5` or `5e18`. | | 400 | `TO_TOO_SMALL` | A TO amount is below the per-recipient minimum. | Raise that amount. | | 400 | `TO_TOO_PRECISE` | A TO amount is off the token's precision grid (4 decimals; whole sats for BTC). | Round it. | | 400 | `FROM_ROUNDS_TO_ZERO` | A TO amount is too small to price in the FROM token. | Raise it. | | 400 | `BELOW_SOL_RENT_MIN` | Solana TO: below the 0.001 SOL rent floor. | Send at least 0.001 SOL per recipient. | | 400 | `BELOW_MIN_TOTAL` | Order total under $1 (single) / $5 (multisend). | Raise the amounts. | | 400 | `NO_LIQUIDITY` | `Σ toAmounts` exceeds what can be dispensed right now. | Check `balance` in `/api/quotes`; lower the amounts or wait. | | 500 | `NO_VAULT` | The FROM chain has no pay target configured. | Pick another FROM chain; tell us. | | 503 | `NO_FROM_PRICE` | The FROM token's price feed is missing or stale (`400` with the same code if the feed reports a non-positive price). | Retry later or pay with another token. | | 503 | `NO_TO_PRICE` | The TO token is unavailable (no rate or no liquidity data; `400` with the same code for a non-positive rate). | See `available` in `/api/quotes`; try later. | | 503 | `SIGNING_DISABLED` | Quoting is paused by the operator. | Try later. | ## Rate limits and availability | HTTP | Body | Meaning | What to do | | --- | --- | --- | --- | | 429 | `{ "error": "RATE_LIMITED", "retryAfterSec": n }` | Over 600 req/min per IP, or 10 quotes/min per IP, or 30 quotes/min per `sender`. | Sleep `Retry-After` seconds, then retry. Quote only when ready to pay. | | 429 | *(empty body)* | Edge flood protection. | Back off harder. | | 503 | `{ "error": "WARMING_UP", "retryAfterSec": 5, "waitingFor": [...] }` | Backend just restarted; prices / balances still loading. | Retry after `retryAfterSec`. | Details: [Rate limits](https://docs.drip.tools/api/rate-limits.md). ## Other endpoints | Endpoint | Error | Meaning | | --- | --- | --- | | `GET /api/quotes` | `400 Missing chainId or symbol` | Pass both or neither. | | `GET /api/quotes` | `404 No quote found for symbol …` | No such TO token on that chain. | | `GET /api/prices` | `400 Invalid address` | Malformed token address. | | `GET /api/prices/at` | `400 chainId, address and ts are required` · `400 Invalid ts` · `404 No price recorded at or before ts` | Fix the parameters; history starts when the token was listed. | | `GET /api/order` | `400 Provide order id or from_tx_hash` | Pass one of the two. | | `GET /api/order?id=` | `200 null` | Not indexed yet — keep polling. Ids are case-sensitive. | | `GET /api/orders` | `400 Incorrect address` | The payer identity is malformed (EVM `0x…`, Solana base58, TON). | | `GET /api/orders` | `400 status must be one of: …` | Bad `status` filter. | | `POST /api/orders/status` | `400 ids must be an array and is required` · `400 too many ids (max 500)` | Fix the body. | | `GET /api/orders/stream` | `400 Incorrect address` · `503 Too many subscribers` | Fix the address · fall back to polling. | ## EVM contract reverts Decode a revert against these custom errors (the ABI is on the [contract page](https://docs.drip.tools/swap/contract.md#reverts-you-can-hit)): | Error | Meaning | What to do | | --- | --- | --- | | `Expired` | Landed after `deadline`. | Request a fresh quote. | | `MissingQuote` | `quoteId` is zero. | Pass the quote's `quoteId`. | | `AmountTooLow` / `ZeroAmount` | Paid amount is zero or below the contract's per-token floor. | Pay `fromTotal`. | | `TokenTransferMismatch` | The token delivered less than `amount` (fee-on-transfer / rebasing). | Use a listed token. | | `TokenNotAllowed` | The token isn't accepted by the pay target. | Use a token from `/api/chains`. | | `DirectPaymentNotAllowed` | A plain transfer to the contract, not a `pay` call. | Call `pay(...)`. | | `EnforcedPause` | The contract is paused. | Try later. | ## Payments held for review A payment that lands but does **not** match its quote — a different payer than `sender`, a different token, or an amount other than `fromTotal` — does not become an order and is not refunded automatically. It is flagged to the operator. If this happens to you, contact support on [Telegram](https://t.me/drip_tools) or [Discord](https://discord.gg/Zn3M3gag2W) with the transaction hash. On EVM, passing the quote's `fromTotal` makes this impossible to hit by accident; on Solana / TON the transfer amount is yours to get right — see [Payment rules](https://docs.drip.tools/reference/lifecycle.md#payment-rules). The same applies to a **second payment of a quote that was already honoured**: it is held, and which of the two payments to return is decided by the operator. Never retry a payment whose transaction confirmed — track it instead. ## Refund reasons A payment that can't become an order gets a refund record (`refund` on [`GET /api/order`](https://docs.drip.tools/api/orders.md#refund-object), and a row in the payer's history). On TON look in the history (`GET /api/orders?address=&view=batches`): a refused payment did not consume its quote, so `from_tx_hash=` does not find it. | Reason | Cause | Avoid it by | | --- | --- | --- | | `expired` | Paid after `deadline` (Solana / TON — on EVM the contract reverts). | Pay with plenty of the 180 s left; on TON set `validUntil = deadline`. | | `unknown_quote` | The memo / comment carried a `quoteId` the backend never issued. | Put the `quoteId` from the quote response verbatim in the memo / comment. | | `unattributed` | No readable `quoteId` at all in a transfer to the treasury (Solana / TON), above a small dust threshold. | Always send the memo / comment; never transfer to the treasury without a quote. | | `ambiguous_memo` | Several different `quoteId`s in one Solana transaction. | One quote per transaction. | | `bad_signature` | The stored quote's signature did not verify against a trusted signer key (operator-side). | Nothing to avoid; the payment comes back. | | `failed_order` | Some recipients of a batch failed. | Nothing to avoid; their slice comes back (for a partial batch, capped so the delivered part still cost at least $1). | Refunds are sent by the operator; the record shows `refund_pending` until the return transaction is out, then `refunded` with `refundTxHash`. --- # Privacy Policy https://docs.drip.tools/privacy-policy We value your privacy. This Privacy Policy explains what data we collect, how we use it, and your rights. ## 1. What We Collect We only collect your wallet address when you interact with the platform. We do not collect your name, email, IP address, or use cookies. ## 2. How We Use Wallet Addresses We use wallet addresses solely to: - Deliver testnet tokens for platform functionality. - Prevent abuse and spam (e.g., rate-limiting). - Maintain anonymized usage statistics. We do not use wallet data for tracking, advertising, or profiling. ## 3. Data Storage & Security Wallet addresses are stored securely in a minimal format. We implement reasonable technical safeguards to protect this data. ## 4. Data Sharing We do not sell, share, or disclose wallet addresses to any third party, unless legally required to do so by court order or regulatory request. ## 5. Your Rights As wallet addresses are pseudonymous and not tied to personal identity: You may request deletion of your wallet data. You may request to know whether we store your address. To exercise these rights, email us at admin@drip.tools with your wallet address. ## 6. Changes to This Policy We may update this Privacy Policy. The latest version will always be available at https://docs.drip.tools/privacy-policy. ## 7. Contact Questions about privacy? Contact us at: admin@drip.tools --- # Terms of Use https://docs.drip.tools/terms-of-use Welcome to Drip Tools. By using our service, you agree to these Terms of Use. If you do not agree, please do not use the platform. _Last updated: 19 October 2026._ ## 1. Purpose Drip Tools is a utility platform that allows users to exchange mainnet tokens for testnet tokens. Payments are accepted on the supported mainnet networks (EVM-compatible networks, Solana and TON), and testnet tokens are delivered on the supported EVM, Solana, TON and Bitcoin testnets. The current list of networks and tokens is published by the service itself and may change at any time. The platform is designed for developers, testers, and blockchain enthusiasts to facilitate testnet interactions without financial risk. ## 2. Eligibility You may use the platform if: - You are at least 18 years old or of legal age in your jurisdiction. - You are not located in a sanctioned or restricted region. - You use the platform for lawful and non-malicious purposes. ## 3. Nature of the Service A swap is executed against a quote issued by the service. A quote fixes the price and the amounts for its validity window; outside a quote no exchange rate is guaranteed, and the availability of testnet tokens depends on faucet liquidity and usage volume. Once a payment that matches its quote has been accepted and the testnet tokens have been sent, the swap is final: the mainnet tokens are not returned and the transaction cannot be reversed. A payment that does not match its quote is handled as described in the documentation. A payment that cannot be honoured — for example, one that arrives after the quote's deadline or without a readable quote reference — is recorded for a refund to the paying wallet. If part of a batch cannot be delivered, the undelivered part is refunded to the payer, subject to the minimum retained amount stated in the documentation. A payment whose payer, token or amount differs from the quote is held for review and is not refunded automatically. Refunds are issued by the operator from the receiving account; their timing is not guaranteed, and network fees are not refunded. Testnet tokens received have no real-world or monetary value. ## 4. Prohibited Use You agree not to: - Engage in money laundering, fraud, or any illegal activity. - Attack, exploit, or reverse-engineer the platform or its smart contracts. - Use compromised or unauthorized wallets or tokens. - Circumvent or overload the service's rate limits, or otherwise interfere with its operation (for example, by flooding the API or the smart contracts). - Send funds to the service's payment addresses other than as a payment for a quote. ## 5. Programmatic Access The public API documented at https://docs.drip.tools may be used by scripts, bots and automated agents on the same terms as the web application: the same quotes, payment rules and rate limits apply. The party operating an automated client is responsible for every payment it makes, including payments that do not match their quote. Requests beyond the published rate limits may be rejected or blocked. ## 6. Risks All use of blockchain assets involves inherent risks. Drip Tools offers no warranties, representations, or guarantees of any kind. Users are solely responsible for the security of their wallets and submitted assets. ## 7. Intellectual Property All platform content, branding, and source code are the property of the service operator unless otherwise specified. Any unauthorized use, reproduction, or distribution is prohibited. ## 8. Modifications We may update or revise these Terms at any time. Continued use of the platform after updates constitutes acceptance of the new Terms. ## 9. Contact For any questions, contact us at: admin@drip.tools