Skip to main content
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.
This step moves money. Once the required signatures are collected, the proposal executes on-chain and cannot be recalled. Test against the sandbox first.

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.

Sign hashToSign, not typedData

Exactly one of userOpHash or safeTxHash is set, and it tells you which hash hashToSign is:
  • Relay-only proposaluserOpHash is set. hashToSign is the EIP-712 digest of the ERC-4337 user operation.
  • Multi-signature proposalsafeTxHash 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.
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.

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.
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.
All three snippets below produce a byte-identical signature. Each is verified against the test vector in Reproduce a known-good signature.
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.

Submit the signature

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

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:
The snippets in 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: Replace the call as follows:

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.
A sandbox validate-only endpoint is tracked as platform work. When it ships, this section will describe it.

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