Signing without the signer
Build the digest yourself, and have it recover
referrer-signer exists so an agent does not have to implement this page. If you implement it
anyway — because your key lives in a wallet, an HSM, or a runtime with no stdio MCP host — this is
the encoding your signature has to match.
Route 1: hand it to a wallet
Step 1 returns commitment as a complete eth_signTypedData_v4 envelope — types,
primaryType, domain, message — with no field left for you to fill in. If your key is behind
a wallet RPC, pass the object through unchanged and you are done.
const sig = await provider.request({
method: 'eth_signTypedData_v4',
params: [address, JSON.stringify(step1.commitment)],
})Two properties of that envelope matter. Its types map declares EIP712Domain alongside the
commitment's own type, which eth_signTypedData_v4 requires. And its message values are already
in the wire forms the method wants: uint256 as decimal strings, bytes32 as 0x-prefixed hex.
referrer-signer itself does not take this route. It accepts types and primaryType in the
payload and then ignores both, rebuilding the struct hash from a type string compiled into the
binary. That is deliberate: it means a tampered types array cannot redirect what gets signed.
If you are writing your own signer rather than driving a wallet, do the same — treat types as
decoration and domain plus message as the input.
Route 2: build the digest
The digest is EIP-712's standard construction, and nothing about it is specific to us.
digest = keccak256(0x19 || 0x01 || domainSeparator || structHash)The domain separator. All eight domains use the same four fields, in the same order.
domainSeparator = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(utf8(domain.name)),
keccak256(utf8(domain.version)),
uint256(domain.chainId),
address(domain.verifyingContract)
))Read name, version, chainId and verifyingContract out of the payload rather than
pinning them, and the separator you build will match ours whichever commitment you are signing.
Our verifier is not uniform here: five of the eight rebuild the separator from the domain in the
payload they were handed, and the three below have their name compiled into the binary and take
only the chain id and contract address as arguments.
| Commitment | Compiled-in name | Why it is not read from a payload |
|---|---|---|
ReviewCommitment | ReviewRegistry | The recover mirrors ReviewRegistry.submitReview, which pins the same string on-chain |
VoteCommitment | ReferrerVotes | The same treatment, applied to the votes verifier |
ChargeCommitment | ReferrerMPPCharge | It is recovered by the facilitator from the buyer's signature on /mpp/charge, so there is no step-1 payload to read a domain out of |
Either way a wrong domain recovers a different signer and fails the ownership check, rather than
being reported as a domain error. version is "1" on all eight today; the per-commitment name
values are on The eight commitments.
The struct hash. abi.encode of the typehash followed by one 32-byte word per field, in the
order the type string lists them.
structHash = keccak256(abi.encode(typehash, field1, field2, ...))
typehash = keccak256(utf8(typeString))Three field encodings are the ones people get wrong.
| Field type | Encodes as |
|---|---|
uint256, uint16 | the integer, right-aligned in a 32-byte word |
bytes32, address | the value, in one 32-byte word |
string | keccak256(utf8(value)) — the hash, not the bytes |
IntentMarkCommitment and IntentSkipCommitment are the only two commitments with string
fields, and each has two of them. An empty string is legal and hashes to the well-defined keccak
of zero bytes, which is why an empty result verifies while an empty reason is refused by the
tool before it ever reaches encoding.
The signature itself
| Rule | What happens otherwise |
|---|---|
Exactly 65 bytes, r ‖ s ‖ v | invalid agent_sig: must be 0x-prefixed 65-byte hex |
v may be 0/1 or 27/28 | Our verifier normalises both. ReviewRegistry needs 27/28, so referrer-signer adds 27 to what its ECDSA library returns |
s must be in the lower half of the curve order | recover signer: invalid signature values (non-canonical or out-of-range) |
r and s must be in range and non-zero | The same message |
The low-s rule is the one to plan for. It applies to all eight commitments, and the REST
API's request signing makes the opposite choice — it accepts high-s so hardware wallets, which
emit them about half the time, can authenticate. If your signing stack can emit high-s,
normalise before sending, or a valid key will produce rejected signatures intermittently.
Verifying your implementation
Sign a step-1 commitment and send step 2. There are only three ways it can come back wrong, and each names its own cause:
| Response | What it tells you |
|---|---|
invalid agent_sig: must be 0x-prefixed 65-byte hex | Length or encoding, before any hashing |
recover signer: invalid signature values … | The signature is structurally invalid — usually high-s |
signature not from agent owner: recovered 0x…, want 0x… | The digest differed. The recovered address is a real one, so compare it against nothing — it is the address of a message you did not mean to sign |
That third message is the diagnostic for an encoding bug, and it is the only one you will see for
a wrong domain, a reordered field, a raw string where a hash was needed, or a retyped type string.
Bisect it by re-signing with the nonce and expiry echoed exactly as step 1 returned them: if
the address changes, one of your other fields is the problem, and if it does not, the nonce form
is. The two nonce forms are on EIP-712 commitments.
Next steps
- The eight commitments — the type strings and domains to encode
- Signer tools — the alternative to all of the above
- The API — the request-signing scheme, which is a different construction