# Privy (iOS, Android, Flutter)

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](/staking#the-transaction-flow),
[buy and sell](/stocks#the-trade-flow) - 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)](/privy) 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](#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)](/privy#before-you-start).

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](/users#wallet-addresses-are-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 |

<Callout type="caution" title="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.
</Callout>

**iOS.** Add both packages in Xcode under **File › Add Package Dependencies**,
or in `Package.swift`:

```swift title="Package.swift"
dependencies: [
    .package(url: "https://github.com/privy-io/privy-ios", from: "2.16.2"),
    .package(url: "https://github.com/krzyzanowskim/CryptoSwift", from: "1.10.0"),
],
targets: [
    .target(
        name: "YourApp",
        dependencies: [
            .product(name: "Privy", package: "privy-ios"),
            .product(name: "CryptoSwift", package: "CryptoSwift"),
        ]
    ),
]
```

The package's product is `Privy` and the module you import is `PrivySDK`.

**Android.** All three libraries are on Maven Central:

```kotlin title="app/build.gradle.kts"
dependencies {
    implementation("io.privy:privy-core:0.15.0")
    implementation("org.bouncycastle:bcprov-jdk18on:1.86")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")

    testImplementation("junit:junit:4.13.2")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
}
```

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.

**Flutter.**

```yaml title="pubspec.yaml"
dependencies:
  privy_flutter: ^0.10.2
  pointycastle: ^4.0.0
```

## What gets signed

| Send field | Digest | Encoding |
| --- | --- | --- |
| `signature` | 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:

```
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)

```swift title="RestakeSigner.swift"
import CryptoSwift
import Foundation
import 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:

```swift
// 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.sender
guard 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)

```kotlin title="RestakeSigner.kt"
import io.privy.wallet.ethereum.EmbeddedEthereumWallet
import io.privy.wallet.ethereum.EthereumRpcRequest
import java.nio.ByteBuffer
import kotlinx.serialization.Serializable
import 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.
 */
@Serializable
data 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`. */
@Serializable
data 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:

```kotlin
// 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.sender
val 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)

```dart title="lib/restake_signer.dart"
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:

```dart
// 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());
```

<Callout type="caution" title="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.
</Callout>

## 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](/wallet-core#test-vectors) uses the same key and bundle.

Every vector uses the development key's address,
`0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266`, as `userOp.sender`, the
`userOpHash` `0x15516a2787b9a2916fe78594f6c6c56d9c63eaea696b14b74d7982f66d8f1447`,
and an authorization for Restake's delegate on chain `8453`:

| Digest | Value | What it covers |
| --- | --- | --- |
| User operation | `0x1dac90fe79708dd13d1ea52e5c3d12c70f037918144c8dbfd8eb613b546c84ad` | EIP-191 over the hash's bytes |
| Authorization, nonce `84` | `0x0f7525a15b33281eb9da517b28a1003c9663c50d0b81125e31b6741e34c8aa05` | The ordinary case, with a recovery bit of `0` |
| Authorization, nonce `0` | `0xe5e53880513d89caa511cfca282f96db91c2ed485d1ec9963e9da16efced30ca` | A zero nonce, with a recovery bit of `1` |
| Authorization, nonce `51` | `0xed7eb03f075ea12cc6f630ff2881786014bf0b48d07fa4a00272f751f8e070f2` | An `r` that begins with a zero byte, which must still be sent as 32 bytes |

<Callout type="caution" title="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.
</Callout>

```swift title="RestakeSignerTests.swift"
import CryptoSwift
import XCTest
@testable import YourApp

final 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:

```kotlin title="RestakeSignerTest.kt"
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Test

class 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) }
        }
    }
}
```

```dart title="test/restake_signer_test.dart"
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](/privy#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 |
| --- | --- |
| `400` `Authorization 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` |
| `400` `AUTHORIZATION_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 |
| `400` `Bundler 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 |
| `400` `Bundler error (eth_sendUserOperation)` mentioning a nonce | The wallet sent a transaction or another bundle after this one was built |
| `400` `Batch user operation intent not found or expired` | The submit came too late, or a second time |
