Quick Start
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
viemorethers, or Python 3.10+ withweb3andrequests.
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
// 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))
}// 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))
}"""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.
// 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')// 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)# 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.
// 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}`)// 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}`)# 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.
// 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) }// 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)# 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.
// 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))
}// 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))
}# 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.
- Every endpoint, field and error: API reference, Errors.
- The whole flow explained: Swap flow.