Paying from Solana & TON
On the Solana and TON FROM chains there is no contract. A swap is still the same four steps — discover, quote, pay, track — but the payment is a plain transfer to the treasury that carries the quoteId: in an SPL Memo instruction on Solana, as the text comment on TON. The backend reads the transfer straight from the chain and matches it against the signed quote.
Because nothing on-chain validates the payment, the transaction shape is the protocol. Build it exactly as described here, or run the scripts below as they are.
The rules in short
- The transfer is sent by the wallet you quoted as
sender(on Solana: the fee payer). - The treasury receives exactly
fromTotalof the quoted token. Nothing else moves to it. - The memo / comment is the
quoteIdverbatim (0x+ 64 hex), once. - It lands before the quote's
deadline. Nothing on-chain stops a late transfer — it becomes a refund case instead of an order.
A payment that breaks the payer / token / amount rule is held for operator review and is not refunded automatically. The full table, with every outcome, is on Payment rules.
Timing
A quote lives 180 s. Request it right before you send; if fewer than ~20 s remain when you are about to broadcast, request a fresh one. On TON, set the message's validUntil to the quote deadline so the network drops a late broadcast for you.
Solana
FROM chain id 1399811149 (family: "solana"). Tokens: native SOL (0x000…0), USDC and USDT (base58 mints — read them from /api/chains). The treasury is contracts.depository on that chain.
Transaction shape
- Native SOL: one
SystemProgram.transferfrom your wallet to the treasury for exactlyfromTotallamports. - SPL token (USDC / USDT): a
TransferCheckedfrom your associated token account to the treasury's associated token account for exactlyfromTotalunits of the mint, preceded by an Associated Token AccountCreateIdempotentfor the treasury's account (a no-op when it exists; otherwise you fund its rent, ~0.002 SOL). Classic Token program only. - Memo: an SPL Memo instruction (program
MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr; the v1 program is accepted too) whose data is thequoteIdas UTF-8 text. No signer accounts are needed on the memo. - Your wallet is the fee payer. Failed transactions are ignored. Payments are indexed at
finalizedcommitment, typically 15–30 s after confirmation.
Native SOL
Two instructions in one transaction — the transfer and the memo:
// 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)# 3. Pay: transfer EXACTLY fromTotal to the treasury + the quoteId as a memo.
# Your wallet is the fee payer — it must be the `sender` you quoted.
async with AsyncClient(solana["rpc"][0]) as client:
blockhash = (await client.get_latest_blockhash()).value.blockhash
instructions = [
transfer(TransferParams(from_pubkey=payer.pubkey(), to_pubkey=treasury, lamports=int(quote["fromTotal"]))),
Instruction(MEMO_PROGRAM_ID, quote["quoteId"].encode(), []),
]
message = Message.new_with_blockhash(instructions, payer.pubkey(), blockhash)
tx = VersionedTransaction(message, [payer])
if DRY_RUN:
print("DRY_RUN — signed tx", len(bytes(tx)), "bytes, signature", tx.signatures[0])
return
signature = (await client.send_transaction(tx)).value
await client.confirm_transaction(signature, "confirmed")
print("Paid", signature)Full script — discover, quote, pay, track
// 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())SPL token (USDC / USDT)
fromToken is the mint address from /api/chains; quote.fromToken echoes it. Keep a little SOL in the paying wallet for the fee and the possible rent of the treasury's token account (~0.003 SOL covers both). Three instructions: create the treasury's token account if missing, TransferChecked, memo:
// 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)# 3. Pay: create the treasury's token account if missing (no-op otherwise), transfer EXACTLY
# fromTotal with TransferChecked, and add the quoteId memo. Keep ~0.003 SOL for fee + rent.
mint = Pubkey.from_string(quote["fromToken"])
payer_ata = get_associated_token_address(payer.pubkey(), mint)
treasury_ata = get_associated_token_address(treasury, mint)
async with AsyncClient(solana["rpc"][0]) as client:
blockhash = (await client.get_latest_blockhash()).value.blockhash
instructions = [
create_idempotent_associated_token_account(payer.pubkey(), treasury, mint),
transfer_checked(TransferCheckedParams(
program_id=TOKEN_PROGRAM_ID, source=payer_ata, mint=mint, dest=treasury_ata,
owner=payer.pubkey(), amount=int(quote["fromTotal"]), decimals=token["decimals"],
)),
Instruction(MEMO_PROGRAM_ID, quote["quoteId"].encode(), []),
]
message = Message.new_with_blockhash(instructions, payer.pubkey(), blockhash)
tx = VersionedTransaction(message, [payer])
if DRY_RUN:
print("DRY_RUN — signed tx", len(bytes(tx)), "bytes, signature", tx.signatures[0])
return
signature = (await client.send_transaction(tx)).value
await client.confirm_transaction(signature, "confirmed")
print("Paid", signature)Full script — discover, quote, pay, track
// 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())Browser wallets
With a Wallet Standard wallet (Phantom, Solflare, …) build the same Transaction with the wallet's public key as feePayer, then signAndSendTransaction. The wallet must be the one you quoted as sender.
TON
FROM chain id 1667471768 (family: "ton"). Token: native Toncoin only (GRAM, address 0x000…0, 9 decimals). The treasury is contracts.depository on that chain, given in the non-bounceable UQ… form.
Transaction shape
- One internal transfer from your wallet to the treasury for exactly
fromTotalnanotons. - Non-bounceable (
bounce: false). The treasury is a plain wallet; use theUQ…address exactly as returned. - Text comment = the
quoteId(0x+ 64 hex). A standard comment cell: 32-bit op0followed by the UTF-8 text, in a single cell — every wallet's "comment" / "memo" field produces exactly this. - Set the message's
validUntilto the quotedeadline(or earlier) so the network refuses a late broadcast. - Send with mode 1 or 3 (fees paid separately,
SendMode.PAY_GAS_SEPARATELY). With mode 0 the forward fee comes out of the value, the treasury receives less thanfromTotal, and the payment is held for review, not refunded. Wallets and TON Connect do this by default;@ton/tondoes not — passsendModeexplicitly, as the script below does. - Payments are indexed from the treasury's transactions, typically 10–20 s after they land.
sender in the quote request can be any form of your address (EQ…, UQ…, raw 0:…); the API normalizes it to the UQ… form, which is also how the payer appears on the order.
Wallet versions
One mnemonic gives a different address per wallet contract version. The JavaScript script uses W5 (v5r1), the default of today's wallets, and prints the address it is about to pay from; set TON_WALLET=v4r2 for a v4 wallet. The Python script (pytoniq) supports v4r2 only. Fund the address the script prints, not the one your wallet app shows, if they differ.
The message
One internal message: non-bounceable, exactly fromTotal nanotons, the quoteId as the text comment, valid until the quote deadline:
// 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)# 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"])// 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>
}Full script — discover, quote, pay, track
// 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())Tracking on TON
Your wallet returns only the hash of the external message it broadcast, which is not the hash of the treasury-side transaction the backend records. Pass the quoteId as from_tx_hash instead — GET /api/order?from_tx_hash=<quoteId> resolves it once the payment is honoured. The order's from.txHash is then the treasury-side transaction hash (64 hex characters), which also works for every later lookup. A payment that was refused (expired, no readable comment) never consumes its quote, so the quoteId lookup stays empty — its refund shows up in the payer's history, GET /api/orders?address=<UQ…>&view=batches.
// 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))
}# 4. Track by the quoteId — your wallet does not learn the treasury-side tx hash
while True:
data = requests.get(f"{API}/order", params={"from_tx_hash": quote["quoteId"]}, timeout=30).json()["data"]
if data and all(o["status"] in ("completed", "failed") for o in data):
for o in data:
print(f"{o['to']['address']}: {o['status']} {o['to']['txHash'] or ''}")
break
time.sleep(5)Checklist
- [ ]
senderin the quote = the wallet that signs and pays the fee. - [ ] Amount =
fromTotal, in base units, exactly. - [ ]
quoteIdin the memo / comment, nothing else, once. - [ ] Only the quoted token moves to the treasury; no other transfers in the transaction.
- [ ] Broadcast with plenty of the 180 s left; on TON,
validUntil = deadline. - [ ] Track by the Solana signature, or by the
quoteIdon TON.