Skip to content

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 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 ​

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)

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.

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')
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)
py
# 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.

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

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

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))
}
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))
}
py
# 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 ​