Skip to content

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.

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
bash
pip install requests web3 solana solders pytoniq
PRIVATE_KEY=0x… python evm_native.py

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).

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))
}
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))
}
py
"""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.

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))
}
py
"""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 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.

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 instead of the full array.

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))
}
py
"""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 for the rules.

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=<base58> 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))
}
py
"""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=<base58> 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.

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=<base58> 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: <mint>, 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))
}
py
"""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=<base58> 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.

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))
}
py
"""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())
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=<quote.quoteId>
}

Verify a quote's signature ​

Optional: check that a quote was really signed by Drip Tools before paying. Needs no keys.

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)