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 from a Privy
wallet, either in your app while the user is present or on your backend through
a Privy signer. The examples were checked against @privy-io/react-auth 3.43,
@privy-io/expo 0.74, @privy-io/node 0.34 and viem 2. For an app built with
Privy's Swift, Android or Flutter SDK, see
Privy (iOS, Android, Flutter).
Before you start
The user's walletAddress must be their Privy embedded wallet - the EOA
Privy creates for them. In React that is the entry in useWallets whose
walletClientType is privy; in Expo, useEmbeddedEthereumWallet. Two other
addresses a Privy integration can hold will not work:
A Privy smart wallet address. That is a separate contract with no key of
its own. The batch runs as the address the user signs with: EIP-7702
upgrades that address in place rather than deploying anything, so the
address you link and the key that signs have to be the same account.
An external wallet connected through Privy (a browser extension,
WalletConnect). These generally will not sign a raw digest, or delegate
their account to a contract, because a website asked them to.
A wallet address is set once, so check
which address you are linking before you create the user.
What gets signed
Send field
Signed over
Format
signature
bundle.userOpHash
EIP-191 over the 32 bytes of the hash, prefixed with 0xff00 - 67 bytes in all
authorization
bundle.authorization - chainId, address, nonce. Only when it is not null
The EIP-7702 tuple signature, as { r, s, yParity }
Three details account for nearly every rejected submit:
Bytes, not text.userOpHash is a 32-byte value written out as hex.
personal_sign over the string"0x1551…" signs 66 characters of text: the
result looks like a valid signature, but it is a 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.
The authorization, exactly as sent. Pass chainId, address and nonce
from the bundle, all three. Privy fills in a missing nonce from the chain, but
the API checks your r / s against the tuple it stored when it built the
bundle. If the two tuples differ, the signature recovers to an unrelated
address and the submit is a 400.
bundle.authorization is null when the wallet already delegates to Restake's
account - normally every bundle after the user's first on that chain. Sign only
userOpHash then, 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.
A shared helper
The file below runs in a browser, in React Native and in Node, so your app and
your backend can import the same copy. It computes the digest, wraps the
signature, and recovers both signers locally before anything is sent. Those are
the checks the API and the account run, so a wrong signature fails here with a
readable message instead of coming back as a bundler error.
Code
import { concatHex, hashMessage, isAddressEqual, parseSignature, recoverMessageAddress, serializeSignature, sliceHex, type Address, type Hex,} from 'viem';import { recoverAuthorizationAddress } from 'viem/utils';/** The parts of `data.bundle` that signing needs. */export type RestakeBundle = { userOp: { sender: Address }; userOpHash: Hex; delegate: Address; /** `null` when the wallet already delegates to `delegate`: nothing to sign. */ authorization: { chainId: number; address: Address; nonce: number } | null;};/** The body of `…/transaction/batch/send`, minus `userId` and `intentId`. */export type RestakeSignatures = { signature: Hex; /** Present exactly when `bundle.authorization` is. */ authorization?: { r: Hex; s: Hex; yParity: number };};/** The EIP-7702 implementation Restake delegates to (Alchemy Modular Account v2). */export const RESTAKE_DELEGATE: Address = '0x69007702764179f14F51cdce752f4f775d74E139';/** `0xff` (no hook data) + `0x00` (the signer is the EOA itself). */const ACCOUNT_SIGNATURE_PREFIX: Hex = '0xff00';/** Refuses a bundle that would delegate the wallet somewhere unexpected. */export function assertExpectedDelegate(bundle: RestakeBundle): void { for (const address of [bundle.delegate, bundle.authorization?.address]) { if (address && !isAddressEqual(address, RESTAKE_DELEGATE)) { throw new Error(`Unexpected delegate ${address}`); } }}/** * The 32-byte digest the account verifies: EIP-191 over the *bytes* of * `userOpHash`. Sign it with a raw signer (`secp256k1_sign`), never with * `personal_sign` - that would apply EIP-191 a second time. */export function userOpDigest(bundle: RestakeBundle): Hex { return hashMessage({ raw: bundle.userOpHash });}/** Wraps a 65-byte ECDSA signature for the account, normalising `v` to 27/28. */export function toAccountSignature(ecdsaSignature: Hex): Hex { return concatHex([ ACCOUNT_SIGNATURE_PREFIX, serializeSignature(parseSignature(ecdsaSignature)), ]);}/** Reduces any signed-authorization shape to the `{ r, s, yParity }` the API takes. */export function toAuthorizationSignature(signed: { r: Hex; s: Hex; yParity?: number; v?: bigint;}): NonNullable<RestakeSignatures['authorization']> { const yParity = signed.yParity ?? (signed.v === undefined ? undefined : Number(signed.v >= 27n ? signed.v - 27n : signed.v)); if (yParity !== 0 && yParity !== 1) { throw new Error('Authorization signature has no recovery bit'); } return { r: signed.r, s: signed.s, yParity };}/** * Recovers both signers locally and checks they are the bundle's sender - the * same two checks the API and the account make, run before anything is sent. */export async function assertSignedBy( bundle: RestakeBundle, signatures: RestakeSignatures,): Promise<void> { const sender = bundle.userOp.sender; const userOpSigner = await recoverMessageAddress({ message: { raw: bundle.userOpHash }, signature: sliceHex(signatures.signature, 2), }); if (!isAddressEqual(userOpSigner, sender)) { throw new Error(`userOpHash signed by ${userOpSigner}, expected ${sender}`); } // A delegated wallet's bundle carries no authorization, and the send must // not carry one either. if (!bundle.authorization || !signatures.authorization) { if (bundle.authorization || signatures.authorization) { throw new Error('Sign an authorization exactly when the bundle has one'); } return; } const authorizationSigner = await recoverAuthorizationAddress({ authorization: bundle.authorization, signature: signatures.authorization, }); if (!isAddressEqual(authorizationSigner, sender)) { throw new Error( `Authorization signed by ${authorizationSigner}, expected ${sender}`, ); }}
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.
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.
React
Code
import { useSign7702Authorization, useWallets } from '@privy-io/react-auth';import { isAddressEqual, type Address, type Hex } from 'viem';import { assertExpectedDelegate, assertSignedBy, toAccountSignature, toAuthorizationSignature, userOpDigest, type RestakeBundle, type RestakeSignatures,} from './restake-signing';export function useRestakeSigner() { const { wallets } = useWallets(); const { signAuthorization } = useSign7702Authorization(); return async function signBundle( bundle: RestakeBundle, ): Promise<RestakeSignatures> { assertExpectedDelegate(bundle); const wallet = wallets.find( (w) => w.walletClientType === 'privy' && isAddressEqual(w.address as Address, bundle.userOp.sender), ); if (!wallet) { throw new Error(`No Privy embedded wallet for ${bundle.userOp.sender}`); } // 1. The user operation: a raw signature over the EIP-191 digest. const provider = await wallet.getEthereumProvider(); const rawSignature = (await provider.request({ method: 'secp256k1_sign', params: [userOpDigest(bundle)], })) as Hex; const signatures: RestakeSignatures = { signature: toAccountSignature(rawSignature), }; // 2. The EIP-7702 authorization, with every field taken from the bundle - // only while the wallet is not delegated yet. if (bundle.authorization) { const authorization = await signAuthorization( { contractAddress: bundle.authorization.address, chainId: bundle.authorization.chainId, nonce: bundle.authorization.nonce, }, { address: wallet.address }, ); signatures.authorization = toAuthorizationSignature(authorization); } await assertSignedBy(bundle, signatures); return signatures; };}
The user operation is signed with secp256k1_sign rather than useSignMessage.
secp256k1_sign signs exactly the 32 bytes it is given. A message hook decides
for itself whether a 0x… string is bytes or text, and passing it the digest
would apply EIP-191 a second time.
Wiring the hook to your backend, where yourApi stands for your own endpoints:
Code
// Inside your component.const signBundle = useRestakeSigner();async function prepareBuy() { // Your backend calls …/stocks/construct/buy/transaction/batch for the signed-in user. return yourApi.buildBuy({ slug: 'apple', amount: 250.5 }); // { intentId, bundle, quote }}// Render the quote - price, amount out, `fee` - and call this when the user confirms.async function confirmBuy({ intentId, bundle }: PreparedBuy) { const signatures = await signBundle(bundle); // Your backend calls …/stocks/transaction/batch/send with the userId from its session. return yourApi.submitBatch({ intentId, ...signatures }); // { userOpHash }}
Build the confirmation 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.
React Native (Expo)
This is the same hook with Privy's Expo wallet hook in place of useWallets,
and getProvider() in place of getEthereumProvider(). The imports from
./restake-signing do not change:
Code
import { useEmbeddedEthereumWallet, useSign7702Authorization } from '@privy-io/expo';import { isAddressEqual, type Address, type Hex } from 'viem';import { assertExpectedDelegate, assertSignedBy, toAccountSignature, toAuthorizationSignature, userOpDigest, type RestakeBundle, type RestakeSignatures,} from './restake-signing';export function useRestakeSigner() { const { wallets } = useEmbeddedEthereumWallet(); const { signAuthorization } = useSign7702Authorization(); return async function signBundle( bundle: RestakeBundle, ): Promise<RestakeSignatures> { assertExpectedDelegate(bundle); const wallet = wallets.find((w) => isAddressEqual(w.address as Address, bundle.userOp.sender), ); if (!wallet) { throw new Error(`No Privy embedded wallet for ${bundle.userOp.sender}`); } const provider = await wallet.getProvider(); const rawSignature = (await provider.request({ method: 'secp256k1_sign', params: [userOpDigest(bundle)], })) as Hex; const signatures: RestakeSignatures = { signature: toAccountSignature(rawSignature), }; if (bundle.authorization) { const authorization = await signAuthorization( { contractAddress: bundle.authorization.address, chainId: bundle.authorization.chainId, nonce: bundle.authorization.nonce, }, { address: wallet.address }, ); signatures.authorization = toAuthorizationSignature(authorization); } await assertSignedBy(bundle, signatures); return signatures; };}
toAuthorizationSignature matters here, not just in the React hook: the Expo
SDK types yParity as optional, and the API needs it as a number.
iOS, Android and Flutter
Privy's Swift, Android and Flutter SDKs have no authorization hook, but their
embedded wallet providers all support secp256k1_sign, and that is enough for
both signatures: the app computes each digest and signs it raw.
Privy (iOS, Android, Flutter) has the signer in Swift,
Kotlin and Dart, with test vectors.
Signing on your backend
If your Privy app holds a signer
on the user's wallet, your backend can sign directly, without a round trip
through the app. It can then build, sign and submit in a single request:
Code
import { PrivyClient } from '@privy-io/node';import { hexToBytes, type Hex } from 'viem';import { assertExpectedDelegate, assertSignedBy, toAccountSignature, toAuthorizationSignature, type RestakeBundle, type RestakeSignatures,} from './restake-signing';const privy = new PrivyClient({ appId: process.env.PRIVY_APP_ID!, appSecret: process.env.PRIVY_APP_SECRET!,});export async function signBundleOnServer( walletId: string, bundle: RestakeBundle,): Promise<RestakeSignatures> { assertExpectedDelegate(bundle); const ethereum = privy.wallets().ethereum(); const authorization_context = { authorization_private_keys: [process.env.PRIVY_AUTHORIZATION_PRIVATE_KEY!], }; // 1. personal_sign over the 32 bytes - a Uint8Array, not the hex string. const { signature } = await ethereum.signMessage(walletId, { message: hexToBytes(bundle.userOpHash), authorization_context, }); const signatures: RestakeSignatures = { signature: toAccountSignature(signature as Hex), }; // 2. The EIP-7702 authorization, with every field taken from the bundle - // only while the wallet is not delegated yet. if (bundle.authorization) { const { authorization } = await ethereum.sign7702Authorization(walletId, { params: { contract: bundle.authorization.address, chain_id: bundle.authorization.chainId, nonce: bundle.authorization.nonce, }, authorization_context, }); signatures.authorization = toAuthorizationSignature({ r: authorization.r as Hex, s: authorization.s as Hex, yParity: Number(authorization.y_parity), }); } await assertSignedBy(bundle, signatures); return signatures;}
For unstake, buy and sell, swap in the construct and send paths from those
guides. The signing call does not change.
Why the server path differs from the app path:
personal_sign instead of secp256k1_sign. Both produce the same bytes:
here Privy applies EIP-191, and in the app the helper does. Privy's policy
engine has method rules for personal_sign and eth_sign7702Authorization
but none for secp256k1_sign, and a signer acting without the user present
is exactly where you want policies to apply.
message is a Uint8Array. Privy's Node SDK happens to read a
0x-prefixed string as hex too, but its REST API defaults to utf-8. Bytes
are unambiguous in both.
The parameters are snake_case (chain_id). Some of Privy's own examples
show chainId, which @privy-io/node 0.34 rejects at compile time.
y_parity goes through Number, because Privy's API reference shows it
both as a number and as a string.
Restrict what the signer may authorize
A signer that acts while the user is offline should only be able to delegate
the wallet to Restake's contract. A Privy
policy can enforce that: a
rule on eth_sign7702Authorization with a condition on the contract field
(field_source: ethereum_7702_authorization) that allows only
0x69007702764179f14F51cdce752f4f775d74E139.
What breaks a signed bundle
A bundle is a snapshot of the wallet at the moment it was built. Anything that
changes that wallet before the submit makes the signatures stale, even though
they still verify locally:
Another transaction from the same wallet. The authorization carries the
wallet's transaction count at build time. An ordinary transaction sent in
between, such as a transfer through Privy's sendTransaction, moves that count
on, and the authorization no longer applies.
A delegation elsewhere. A bundle with authorization: null assumes the
wallet still points at Restake's account when it is submitted. If anything
re-delegates the wallet in between - Privy's own gas sponsorship, for
example - the operation no longer validates.
Two bundles at once. Two bundles built for one wallet before either is
submitted 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. A confirmation screen left open can outlast
it, and the submit is then a 400. Build a new bundle; do not re-send the old
signatures.
In each case the fix is to build a new bundle and sign it again. Never re-sign
the old one.
Two Privy features to keep away from this flow:
ERC-1271 signature mode. Do not pass signature_options: { type: 'erc1271' }
to Privy's signing calls. That mode shapes a signature for a contract's
isValidSignature check, which is not how a user operation is validated.
Privy's own gas sponsorship on the same wallet. Privy upgrades a wallet
through EIP-7702 for that feature too, and an address holds only one
delegation at a time. Each flow points the address back at its own contract
when it runs. Gas on Restake bundles is always sponsored, so there is rarely
a reason to combine the two. If you do, test the combination on staging
first.
Failure modes
The local checks in assertSignedBy catch most signing mistakes before a
request is made. 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 was signed by a wallet other than the user's walletAddress, or over a chainId, address or nonce other than the bundle's
400 validation error on authorization.yParity
yParity was sent as a string or a bigint. 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 hash signed as text, EIP-191 applied twice, 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