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

Подпись авторизаций

Депозитному адресу нужна одна подпись его ключа — авторизация EIP-7702 на делегат вашего горячего кошелька (котла). Её подписывает ваша кастоди, а вы присылаете её в поле authorization первого POST /v1/sweeps этого адреса. Справится почти любое хранилище ключей: нужна «сырая» подпись secp256k1 над готовым 32-байтным хэшем — без повторного хэширования и без префикса \x19Ethereum Signed Message.

Авторизация — это три значения:

ПолеЧто ставить
chain_idСеть sweep: 11155111 — Sepolia, 97 — BSC testnet. Никогда не 0: авторизация для сети 0 действует во всех сетях, и API её не примет.
addressДелегат горячего кошелька — поле delegate из POST /v1/hot-wallets.
nonceТекущий nonce депозитного адреса (eth_getTransactionCount на latest): 0 у адреса, с которого не уходило транзакций.

Хэш для подписи:

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

0x05 — тип авторизации из EIP-7702. Целые числа кодируются в RLP без ведущих нулей, ноль — пустой строкой (0x80), адрес — 20 байтами.

Чтобы снять делегацию, подписывается то же самое, но address — нулевой адрес 0x0000000000000000000000000000000000000000, а nonce — текущий nonce адреса, после первого sweep обычно 1.

{ "chain_id": 11155111, "address": "0x…делегат", "nonce": 0, "y_parity": 0, "r": "0x…", "s": "0x…" }
  • r и s — два 32-байтных числа подписи в 0x-hex.
  • s должен быть в нижней половине порядка кривой: s ≤ n/2, где n — порядок secp256k1. Этого требует EIP-7702: авторизацию с верхним s сеть молча пропустит, и делегат не установится. API такую подпись отклоняет сразу — invalid_authorization. HSM и KMS примерно в половине случаев возвращают верхний s: замените s на n − s и инвертируйте y_parity.
  • y_parity — 0 или 1, бит для восстановления ключа. HSM и KMS его обычно не возвращают: восстановите адрес из подписи с 0 и с 1 и возьмите тот вариант, который дал депозитный адрес.
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
n/2 = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0

Перед отправкой проверьте, что адрес, восстановленный из подписи, — это депозитный адрес. API проверяет то же самое и отвечает invalid_authorization, если подписал другой ключ.

SDK считает хэш и превращает «сырую» подпись вашей кастоди — DER или r‖s, с любым s — в формат API: нормализует s, подбирает y_parity и проверяет, что подписал депозитный адрес. Неверный ключ или неверный хэш отсекаются здесь, до 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)
}
Где ключТип ключаКак подписать хэшЧто вернётся
AWS KMSECC_SECG_P256K1Sign с MessageType=DIGEST и SigningAlgorithm=ECDSA_SHA_256: с DIGEST KMS не хэширует повторноDER
Google Cloud KMSEC_SIGN_SECP256K1_SHA256asymmetricSign с digest.sha256, равным хэшу: KMS принимает готовые 32 байтаDER
Azure Key VaultEC, кривая P-256Ksign с алгоритмом ES256K, на вход — хэшr‖s, 64 байта
HSM через PKCS#11secp256k1механизм CKM_ECDSA (без хэширования) над хэшемr‖s
MPC-кастоди—«raw signing» — подпись 32-байтного сообщенияу каждого провайдера свой формат

DER — это ASN.1 SEQUENCE { INTEGER r, INTEGER s }, r‖s — по 32 байта каждого. В любом случае нормализуйте s и подберите y_parity, как описано выше, или доверьте это SDK.

Сверьте реализацию на этом тестовом ключе. Ключ публичный: используйте его только для этой проверки и ни для чего больше.

приватный ключ0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318
депозитный адрес0x2c7536E3605D9C16a7a3D7b1898e529396a65c23
chain_id11155111 (Sepolia)
address0x9C1386861FDf87e66F21fEb3dD2C50959F1426cf
nonce0
RLP0xda83aa36a7949c1386861fdf87e66f21feb3dd2c50959f1426cf80
digest0x62e5b797a86f9b7aae715cf3e62975eb5ed9c9e9df07d50b057fc37ce5469b74
y_parity0
r0xc7c867f57978aae7701358ade99ac1aba61ec87006bff5cd48c1490ece919f91
s0x1ca4c1e888f8abdd1b015d6c9e611fd4e5d6d258cdebc511a2a0802312b64d62

Подпись детерминированная (RFC 6979), её так дают go-ethereum, viem и Foundry, поэтому совпадение r и s проверяет весь путь, а совпадение digest — сборку хэша. HSM с недетерминированной подписью даст другие r и s: тогда сверяйте digest и адрес, восстановленный из вашей подписи. Проверка в Foundry:

Окно терминала
cast wallet sign-auth 0x9C1386861FDf87e66F21fEb3dD2C50959F1426cf \
--private-key 0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318 --nonce 0 --chain 11155111

Команда печатает подписанную авторизацию в RLP: chain_id, address, nonce, y_parity, r, s. Та же проверка на Go с 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)
}

С go-ethereum: хэш — SigHash, подписавшего восстанавливает Authority:

// 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Причина
invalid_authorization: chain …Подпись для другой сети или для сети 0.
invalid_authorization: delegates to …Подписан не делегат этого горячего кошелька: возьмите delegate из POST /v1/hot-wallets.
invalid_authorization: signed by …Подписал другой ключ: не тот ключ, неверный y_parity или неверный хэш.
invalid_authorization: signature …Верхний s или r, s вне допустимого диапазона.
задание rejected, stale_authorizationNonce адреса изменился: с него ушла транзакция. Подпишите заново с текущим nonce.

Перед первой подписью для горячего кошелька убедитесь, что его делегат — открытый код контракта для вашего кошелька: Проверка делегата.