Skip to content
PortunaPortunaPortunaDocsTestnet

Go SDK

The Go SDK covers what your backend does on its side: calling the API, turning your custody’s signatures into authorizations, verifying webhooks and checking delegates on chain. It depends on nothing but the standard library and go-ethereum.

Terminal window
GOPRIVATE=github.com/Coddycoder go get github.com/Coddycoder/gasdelegate/sdk/go
AreaAPI
API clientNewClient, with a method for each request of the API; errors are *portuna.Error
AuthorizationsAuthorizationDigest, AuthorizationFromSignature, ParseSignature, NormalizeS, Authorization.Verify
RevocationsRevocationDigest, RevocationFromSignature, Authorization.VerifyRevocation
ListsSweeps, Revocations, LedgerEntries, WebhookDeliveries a page at a time; AllSweeps and the like through every page
WebhooksParseWebhook, VerifyWebhook, SignWebhook for tests, Event.Sweep() and the other decoders
Delegate checkVerifyDeployment, FactoryAddress, BatcherAddress, DelegateAddress, and the cmd/verify-delegate command
client := portuna.NewClient("https://api-testnet.portuna.io", os.Getenv("PORTUNA_API_KEY"))
client.HTTPClient = &http.Client{Timeout: 30 * time.Second} // optional; http.DefaultClient otherwise

Every method takes a context.Context. Methods: Balance, Tariff, Settings, UpdateSettings, HotWallets, CreateHotWallet, CreateSweep, Sweep, Sweeps, CreateRevocation, Revocation, Revocations, LedgerEntries, Usage, Webhook, SetWebhook, DeleteWebhook, WebhookDeliveries, SendTestWebhook, Health and OpenAPI. CreateSweep and CreateRevocation also return created: false when the ExternalID was used before and the existing object came back.

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

Sweeps, Revocations, LedgerEntries and WebhookDeliveries return one page, a Page with Items and NextCursor, empty on the last page. Their filters, such as SweepFilter, leave out the fields left zero. The All methods, AllSweeps and the like, go through every page as an iterator and stop at the first error:

for sweep, err := range client.AllSweeps(ctx, portuna.SweepFilter{Status: portuna.SweepFailed}) {
if err != nil {
return err
}
log.Printf("sweep %s failed: %s", sweep.ID, sweep.Reason)
}

An answer other than 2xx is a *portuna.Error with StatusCode, Code and Message. portuna.ErrorCode(err) returns the code, or "" when err is not an API error, for example a network failure. Codes and what to do: Errors and retries.

AuthorizationDigest gives the 32 bytes your custody signs. AuthorizationFromSignature takes the raw signature, DER or r‖s, with any s, normalizes it, finds y_parity and fails unless the deposit address signed. Chain 0 is refused.

func signAuthorization(ctx context.Context, sign Signer, keyID string, deposit common.Address,
chainID int64, delegate common.Address, nonce uint64) (portuna.Authorization, error) {
digest, err := portuna.AuthorizationDigest(chainID, delegate, nonce) // refuses chain 0
if err != nil {
return portuna.Authorization{}, err
}
sig, err := sign(ctx, keyID, digest[:])
if err != nil {
return portuna.Authorization{}, err
}
// Reads DER or r‖s, moves s to the lower half and finds y_parity. Fails unless the deposit address
// signed this very digest, so a wrong key never reaches the API.
return portuna.AuthorizationFromSignature(chainID, delegate, nonce, deposit, sig)
}

More in Signing authorizations.

ParseWebhook(secret, header, body) verifies the Portuna-Signature header against the raw body, within five minutes, and decodes the event. event.Sweep(), event.Revocation(), event.HotWallet(), event.BalanceLow() and event.WebhookTest() decode its data. The constants EventSweepSwept and so on name the event types, SignatureHeader, EventHeader and DeliveryHeader the headers.

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

VerifyDeployment computes your batcher and the hot wallet’s delegate from the bytecode built into the SDK and checks them on chain; cmd/verify-delegate does the same from the command line. See Verifying the delegate.

eth, err := ethclient.DialContext(ctx, os.Getenv("RPC_URL"))
if err != nil {
log.Fatal(err)
}
report, err := portuna.VerifyDeployment(ctx, eth, portuna.Deployment{
GasWallet: common.HexToAddress(os.Getenv("GAS_WALLET")),
HotWallet: common.HexToAddress(os.Getenv("HOT_WALLET")),
ExpectDelegate: common.HexToAddress(os.Getenv("DELEGATE")),
})
if err != nil {
log.Fatal(err)
}
for _, c := range report.Checks {
fmt.Println(c.OK, c.Name, c.Detail)
}
if !report.OK() {
log.Fatal("do not sign authorizations for this delegate")
}