# Privy (Web, React Native)

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 from a [Privy](https://privy.io)
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)](/privy-native).

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

```ts title="restake-signing.ts"
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}`,
    );
  }
}
```

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

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

### React

```tsx title="use-restake-signer.tsx"
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:

```tsx
// 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:

```tsx title="use-restake-signer.native.tsx"
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)](/privy-native) has the signer in Swift,
Kotlin and Dart, with test vectors.

## Signing on your backend

If your Privy app holds a [signer](https://docs.privy.io/wallets/using-wallets/signers/overview)
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:

```ts title="privy-server-signer.ts"
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;
}
```

```ts title="stake-for-user.ts"
import type { RestakeBundle } from './restake-signing';
import { signBundleOnServer } from './privy-server-signer';

const RESTAKE_API = 'https://stg-s2s.restake.gg/api/v1/s2s';

async function post<T>(path: string, accessToken: string, body: unknown): Promise<T> {
  const response = await fetch(`${RESTAKE_API}${path}`, {
    method: 'POST',
    headers: {
      authorization: `Bearer ${accessToken}`,
      'content-type': 'application/json',
    },
    body: JSON.stringify(body),
  });
  if (!response.ok) {
    throw new Error(`${path}: ${response.status} ${await response.text()}`);
  }
  return ((await response.json()) as { data: T }).data;
}

export async function stakeForUser(
  accessToken: string,
  userId: string,
  privyWalletId: string,
): Promise<string> {
  const { intentId, bundle } = await post<{ intentId: string; bundle: RestakeBundle }>(
    '/staking-pools/construct/stake/transaction/batch',
    accessToken,
    { userId, slug: 'aave-usdc-base', amount: 250.5 },
  );

  const signatures = await signBundleOnServer(privyWalletId, bundle);

  const { userOpHash } = await post<{ userOpHash: string }>(
    '/staking-pools/transaction/batch/send',
    accessToken,
    { userId, intentId, ...signatures },
  );
  return userOpHash;
}
```

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.

<Callout type="info" title="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](https://docs.privy.io/controls/policies/overview) can enforce that: a
  rule on `eth_sign7702Authorization` with a condition on the `contract` field
  (`field_source: ethereum_7702_authorization`) that allows only
  `0x69007702764179f14F51cdce752f4f775d74E139`.
</Callout>

## 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 |
| --- | --- |
| `400` `Authorization 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` |
| `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 hash signed as text, EIP-191 applied twice, 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 |
