Skip to content
PortunaPortunaPortunaDocsTestnet

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;
  • curl and jq, and Foundry for cast;
  • 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. Sign up in the console and confirm your email. Your organization gets a testnet account right away.
  2. Open API keys and create a key. Copy the secret now: it is shown only once. Test keys start with ptn_test_.
  3. Keep the key on your backend, and export it with the testnet API address for the commands below:
Terminal window
export PORTUNA_API_URL=https://api-testnet.portuna.io
export PORTUNA_API_KEY=ptn_test_…

Check that it works:

Terminal window
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", … }
]
}

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:

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.

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.

Terminal window
export HOT_WALLET=0x… # an address you control
curl -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\"}"
202 Accepted
{
"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:

Terminal window
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 answer

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.

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:

Terminal window
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.

Create a fresh key for a test deposit address. It is a test key only: real deposit keys stay in your custody.

Terminal window
cast wallet new
export DEPOSIT=0x… # "Address" from the output
export DEPOSIT_KEY=0x… # "Private key" from the output
export RPC_URL=https://ethereum-sepolia-rpc.publicnode.com

Give 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:

Terminal window
cast send 0x5B74f75040584b133Ff1EB67eEe646B0da26e39C "mint(address,uint256)" "$DEPOSIT" 100000000 \
--rpc-url "$RPC_URL" --interactive

100000000 is 100 USDT: amounts in the API are integers in the token’s smallest units, and USDT has 6 decimals.

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:

Terminal window
export NONCE=$(cast nonce "$DEPOSIT" --rpc-url "$RPC_URL") # 0 for a fresh address
Terminal window
SIGNED=$(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"

Ask for a sweep of the address’s whole USDT balance, with the authorization:

Terminal window
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:

Terminal window
curl "$PORTUNA_API_URL/v1/sweeps/<id>" -H "Authorization: Bearer $PORTUNA_API_KEY"

Within a minute or two the sweep is swept: the transaction is mined and has its confirmations.

200 OK
{
"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:

Terminal window
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.

Mint more test USDT to the same address and ask for another sweep. The address is already delegated, so the request needs no authorization:

Terminal window
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.

Set the URL your backend receives events at, and an alert for when the gas wallet runs low:

Terminal window
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)
})

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.

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.

main.go
// 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())
}