> ## Documentation Index
> Fetch the complete documentation index at: https://bkey.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Sign a proposal

> Produce a signature that BMONI accepts for a smart wallet proposal, using ethers, viem, or web3.py, and diagnose a rejected signature.

<div className="bmoni-spine">
  <span>Lifecycle</span>
  <a data-stage="1" href="/lifecycle#1-create-the-user">User</a>
  <a data-stage="2" href="/lifecycle#2-provision-the-smart-wallet">Wallet</a>
  <a data-stage="3" href="/lifecycle#3-verify-identity-kyc">KYC</a>
  <a data-stage="4" href="/lifecycle#4-activate-the-rail">Rail</a>
  <a data-stage="5" href="/lifecycle#5-fund-the-wallet">Fund</a>
  <a data-stage="6" href="/lifecycle#6-move-money">Move money</a>
</div>

This page shows you how to turn a proposal's signing payload into a signature BMONI accepts, and how to work out why a signature was rejected.

Signing is the step where most integrations stall, and almost always for one of three reasons: you signed through the wrong library method, your `v` byte is `0`/`1` instead of `27`/`28`, or your payload expired. Each has a specific symptom, listed under [Why your signature is rejected](#why-your-signature-is-rejected).

<Warning>
  This step moves money. Once the required signatures are collected, the proposal executes on-chain and cannot be recalled. Test against the sandbox first.
</Warning>

## Before you start

You need:

* A proposal in status `PENDING_SIGNATURES`, or `PENDING_APPROVALS` if you are capturing a co-signer early.
* The private key for the address you registered as `userOwnerAddress` when you created the smart wallet.
* One of `ethers` 6, `viem` 2, or `eth-account` 0.13 (used by `web3.py`).

## What you are actually signing

`GET /v1/users/{userId}/smart-wallets/proposals/{proposalId}/sign-payload` hands you a digest that has already been constructed for you. **You do not build the EIP-712 domain or types yourself.** The backend prepares the structured data, hashes it, and gives you the resulting 32-byte digest in `hashToSign`.

That single fact removes most of the difficulty. Your job is to produce a raw secp256k1 signature over those 32 bytes — nothing more.

```http theme={null}
GET /v1/users/{userId}/smart-wallets/proposals/{proposalId}/sign-payload
```

```json theme={null}
{
  "success": true,
  "data": {
    "method": "evm",
    "walletIndex": 0,
    "workflowId": "proposal-sign-47665949",
    "hashToSign": "0x8f5156823a5c2cdc7bedc12253e49e4946c6fff0273034eb485750035d21ad31",
    "payload": "0x8f5156823a5c2cdc7bedc12253e49e4946c6fff0273034eb485750035d21ad31",
    "deadline": "2026-08-07T13:05:00.000Z",
    "proposalId": "47665949-8654-4461-b52a-3ef3624b1234",
    "userOpHash": "0x8f5156823a5c2cdc7bedc12253e49e4946c6fff0273034eb485750035d21ad31",
    "safeTxHash": null,
    "typedData": null
  }
}
```

| Field         | What it is                                                                            |
| ------------- | ------------------------------------------------------------------------------------- |
| `method`      | `evm` or `solana`. Everything on this page describes `evm`.                           |
| `walletIndex` | Index of the key the signature must come from. `0` for a single-owner smart wallet.   |
| `workflowId`  | Internal workflow identifier. Echo it back only where an endpoint asks for it.        |
| `hashToSign`  | **The 32-byte digest you sign.** Always present for `evm`.                            |
| `payload`     | Equal to `hashToSign` for `evm`. Present for parity with non-EVM chains.              |
| `deadline`    | ISO 8601 instant after which the signature is refused.                                |
| `userOpHash`  | Set when the proposal executes as an ERC-4337 user operation (a relay-only proposal). |
| `safeTxHash`  | Set when the proposal executes as a Safe transaction (a multi-signature proposal).    |
| `typedData`   | The full EIP-712 object, when the upstream includes it. Informational — see below.    |

### Sign `hashToSign`, not `typedData`

Exactly one of `userOpHash` or `safeTxHash` is set, and it tells you which hash `hashToSign` is:

* **Relay-only proposal** — `userOpHash` is set. `hashToSign` is the EIP-712 digest of the ERC-4337 user operation.
* **Multi-signature proposal** — `safeTxHash` is set. `hashToSign` is the Safe transaction hash.

Either way you sign `hashToSign`. The distinction matters for understanding what you are authorising, not for the signing call.

<Note>
  `typedData` is populated only when the upstream includes the full EIP-712 object, and it is `null` otherwise. Treat it as a debugging aid for inspecting what the digest covers. Do not re-hash it and do not pass it to a `signTypedData` method — you will produce a different digest from the one the backend is expecting, and the signature will be rejected.
</Note>

## Sign the digest

The rule in every language is the same: use the method that signs a **raw hash**, not the one that signs a *message*. A message-signing method applies the EIP-191 prefix `\x19Ethereum Signed Message:\n32` and hashes your digest a second time.

<Warning>
  This rule applies to **proposal signing only**. The other place you sign with the owner key — the owner-proof challenge at wallet creation — wants the opposite: a text message signed **with** the EIP-191 prefix, so `signMessage` is correct there. Confusing the two is the most common signing mistake. See [the side-by-side comparison](/api-quickstart#the-two-signatures-side-by-side).
</Warning>

All three snippets below produce a byte-identical signature. Each is verified against the test vector in [Reproduce a known-good signature](#reproduce-a-known-good-signature).

<CodeGroup>
  ```javascript ethers.js theme={null}
  import { ethers } from 'ethers'

  const wallet = new ethers.Wallet(OWNER_PRIVATE_KEY)

  // signingKey.sign() signs the raw digest.
  // Do NOT use wallet.signMessage() — it applies the EIP-191 prefix.
  const signature = wallet.signingKey.sign(signPayload.hashToSign).serialized

  await fetch(
    `${BASE_URL}/v1/users/${bmoniUserId}/smart-wallets/proposals/${proposalId}/sign`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': API_KEY,
      },
      body: JSON.stringify({ signature }),
    },
  )
  ```

  ```javascript viem theme={null}
  import { sign } from 'viem/accounts'

  // sign() from viem/accounts signs the raw digest and serialises v as 27/28.
  // Do NOT use account.signMessage() — it applies the EIP-191 prefix.
  const signature = await sign({
    hash: signPayload.hashToSign,
    privateKey: OWNER_PRIVATE_KEY,
    to: 'hex',
  })

  await fetch(
    `${BASE_URL}/v1/users/${bmoniUserId}/smart-wallets/proposals/${proposalId}/sign`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': API_KEY,
      },
      body: JSON.stringify({ signature }),
    },
  )
  ```

  ```python web3.py theme={null}
  import requests
  from eth_account import Account

  # unsafe_sign_hash() signs the raw digest. The name warns you to be sure the
  # hash is one you trust — here it comes from the sign-payload endpoint.
  # Do NOT use Account.sign_message() — it applies the EIP-191 prefix.
  signed = Account.unsafe_sign_hash(
      bytes.fromhex(sign_payload["hashToSign"][2:]),
      OWNER_PRIVATE_KEY,
  )

  requests.post(
      f"{BASE_URL}/v1/users/{bmoni_user_id}"
      f"/smart-wallets/proposals/{proposal_id}/sign",
      json={"signature": "0x" + signed.signature.hex()},
      headers={"x-api-key": API_KEY},
      timeout=30,
  )
  ```
</CodeGroup>

<Tip>
  On `eth-account` older than 0.13, `unsafe_sign_hash` is named `signHash`. Upgrade rather than pin: the older name is deprecated and removed in 0.13.
</Tip>

## Submit the signature

```http theme={null}
POST /v1/users/{userId}/smart-wallets/proposals/{proposalId}/sign
{
  "signature": "0x628f1aff48c9d1f35d45a735eb026db0437c5ed334a94dc7fb0ac86ca32c10bd173a653a7f064c4512244f6fcbefb07e13bfe7368fcacdcc4e6fb153f50050991b"
}
```

The backend recovers the signer address from the signature, checks it against the proposal's signer snapshot, and records it. When the collected signatures reach `requiredSignatures`, the proposal is submitted on-chain.

### Expected result

A `200` response carrying the updated proposal. Its `status` stays `PENDING_SIGNATURES` while further signatures are outstanding, and becomes `COMPLETED` after on-chain execution settles.

Poll `GET /v1/users/{userId}/smart-wallets/proposals/{proposalId}` for the terminal status.

## Reproduce a known-good signature

Run this before you debug your integration. It uses the well-known Anvil test account, so you can confirm your toolchain produces the exact bytes BMONI expects without touching a real key or a real proposal.

The digest is `keccak256` of a fixed string, so you can regenerate every value here from scratch:

| Value         |                                                                                                                                        |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Preimage      | `bmoni-embedded:BKE-2041:sign-payload-example`                                                                                         |
| Private key   | `0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80`                                                                   |
| Owner address | `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266`                                                                                           |
| `hashToSign`  | `0x8f5156823a5c2cdc7bedc12253e49e4946c6fff0273034eb485750035d21ad31`                                                                   |
| Signature     | `0x628f1aff48c9d1f35d45a735eb026db0437c5ed334a94dc7fb0ac86ca32c10bd173a653a7f064c4512244f6fcbefb07e13bfe7368fcacdcc4e6fb153f50050991b` |
| `v` byte      | `27` (`0x1b`)                                                                                                                          |

<Warning>
  That private key is the public Anvil and Hardhat test account. It is published in their documentation, holds nothing, and must never be used for anything but local testing.
</Warning>

<CodeGroup>
  ```javascript verify.mjs theme={null}
  import assert from 'node:assert/strict'
  import { ethers } from 'ethers'
  import { sign } from 'viem/accounts'
  import { keccak256, toHex } from 'viem'

  const PK = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'
  const hash = keccak256(toHex('bmoni-embedded:BKE-2041:sign-payload-example'))
  const expected =
    '0x628f1aff48c9d1f35d45a735eb026db0437c5ed334a94dc7fb0ac86ca32c10bd' +
    '173a653a7f064c4512244f6fcbefb07e13bfe7368fcacdcc4e6fb153f50050991b'

  assert.equal(hash, '0x8f5156823a5c2cdc7bedc12253e49e4946c6fff0273034eb485750035d21ad31')
  assert.equal(new ethers.Wallet(PK).signingKey.sign(hash).serialized, expected)
  assert.equal(await sign({ hash, privateKey: PK, to: 'hex' }), expected)
  assert.equal(
    ethers.recoverAddress(hash, expected),
    '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
  )

  console.log('Toolchain produces the expected signature.')
  ```

  ```python verify.py theme={null}
  from eth_account import Account
  from eth_utils import keccak

  PK = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
  digest = keccak(text="bmoni-embedded:BKE-2041:sign-payload-example")
  expected = (
      "0x628f1aff48c9d1f35d45a735eb026db0437c5ed334a94dc7fb0ac86ca32c10bd"
      "173a653a7f064c4512244f6fcbefb07e13bfe7368fcacdcc4e6fb153f50050991b"
  )

  assert "0x" + digest.hex() == (
      "0x8f5156823a5c2cdc7bedc12253e49e4946c6fff0273034eb485750035d21ad31"
  )
  signed = Account.unsafe_sign_hash(digest, PK)
  assert "0x" + signed.signature.hex() == expected
  assert signed.v == 27
  assert Account._recover_hash(digest, signature=signed.signature) == (
      "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
  )

  print("Toolchain produces the expected signature.")
  ```
</CodeGroup>

If your output differs from `expected`, the fault is in your signing code, not in your proposal. Work through the next section.

## Why your signature is rejected

### `Point is not on curve`

The bytes you sent are not a decodable secp256k1 signature. Check, in order:

1. **Length.** A signature is exactly 65 bytes — `0x` plus 130 hex characters. A 64-byte signature is missing its `v` byte; a 66-byte one usually has a stray `0x` in the middle from concatenating `r`, `s`, and `v` as prefixed strings.
2. **Encoding.** Send hex, not base64, and not a byte array serialised as JSON.
3. **Component order.** The layout is `r` (32 bytes), then `s` (32 bytes), then `v` (1 byte). Assembling `v` first, or `s` before `r`, produces bytes that decode to a point off the curve.

```javascript theme={null}
// Assert before you send. This catches every length and encoding fault.
if (!/^0x[0-9a-fA-F]{130}$/.test(signature)) {
  throw new Error(`Malformed signature: expected 65 bytes, got ${signature}`)
}
```

### `Invalid yParityOrV`

Your `v` byte is `0` or `1`. Some libraries return the raw recovery bit as `yParity`; BMONI expects the Ethereum convention of `27` or `28`.

Normalise the last byte:

```javascript theme={null}
const v = parseInt(signature.slice(-2), 16)
const normalised =
  v < 27 ? signature.slice(0, -2) + (v + 27).toString(16).padStart(2, '0') : signature
```

The snippets in [Sign the digest](#sign-the-digest) already emit `27`/`28`. You hit this when assembling `r`, `s`, and `v` by hand from a lower-level library.

### `500`, or the signature is recorded but never executes

You almost certainly signed through a message-signing method. This is the most common failure and the hardest to spot, because the signature is *structurally valid* — it decodes cleanly and has a correct `v` byte. It simply recovers to a different address, so it never matches the proposal's signer snapshot.

Using the test vector above, the two paths diverge like this:

| Method                     | Recovers to                                  |                                 |
| -------------------------- | -------------------------------------------- | ------------------------------- |
| `signingKey.sign(hash)`    | `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` | Correct — the registered owner. |
| `wallet.signMessage(hash)` | `0xC69f336bb0C1e391a97861cC1837e1f10a9Ba041` | Wrong — rejected.               |

Replace the call as follows:

| Library     | Do not use                                        | Use                                       |
| ----------- | ------------------------------------------------- | ----------------------------------------- |
| ethers      | `wallet.signMessage(hash)`                        | `wallet.signingKey.sign(hash).serialized` |
| viem        | `account.signMessage({ message: { raw: hash } })` | `sign({ hash, privateKey, to: 'hex' })`   |
| eth-account | `Account.sign_message(encode_defunct(...))`       | `Account.unsafe_sign_hash(digest, key)`   |

### `Signature deadline exceeded`

The `deadline` in the sign payload has passed. Fetch a fresh payload with `GET …/sign-payload` and sign again. Do not cache a payload across a user session — fetch it immediately before signing.

### The recovered signer is not authorised

The signature is valid but the address is not on the proposal's signer snapshot. The signing key must be the one registered as `userOwnerAddress` at wallet creation. If you rotate keys in your own store between wallet creation and signing, the snapshot still holds the original address.

## Validating a signature without moving money

There is currently no endpoint that validates a signature without submitting it. Until one exists, use the test vector on this page to prove your toolchain end to end, then run a minimum-value proposal in the sandbox as your first live exercise.

<Note>
  A sandbox validate-only endpoint is tracked as platform work. When it ships, this section will describe it.
</Note>

## Related

* [Transfers](/api-reference/transfers) — creating and approving the proposal you sign here.
* [Errors and status codes](/api-reference/errors) — every error this endpoint can return.
* [SDK signing](/sdk/signing) — signing through `bmoni_embedded_sdk` instead of a raw key.
* [Sandbox test data](/api-reference/sandbox-test-data) — values that resolve in the sandbox.

***

*Last reviewed: 7 August 2026. Snippets verified against `ethers` 6.17.0, `viem` 2.55.10, and `eth-account` 0.13.7.*
