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.
pnpm add viem ethers @solana/web3.js @solana/spl-token bs58 @ton/ton @ton/crypto @ton/core
PRIVATE_KEY=0x… node evm-native.mjspip install requests web3 solana solders pytoniq
PRIVATE_KEY=0x… python evm_native.pyTry 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).
// 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)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.
// 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))
}"""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.
// 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.
// 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))
}"""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.
// 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))
}"""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.
// 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))
}"""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.
// 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))
}"""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())// 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.
// 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)