Skip to content
PortunaPortunaPortunaDocsTestnet

Signing authorizations

A deposit address needs one signature from its key: an EIP-7702 authorization of your hot wallet’s delegate. Your custody signs it, and you send it in the authorization field of the address’s first POST /v1/sweeps. Nearly any key store can do it: it takes a raw secp256k1 signature of a ready 32-byte digest, without hashing it again and without the \x19Ethereum Signed Message prefix.

An authorization is three values:

FieldValue
chain_idThe chain of the sweep: 11155111 for Sepolia, 97 for BSC testnet. Never 0: an authorization for chain 0 is valid on every chain, and the API refuses it.
addressThe hot wallet’s delegate: delegate from POST /v1/hot-wallets.
nonceThe deposit address’s current nonce (eth_getTransactionCount at latest): 0 for an address that has never sent a transaction.

The digest to sign:

digest = keccak256(0x05 || rlp([chain_id, address, nonce]))

0x05 is the EIP-7702 authorization type. RLP encodes integers without leading zeros and zero as the empty string (0x80), and the address as 20 bytes.

To revoke a delegation you sign the same structure with address set to the zero address, 0x0000000000000000000000000000000000000000, and the address’s current nonce, usually 1 after the first sweep.

{ "chain_id": 11155111, "address": "0x…delegate", "nonce": 0, "y_parity": 0, "r": "0x…", "s": "0x…" }
  • r and s are the two 32-byte numbers of the signature, as 0x-hex.
  • s must be in the lower half of the curve order: s ≤ n/2, where n is the order of secp256k1. EIP-7702 requires it: the chain silently skips an authorization with a high s, and the delegate is never set. The API refuses such a signature at once with invalid_authorization. HSMs and KMSs return a high s about half of the time: replace s with n − s and flip y_parity.
  • y_parity is 0 or 1, the recovery bit. HSMs and KMSs usually do not return it: recover the address from the signature with 0 and with 1, and take the one that gives the deposit address.
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
n/2 = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0

Before sending, check that the address recovered from the signature is the deposit address. The API checks the same, and answers invalid_authorization if another key signed.

The SDK makes the digest, and turns your custody’s raw signature, DER or r‖s, with any s, into the API’s format: it normalizes s, finds y_parity and checks that the deposit address signed. A wrong key or a wrong digest fails here, before the API.

// Signer signs a 32-byte digest raw, without hashing it again and without a message prefix, with the key of a
// deposit address. It returns the signature as the custody gives it: DER (AWS KMS, Google Cloud KMS) or r‖s
// (Azure Key Vault, PKCS#11, most MPC providers).
type Signer func(ctx context.Context, keyID string, digest []byte) ([]byte, error)
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)
}
Where the key isKey typeHow to sign the digestWhat comes back
AWS KMSECC_SECG_P256K1Sign with MessageType=DIGEST and SigningAlgorithm=ECDSA_SHA_256: with DIGEST, KMS does not hash againDER
Google Cloud KMSEC_SIGN_SECP256K1_SHA256asymmetricSign with digest.sha256 set to the digest: KMS takes the 32 bytes as they areDER
Azure Key VaultEC, curve P-256Ksign with algorithm ES256K and the digest as inputr‖s, 64 bytes
HSM over PKCS#11secp256k1mechanism CKM_ECDSA (no hashing) over the digestr‖s
MPC custody—“raw signing” of a 32-byte messagedepends on the provider

DER is ASN.1 SEQUENCE { INTEGER r, INTEGER s }; r‖s is 32 bytes of each. Either way, normalize s and find y_parity as described above, or let the SDK do it.

Check your implementation with this test key. It is public: use it for this check only, never for anything else.

private key0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318
deposit address0x2c7536E3605D9C16a7a3D7b1898e529396a65c23
chain_id11155111 (Sepolia)
address0x9C1386861FDf87e66F21fEb3dD2C50959F1426cf
nonce0
RLP0xda83aa36a7949c1386861fdf87e66f21feb3dd2c50959f1426cf80
digest0x62e5b797a86f9b7aae715cf3e62975eb5ed9c9e9df07d50b057fc37ce5469b74
y_parity0
r0xc7c867f57978aae7701358ade99ac1aba61ec87006bff5cd48c1490ece919f91
s0x1ca4c1e888f8abdd1b015d6c9e611fd4e5d6d258cdebc511a2a0802312b64d62

The signature is deterministic (RFC 6979), as go-ethereum, viem and Foundry make it, so matching r and s checks the whole path, and a matching digest checks the hashing. An HSM with non-deterministic signatures gives other r and s: compare the digest, and the address recovered from your signature. To check with Foundry:

Terminal window
cast wallet sign-auth 0x9C1386861FDf87e66F21fEb3dD2C50959F1426cf \
--private-key 0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318 --nonce 0 --chain 11155111

It prints the signed authorization in RLP: chain_id, address, nonce, y_parity, r, s. In Go, the same check with the SDK:

key, err := crypto.HexToECDSA("4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318")
if err != nil {
log.Fatal(err)
}
inMemory := func(_ context.Context, _ string, digest []byte) ([]byte, error) { return crypto.Sign(digest, key) }
deposit := common.HexToAddress("0x2c7536E3605D9C16a7a3D7b1898e529396a65c23")
delegate := common.HexToAddress("0x9C1386861FDf87e66F21fEb3dD2C50959F1426cf")
auth, err := signAuthorization(context.Background(), inMemory, "test", deposit, 11155111, delegate, 0)
if err != nil {
log.Fatal(err)
}

With go-ethereum, SigHash is the digest and Authority recovers the signer:

// Without the SDK, with go-ethereum: SigHash is the digest, and Authority recovers the signer.
func signWithGeth(sign func(digest []byte) (r, s *uint256.Int, yParity uint8, err error),
deposit common.Address, chainID uint64, delegate common.Address, nonce uint64) (types.SetCodeAuthorization, error) {
auth := types.SetCodeAuthorization{ChainID: *uint256.NewInt(chainID), Address: delegate, Nonce: nonce}
digest := auth.SigHash() // 32 bytes for the HSM
r, s, yParity, err := sign(digest.Bytes()) // your call; s must already be in the lower half
if err != nil {
return auth, err
}
auth.R, auth.S, auth.V = *r, *s, yParity
if who, err := auth.Authority(); err != nil || who != deposit {
return auth, fmt.Errorf("not signed by the deposit address %s", deposit.Hex())
}
return auth, nil
}
API answerCause
invalid_authorization: chain …Signed for another chain, or for chain 0.
invalid_authorization: delegates to …Signed for something other than this hot wallet’s delegate: take delegate from POST /v1/hot-wallets.
invalid_authorization: signed by …Another key signed: a wrong key, a wrong y_parity or a wrong digest.
invalid_authorization: signature …A high s, or r or s out of range.
sweep rejected, stale_authorizationThe address’s nonce changed: it sent a transaction. Sign again with the current nonce.

Before the first signature for a hot wallet, check that its delegate is the open contract code for your hot wallet: Verifying the delegate.