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.
What is signed
Section titled “What is signed”An authorization is three values:
| Field | Value |
|---|---|
chain_id | The 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. |
address | The hot wallet’s delegate: delegate from POST /v1/hot-wallets. |
nonce | The 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.
What to send to the API
Section titled “What to send to the API”{ "chain_id": 11155111, "address": "0x…delegate", "nonce": 0, "y_parity": 0, "r": "0x…", "s": "0x…" }randsare the two 32-byte numbers of the signature, as0x-hex.smust 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 highs, and the delegate is never set. The API refuses such a signature at once withinvalid_authorization. HSMs and KMSs return a highsabout half of the time: replaceswith n − s and flipy_parity.y_parityis 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 = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n/2 = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0Before 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.
With the SDK
Section titled “With the SDK”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)}/** * Signs a 32-byte digest raw, without hashing it again and without a message prefix, with the key of a deposit * address. 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), as hex or bytes. */export type Signer = (keyId: string, digest: Hex) => Promise<RawSignature>export async function signAuthorization( sign: Signer, keyId: string, deposit: Address, chainId: number, delegate: Address, nonce: number,): Promise<Authorization> { const digest = authorizationDigest({ chainId, address: delegate, nonce }) // refuses chain 0 const signature = await sign(keyId, digest) // Reads DER or r‖s, moves s to the lower half and finds y_parity. Throws unless the deposit address signed // this very digest, so a wrong key never reaches the API. return authorizationFromSignature({ chainId, address: delegate, nonce, deposit, signature })}Key stores
Section titled “Key stores”| Where the key is | Key type | How to sign the digest | What comes back |
|---|---|---|---|
| AWS KMS | ECC_SECG_P256K1 | Sign with MessageType=DIGEST and SigningAlgorithm=ECDSA_SHA_256: with DIGEST, KMS does not hash again | DER |
| Google Cloud KMS | EC_SIGN_SECP256K1_SHA256 | asymmetricSign with digest.sha256 set to the digest: KMS takes the 32 bytes as they are | DER |
| Azure Key Vault | EC, curve P-256K | sign with algorithm ES256K and the digest as input | r‖s, 64 bytes |
| HSM over PKCS#11 | secp256k1 | mechanism CKM_ECDSA (no hashing) over the digest | r‖s |
| MPC custody | — | “raw signing” of a 32-byte message | depends 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.
Test vector
Section titled “Test vector”Check your implementation with this test key. It is public: use it for this check only, never for anything else.
| private key | 0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318 |
| deposit address | 0x2c7536E3605D9C16a7a3D7b1898e529396a65c23 |
chain_id | 11155111 (Sepolia) |
address | 0x9C1386861FDf87e66F21fEb3dD2C50959F1426cf |
nonce | 0 |
| RLP | 0xda83aa36a7949c1386861fdf87e66f21feb3dd2c50959f1426cf80 |
digest | 0x62e5b797a86f9b7aae715cf3e62975eb5ed9c9e9df07d50b057fc37ce5469b74 |
y_parity | 0 |
r | 0xc7c867f57978aae7701358ade99ac1aba61ec87006bff5cd48c1490ece919f91 |
s | 0x1ca4c1e888f8abdd1b015d6c9e611fd4e5d6d258cdebc511a2a0802312b64d62 |
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:
cast wallet sign-auth 0x9C1386861FDf87e66F21fEb3dD2C50959F1426cf \ --private-key 0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318 --nonce 0 --chain 11155111It 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)}Without the SDK
Section titled “Without the SDK”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}With viem, hashAuthorization is the digest and recoverAuthorizationAddress recovers the signer:
// Without the SDK, with viem: hashAuthorization is the digest, and recoverAuthorizationAddress finds the signer.export async function signWithViem( sign: (digest: Hex) => Promise<{ r: Hex; s: Hex; yParity: 0 | 1 }>, // your call; s already in the lower half deposit: Address, chainId: number, delegate: Address, nonce: number,): Promise<Authorization> { const authorization = { chainId, address: delegate, nonce } const digest = hashAuthorization(authorization) // 32 bytes for the HSM const signature = await sign(digest) const signer = await recoverAuthorizationAddress({ authorization, signature }) if (!isAddressEqual(signer, deposit)) throw new Error(`not signed by the deposit address ${deposit}`) return { chain_id: chainId, address: delegate, nonce, y_parity: signature.yParity, r: signature.r, s: signature.s }}Common errors
Section titled “Common errors”| API answer | Cause |
|---|---|
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_authorization | The 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.