Every transaction flow in this API ends in the same wallet step: the construct
call returns an unsigned bundle, the user's wallet produces two
signatures over it, and the send call submits them. The step is identical for
all four flows -
stake and unstake,
buy and sell - so everything on this page applies to
each of them unchanged.
This page covers producing those signatures with Privy's native SDKs, with the
same signer written in Swift for iOS, in Kotlin for Android and in Dart for
Flutter. Privy (Web, React Native) covers React, Expo, and signing on your
backend through a Privy signer. The backend path works the same whatever your
app is built with.
The code was compiled against PrivySDK 2.16.2 (iOS), privy-core 0.15.0
(Android) and privy_flutter 0.10.2. Every value in the
test vectors matches ethers byte for byte.
Before you start
Use the user's embedded wallet
The user's walletAddress must be one of their Privy embedded wallets,
the entries in embeddedEthereumWallets on the Privy user in all three SDKs. A
Privy smart wallet address or an external wallet connected through Privy will
not work, for the reasons in Privy (Web, React Native).
A user can hold more than one embedded Ethereum wallet, because Privy can create
additional HD wallets for the same user. Find the one to sign with by the
bundle's userOp.sender, never by taking the first entry in the list. A wallet
address is set once, so check which
address you are linking before you create the user.
Why the app computes the digests
A React or Expo app signs the authorization with Privy's
useSign7702Authorization hook. The native SDKs have no equivalent. Their
embedded wallet providers have request helpers for personal_sign, eth_sign,
eth_signTypedData_v4, eth_signTransaction, eth_sendTransaction and
secp256k1_sign. None of these signs an EIP-7702 authorization, and the
Flutter plugin refuses any method outside that list.
secp256k1_sign is enough for both signatures. It signs exactly the 32 bytes it
is given and adds no prefix of its own, so the app computes each digest and
signs it raw. The user operation goes through secp256k1_sign too, not
personal_sign. personal_sign takes its message as a string, which leaves the
wallet to decide whether a 0x… string means bytes or text.
Installing
The digests need Keccak-256, and the standard libraries of all three platforms
lack it. Each signer adds one small library for it:
Platform
Privy SDK
Keccak-256
iOS 17+
PrivySDK 2.16.2
CryptoSwift 1.10
Android
io.privy:privy-core 0.15.0
BouncyCastle bcprov-jdk18on 1.86
Flutter
privy_flutter 0.10.2
pointycastle 4.0
SHA3-256 is not Keccak-256
Ethereum's hash is the original Keccak submission. The SHA3-256 that NIST
standardised later pads its input differently, so it returns a different
hash for the same bytes. If you swap in a platform's SHA3-256, every
signature is made over a digest that nothing will verify.
iOS. Add both packages in Xcode under File › Add Package Dependencies,
or in Package.swift:
The data classes below use kotlinx.serialization, which also needs the
org.jetbrains.kotlin.plugin.serialization Gradle plugin. If your app uses a
different JSON library, drop the @Serializable annotations.
Keccak-256 of "\x19Ethereum Signed Message:\n32" followed by the 32 bytes of bundle.userOpHash
0xff00, then r ‖ s ‖ v with v as 27 or 28: 67 bytes in all
authorization
Keccak-256 of 0x05 followed by the RLP encoding of [chainId, address, nonce] from bundle.authorization. Only when it is not null
{ r, s, yParity }, with yParity as 0 or 1
Each digest goes to secp256k1_sign as 0x followed by 64 hex characters, and
comes back as 65 bytes: r, s and a recovery byte. Six details account for
nearly every rejected submit:
The recovery byte, which the two fields encode differently. The example in
Privy's API reference ends in 27 or 28 (0x1c), but nothing in the SDKs fixes
that, so the signers below accept 0 or 1 as well. The account recovers
the user-operation signer with ecrecover, which needs 27 or 28. The
authorization sends the same bit as yParity: 0 or 1, as a JSON number.
Bytes, not text.userOpHash is a 32-byte value written out as hex.
Decode it before hashing. Hashing the UTF-8 of the string "0x1551…"
produces a valid signature over a different message.
The prefix. The user's address is delegated to Alchemy's Modular
Account v2, which reads the first byte of the signature as "no hook data"
(0xff) and the second as "signed by the account's own key" (0x00). A bare
65-byte signature is refused.
Minimal integers. RLP writes chainId and nonce as big-endian bytes
with no leading zeros, so a nonce of 0 is the empty byte string (0x80), not
0x00. Fixed-width integers hash to a different digest.
The authorization, exactly as sent. Take chainId, address and nonce
from the bundle, all three, and do not look the nonce up yourself. The API
checks your r / s against the tuple it stored when it built the bundle,
and a signature over any other tuple recovers to an unrelated address.
r and s at full width. Send each as 32 bytes, including leading zero
bytes. About one signature in 85 has an r or s that starts with a zero
byte, so trimming them passes most tests and then fails at random.
bundle.authorization is null when the wallet already delegates to Restake's
account - normally every bundle after the user's first on that chain. The
signers below then sign only userOpHash and leave authorization out of the
send body: the API refuses an authorization the bundle did not ask for, and a
missing one when it did.
Signing in your app
Your API credentials must stay on your backend, so a bundle travels from
Restake to your backend, then to the app for signing, and back:
Code
App (Privy) Your backend Restake API │ user asks to stake / trade ─▶ │ POST …/construct/…/batch ─────────▶ │ │ │ ◀────────────────── intentId, bundle │ │ ◀──────── intentId, bundle │ │ │ user confirms, wallet signs │ │ │ intentId + signatures ──────▶ │ POST …/transaction/batch/send ─────▶ │ │ │ ◀─────────────────────── userOpHash │
When your backend calls send, take userId from your own session, not from
the app's request. The API only accepts an intent for the user it was built
for, but it can only apply that check to the userId you pass it.
Build the confirmation screen yourself, from the amount you sent and, for a
trade, the quote the construct call returned. The two signatures cover a
hash and a delegation tuple, and neither tells a person what they are agreeing
to.
Each signer below has two entry points. sign(bundle, wallet) is the one your
app calls. The other takes any function that turns a digest into a signature,
and it does all the work: the Privy call is the only thing the first one adds.
The tests use the second one, with Privy replaced.
iOS (Swift)
Code
import CryptoSwiftimport Foundationimport PrivySDK/// The parts of `data.bundle` that signing needs. Decoding ignores the rest.struct RestakeBundle: Decodable { struct UserOp: Decodable { let sender: String } struct Authorization: Decodable { let chainId: UInt64 let address: String let nonce: UInt64 } let userOp: UserOp let userOpHash: String /// `nil` when the wallet already delegates to Restake: nothing to sign. let authorization: Authorization?}/// The body of `…/transaction/batch/send`, minus `userId` and `intentId`.struct RestakeSignatures: Encodable { struct Authorization: Encodable { let r: String let s: String let yParity: Int } let signature: String /// Present exactly when `bundle.authorization` is; `nil` is left out of the JSON. let authorization: Authorization?}struct RestakeSigningError: Error, CustomStringConvertible { let description: String}enum RestakeSigner { /// The EIP-7702 implementation Restake delegates to (Alchemy Modular Account v2). static let delegate = "0x69007702764179f14F51cdce752f4f775d74E139" /// `0xff` (no hook data) + `0x00` (the signer is the EOA itself). private static let accountSignaturePrefix = Data([0xff, 0x00]) /// Produces both signatures with the user's Privy embedded wallet. static func sign( _ bundle: RestakeBundle, with wallet: any EmbeddedEthereumWallet ) async throws -> RestakeSignatures { guard wallet.address.caseInsensitiveCompare(bundle.userOp.sender) == .orderedSame else { throw RestakeSigningError( description: "Wallet is \(wallet.address), bundle is for \(bundle.userOp.sender)" ) } return try await sign(bundle) { digest in try await wallet.provider.request(.secp256k1Sign(hash: digest)) } } /// Produces both signatures with a raw signer: `signDigest` receives a /// `0x`-prefixed 32-byte digest and returns a 65-byte signature over exactly it. static func sign( _ bundle: RestakeBundle, signDigest: (String) async throws -> String ) async throws -> RestakeSignatures { try assertExpectedDelegate(bundle) // 1. The user operation: v as 27/28, behind the account prefix. let userOp = try splitSignature(await signDigest(hex(userOpDigest(bundle)))) // 2. The EIP-7702 authorization: yParity as 0/1. Only while the wallet // is not delegated yet. var authorization: RestakeSignatures.Authorization? if bundle.authorization != nil { let signed = try splitSignature(await signDigest(hex(authorizationDigest(bundle)))) authorization = .init( r: hex(signed.rs.prefix(32)), s: hex(signed.rs.suffix(32)), yParity: Int(signed.yParity) ) } return RestakeSignatures( signature: hex(accountSignaturePrefix + userOp.rs + Data([27 + userOp.yParity])), authorization: authorization ) } /// Refuses a bundle that would delegate the wallet somewhere unexpected. static func assertExpectedDelegate(_ bundle: RestakeBundle) throws { guard let address = bundle.authorization?.address else { return } guard address.caseInsensitiveCompare(delegate) == .orderedSame else { throw RestakeSigningError(description: "Unexpected delegate \(address)") } } /// EIP-191 over the 32 bytes of `userOpHash`, never over its hex text. static func userOpDigest(_ bundle: RestakeBundle) throws -> Data { let hash = try bytes(bundle.userOpHash, count: 32, name: "userOpHash") return (Data("\u{19}Ethereum Signed Message:\n32".utf8) + hash).sha3(.keccak256) } /// `keccak256(0x05 ‖ rlp([chainId, address, nonce]))`, every field from the bundle. static func authorizationDigest(_ bundle: RestakeBundle) throws -> Data { guard let authorization = bundle.authorization else { throw RestakeSigningError(description: "The bundle has no authorization to sign") } let address = try bytes(authorization.address, count: 20, name: "authorization.address") let tuple = rlp(authorization.chainId) + rlp(address) + rlp(authorization.nonce) // At most 9 + 21 + 9 bytes, so the list always takes RLP's short form. return (Data([0x05, 0xc0 + UInt8(tuple.count)]) + tuple).sha3(.keccak256) } /// Splits a 65-byte `r ‖ s ‖ v` signature, accepting `v` as 27/28 or 0/1. private static func splitSignature(_ signature: String) throws -> (rs: Data, yParity: UInt8) { let data = try bytes(signature, count: 65, name: "signature") switch data[64] { case 0, 1: return (data.prefix(64), data[64]) case 27, 28: return (data.prefix(64), data[64] - 27) default: throw RestakeSigningError(description: "Unexpected recovery byte \(data[64])") } } /// An unsigned integer as minimal big-endian bytes, so `0` is the empty string. private static func rlp(_ value: UInt64) -> Data { let bigEndian = withUnsafeBytes(of: value.bigEndian) { Data($0) } return rlp(Data(bigEndian.drop(while: { $0 == 0 }))) } /// A byte string shorter than 56 bytes. private static func rlp(_ bytes: Data) -> Data { if bytes.count == 1, bytes[0] < 0x80 { return bytes } return Data([0x80 + UInt8(bytes.count)]) + bytes } /// Decodes a `0x`-prefixed hex value of an exact byte length. private static func bytes(_ value: String, count: Int, name: String) throws -> Data { let data = Data(hex: value) guard value.hasPrefix("0x"), value.count == 2 + 2 * count, data.count == count else { throw RestakeSigningError(description: "\(name) is not a \(count)-byte hex value") } return data } private static func hex(_ data: Data) -> String { "0x" + data.toHexString() }}
Wiring the signer to your backend, where yourApi stands for your own code and
privy is the instance PrivySdk.initialize returned:
Code
// Your backend calls …/stocks/construct/buy/transaction/batch for the signed-in user.let prepared = try await yourApi.buildBuy(slug: "apple", amount: 250.5) // intentId, bundle, quote// Render the quote (price, amount out, `fee`) and continue when the user confirms.let sender = prepared.bundle.userOp.senderguard let wallet = await privy.getUser()?.embeddedEthereumWallets.first(where: { $0.address.caseInsensitiveCompare(sender) == .orderedSame}) else { throw RestakeSigningError(description: "No Privy embedded wallet for \(sender)")}let signatures = try await RestakeSigner.sign(prepared.bundle, with: wallet)// Your backend calls …/stocks/transaction/batch/send with the userId from its session.let userOpHash = try await yourApi.submitBatch(intentId: prepared.intentId, signatures: signatures)
Android (Kotlin)
Code
import io.privy.wallet.ethereum.EmbeddedEthereumWalletimport io.privy.wallet.ethereum.EthereumRpcRequestimport java.nio.ByteBufferimport kotlinx.serialization.Serializableimport org.bouncycastle.jcajce.provider.digest.Keccak/** * The parts of `data.bundle` that signing needs. Decode it with * `Json { ignoreUnknownKeys = true }`, because the bundle carries more than this. */@Serializabledata class RestakeBundle( val userOp: UserOp, val userOpHash: String, /** `null` when the wallet already delegates to Restake: nothing to sign. */ val authorization: Authorization? = null,) { @Serializable data class UserOp(val sender: String) @Serializable data class Authorization(val chainId: Long, val address: String, val nonce: Long)}/** The body of `…/transaction/batch/send`, minus `userId` and `intentId`. */@Serializabledata class RestakeSignatures( val signature: String, /** Present exactly when `bundle.authorization` is; the default `null` is not encoded. */ val authorization: Authorization? = null,) { @Serializable data class Authorization(val r: String, val s: String, val yParity: Int)}object RestakeSigner { /** The EIP-7702 implementation Restake delegates to (Alchemy Modular Account v2). */ const val DELEGATE = "0x69007702764179f14F51cdce752f4f775d74E139" /** `0xff` (no hook data) + `0x00` (the signer is the EOA itself). */ private val ACCOUNT_SIGNATURE_PREFIX = byteArrayOf(0xff.toByte(), 0x00) private val EIP191_PREFIX = "\u0019Ethereum Signed Message:\n32".toByteArray(Charsets.US_ASCII) /** Produces both signatures with the user's Privy embedded wallet. */ suspend fun sign(bundle: RestakeBundle, wallet: EmbeddedEthereumWallet): RestakeSignatures { require(wallet.address.equals(bundle.userOp.sender, ignoreCase = true)) { "Wallet is ${wallet.address}, bundle is for ${bundle.userOp.sender}" } return sign(bundle) { digest -> wallet.provider.request(EthereumRpcRequest.secp256k1Sign(digest)).getOrThrow().data } } /** * Produces both signatures with a raw signer: [signDigest] receives a `0x`-prefixed * 32-byte digest and returns a 65-byte signature over exactly it. */ suspend fun sign( bundle: RestakeBundle, signDigest: suspend (String) -> String, ): RestakeSignatures { assertExpectedDelegate(bundle) // 1. The user operation: v as 27/28, behind the account prefix. val userOp = splitSignature(signDigest(userOpDigest(bundle).toHex())) // 2. The EIP-7702 authorization: yParity as 0/1. Only while the wallet // is not delegated yet. val authorization = bundle.authorization?.let { val signed = splitSignature(signDigest(authorizationDigest(bundle).toHex())) RestakeSignatures.Authorization( r = signed.rs.copyOfRange(0, 32).toHex(), s = signed.rs.copyOfRange(32, 64).toHex(), yParity = signed.yParity, ) } return RestakeSignatures( signature = (ACCOUNT_SIGNATURE_PREFIX + userOp.rs + (27 + userOp.yParity).toByte()).toHex(), authorization = authorization, ) } /** Refuses a bundle that would delegate the wallet somewhere unexpected. */ fun assertExpectedDelegate(bundle: RestakeBundle) { val address = bundle.authorization?.address ?: return require(address.equals(DELEGATE, ignoreCase = true)) { "Unexpected delegate $address" } } /** EIP-191 over the 32 bytes of `userOpHash`, never over its hex text. */ fun userOpDigest(bundle: RestakeBundle): ByteArray = keccak256(EIP191_PREFIX + bundle.userOpHash.hexToBytes(32, "userOpHash")) /** `keccak256(0x05 ‖ rlp([chainId, address, nonce]))`, every field from the bundle. */ fun authorizationDigest(bundle: RestakeBundle): ByteArray { val authorization = requireNotNull(bundle.authorization) { "The bundle has no authorization to sign" } val tuple = rlp(authorization.chainId) + rlp(authorization.address.hexToBytes(20, "authorization.address")) + rlp(authorization.nonce) // At most 9 + 21 + 9 bytes, so the list always takes RLP's short form. return keccak256(byteArrayOf(0x05, (0xc0 + tuple.size).toByte()) + tuple) } private class SplitSignature(val rs: ByteArray, val yParity: Int) /** Splits a 65-byte `r ‖ s ‖ v` signature, accepting `v` as 27/28 or 0/1. */ private fun splitSignature(signature: String): SplitSignature { val bytes = signature.hexToBytes(65, "signature") val yParity = when (val v = bytes[64].toInt() and 0xff) { 0, 1 -> v 27, 28 -> v - 27 else -> throw IllegalArgumentException("Unexpected recovery byte $v") } return SplitSignature(bytes.copyOfRange(0, 64), yParity) } /** An unsigned integer as minimal big-endian bytes, so `0` is the empty string. */ private fun rlp(value: Long): ByteArray { require(value >= 0) { "Negative integer $value" } val bigEndian = ByteBuffer.allocate(Long.SIZE_BYTES).putLong(value).array() return rlp(bigEndian.dropWhile { it == 0.toByte() }.toByteArray()) } /** A byte string shorter than 56 bytes. */ private fun rlp(bytes: ByteArray): ByteArray = if (bytes.size == 1 && (bytes[0].toInt() and 0xff) < 0x80) { bytes } else { byteArrayOf((0x80 + bytes.size).toByte()) + bytes } private fun keccak256(input: ByteArray): ByteArray = Keccak.Digest256().digest(input) /** Decodes a `0x`-prefixed hex value of an exact byte length. */ private fun String.hexToBytes(size: Int, name: String): ByteArray { require(startsWith("0x") && length == 2 + 2 * size) { "$name is not a $size-byte hex value" } return ByteArray(size) { i -> substring(2 + 2 * i, 4 + 2 * i).toInt(16).toByte() } } private fun ByteArray.toHex(): String = joinToString(separator = "", prefix = "0x") { (it.toInt() and 0xff).toString(16).padStart(2, '0') }}
Wiring the signer to your backend, where yourApi stands for your own code and
privy is the instance Privy.init returned:
Code
// Your backend calls …/stocks/construct/buy/transaction/batch for the signed-in user.val prepared = yourApi.buildBuy(slug = "apple", amount = 250.5) // intentId, bundle, quote// Render the quote (price, amount out, `fee`) and continue when the user confirms.val sender = prepared.bundle.userOp.senderval wallet = privy.getUser()?.embeddedEthereumWallets ?.firstOrNull { it.address.equals(sender, ignoreCase = true) } ?: error("No Privy embedded wallet for $sender")val signatures = RestakeSigner.sign(prepared.bundle, wallet)// Your backend calls …/stocks/transaction/batch/send with the userId from its session.val userOpHash = yourApi.submitBatch(prepared.intentId, signatures)
Flutter (Dart)
Code
import 'dart:typed_data';import 'package:pointycastle/digests/keccak.dart';import 'package:privy_flutter/privy_flutter.dart';/// The parts of `data.bundle` that signing needs.class RestakeBundle { const RestakeBundle({ required this.sender, required this.userOpHash, required this.authorization, }); /// Reads `data.bundle` from a construct response and ignores the rest of it. factory RestakeBundle.fromJson(Map<String, dynamic> json) => RestakeBundle( sender: json['userOp']['sender'] as String, userOpHash: json['userOpHash'] as String, authorization: json['authorization'] == null ? null : RestakeAuthorization( chainId: json['authorization']['chainId'] as int, address: json['authorization']['address'] as String, nonce: json['authorization']['nonce'] as int, ), ); /// `userOp.sender`, the address the batch runs as. final String sender; final String userOpHash; /// `null` when the wallet already delegates to Restake: nothing to sign. final RestakeAuthorization? authorization;}class RestakeAuthorization { const RestakeAuthorization({ required this.chainId, required this.address, required this.nonce, }); final int chainId; final String address; final int nonce;}/// The body of `…/transaction/batch/send`, minus `userId` and `intentId`.class RestakeSignatures { const RestakeSignatures({ required this.signature, this.r, this.s, this.yParity, }); final String signature; /// The authorization's signature: set exactly when `bundle.authorization` is. final String? r; final String? s; final int? yParity; Map<String, dynamic> toJson() => { 'signature': signature, if (r != null) 'authorization': {'r': r, 's': s, 'yParity': yParity}, };}class RestakeSigningException implements Exception { const RestakeSigningException(this.message); final String message; @override String toString() => 'RestakeSigningException: $message';}abstract final class RestakeSigner { /// The EIP-7702 implementation Restake delegates to (Alchemy Modular Account v2). static const delegate = '0x69007702764179f14F51cdce752f4f775d74E139'; /// Produces both signatures with the user's Privy embedded wallet. static Future<RestakeSignatures> sign( RestakeBundle bundle, EmbeddedEthereumWallet wallet, ) async { if (wallet.address.toLowerCase() != bundle.sender.toLowerCase()) { throw RestakeSigningException( 'Wallet is ${wallet.address}, bundle is for ${bundle.sender}', ); } return signWith(bundle, (digest) async { final result = await wallet.provider.request( EthereumRpcRequest.secp256k1Sign(digest), ); return switch (result) { Success(:final value) => value.data, Failure(:final error) => throw error, }; }); } /// Produces both signatures with a raw signer: [signDigest] receives a /// `0x`-prefixed 32-byte digest and returns a 65-byte signature over exactly it. static Future<RestakeSignatures> signWith( RestakeBundle bundle, Future<String> Function(String digest) signDigest, ) async { assertExpectedDelegate(bundle); // 1. The user operation: v as 27/28, behind the account prefix. final userOp = _splitSignature(await signDigest(_hex(userOpDigest(bundle)))); // 2. The EIP-7702 authorization: yParity as 0/1. Only while the wallet is // not delegated yet. final authorization = bundle.authorization == null ? null : _splitSignature(await signDigest(_hex(authorizationDigest(bundle)))); return RestakeSignatures( // 0xff (no hook data) + 0x00 (the signer is the EOA itself). signature: _hex([0xff, 0x00, ...userOp.rs, 27 + userOp.yParity]), r: authorization == null ? null : _hex(authorization.rs.sublist(0, 32)), s: authorization == null ? null : _hex(authorization.rs.sublist(32, 64)), yParity: authorization?.yParity, ); } /// Refuses a bundle that would delegate the wallet somewhere unexpected. static void assertExpectedDelegate(RestakeBundle bundle) { final address = bundle.authorization?.address; if (address != null && address.toLowerCase() != delegate.toLowerCase()) { throw RestakeSigningException('Unexpected delegate $address'); } } /// EIP-191 over the 32 bytes of `userOpHash`, never over its hex text. static Uint8List userOpDigest(RestakeBundle bundle) => _keccak256([ ...'\x19Ethereum Signed Message:\n32'.codeUnits, ..._bytes(bundle.userOpHash, 32, 'userOpHash'), ]); /// `keccak256(0x05 ‖ rlp([chainId, address, nonce]))`, every field from the bundle. static Uint8List authorizationDigest(RestakeBundle bundle) { final authorization = bundle.authorization; if (authorization == null) { throw const RestakeSigningException('The bundle has no authorization to sign'); } final tuple = [ ..._rlpInteger(authorization.chainId), ..._rlpBytes(_bytes(authorization.address, 20, 'authorization.address')), ..._rlpInteger(authorization.nonce), ]; // At most 9 + 21 + 9 bytes, so the list always takes RLP's short form. return _keccak256([0x05, 0xc0 + tuple.length, ...tuple]); } /// Splits a 65-byte `r ‖ s ‖ v` signature, accepting `v` as 27/28 or 0/1. static ({Uint8List rs, int yParity}) _splitSignature(String signature) { final bytes = _bytes(signature, 65, 'signature'); final yParity = switch (bytes[64]) { 0 || 1 => bytes[64], 27 || 28 => bytes[64] - 27, final v => throw RestakeSigningException('Unexpected recovery byte $v'), }; return (rs: bytes.sublist(0, 64), yParity: yParity); } /// An unsigned integer as minimal big-endian bytes, so `0` is the empty string. static List<int> _rlpInteger(int value) { if (value < 0) throw RestakeSigningException('Negative integer $value'); final bytes = <int>[]; for (var rest = value; rest > 0; rest >>= 8) { bytes.insert(0, rest & 0xff); } return _rlpBytes(bytes); } /// A byte string shorter than 56 bytes. static List<int> _rlpBytes(List<int> bytes) => bytes.length == 1 && bytes[0] < 0x80 ? bytes : [0x80 + bytes.length, ...bytes]; static Uint8List _keccak256(List<int> input) => KeccakDigest(256).process(Uint8List.fromList(input)); /// Decodes a `0x`-prefixed hex value of an exact byte length. static Uint8List _bytes(String value, int length, String name) { if (!value.startsWith('0x') || value.length != 2 + 2 * length || !RegExp(r'^0x[0-9a-fA-F]*$').hasMatch(value)) { throw RestakeSigningException('$name is not a $length-byte hex value'); } return Uint8List.fromList([ for (var i = 2; i < value.length; i += 2) int.parse(value.substring(i, i + 2), radix: 16), ]); } static String _hex(List<int> bytes) => '0x${bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';}
Wiring the signer to your backend, where yourApi stands for your own code and
privy is the instance Privy.init returned:
Code
// Your backend calls …/stocks/construct/buy/transaction/batch for the signed-in user.final prepared = await yourApi.buildBuy(slug: 'apple', amount: 250.5); // intentId, bundle, quote// Render the quote (price, amount out, `fee`) and continue when the user confirms.final bundle = RestakeBundle.fromJson(prepared.bundle);final user = await privy.getUser();final wallet = user?.embeddedEthereumWallets .where((w) => w.address.toLowerCase() == bundle.sender.toLowerCase()) .firstOrNull;if (wallet == null) { throw RestakeSigningException('No Privy embedded wallet for ${bundle.sender}');}final signatures = await RestakeSigner.sign(bundle, wallet);// Your backend calls …/stocks/transaction/batch/send with the userId from its session.final userOpHash = await yourApi.submitBatch(prepared.intentId, signatures.toJson());
Keep the delegate check
Of the two signatures, the authorization matters more: it points the user's
address at a contract's code. assertExpectedDelegate refuses any contract
other than the one Restake uses, which is the same address on every chain.
If a bundle ever fails that check, stop and contact us. Do not update the
constant to match the bundle.
Test vectors
The digests are deterministic, and so is a standard (RFC 6979) signature over
them. The tests below replace Privy with a table of signatures made by a public
development key, so they need neither a Privy account nor a network. When a
digest is computed differently, the test fails and prints the digest that was
computed. The values match ethers 6, and the
Wallet Core guide uses the same key and bundle.
Every vector uses the development key's address,
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, as userOp.sender, the
userOpHash0x15516a2787b9a2916fe78594f6c6c56d9c63eaea696b14b74d7982f66d8f1447,
and an authorization for Restake's delegate on chain 8453:
An r that begins with a zero byte, which must still be sent as 32 bytes
A public key
The signatures in these tests come from the well-known development mnemonic
(test test test test test test test test test test test junk), and anyone
can derive its keys. Use it in tests only, and never send funds to its
addresses.
Code
import CryptoSwiftimport XCTest@testable import YourAppfinal class RestakeSignerTests: XCTestCase { /// What a raw signer holding the public development key returns for each digest. /// It stands in for Privy's `secp256k1_sign`, so the tests need no Privy account. private let developmentKeySignatures = [ "0x1dac90fe79708dd13d1ea52e5c3d12c70f037918144c8dbfd8eb613b546c84ad": "0x4d5aa8958bf02e129786ab6acec463a8e69b51b5468613580fbc1d5e3960e640" + "0dac52a400d778e11d60c90801b5137a7572c0265bead1bbaf423fc0f07bfda81c", "0x0f7525a15b33281eb9da517b28a1003c9663c50d0b81125e31b6741e34c8aa05": "0xe4e80d285e2e48907f94454019dd6f9e90c6dc5fad61c6f4922940c4ce472e08" + "61629e69dfffc96fc9c756091d3ae5fedbb2a21e0c75ef41e771c763201662251b", "0xe5e53880513d89caa511cfca282f96db91c2ed485d1ec9963e9da16efced30ca": "0xdba15187a3e8dd38c798d29b29613db6666147b991bc7126933475549cc21e3f" + "1fbec578f7dd59f29ca1ccf8599b0f20a757e698bffd31da8988e057e556ca871c", "0xed7eb03f075ea12cc6f630ff2881786014bf0b48d07fa4a00272f751f8e070f2": "0x00229d4cbb445e50c2e337080db7d7c703e99c8c58a92632a72cd46dca34d1f3" + "66e413aca7882b79201681190553a730909604173928bcf5b76360eadc1426141b", ] /// `nonce: nil` is a wallet that is already delegated: no authorization. private func bundle(nonce: UInt64?, delegate: String = RestakeSigner.delegate) -> RestakeBundle { RestakeBundle( userOp: .init(sender: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"), userOpHash: "0x15516a2787b9a2916fe78594f6c6c56d9c63eaea696b14b74d7982f66d8f1447", authorization: nonce.map { .init(chainId: 8453, address: delegate, nonce: $0) } ) } /// A digest missing from the table is a digest computed differently from ours. private func developmentKey(_ digest: String) throws -> String { guard let signature = developmentKeySignatures[digest] else { throw RestakeSigningError(description: "Unexpected digest \(digest)") } return signature } func testDigests() throws { XCTAssertEqual( try RestakeSigner.userOpDigest(bundle(nonce: 84)).toHexString(), "1dac90fe79708dd13d1ea52e5c3d12c70f037918144c8dbfd8eb613b546c84ad" ) XCTAssertEqual( try RestakeSigner.authorizationDigest(bundle(nonce: 84)).toHexString(), "0f7525a15b33281eb9da517b28a1003c9663c50d0b81125e31b6741e34c8aa05" ) XCTAssertEqual( try RestakeSigner.authorizationDigest(bundle(nonce: 0)).toHexString(), "e5e53880513d89caa511cfca282f96db91c2ed485d1ec9963e9da16efced30ca" ) } func testSignatures() async throws { let signatures = try await RestakeSigner.sign(bundle(nonce: 84), signDigest: developmentKey) XCTAssertEqual( signatures.signature, "0xff004d5aa8958bf02e129786ab6acec463a8e69b51b5468613580fbc1d5e3960e640" + "0dac52a400d778e11d60c90801b5137a7572c0265bead1bbaf423fc0f07bfda81c" ) XCTAssertEqual(signatures.authorization?.r, "0xe4e80d285e2e48907f94454019dd6f9e90c6dc5fad61c6f4922940c4ce472e08") XCTAssertEqual(signatures.authorization?.s, "0x61629e69dfffc96fc9c756091d3ae5fedbb2a21e0c75ef41e771c76320166225") XCTAssertEqual(signatures.authorization?.yParity, 0) } func testAuthorizationWithZeroNonce() async throws { let authorization = try await RestakeSigner.sign(bundle(nonce: 0), signDigest: developmentKey).authorization XCTAssertEqual(authorization?.r, "0xdba15187a3e8dd38c798d29b29613db6666147b991bc7126933475549cc21e3f") XCTAssertEqual(authorization?.s, "0x1fbec578f7dd59f29ca1ccf8599b0f20a757e698bffd31da8988e057e556ca87") XCTAssertEqual(authorization?.yParity, 1) } func testAuthorizationKeepsLeadingZeroInR() async throws { let authorization = try await RestakeSigner.sign(bundle(nonce: 51), signDigest: developmentKey).authorization XCTAssertEqual(authorization?.r, "0x00229d4cbb445e50c2e337080db7d7c703e99c8c58a92632a72cd46dca34d1f3") XCTAssertEqual(authorization?.s, "0x66e413aca7882b79201681190553a730909604173928bcf5b76360eadc142614") XCTAssertEqual(authorization?.yParity, 0) } func testAcceptsRecoveryByteAsZeroOrOne() async throws { let expected = try await RestakeSigner.sign(bundle(nonce: 84), signDigest: developmentKey) let signatures = try await RestakeSigner.sign(bundle(nonce: 84)) { digest in // The same signatures with the last byte as 0/1 instead of 27/28. let signature = try self.developmentKey(digest) let v = UInt8(signature.suffix(2), radix: 16)! - 27 return signature.dropLast(2) + String(format: "%02x", v) } XCTAssertEqual(signatures.signature, expected.signature) XCTAssertEqual(signatures.authorization?.yParity, expected.authorization?.yParity) } func testDelegatedWalletSignsOnlyTheUserOp() async throws { let expected = try await RestakeSigner.sign(bundle(nonce: 84), signDigest: developmentKey) let signatures = try await RestakeSigner.sign(bundle(nonce: nil), signDigest: developmentKey) XCTAssertEqual(signatures.signature, expected.signature) XCTAssertNil(signatures.authorization) } func testRefusesAnotherDelegate() async { let wrong = bundle(nonce: 84, delegate: "0x000000000000000000000000000000000000dEaD") do { _ = try await RestakeSigner.sign(wrong, signDigest: developmentKey) XCTFail("Signed a bundle for another delegate") } catch {} }}
None of the Android signer is native code, so its tests run as a local unit
test in src/test, without a device or an emulator:
Code
import kotlinx.coroutines.runBlockingimport org.junit.Assert.assertEqualsimport org.junit.Assert.assertNullimport org.junit.Assert.assertThrowsimport org.junit.Testclass RestakeSignerTest { /** * What a raw signer holding the public development key returns for each digest. * It stands in for Privy's `secp256k1_sign`, so the tests need no Privy account. */ private val developmentKeySignatures = mapOf( "0x1dac90fe79708dd13d1ea52e5c3d12c70f037918144c8dbfd8eb613b546c84ad" to "0x4d5aa8958bf02e129786ab6acec463a8e69b51b5468613580fbc1d5e3960e640" + "0dac52a400d778e11d60c90801b5137a7572c0265bead1bbaf423fc0f07bfda81c", "0x0f7525a15b33281eb9da517b28a1003c9663c50d0b81125e31b6741e34c8aa05" to "0xe4e80d285e2e48907f94454019dd6f9e90c6dc5fad61c6f4922940c4ce472e08" + "61629e69dfffc96fc9c756091d3ae5fedbb2a21e0c75ef41e771c763201662251b", "0xe5e53880513d89caa511cfca282f96db91c2ed485d1ec9963e9da16efced30ca" to "0xdba15187a3e8dd38c798d29b29613db6666147b991bc7126933475549cc21e3f" + "1fbec578f7dd59f29ca1ccf8599b0f20a757e698bffd31da8988e057e556ca871c", "0xed7eb03f075ea12cc6f630ff2881786014bf0b48d07fa4a00272f751f8e070f2" to "0x00229d4cbb445e50c2e337080db7d7c703e99c8c58a92632a72cd46dca34d1f3" + "66e413aca7882b79201681190553a730909604173928bcf5b76360eadc1426141b", ) /** A digest missing from the table is a digest computed differently from ours. */ private val developmentKey: suspend (String) -> String = { digest -> developmentKeySignatures[digest] ?: error("Unexpected digest $digest") } /** `nonce = null` is a wallet that is already delegated: no authorization. */ private fun bundle(nonce: Long?, delegate: String = RestakeSigner.DELEGATE) = RestakeBundle( userOp = RestakeBundle.UserOp("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"), userOpHash = "0x15516a2787b9a2916fe78594f6c6c56d9c63eaea696b14b74d7982f66d8f1447", authorization = nonce?.let { RestakeBundle.Authorization(8453, delegate, it) }, ) @Test fun signatures() = runBlocking { val signatures = RestakeSigner.sign(bundle(nonce = 84), developmentKey) assertEquals( "0xff004d5aa8958bf02e129786ab6acec463a8e69b51b5468613580fbc1d5e3960e640" + "0dac52a400d778e11d60c90801b5137a7572c0265bead1bbaf423fc0f07bfda81c", signatures.signature, ) assertEquals( RestakeSignatures.Authorization( r = "0xe4e80d285e2e48907f94454019dd6f9e90c6dc5fad61c6f4922940c4ce472e08", s = "0x61629e69dfffc96fc9c756091d3ae5fedbb2a21e0c75ef41e771c76320166225", yParity = 0, ), signatures.authorization, ) } @Test fun authorizationWithZeroNonce() = runBlocking { assertEquals( RestakeSignatures.Authorization( r = "0xdba15187a3e8dd38c798d29b29613db6666147b991bc7126933475549cc21e3f", s = "0x1fbec578f7dd59f29ca1ccf8599b0f20a757e698bffd31da8988e057e556ca87", yParity = 1, ), RestakeSigner.sign(bundle(nonce = 0), developmentKey).authorization, ) } @Test fun authorizationKeepsLeadingZeroInR() = runBlocking { assertEquals( RestakeSignatures.Authorization( r = "0x00229d4cbb445e50c2e337080db7d7c703e99c8c58a92632a72cd46dca34d1f3", s = "0x66e413aca7882b79201681190553a730909604173928bcf5b76360eadc142614", yParity = 0, ), RestakeSigner.sign(bundle(nonce = 51), developmentKey).authorization, ) } @Test fun acceptsRecoveryByteAsZeroOrOne() = runBlocking { // The same signatures with the last byte as 0/1 instead of 27/28. val zeroOrOne: suspend (String) -> String = { digest -> val signature = developmentKey(digest) signature.dropLast(2) + "%02x".format(signature.takeLast(2).toInt(16) - 27) } assertEquals( RestakeSigner.sign(bundle(nonce = 84), developmentKey), RestakeSigner.sign(bundle(nonce = 84), zeroOrOne), ) } @Test fun delegatedWalletSignsOnlyTheUserOp() = runBlocking { val signatures = RestakeSigner.sign(bundle(nonce = null), developmentKey) assertEquals(RestakeSigner.sign(bundle(nonce = 84), developmentKey).signature, signatures.signature) assertNull(signatures.authorization) } @Test fun refusesAnotherDelegate() { val wrong = bundle(nonce = 84, delegate = "0x000000000000000000000000000000000000dEaD") assertThrows(IllegalArgumentException::class.java) { runBlocking { RestakeSigner.sign(wrong, developmentKey) } } }}
Code
import 'package:flutter_test/flutter_test.dart';import 'package:your_app/restake_signer.dart';/// What a raw signer holding the public development key returns for each digest./// It stands in for Privy's `secp256k1_sign`, so the tests need no Privy account.const developmentKeySignatures = { '0x1dac90fe79708dd13d1ea52e5c3d12c70f037918144c8dbfd8eb613b546c84ad': '0x4d5aa8958bf02e129786ab6acec463a8e69b51b5468613580fbc1d5e3960e640' '0dac52a400d778e11d60c90801b5137a7572c0265bead1bbaf423fc0f07bfda81c', '0x0f7525a15b33281eb9da517b28a1003c9663c50d0b81125e31b6741e34c8aa05': '0xe4e80d285e2e48907f94454019dd6f9e90c6dc5fad61c6f4922940c4ce472e08' '61629e69dfffc96fc9c756091d3ae5fedbb2a21e0c75ef41e771c763201662251b', '0xe5e53880513d89caa511cfca282f96db91c2ed485d1ec9963e9da16efced30ca': '0xdba15187a3e8dd38c798d29b29613db6666147b991bc7126933475549cc21e3f' '1fbec578f7dd59f29ca1ccf8599b0f20a757e698bffd31da8988e057e556ca871c', '0xed7eb03f075ea12cc6f630ff2881786014bf0b48d07fa4a00272f751f8e070f2': '0x00229d4cbb445e50c2e337080db7d7c703e99c8c58a92632a72cd46dca34d1f3' '66e413aca7882b79201681190553a730909604173928bcf5b76360eadc1426141b',};/// A digest missing from the table is a digest computed differently from ours.Future<String> developmentKey(String digest) async => developmentKeySignatures[digest] ?? (throw StateError('Unexpected digest $digest'));/// `nonce: null` is a wallet that is already delegated: no authorization.RestakeBundle bundle({required int? nonce, String delegate = RestakeSigner.delegate}) => RestakeBundle( sender: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', userOpHash: '0x15516a2787b9a2916fe78594f6c6c56d9c63eaea696b14b74d7982f66d8f1447', authorization: nonce == null ? null : RestakeAuthorization(chainId: 8453, address: delegate, nonce: nonce), );void main() { test('signatures', () async { final signatures = await RestakeSigner.signWith(bundle(nonce: 84), developmentKey); expect(signatures.toJson(), { 'signature': '0xff004d5aa8958bf02e129786ab6acec463a8e69b51b5468613580fbc1d5e3960e640' '0dac52a400d778e11d60c90801b5137a7572c0265bead1bbaf423fc0f07bfda81c', 'authorization': { 'r': '0xe4e80d285e2e48907f94454019dd6f9e90c6dc5fad61c6f4922940c4ce472e08', 's': '0x61629e69dfffc96fc9c756091d3ae5fedbb2a21e0c75ef41e771c76320166225', 'yParity': 0, }, }); }); test('authorization with a zero nonce', () async { final signatures = await RestakeSigner.signWith(bundle(nonce: 0), developmentKey); expect(signatures.r, '0xdba15187a3e8dd38c798d29b29613db6666147b991bc7126933475549cc21e3f'); expect(signatures.s, '0x1fbec578f7dd59f29ca1ccf8599b0f20a757e698bffd31da8988e057e556ca87'); expect(signatures.yParity, 1); }); test('authorization keeps a leading zero in r', () async { final signatures = await RestakeSigner.signWith(bundle(nonce: 51), developmentKey); expect(signatures.r, '0x00229d4cbb445e50c2e337080db7d7c703e99c8c58a92632a72cd46dca34d1f3'); expect(signatures.s, '0x66e413aca7882b79201681190553a730909604173928bcf5b76360eadc142614'); expect(signatures.yParity, 0); }); test('accepts the recovery byte as 0/1', () async { // The same signatures with the last byte as 0/1 instead of 27/28. Future<String> zeroOrOne(String digest) async { final signature = await developmentKey(digest); final v = int.parse(signature.substring(signature.length - 2), radix: 16) - 27; return signature.substring(0, signature.length - 2) + v.toRadixString(16).padLeft(2, '0'); } expect( (await RestakeSigner.signWith(bundle(nonce: 84), zeroOrOne)).toJson(), (await RestakeSigner.signWith(bundle(nonce: 84), developmentKey)).toJson(), ); }); test('a delegated wallet signs only the user operation', () async { final signatures = await RestakeSigner.signWith(bundle(nonce: null), developmentKey); expect(signatures.toJson(), { 'signature': (await RestakeSigner.signWith(bundle(nonce: 84), developmentKey)).signature, }); }); test('refuses another delegate', () { final wrong = bundle(nonce: 84, delegate: '0x000000000000000000000000000000000000dEaD'); expect( RestakeSigner.signWith(wrong, developmentKey), throwsA(isA<RestakeSigningException>()), ); });}
What breaks a signed bundle
A bundle is a snapshot of the wallet at the moment it was built. Everything in
What breaks a signed bundle applies
unchanged:
Another transaction from the same wallet, such as an
eth_sendTransaction through the same embedded wallet provider, moves the
wallet's nonce on, and the authorization no longer applies.
Two bundles at once for one wallet share both nonces, so only the first
can land. Build, sign and submit one at a time per wallet.
Time. An intent is short-lived, and a confirmation screen left open can
outlast it.
In each case the fix is to build a new bundle and sign it again. Never re-send
the old signatures.
Failure modes
The signers stop on three mistakes before anything is sent:
Error
Usual cause
Unexpected delegate …
The bundle delegates to a contract other than Restake's. Stop and contact us
Wallet is …, bundle is for …
The app picked an embedded wallet other than the user's walletAddress, typically the first entry in embeddedEthereumWallets
… is not a 32-byte hex value (or 20, or 65)
A bundle field lost its 0x prefix or was truncated on the way from your backend to the app, or the wallet returned something other than a 65-byte signature
These are what reaches the send endpoint when a mistake gets past them:
Response
Usual cause
400Authorization signer does not match user operation sender
The authorization digest was computed over a chainId, address or nonce other than the bundle's, or with fixed-width integers, or with SHA3-256
400 validation error on authorization.yParity
yParity was sent as a string. It must be a JSON number: 0 or 1
400AUTHORIZATION_INVALID, "requires a signed EIP-7702 authorization" or "takes no EIP-7702 authorization"
The send carried an authorization for a bundle whose authorization was null, or left it out when it was not
400Bundler error (eth_sendUserOperation) mentioning AA24 or AA23
The user-operation signature: the recovery byte sent as 0 / 1 instead of 27 / 28, the hash signed as text or through personal_sign, or the 0xff00 prefix missing
400Bundler error (eth_sendUserOperation) mentioning a nonce
The wallet sent a transaction or another bundle after this one was built
400Batch user operation intent not found or expired