Quickstart
This guide takes you from sign-up to your first sweep on the Sepolia testnet. You register a hot wallet, sign an
EIP-7702 authorization for a test deposit address, sweep the address’s test USDT without sending it any gas, and
receive the webhook. BSC testnet works the same way with chain ID 97.
You need:
- some Sepolia ETH from a faucet, to fund your gas wallet and to mint test tokens;
curlandjq, and Foundry forcast;- for the Go or TypeScript examples, Go 1.25+ or Node.js 20+ and the SDK;
- an address you control to receive the funds: your hot wallet. On a testnet any fresh address will do.
1. Create an account and a test key
Section titled “1. Create an account and a test key”- Sign up in the console and confirm your email. Your organization gets a testnet account right away.
- Open API keys and create a key. Copy the secret now: it is shown only once. Test keys start with
ptn_test_. - Keep the key on your backend, and export it with the testnet API address for the commands below:
export PORTUNA_API_URL=https://api-testnet.portuna.ioexport PORTUNA_API_KEY=ptn_test_…Check that it works:
curl "$PORTUNA_API_URL/v1/balance" -H "Authorization: Bearer $PORTUNA_API_KEY"{ "gas_wallet": "0xf0259b04f5D336E624A84780d1B365fAFc3f5A85", "chains": [ { "chain_id": 97, "name": "bsc-testnet", "native": "BNB", "balance": "0", "available": "0", … }, { "chain_id": 11155111, "name": "sepolia", "native": "ETH", "balance": "0", "available": "0", … } ]}2. Fund your gas wallet
Section titled “2. Fund your gas wallet”gas_wallet is your gas wallet: an address created for you, the same on every chain. Portuna pays the gas of your
sweeps from it, so your deposit addresses never need any. Send it test coins with a plain transfer:
- Sepolia ETH — from the Google Cloud Web3 faucet or the Sepolia PoW faucet;
- BSC testnet tBNB — from the BNB Chain faucet.
0.02 ETH is plenty to deploy your contracts and run many test sweeps. Once the transfer is mined, available in
GET /v1/balance shows it, in wei.
3. Register a hot wallet
Section titled “3. Register a hot wallet”Register the address that receives the funds. Portuna deploys its delegate from your gas wallet: the contract your deposit addresses will authorize. The delegate can only send funds to this hot wallet.
export HOT_WALLET=0x… # an address you controlcurl -X POST "$PORTUNA_API_URL/v1/hot-wallets" \ -H "Authorization: Bearer $PORTUNA_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"chain_id\": 11155111, \"address\": \"$HOT_WALLET\"}"{ "chain_id": 11155111, "address": "0x2Cd50979a8A33e8CA37DA85F8517fb148a67b769", "delegate": "0xE60fdf70a794C76094f86b990a821D55e3096AA5", "status": "pending", "created_at": "2026-10-10T09:14:03Z"}The status turns active within a few blocks. Check it, and keep the delegate’s address:
curl "$PORTUNA_API_URL/v1/hot-wallets" -H "Authorization: Bearer $PORTUNA_API_KEY" \ | jq '.hot_wallets[] | {chain_id, address, delegate, status}'export DELEGATE=0x… # "delegate" from the answerhotWallet := common.HexToAddress(os.Getenv("HOT_WALLET"))hot, err := client.CreateHotWallet(ctx, sepolia, hotWallet)if err != nil { log.Fatal(err)}// The delegate is deployed within a few blocks; the hot_wallet.active webhook event tells the same.for hot.Status == portuna.HotWalletPending { time.Sleep(5 * time.Second) if hot, err = findHotWallet(ctx, client, hotWallet); err != nil { log.Fatal(err) }}if hot.Status != portuna.HotWalletActive { log.Fatalf("the delegate was not deployed: %s", hot.Error)}fmt.Println("delegate:", hot.Delegate.Hex())The client and the helper findHotWallet are in the complete program.
const hotWallet = env('HOT_WALLET') as Addresslet hot: HotWallet = await client.createHotWallet({ chain_id: chainId, address: hotWallet })// The delegate is deployed within a few blocks; the hot_wallet.active webhook event tells the same.while (hot.status === 'pending') { await sleep(5000) const all = await client.hotWallets() hot = all.find((h) => h.chain_id === chainId && isAddressEqual(h.address, hotWallet)) ?? hot}if (hot.status !== 'active') throw new Error(`the delegate was not deployed: ${hot.error}`)console.log('delegate:', hot.delegate)If the status is failed, error says why, usually a gas wallet without gas. Fund it and send the same request
again: it restarts the deployment.
4. Verify the delegate
Section titled “4. Verify the delegate”Before any deposit address signs for the delegate, check that it is built from the open contract code for your hot
wallet and gas wallet. The verify-delegate command of the SDK does it in one go; from the Go SDK’s directory:
go run ./cmd/verify-delegate -rpc https://ethereum-sepolia-rpc.publicnode.com \ -gas-wallet <gas_wallet> -hot-wallet "$HOT_WALLET" -expect-delegate "$DELEGATE"Every line must say PASS. Details and the manual way: Verifying the delegate.
5. Prepare a test deposit address
Section titled “5. Prepare a test deposit address”Create a fresh key for a test deposit address. It is a test key only: real deposit keys stay in your custody.
cast wallet newexport DEPOSIT=0x… # "Address" from the outputexport DEPOSIT_KEY=0x… # "Private key" from the outputexport RPC_URL=https://ethereum-sepolia-rpc.publicnode.comGive it some test USDT. The test token of the testnets, 0x5B74f75040584b133Ff1EB67eEe646B0da26e39C on Sepolia
and BSC testnet alike, lets anyone mint it. Pay for the mint from any funded Sepolia account except the deposit
address itself, so that the deposit address keeps no ETH and its nonce stays 0:
cast send 0x5B74f75040584b133Ff1EB67eEe646B0da26e39C "mint(address,uint256)" "$DEPOSIT" 100000000 \ --rpc-url "$RPC_URL" --interactive100000000 is 100 USDT: amounts in the API are integers in the token’s smallest units, and USDT has 6 decimals.
6. Sign the authorization
Section titled “6. Sign the authorization”The deposit address’s key signs an EIP-7702 authorization: on chain 11155111, run the code of $DELEGATE, at the
address’s current nonce. Here a test key signs it; in production your HSM, KMS or MPC signs the same 32-byte digest,
see Signing authorizations.
With Foundry’s cast and jq, in the API’s format:
export NONCE=$(cast nonce "$DEPOSIT" --rpc-url "$RPC_URL") # 0 for a fresh addressSIGNED=$(cast wallet sign-auth "$DELEGATE" --private-key "$DEPOSIT_KEY" --nonce "$NONCE" --chain 11155111)AUTH=$(cast from-rlp "$SIGNED" | jq -c --argjson nonce "$NONCE" \ '{chain_id: 11155111, address: .[1], nonce: $nonce, y_parity: (if .[3] == "0x" then 0 else 1 end), r: .[4], s: .[5]}')echo "$AUTH"// A test key in memory stands in for your custody here. An HSM, KMS or MPC signs the same digest raw, and// AuthorizationFromSignature takes its DER or r‖s signature as it is.key, err := crypto.HexToECDSA(strings.TrimPrefix(os.Getenv("DEPOSIT_KEY"), "0x"))if err != nil { log.Fatal(err)}deposit := crypto.PubkeyToAddress(key.PublicKey)
eth, err := ethclient.DialContext(ctx, os.Getenv("RPC_URL"))if err != nil { log.Fatal(err)}nonce, err := eth.NonceAt(ctx, deposit, nil) // 0 for an address that never sent a transactionif err != nil { log.Fatal(err)}digest, err := portuna.AuthorizationDigest(sepolia, hot.Delegate, nonce)if err != nil { log.Fatal(err)}sig, err := crypto.Sign(digest[:], key)if err != nil { log.Fatal(err)}auth, err := portuna.AuthorizationFromSignature(sepolia, hot.Delegate, nonce, deposit, sig)if err != nil { log.Fatal(err)}// A test key in memory stands in for your custody here. An HSM, KMS or MPC signs the same digest raw, and// authorizationFromSignature takes its DER or r‖s signature as it is.const privateKey = env('DEPOSIT_KEY') as Hexconst deposit = privateKeyToAccount(privateKey).address
const rpc = createPublicClient({ chain: sepolia, transport: http(env('RPC_URL')) })const nonce = await rpc.getTransactionCount({ address: deposit }) // 0 for an address that never sent a transactionconst digest = authorizationDigest({ chainId, address: hot.delegate, nonce })const signature = await sign({ hash: digest, privateKey, to: 'hex' })const authorization = await authorizationFromSignature({ chainId, address: hot.delegate, nonce, deposit, signature })7. Send the first sweep
Section titled “7. Send the first sweep”Ask for a sweep of the address’s whole USDT balance, with the authorization:
curl -X POST "$PORTUNA_API_URL/v1/sweeps" \ -H "Authorization: Bearer $PORTUNA_API_KEY" \ -H "Content-Type: application/json" \ -d "$(jq -n --arg hot "$HOT_WALLET" --arg account "$DEPOSIT" --argjson auth "$AUTH" \ '{external_id: "quickstart-1", chain_id: 11155111, hot_wallet: $hot, account: $account, token: "USDT", authorization: $auth}')"The answer is 202 with the sweep in status queued. Poll it by its id:
curl "$PORTUNA_API_URL/v1/sweeps/<id>" -H "Authorization: Bearer $PORTUNA_API_KEY"sweep, _, err := client.CreateSweep(ctx, portuna.SweepRequest{ ExternalID: "quickstart-" + deposit.Hex(), // repeating the request returns this same sweep ChainID: sepolia, HotWallet: hotWallet, Account: deposit, Token: "USDT", // the whole balance: no Amount Authorization: &auth,})if err != nil { log.Fatal(err)}for sweep.Status == portuna.SweepQueued || sweep.Status == portuna.SweepProcessing { time.Sleep(5 * time.Second) if sweep, err = client.Sweep(ctx, sweep.ID); err != nil { log.Fatal(err) }}fmt.Println(sweep.Status, sweep.SweptAmount, sweep.Reason, sweep.TxHash)let { sweep } = await client.createSweep({ external_id: `quickstart-${deposit}`, // repeating the request returns this same sweep chain_id: chainId, hot_wallet: hotWallet, account: deposit, token: 'USDT', // the whole balance: no amount authorization,})while (sweep.status === 'queued' || sweep.status === 'processing') { await sleep(5000) sweep = await client.sweep(sweep.id)}console.log(sweep.status, sweep.swept_amount ?? sweep.reason, sweep.tx_hash)Within a minute or two the sweep is swept: the transaction is mined and has its confirmations.
{ "id": "3d0c5d0e-6b7f-4a5e-9a51-3c8f0b2e7d14", "external_id": "quickstart-1", "chain_id": 11155111, "hot_wallet": "0x2Cd50979a8A33e8CA37DA85F8517fb148a67b769", "account": "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23", "token": "USDT", "token_address": "0x5B74f75040584b133Ff1EB67eEe646B0da26e39C", "status": "swept", "attempts": 1, "swept_amount": "100000000", "tx_hash": "0x08deabdb72703741f2a02fc9eb22a93f74b0411156530e33da5237f4ef305704", "kind": "first_new", "gas": 270245, "gas_cost": "324294000000000", "fee": "0", "classic_gas": 237233, "rate_bps": 7000, "created_at": "2026-10-10T09:20:41Z", "updated_at": "2026-10-10T09:21:52Z"}Open tx_hash on Sepolia Etherscan: one transaction from your gas wallet set the
delegate on the deposit address and moved its USDT to your hot wallet. The deposit address now carries a delegation,
0xef0100 followed by the delegate’s address:
cast code "$DEPOSIT" --rpc-url "$RPC_URL"This first sweep carried no fee. On Sepolia, which already runs the Glamsterdam gas rules, giving an address that holds only tokens its first code costs about as much gas as the classic top-up and transfer, so you pay only the gas. The savings come with repeat sweeps: see First and repeat sweeps.
8. Sweep again, without an authorization
Section titled “8. Sweep again, without an authorization”Mint more test USDT to the same address and ask for another sweep. The address is already delegated, so the request needs no authorization:
curl -X POST "$PORTUNA_API_URL/v1/sweeps" \ -H "Authorization: Bearer $PORTUNA_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"external_id\": \"quickstart-2\", \"chain_id\": 11155111, \"hot_wallet\": \"$HOT_WALLET\", \"account\": \"$DEPOSIT\", \"token\": \"USDT\"}"This sweep is kind: repeat. Alone in its batch it costs about 44,600 gas; in a batch of 50 addresses, about 18,100
gas against 53,633 for the classic way.
9. Receive webhooks
Section titled “9. Receive webhooks”Set the URL your backend receives events at, and an alert for when the gas wallet runs low:
curl -X PUT "$PORTUNA_API_URL/v1/webhook" \ -H "Authorization: Bearer $PORTUNA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://processing.example/hooks/sweeps", "low_balance": [{"chain_id": 11155111, "threshold": "10000000000000000"}]}'The answer holds secret, whsec_…: store it on your backend to verify every delivery. The URL must be public and
use https; to test from your machine, put it behind an HTTPS tunnel.
Verify each delivery’s Portuna-Signature header against the raw body, then handle the event:
http.HandleFunc("POST /hooks/sweeps", func(w http.ResponseWriter, r *http.Request) { // Verify the body exactly as it arrived, before parsing it. body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "cannot read the body", http.StatusBadRequest) return } event, err := portuna.ParseWebhook(secret, r.Header.Get(portuna.SignatureHeader), body) if err != nil { http.Error(w, "bad signature", http.StatusUnauthorized) return } // Delivery is at least once: an event may come again, with the same id. if handled(event.ID) { w.WriteHeader(http.StatusNoContent) return } switch event.Type { case portuna.EventSweepSwept, portuna.EventSweepFailed, portuna.EventSweepRejected: sweep, err := event.Sweep() if err != nil { http.Error(w, "bad event", http.StatusBadRequest) return } // Events may come out of order: the status in the data is the current one. log.Printf("sweep %s (%s): %s %s %s", sweep.ID, sweep.ExternalID, sweep.Status, sweep.SweptAmount, sweep.Reason) case portuna.EventBalanceLow: alert, err := event.BalanceLow() if err != nil { http.Error(w, "bad event", http.StatusBadRequest) return } log.Printf("top up the gas wallet on chain %d: %s wei left", alert.ChainID, alert.Balance) default: // Other and future event types: accept them. } markHandled(event.ID) w.WriteHeader(http.StatusNoContent)})function handle(event: Event): void { switch (event.type) { case 'sweep.swept': case 'sweep.failed': case 'sweep.rejected': // Events may come out of order: the status in the data is the current one. console.log(`sweep ${event.data.id} (${event.data.external_id}): ${event.data.status}`) break case 'balance.low': console.log(`top up the gas wallet on chain ${event.data.chain_id}: ${event.data.balance} wei left`) break default: // Other and future event types: accept them. }}
createServer((req, res) => { const chunks: Buffer[] = [] req.on('data', (chunk: Buffer) => chunks.push(chunk)) req.on('end', () => { // Verify the body exactly as it arrived, before parsing it. const body = Buffer.concat(chunks) let event: Event try { event = parseWebhookSync(secret, String(req.headers[signatureHeader.toLowerCase()] ?? ''), body) } catch { res.writeHead(401).end() return } // Delivery is at least once: an event may come again, with the same id. if (!handled.has(event.id)) { handle(event) handled.add(event.id) } res.writeHead(204).end() })}).listen(8080)# Verifies the Portuna-Signature header of a webhook delivery, with the standard library only.# The docs tests run it against the signature vectors the SDKs and the service share.import hashlibimport hmacimport reimport time
TOLERANCE = 300 # seconds between the signature time and now, either way
class SignatureError(Exception): pass
def verify_webhook(secret: str, header: str, body: bytes, now: float | None = None, tolerance: int = TOLERANCE) -> None: """Raises SignatureError unless the header signs body with secret within tolerance of now.
body must be the request body exactly as received, before any JSON parsing. """ ts, sig = None, None for part in header.split(","): key, _, value = part.partition("=") if key == "t": ts = value elif key == "v1": sig = value if ts is None or not re.fullmatch(r"[+-]?[0-9]+", ts) or not sig: raise SignatureError("malformed signature header") if abs((time.time() if now is None else now) - int(ts)) > tolerance: raise SignatureError("signature timestamp out of tolerance") expected = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig.encode(), expected.encode()): raise SignatureError("signature mismatch")POST /v1/webhook/test sends a webhook.test event to check your endpoint, and GET /v1/webhook/deliveries shows
how the last deliveries went. More in Webhooks.
Complete programs
Section titled “Complete programs”The Go and TypeScript examples above come from these programs. They are built against the SDKs whenever the docs are, so they match the current API.
// Quickstart on Sepolia: register a hot wallet, sign a deposit address's EIP-7702 authorization and sweep the// address's test USDT to the hot wallet.//// Environment: PORTUNA_API_URL, PORTUNA_API_KEY, HOT_WALLET, RPC_URL (a Sepolia RPC) and DEPOSIT_KEY, the private key of// a test deposit address. Never put a real deposit key in an environment variable: in production the key// stays in your HSM, KMS or MPC.package main
import ( "context" "fmt" "log" "os" "strings" "time"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient"
portuna "github.com/Coddycoder/gasdelegate/sdk/go")
const sepolia = 11155111
func main() { ctx := context.Background()
client := portuna.NewClient(os.Getenv("PORTUNA_API_URL"), os.Getenv("PORTUNA_API_KEY"))
balance, err := client.Balance(ctx) if err != nil { log.Fatal(err) } fmt.Println("gas wallet:", balance.GasWallet.Hex()) for _, c := range balance.Chains { fmt.Printf("%s: %s wei available for gas\n", c.Name, c.Available) }
hotWallet := common.HexToAddress(os.Getenv("HOT_WALLET")) hot, err := client.CreateHotWallet(ctx, sepolia, hotWallet) if err != nil { log.Fatal(err) } // The delegate is deployed within a few blocks; the hot_wallet.active webhook event tells the same. for hot.Status == portuna.HotWalletPending { time.Sleep(5 * time.Second) if hot, err = findHotWallet(ctx, client, hotWallet); err != nil { log.Fatal(err) } } if hot.Status != portuna.HotWalletActive { log.Fatalf("the delegate was not deployed: %s", hot.Error) } fmt.Println("delegate:", hot.Delegate.Hex())
// A test key in memory stands in for your custody here. An HSM, KMS or MPC signs the same digest raw, and // AuthorizationFromSignature takes its DER or r‖s signature as it is. key, err := crypto.HexToECDSA(strings.TrimPrefix(os.Getenv("DEPOSIT_KEY"), "0x")) if err != nil { log.Fatal(err) } deposit := crypto.PubkeyToAddress(key.PublicKey)
eth, err := ethclient.DialContext(ctx, os.Getenv("RPC_URL")) if err != nil { log.Fatal(err) } nonce, err := eth.NonceAt(ctx, deposit, nil) // 0 for an address that never sent a transaction if err != nil { log.Fatal(err) } digest, err := portuna.AuthorizationDigest(sepolia, hot.Delegate, nonce) if err != nil { log.Fatal(err) } sig, err := crypto.Sign(digest[:], key) if err != nil { log.Fatal(err) } auth, err := portuna.AuthorizationFromSignature(sepolia, hot.Delegate, nonce, deposit, sig) if err != nil { log.Fatal(err) }
sweep, _, err := client.CreateSweep(ctx, portuna.SweepRequest{ ExternalID: "quickstart-" + deposit.Hex(), // repeating the request returns this same sweep ChainID: sepolia, HotWallet: hotWallet, Account: deposit, Token: "USDT", // the whole balance: no Amount Authorization: &auth, }) if err != nil { log.Fatal(err) } for sweep.Status == portuna.SweepQueued || sweep.Status == portuna.SweepProcessing { time.Sleep(5 * time.Second) if sweep, err = client.Sweep(ctx, sweep.ID); err != nil { log.Fatal(err) } } fmt.Println(sweep.Status, sweep.SweptAmount, sweep.Reason, sweep.TxHash)}
func findHotWallet(ctx context.Context, client *portuna.Client, address common.Address) (portuna.HotWallet, error) { list, err := client.HotWallets(ctx) if err != nil { return portuna.HotWallet{}, err } for _, h := range list { if h.ChainID == sepolia && h.Address == address { return h, nil } } return portuna.HotWallet{}, fmt.Errorf("hot wallet %s not found", address.Hex())}// Quickstart on Sepolia: register a hot wallet, sign a deposit address's EIP-7702 authorization and sweep the// address's test USDT to the hot wallet.//// Environment: PORTUNA_API_URL, PORTUNA_API_KEY, HOT_WALLET, RPC_URL (a Sepolia RPC) and DEPOSIT_KEY, the private key of a// test deposit address. Never put a real deposit key in an environment variable: in production the key stays// in your HSM, KMS or MPC.import { Client, type HotWallet, authorizationDigest, authorizationFromSignature } from '@portuna/sdk'import { type Address, type Hex, createPublicClient, http, isAddressEqual } from 'viem'import { privateKeyToAccount, sign } from 'viem/accounts'import { sepolia } from 'viem/chains'
const chainId = 11155111const env = (name: string): string => { const value = process.env[name] if (!value) throw new Error(`set ${name}`) return value}const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const client = new Client({ baseUrl: env('PORTUNA_API_URL'), apiKey: env('PORTUNA_API_KEY') })
const balance = await client.balance()console.log('gas wallet:', balance.gas_wallet)for (const c of balance.chains) console.log(`${c.name}: ${c.available} wei available for gas`)
const hotWallet = env('HOT_WALLET') as Addresslet hot: HotWallet = await client.createHotWallet({ chain_id: chainId, address: hotWallet })// The delegate is deployed within a few blocks; the hot_wallet.active webhook event tells the same.while (hot.status === 'pending') { await sleep(5000) const all = await client.hotWallets() hot = all.find((h) => h.chain_id === chainId && isAddressEqual(h.address, hotWallet)) ?? hot}if (hot.status !== 'active') throw new Error(`the delegate was not deployed: ${hot.error}`)console.log('delegate:', hot.delegate)
// A test key in memory stands in for your custody here. An HSM, KMS or MPC signs the same digest raw, and// authorizationFromSignature takes its DER or r‖s signature as it is.const privateKey = env('DEPOSIT_KEY') as Hexconst deposit = privateKeyToAccount(privateKey).address
const rpc = createPublicClient({ chain: sepolia, transport: http(env('RPC_URL')) })const nonce = await rpc.getTransactionCount({ address: deposit }) // 0 for an address that never sent a transactionconst digest = authorizationDigest({ chainId, address: hot.delegate, nonce })const signature = await sign({ hash: digest, privateKey, to: 'hex' })const authorization = await authorizationFromSignature({ chainId, address: hot.delegate, nonce, deposit, signature })
let { sweep } = await client.createSweep({ external_id: `quickstart-${deposit}`, // repeating the request returns this same sweep chain_id: chainId, hot_wallet: hotWallet, account: deposit, token: 'USDT', // the whole balance: no amount authorization,})while (sweep.status === 'queued' || sweep.status === 'processing') { await sleep(5000) sweep = await client.sweep(sweep.id)}console.log(sweep.status, sweep.swept_amount ?? sweep.reason, sweep.tx_hash)Next steps
Section titled “Next steps”- How it works: the contracts and what each one may do.
- Signing authorizations in an HSM, KMS or MPC.
- Recipes: how much to sweep, batching and the native coin.
- Going live: what to prepare for mainnet.