# Wallet-Core (IOS, Android)

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 in a native app that holds the
user's key itself through
[Trust Wallet Core](https://github.com/trustwallet/wallet-core), with the same
signer written in Swift for iOS and in Kotlin for Android. The examples were
checked against Wallet Core 4.8.2, and every value in the
[test vectors](#test-vectors) was produced by that release and matches ethers
byte for byte.

## Before you start

### The address has to be the key's

Wallet Core is a signing library, not a wallet service. The private key lives in
your app, and the only thing that leaves the device is the two signatures.

**The user's `walletAddress` must be the Ethereum address of the key that
signs.** The batch runs *as that address*: EIP-7702 upgrades it in place rather
than deploying anything, so the address you link and the key that signs have
to be the same account. Two ways to get this wrong:

- **Another account from the same seed.** `getKeyForCoin` derives Wallet Core's
  default Ethereum path, `m/44'/60'/0'/0/0`. If your app gives a user more than
  one Ethereum account, link and sign with the same one: the address to link is
  `deriveAddress` on the key that will sign.
- **A Barz or Biz smart-account address.** Wallet Core can compute these for
  Trust Wallet's own smart accounts, but they are contracts with no key of their
  own.

A wallet address is [set once](/users#wallet-addresses-are-set-once), so check
which address you are linking before you create the user.

### Installing Wallet Core

The signer below needs the `Eip7702` module, which Wallet Core added in
**4.3.15**.

**iOS.** Download `Package.swift` from the
[4.8.2 release](https://github.com/trustwallet/wallet-core/releases/tag/4.8.2)
into a local `WalletCore` folder, and add that folder as a local package:

```swift title="Package.swift"
dependencies: [
    .package(name: "WalletCore", path: "../WalletCore"),
],
targets: [
    .target(
        name: "YourApp",
        dependencies: [.product(name: "WalletCore", package: "WalletCore")]
    ),
]
```

<Callout type="caution" title="Do not add the package by its Git URL">
  The `Package.swift` committed to Wallet Core's repository downloads the 4.2.9
  binary, on `master` and at every release tag from 4.3.15 to 4.8.2. That binary
  predates `Eip7702`, so a package added by URL resolves without a warning and
  then fails to compile with `cannot find 'Eip7702' in scope`. Only the copy
  attached to a release points at that release's binary.
</Callout>

**Android.** Releases are published to GitHub Packages, which requires a GitHub
token with the `read:packages` scope, even for a public package:

```kotlin title="settings.gradle.kts"
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven {
            url = uri("https://maven.pkg.github.com/trustwallet/wallet-core")
            credentials {
                username = providers.gradleProperty("gpr.user").get()
                password = providers.gradleProperty("gpr.key").get()
            }
        }
    }
}
```

```kotlin title="app/build.gradle.kts"
dependencies {
    implementation("com.trustwallet:wallet-core:4.8.2")
}
```

Load the native library once, before the first Wallet Core call.
`Application.onCreate` is the usual place:

```kotlin
System.loadLibrary("TrustWalletCore")
```

## 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` | `Eip7702.getAuthorizationHash` over `bundle.authorization` (`chainId`, `address`, `nonce`). **Only when it is not `null`** | `{ r, s, yParity }`, with `yParity` as **0 or 1** |

Both digests are signed with the same call, `PrivateKey.sign` on the
`secp256k1` curve. It returns 65 bytes: `r`, `s`, and a recovery byte of `0` or
`1`. Four details account for nearly every rejected submit:

- **The recovery byte, which the two fields encode differently.** The account
  recovers the user-operation signer with `ecrecover`, which only accepts 27 or
  28, so add 27 to the last byte of that signature. The authorization keeps the
  byte as `0` or `1`, sent 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.
- **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.

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

### Three Wallet Core APIs to leave out

Wallet Core has higher-level calls that look like shortcuts for both
signatures. Each one produces something close enough to look right:

- **`Eip7702.signAuthorization`** takes the private key as a hex string, so
  calling it means copying the key into a `String`. Its JSON result writes
  `yParity` as a hex string (`"0x00"`), which the API refuses, and drops leading
  zero bytes from `r` and `s`: for the [third test vector](#test-vectors) it
  returns a 31-byte `r`. Hash with `Eip7702.getAuthorizationHash` and sign the
  digest instead.
- **`EthereumMessageSigner.signMessage`** does return the right 65 bytes for a
  `userOpHash`, because it signs any string that parses as hex as the bytes it
  encodes. It decides that from the string's content, not from anything you
  pass it. Computing the digest yourself makes the choice explicit, and keeps
  both signatures on one signing call.
- **The `Barz` and `Biz` modules** format signatures for Trust Wallet's own
  smart accounts, not for the account this API delegates to.

## 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 (Wallet Core)              Your backend                        Restake API
 │ user asks to stake / trade ─▶ │ POST …/construct/…/batch ─────────▶ │
 │                               │ ◀────────────────── intentId, bundle │
 │ ◀──────── intentId, bundle    │                                      │
 │ user confirms, key 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.

How the key is stored (Keychain, Android Keystore, an encrypted mnemonic) is up
to your app. The signer only needs a Wallet Core `PrivateKey` for the duration
of the call.

### iOS (Swift)

```swift title="RestakeSigner.swift"
import Foundation
import WalletCore

/// 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 for a bundle, with the key of its sender.
    static func sign(_ bundle: RestakeBundle, with privateKey: PrivateKey) throws -> RestakeSignatures {
        try assertExpectedDelegate(bundle)

        let address = CoinType.ethereum.deriveAddress(privateKey: privateKey)
        guard address.caseInsensitiveCompare(bundle.userOp.sender) == .orderedSame else {
            throw RestakeSigningError(
                description: "Key is for \(address), bundle is for \(bundle.userOp.sender)"
            )
        }

        // 1. The user operation: v as 27/28, behind the account prefix.
        var userOpSignature = try signDigest(userOpDigest(bundle), with: privateKey)
        userOpSignature[64] += 27

        // 2. The EIP-7702 authorization: yParity stays 0/1. Only while the
        //    wallet is not delegated yet.
        var authorization: RestakeSignatures.Authorization?
        if bundle.authorization != nil {
            let authorizationSignature = try signDigest(authorizationDigest(bundle), with: privateKey)
            authorization = .init(
                r: hex(authorizationSignature[0..<32]),
                s: hex(authorizationSignature[32..<64]),
                yParity: Int(authorizationSignature[64])
            )
        }

        return RestakeSignatures(
            signature: hex(accountSignaturePrefix + userOpSignature),
            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 {
        guard let hash = Data(hexString: bundle.userOpHash), hash.count == 32 else {
            throw RestakeSigningError(description: "userOpHash is not a 32-byte hex value")
        }
        return Hash.keccak256(data: Data("\u{19}Ethereum Signed Message:\n32".utf8) + hash)
    }

    /// `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")
        }
        guard let digest = Eip7702.getAuthorizationHash(
            chainId: bigEndian(authorization.chainId),
            contractAddress: authorization.address,
            nonce: bigEndian(authorization.nonce)
        ) else {
            throw RestakeSigningError(
                description: "Cannot hash the authorization for \(authorization.address)"
            )
        }
        return digest
    }

    /// `r ‖ s ‖ recovery byte`, with the recovery byte as 0 or 1.
    private static func signDigest(_ digest: Data, with privateKey: PrivateKey) throws -> Data {
        guard let signature = privateKey.sign(digest: digest, curve: .secp256k1),
              signature.count == 65
        else {
            throw RestakeSigningError(description: "secp256k1 signing failed")
        }
        return signature
    }

    private static func bigEndian(_ value: UInt64) -> Data {
        withUnsafeBytes(of: value.bigEndian) { Data($0) }
    }

    private static func hex(_ data: Data) -> String {
        "0x" + data.hexString
    }
}
```

`chainId` and `nonce` are passed as eight big-endian bytes. The hash encodes
them minimally, as RLP requires, so a nonce of `0` needs no special case.

Wiring the signer to your backend, where `yourApi` and `yourKeyStore` stand for
your own code:

```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.
// For a wallet created from a mnemonic, the key is
// HDWallet(mnemonic: mnemonic, passphrase: "")!.getKeyForCoin(coin: .ethereum)!
let privateKey = try yourKeyStore.ethereumKey()
let signatures = try RestakeSigner.sign(prepared.bundle, with: privateKey)

// 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 java.nio.ByteBuffer
import kotlinx.serialization.Serializable
import wallet.core.jni.CoinType
import wallet.core.jni.Curve
import wallet.core.jni.Eip7702
import wallet.core.jni.Hash
import wallet.core.jni.PrivateKey

/**
 * 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 for a bundle, with the key of its sender. */
    fun sign(bundle: RestakeBundle, privateKey: PrivateKey): RestakeSignatures {
        assertExpectedDelegate(bundle)

        val address = CoinType.ETHEREUM.deriveAddress(privateKey)
        require(address.equals(bundle.userOp.sender, ignoreCase = true)) {
            "Key is for $address, bundle is for ${bundle.userOp.sender}"
        }

        // 1. The user operation: v as 27/28, behind the account prefix.
        val userOpSignature = signDigest(userOpDigest(bundle), privateKey)
        userOpSignature[64] = (userOpSignature[64] + 27).toByte()

        // 2. The EIP-7702 authorization: yParity stays 0/1. Only while the
        //    wallet is not delegated yet.
        val authorization = bundle.authorization?.let {
            val authorizationSignature = signDigest(authorizationDigest(bundle), privateKey)
            RestakeSignatures.Authorization(
                r = authorizationSignature.copyOfRange(0, 32).toHex(),
                s = authorizationSignature.copyOfRange(32, 64).toHex(),
                yParity = authorizationSignature[64].toInt(),
            )
        }

        return RestakeSignatures(
            signature = (ACCOUNT_SIGNATURE_PREFIX + userOpSignature).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 {
        val hash = bundle.userOpHash.hexToBytes()
        require(hash.size == 32) { "userOpHash is not a 32-byte hex value" }
        return Hash.keccak256(EIP191_PREFIX + hash)
    }

    /** `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" }
        return Eip7702.getAuthorizationHash(
            authorization.chainId.toBigEndian(),
            authorization.address,
            authorization.nonce.toBigEndian(),
        ) ?: error("Cannot hash the authorization for ${authorization.address}")
    }

    /** `r ‖ s ‖ recovery byte`, with the recovery byte as 0 or 1. */
    private fun signDigest(digest: ByteArray, privateKey: PrivateKey): ByteArray {
        val signature: ByteArray? = privateKey.sign(digest, Curve.SECP256K1)
        check(signature != null && signature.size == 65) { "secp256k1 signing failed" }
        return signature
    }

    private fun Long.toBigEndian(): ByteArray =
        ByteBuffer.allocate(Long.SIZE_BYTES).putLong(this).array()

    private fun String.hexToBytes(): ByteArray {
        val digits = removePrefix("0x")
        require(digits.length % 2 == 0) { "Odd-length hex: $this" }
        return ByteArray(digits.length / 2) { i ->
            digits.substring(2 * i, 2 * i + 2).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` and `yourKeyStore` stand for
your own code:

```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.
// For a wallet created from a mnemonic, the key is
// HDWallet(mnemonic, "").getKeyForCoin(CoinType.ETHEREUM)
val privateKey = yourKeyStore.ethereumKey()
val signatures = RestakeSigner.sign(prepared.bundle, privateKey)

// Your backend calls …/stocks/transaction/batch/send with the userId from its session.
val userOpHash = yourApi.submitBatch(prepared.intentId, signatures)
```

<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

Wallet Core signs deterministically (RFC 6979): the same key and digest always
produce the same signature. The values below are therefore exact, and a
mismatch means an encoding mistake, never randomness. They were produced by
Wallet Core 4.8.2 and match ethers 6 for the same inputs.

Every vector uses the same key and user operation. The authorization is signed
three times, to cover three cases:

| `chainId` | `nonce` | What it covers |
| --- | --- | --- |
| `8453` | `84` | The ordinary case, with a recovery byte of `0` |
| `8453` | `0` | A zero nonce, with a recovery byte of `1` |
| `8453` | `51` | An `r` that begins with a zero byte, which must still be sent as 32 bytes |

<Callout type="caution" title="A public key">
  The mnemonic below is the well-known development mnemonic, and anyone can
  derive its keys. Use it in tests only, and never send funds to its addresses.
</Callout>

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

final class RestakeSignerTests: XCTestCase {
    /// The public development mnemonic. Never send funds to its addresses.
    private let privateKey = HDWallet(
        mnemonic: "test test test test test test test test test test test junk",
        passphrase: ""
    )!.getKeyForCoin(coin: .ethereum)!

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

    func testUserOperationDigest() throws {
        XCTAssertEqual(
            try RestakeSigner.userOpDigest(bundle(nonce: 84)).hexString,
            "1dac90fe79708dd13d1ea52e5c3d12c70f037918144c8dbfd8eb613b546c84ad"
        )
    }

    func testUserOperationSignature() throws {
        let signatures = try RestakeSigner.sign(bundle(nonce: 84), with: privateKey)
        XCTAssertEqual(
            signatures.signature,
            "0xff004d5aa8958bf02e129786ab6acec463a8e69b51b5468613580fbc1d5e3960e640" +
                "0dac52a400d778e11d60c90801b5137a7572c0265bead1bbaf423fc0f07bfda81c"
        )
    }

    func testAuthorization() throws {
        let authorization = try RestakeSigner.sign(bundle(nonce: 84), with: privateKey).authorization
        XCTAssertEqual(authorization?.r, "0xe4e80d285e2e48907f94454019dd6f9e90c6dc5fad61c6f4922940c4ce472e08")
        XCTAssertEqual(authorization?.s, "0x61629e69dfffc96fc9c756091d3ae5fedbb2a21e0c75ef41e771c76320166225")
        XCTAssertEqual(authorization?.yParity, 0)
    }

    func testAuthorizationWithZeroNonce() throws {
        let authorization = try RestakeSigner.sign(bundle(nonce: 0), with: privateKey).authorization
        XCTAssertEqual(authorization?.r, "0xdba15187a3e8dd38c798d29b29613db6666147b991bc7126933475549cc21e3f")
        XCTAssertEqual(authorization?.s, "0x1fbec578f7dd59f29ca1ccf8599b0f20a757e698bffd31da8988e057e556ca87")
        XCTAssertEqual(authorization?.yParity, 1)
    }

    func testAuthorizationKeepsLeadingZeroInR() throws {
        let authorization = try RestakeSigner.sign(bundle(nonce: 51), with: privateKey).authorization
        XCTAssertEqual(authorization?.r, "0x00229d4cbb445e50c2e337080db7d7c703e99c8c58a92632a72cd46dca34d1f3")
        XCTAssertEqual(authorization?.s, "0x66e413aca7882b79201681190553a730909604173928bcf5b76360eadc142614")
        XCTAssertEqual(authorization?.yParity, 0)
    }

    func testDelegatedWalletSignsOnlyTheUserOp() throws {
        let signatures = try RestakeSigner.sign(bundle(nonce: nil), with: privateKey)
        XCTAssertEqual(signatures.signature, try RestakeSigner.sign(bundle(nonce: 84), with: privateKey).signature)
        XCTAssertNil(signatures.authorization)
    }

    func testRefusesAnotherDelegate() {
        let wrong = bundle(nonce: 84, delegate: "0x000000000000000000000000000000000000dEaD")
        XCTAssertThrowsError(try RestakeSigner.sign(wrong, with: privateKey))
    }
}
```

On Android the same checks run as an instrumented test, in `src/androidTest`.
Wallet Core's native library is built for Android only, so a local JVM unit
test cannot load it.

```kotlin title="RestakeSignerTest.kt"
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Test
import org.junit.runner.RunWith
import wallet.core.jni.CoinType
import wallet.core.jni.HDWallet

@RunWith(AndroidJUnit4::class)
class RestakeSignerTest {
    init {
        System.loadLibrary("TrustWalletCore")
    }

    /** The public development mnemonic. Never send funds to its addresses. */
    private val privateKey =
        HDWallet("test test test test test test test test test test test junk", "")
            .getKeyForCoin(CoinType.ETHEREUM)

    /** `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 userOperationSignature() {
        assertEquals(
            "0xff004d5aa8958bf02e129786ab6acec463a8e69b51b5468613580fbc1d5e3960e640" +
                "0dac52a400d778e11d60c90801b5137a7572c0265bead1bbaf423fc0f07bfda81c",
            RestakeSigner.sign(bundle(nonce = 84), privateKey).signature,
        )
    }

    @Test
    fun authorization() {
        assertEquals(
            RestakeSignatures.Authorization(
                r = "0xe4e80d285e2e48907f94454019dd6f9e90c6dc5fad61c6f4922940c4ce472e08",
                s = "0x61629e69dfffc96fc9c756091d3ae5fedbb2a21e0c75ef41e771c76320166225",
                yParity = 0,
            ),
            RestakeSigner.sign(bundle(nonce = 84), privateKey).authorization,
        )
    }

    @Test
    fun authorizationWithZeroNonce() {
        assertEquals(
            RestakeSignatures.Authorization(
                r = "0xdba15187a3e8dd38c798d29b29613db6666147b991bc7126933475549cc21e3f",
                s = "0x1fbec578f7dd59f29ca1ccf8599b0f20a757e698bffd31da8988e057e556ca87",
                yParity = 1,
            ),
            RestakeSigner.sign(bundle(nonce = 0), privateKey).authorization,
        )
    }

    @Test
    fun authorizationKeepsLeadingZeroInR() {
        assertEquals(
            RestakeSignatures.Authorization(
                r = "0x00229d4cbb445e50c2e337080db7d7c703e99c8c58a92632a72cd46dca34d1f3",
                s = "0x66e413aca7882b79201681190553a730909604173928bcf5b76360eadc142614",
                yParity = 0,
            ),
            RestakeSigner.sign(bundle(nonce = 51), privateKey).authorization,
        )
    }

    @Test
    fun delegatedWalletSignsOnlyTheUserOp() {
        val signatures = RestakeSigner.sign(bundle(nonce = null), privateKey)
        assertEquals(RestakeSigner.sign(bundle(nonce = 84), privateKey).signature, signatures.signature)
        assertNull(signatures.authorization)
    }

    @Test
    fun refusesAnotherDelegate() {
        val wrong = bundle(nonce = 84, delegate = "0x000000000000000000000000000000000000dEaD")
        assertThrows(IllegalArgumentException::class.java) {
            RestakeSigner.sign(wrong, privateKey)
        }
    }
}
```

## 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 are still correctly formed:

- **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 your app signs with `AnySigner` and broadcasts,
  moves that count on, and the authorization no longer applies.
- **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.

**One delegation per address.** If your app also delegates the same address
through EIP-7702 somewhere else, such as a Trust Wallet Biz account or a
set-code transaction built with Wallet Core, each flow points the address back
at its own contract when it runs. If you combine them, test the combination on
staging first.

## Failure modes

| Response | Usual cause |
| --- | --- |
| `400` `Authorization signer does not match user operation sender` | The key belongs to an account other than the user's `walletAddress`, or the authorization was hashed over a `chainId`, `address` or `nonce` other than the bundle's |
| `400` validation error on `authorization.yParity` | `yParity` was sent as a string, typically the `"0x00"` from `Eip7702.signAuthorization`. 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 left as `0` / `1` instead of 27 / 28, the hash signed as text, 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 |
