Подпись авторизаций
Депозитному адресу нужна одна подпись его ключа — авторизация 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.
Что отправить в API
Заголовок раздела «Что отправить в API»{ "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 = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n/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)}/** * 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 })}Хранилища ключей
Заголовок раздела «Хранилища ключей»| Где ключ | Тип ключа | Как подписать хэш | Что вернётся |
|---|---|---|---|
| AWS KMS | ECC_SECG_P256K1 | Sign с MessageType=DIGEST и SigningAlgorithm=ECDSA_SHA_256: с DIGEST KMS не хэширует повторно | DER |
| Google Cloud KMS | EC_SIGN_SECP256K1_SHA256 | asymmetricSign с digest.sha256, равным хэшу: KMS принимает готовые 32 байта | DER |
| Azure Key Vault | EC, кривая P-256K | sign с алгоритмом ES256K, на вход — хэш | r‖s, 64 байта |
| HSM через PKCS#11 | secp256k1 | механизм 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_id | 11155111 (Sepolia) |
address | 0x9C1386861FDf87e66F21fEb3dD2C50959F1426cf |
nonce | 0 |
| RLP | 0xda83aa36a7949c1386861fdf87e66f21feb3dd2c50959f1426cf80 |
digest | 0x62e5b797a86f9b7aae715cf3e62975eb5ed9c9e9df07d50b057fc37ce5469b74 |
y_parity | 0 |
r | 0xc7c867f57978aae7701358ade99ac1aba61ec87006bff5cd48c1490ece919f91 |
s | 0x1ca4c1e888f8abdd1b015d6c9e611fd4e5d6d258cdebc511a2a0802312b64d62 |
Подпись детерминированная (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)}Без SDK
Заголовок раздела «Без SDK»С 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}С viem: хэш — hashAuthorization, подписавшего восстанавливает recoverAuthorizationAddress:
// 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 }}Частые ошибки
Заголовок раздела «Частые ошибки»| Ответ 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_authorization | Nonce адреса изменился: с него ушла транзакция. Подпишите заново с текущим nonce. |
Перед первой подписью для горячего кошелька убедитесь, что его делегат — открытый код контракта для вашего кошелька: Проверка делегата.