Перейти к содержимому
PortunaPortunaPortunaДокументацияTestnet

Быстрый старт

Этот гайд проводит от регистрации до первого sweep в тестнете Sepolia. Вы зарегистрируете горячий кошелёк (котёл), подпишете авторизацию EIP-7702 для тестового депозитного адреса, сметёте с него тестовый USDT, не отправляя на адрес газ, и получите вебхук. В BSC testnet всё так же, только chain_id — 97.

Понадобятся:

  • немного Sepolia ETH из крана — пополнить газ-кошелёк и выпустить тестовые токены;
  • curl и jq, а также Foundry ради cast;
  • для примеров на Go и TypeScript — Go 1.25+ или Node.js 20+ и SDK;
  • адрес, которым вы управляете, — сюда придут средства, это и есть горячий кошелёк. В тестнете подойдёт любой новый адрес.
  1. Зарегистрируйтесь в кабинете и подтвердите почту. Организация сразу получает аккаунт в тестнетах.
  2. Откройте API-ключи и создайте ключ. Скопируйте секрет сразу: он показывается один раз. Тестовые ключи начинаются с ptn_test_.
  3. Храните ключ на бэкенде. Для команд ниже экспортируйте его вместе с адресом API тестнетов:
Окно терминала
export PORTUNA_API_URL=https://api-testnet.portuna.io
export PORTUNA_API_KEY=ptn_test_…

Проверьте, что всё работает:

Окно терминала
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 — ваш газ-кошелёк: адрес, созданный для вас, один и тот же во всех сетях. С него Portuna платит газ ваших sweep, так что депозитным адресам газ не нужен вообще. Пришлите на него тестовые монеты обычным переводом:

0,02 ETH с запасом хватит на развёртывание ваших контрактов и множество тестовых sweep. Когда перевод попадёт в блок, available в GET /v1/balance покажет его в wei.

Зарегистрируйте адрес, на который придут средства. Portuna развернёт с вашего газ-кошелька его делегат — контракт, который будут авторизовать депозитные адреса. Делегат умеет отправлять средства только в этот горячий кошелёк.

Окно терминала
export HOT_WALLET=0x… # адрес, которым вы управляете
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"
}

Через несколько блоков статус станет active. Проверьте его и сохраните адрес делегата:

Окно терминала
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" из ответа

Если статус failed, причина — в поле error, чаще всего это газ-кошелёк без газа. Пополните его и отправьте тот же запрос ещё раз: развёртывание начнётся заново.

Прежде чем депозитный адрес подпишет что-либо для делегата, убедитесь, что это открытый код контракта, собранный для вашего горячего кошелька и газ-кошелька. Команда verify-delegate из SDK делает это за один запуск; в каталоге Go SDK:

Окно терминала
go run ./cmd/verify-delegate -rpc https://ethereum-sepolia-rpc.publicnode.com \
-gas-wallet <gas_wallet> -hot-wallet "$HOT_WALLET" -expect-delegate "$DELEGATE"

Каждая строка должна начинаться с PASS. Подробности и ручная проверка — в гайде Проверка делегата.

Создайте новый ключ для тестового депозитного адреса. Это только тестовый ключ: настоящие ключи депозитных адресов остаются в вашей кастоди.

Окно терминала
cast wallet new
export DEPOSIT=0x… # "Address" из вывода
export DEPOSIT_KEY=0x… # "Private key" из вывода
export RPC_URL=https://ethereum-sepolia-rpc.publicnode.com

Выпустите на него тестовый USDT. Тестовый токен тестнетов — 0x5B74f75040584b133Ff1EB67eEe646B0da26e39C, один адрес в Sepolia и BSC testnet — позволяет выпускать его кому угодно. Платите за выпуск с любого пополненного аккаунта в Sepolia, только не с самого депозитного адреса: тогда на нём не будет ETH, а nonce останется 0:

Окно терминала
cast send 0x5B74f75040584b133Ff1EB67eEe646B0da26e39C "mint(address,uint256)" "$DEPOSIT" 100000000 \
--rpc-url "$RPC_URL" --interactive

100000000 — это 100 USDT: суммы в API — целые числа в минимальных единицах токена, а у USDT 6 знаков после запятой.

Ключ депозитного адреса подписывает авторизацию EIP-7702: «в сети 11155111 выполнять код $DELEGATE» — при текущем nonce адреса. Здесь подписывает тестовый ключ; в бою тот же 32-байтный хэш подписывает ваш HSM, KMS или MPC, см. Подпись авторизаций.

С cast из Foundry и jq, сразу в формате API:

Окно терминала
export NONCE=$(cast nonce "$DEPOSIT" --rpc-url "$RPC_URL") # 0 у нового адреса
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"

Запросите sweep всего баланса USDT на адресе — вместе с авторизацией:

Окно терминала
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}')"

Ответ — 202 с заданием в статусе queued. Следите за ним по id:

Окно терминала
curl "$PORTUNA_API_URL/v1/sweeps/<id>" -H "Authorization: Bearer $PORTUNA_API_KEY"

Через минуту-другую задание станет swept: транзакция в блоке и набрала подтверждения.

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"
}

Откройте tx_hash в Sepolia Etherscan: одна транзакция с вашего газ-кошелька поставила делегат на депозитный адрес и перевела его USDT в горячий кошелёк. Теперь у адреса есть делегация — 0xef0100 и адрес делегата:

Окно терминала
cast code "$DEPOSIT" --rpc-url "$RPC_URL"

Комиссии за этот первый sweep не было. В Sepolia уже действуют правила газа Glamsterdam, и первый код на адресе, где лежат только токены, стоит примерно столько же газа, сколько классическое пополнение и перевод, — вы платите только газ. Экономия начинается с повторных sweep: см. Первый и повторный sweep.

Выпустите на тот же адрес ещё тестового USDT и запросите новый sweep. На адресе уже стоит делегат, поэтому авторизация не нужна:

Окно терминала
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\"}"

У этого задания kind: repeat. В батче из одного адреса оно стоит около 44 600 газа, в батче из 50 адресов — около 18 100 газа против 53 633 в классике.

Задайте адрес, на который бэкенд будет получать события, и алерт на случай, когда газ-кошелёк опустеет:

Окно терминала
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"}]}'

В ответе есть secret вида whsec_…: сохраните его на бэкенде, им проверяется каждая доставка. Адрес должен быть публичным и по https; чтобы проверить приём со своей машины, поставьте перед ним HTTPS-туннель.

Проверяйте заголовок Portuna-Signature каждой доставки по сырому телу запроса и только потом обрабатывайте событие:

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 присылает событие webhook.test — проверить ваш приёмник, а GET /v1/webhook/deliveries показывает, как прошли последние доставки. Подробнее — в гайде Вебхуки.

Примеры на Go и TypeScript выше взяты из этих программ. Они собираются против SDK при каждой сборке документации, поэтому соответствуют текущему 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())
}