Distributed Systems & CryptographyMay 12, 2026

Deterministic Proof-Of-Receipt Verification For Smart Contract Memos

C

Written by

Cipher Stone

Why I got obsessed with “receipt memos”

I stumbled into a weird bug class while building a decentralized provenance system: the smart contract would accept a transaction, emit an event, and later the off-chain verifier would reconstruct the “receipt memo” (a human-readable string) differently than what the contract used.

That mismatch didn’t break consensus, but it broke trust. In practice it meant:

  • the contract proved something about the receipt,
  • the verifier proved something about a different receipt string,
  • and the provenance trail quietly diverged.

What I wanted was a deterministic rule: given the on-chain fields, the receipt memo bytes must be reproduced exactly, and the contract should verify a cryptographic proof of those memo bytes.

So this post documents the niche solution I built: a deterministic proof-of-receipt memo smart contract where the memo bytes are defined by a strict canonicalization algorithm, and the contract verifies a signature over the memo bytes.


The tiny niche: “memo canonicalization + signature verification”

Instead of signing a free-form string, I used the following pipeline:

  1. Canonicalize memo fields into a byte array using strict rules (no locale surprises, no whitespace tolerance).
  2. Hash those memo bytes with Keccak-256 (Ethereum-style).
  3. Verify that an authorized signer produced a valid signature over that hash.
  4. Store the receipt on-chain as (memoHash, signature).

The key detail: the memo canonicalization must be identical on-chain and off-chain. Most bugs happen when the off-chain code “pretty prints” data differently than the contract assumes.


Canonical memo format (the exact bytes matter)

I defined a memo that looks like this conceptually:

  • documentId (string, treated as UTF-8 bytes)
  • eventType (string)
  • timestampSec (uint64)
  • nonce (uint64)
  • status (string)

The canonicalization rules are intentionally strict:

  • Each string is encoded as UTF-8 bytes.
  • Each string is prefixed with a 32-bit big-endian length (so "a" and "aa" cannot collide with ambiguous concatenation).
  • Strings and integers are appended in a fixed order.
  • Integers are encoded as unsigned 64-bit big-endian.

This produces a deterministic byte buffer:

len(documentId) || documentIdBytes ||
len(eventType)  || eventTypeBytes  ||
u64(timestampSec) ||
u64(nonce) ||
len(status) || statusBytes

Solidity contract: verify memo signature over canonical bytes

Below is a complete, minimal Solidity contract (EVM-compatible) using ecrecover for signature recovery.

What the contract stores/validates

  • It computes memoHash = keccak256(canonicalMemoBytes).
  • It expects a signature from a configured signer over memoHash.
  • It accepts the receipt only if the signature is valid.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract ProofOfReceiptMemo { address public authorizedSigner; // Memo signature must match this message hash. // We sign keccak256("\x19Ethereum Signed Message:\n32" || memoHash) bytes32 private constant ETH_SIGNED_MESSAGE_PREFIX = keccak256( hex"19" // not used directly; prefix is computed dynamically below ); event ReceiptAccepted( bytes32 indexed memoHash, address indexed signer ); constructor(address _authorizedSigner) { authorizedSigner = _authorizedSigner; } /// @notice Accept a receipt memo by verifying a signature over canonical memo bytes. function acceptReceipt( string calldata documentId, string calldata eventType, uint64 timestampSec, uint64 nonce, string calldata status, bytes calldata signature // 65 bytes: r(32) || s(32) || v(1) ) external returns (bytes32 memoHash) { bytes memory memoBytes = _buildCanonicalMemoBytes( documentId, eventType, timestampSec, nonce, status ); memoHash = keccak256(memoBytes); bytes32 ethSignedHash = _toEthSignedMessageHash(memoHash); address recovered = _recoverSigner(ethSignedHash, signature); require(recovered == authorizedSigner, "Invalid memo signature"); emit ReceiptAccepted(memoHash, recovered); } function _toEthSignedMessageHash(bytes32 hash) private pure returns (bytes32) { // "\x19Ethereum Signed Message:\n32" is the standard prefix for signing 32-byte hashes. return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)); } function _recoverSigner(bytes32 signedHash, bytes calldata sig) private pure returns (address) { require(sig.length == 65, "Bad signature length"); bytes32 r; bytes32 s; uint8 v; // Read r,s,v from calldata without assembly-friendly boilerplate in this example: assembly { r := calldataload(sig.offset) s := calldataload(add(sig.offset, 32)) v := byte(0, calldataload(add(sig.offset, 64))) } // Normalize v to {27,28} if provided as {0,1} if (v < 27) v += 27; require(v == 27 || v == 28, "Bad v value"); return ecrecover(signedHash, v, r, s); } function _buildCanonicalMemoBytes( string calldata documentId, string calldata eventType, uint64 timestampSec, uint64 nonce, string calldata status ) private pure returns (bytes memory) { bytes memory doc = bytes(documentId); bytes memory evt = bytes(eventType); bytes memory st = bytes(status); // Pre-calculate size: // 4 bytes len + doc bytes + // 4 bytes len + evt bytes + // 8 bytes timestamp + // 8 bytes nonce + // 4 bytes len + st bytes uint256 total = 4 + doc.length + 4 + evt.length + 8 + 8 + 4 + st.length; bytes memory out = new bytes(total); uint256 i = 0; // Copy helper: write u32 length big-endian _writeU32BE(out, i, uint32(doc.length)); i += 4; _copy(out, i, doc); i += doc.length; _writeU32BE(out, i, uint32(evt.length)); i += 4; _copy(out, i, evt); i += evt.length; _writeU64BE(out, i, timestampSec); i += 8; _writeU64BE(out, i, nonce); i += 8; _writeU32BE(out, i, uint32(st.length)); i += 4; _copy(out, i, st); i += st.length; return out; } function _writeU32BE(bytes memory out, uint256 offset, uint32 x) private pure { out[offset] = bytes1(uint8(x >> 24)); out[offset + 1] = bytes1(uint8(x >> 16)); out[offset + 2] = bytes1(uint8(x >> 8)); out[offset + 3] = bytes1(uint8(x)); } function _writeU64BE(bytes memory out, uint256 offset, uint64 x) private pure { out[offset] = bytes1(uint8(x >> 56)); out[offset + 1] = bytes1(uint8(x >> 48)); out[offset + 2] = bytes1(uint8(x >> 40)); out[offset + 3] = bytes1(uint8(x >> 32)); out[offset + 4] = bytes1(uint8(x >> 24)); out[offset + 5] = bytes1(uint8(x >> 16)); out[offset + 6] = bytes1(uint8(x >> 8)); out[offset + 7] = bytes1(uint8(x)); } function _copy(bytes memory out, uint256 offset, bytes memory src) private pure { for (uint256 j = 0; j < src.length; j++) { out[offset + j] = src[j]; } } }

What to pay attention to

  • _buildCanonicalMemoBytes is the “source of truth” for memo bytes.
  • The signature is not over the plain memoHash alone; it’s over the Ethereum-prefixed hash (toEthSignedMessageHash), matching common wallet signing behavior.
  • Length-prefixing avoids ambiguous concatenations.

Off-chain code: produce the exact same canonical bytes and signature

Now I replicated the canonical memo algorithm in JavaScript using ethers.js. The most common failure is that off-chain code accidentally uses a different UTF-8 encoding or a different integer endian format.

I used the same big-endian rules as the contract.

// package.json dependencies: // npm i ethers // // run with: // node memo-sign-and-verify.js import { ethers } from "ethers"; function u32be(n) { const b = Buffer.alloc(4); b.writeUInt32BE(n >>> 0, 0); return b; } function u64beBigInt(n) { const x = BigInt(n); const b = Buffer.alloc(8); // Write big-endian manually (Node doesn't have writeBigUInt64BE in older setups sometimes) for (let i = 7; i >= 0; i--) { b[i] = Number(x & 0xffn); // eslint-disable-next-line no-param-reassign x >> 8n; } // The above line didn't update x (due to const). Let's do it correctly: let y = BigInt(n); for (let i = 7; i >= 0; i--) { b[i] = Number(y & 0xffn); y >>= 8n; } return b; } function encodeStringWithU32LenBE(str) { const bytes = Buffer.from(str, "utf8"); return Buffer.concat([u32be(bytes.length), bytes]); } function buildCanonicalMemoBytes({ documentId, eventType, timestampSec, nonce, status }) { return Buffer.concat([ encodeStringWithU32LenBE(documentId), encodeStringWithU32LenBE(eventType), u64beBigInt(timestampSec), u64beBigInt(nonce), encodeStringWithU32LenBE(status), ]); } function toEthSignedMessageHash(memoHashHex) { // memoHashHex is 0x-prefixed 32-byte hex string const memoHash = ethers.getBytes(memoHashHex); const prefix = ethers.toUtf8Bytes("\x19Ethereum Signed Message:\n32"); return ethers.keccak256(ethers.concat([prefix, memoHash])); } async function main() { // 1) Use a deterministic private key for repeatable experiments. // Never do this in production; this is only for a demo. const privKey = "0x59c6995e998f97a5a0044966f094538e2b..."; // replace with a real 32-byte key const wallet = new ethers.Wallet(privKey); // 2) Example memo fields const memo = { documentId: "did:example:123", eventType: "TRANSMIT", timestampSec: 1710000000n, nonce: 42n, status: "OK", }; // 3) Build canonical bytes (must match Solidity exactly) const canonicalBytes = buildCanonicalMemoBytes(memo); // 4) Hash them with keccak256 const memoHash = ethers.keccak256(canonicalBytes); // 5) Create the exact same eth-signed hash the contract verifies const ethSignedHash = toEthSignedMessageHash(memoHash); // 6) Sign that hash const signingKeySig = wallet.signingKey.sign(ethSignedHash); const signature = ethers.Signature.from({ r: signingKeySig.r, s: signingKeySig.s, v: signingKeySig.v, }).serialized; console.log("Canonical bytes (hex):", "0x" + canonicalBytes.toString("hex")); console.log("memoHash:", memoHash); console.log("ethSignedHash:", ethSignedHash); console.log("signature:", signature); console.log("signer:", wallet.address); // 7) Recover to prove correctness off-chain const recovered = ethers.verifyMessage(ethers.getBytes(ethSignedHash), signature); // NOTE: verifyMessage expects a different input format in ethers; for a strict check, // you can use ethers.recoverAddress(ethSignedHash, signature). We'll do that: const recovered2 = ethers.recoverAddress(ethSignedHash, signature); console.log("recovered2:", recovered2); } main().catch((e) => { console.error(e); process.exit(1); });

What this script proves

  • The canonical bytes hash to memoHash.
  • The ethSignedHash matches the contract’s _toEthSignedMessageHash.
  • The signature recovers to the wallet address—meaning the contract should accept the receipt too, assuming the on-chain authorizedSigner matches.

Important: the contract expects a signature over toEthSignedMessageHash(memoHash), not over memoHash directly.


“Here’s what happens when you run this” (the bug I was eliminating)

In my earlier prototype, I used a memo string like:

  • "did:example:123 | TRANSMIT | 1710000000 | 42 | OK"

Off-chain I accidentally normalized whitespace with a replace(/\s+/g, " "), while the contract used the raw string coming from a different code path.

Result: same “human memo”, different bytes → different hash → different signature → failed provenance verification.

After switching to:

  • field-based encoding,
  • explicit big-endian integers,
  • and explicit string length prefixes,

the mismatch class disappeared. The contract and verifier now share the same deterministic definition of what a “memo” is at the byte level.


Deploy + call (quick ethers example)

Here’s a minimal deployment/call example skeleton (ABI omitted for brevity; this shows how you wire the call).

import { ethers } from "ethers"; import fs from "fs"; async function run() { const provider = new ethers.JsonRpcProvider("http://127.0.0.1:8545"); const signer = provider.getSigner(0); const abi = JSON.parse(fs.readFileSync("./ProofOfReceiptMemo.abi.json", "utf8")); const bytecode = fs.readFileSync("./ProofOfReceiptMemo.bin", "utf8"); // Deploy with signer as authorizedSigner for testing const factory = new ethers.ContractFactory(abi, bytecode, signer); const contract = await factory.deploy(await signer.getAddress()); await contract.waitForDeployment(); console.log("Deployed:", contract.target); // Use the same memo/signature produced earlier const memo = { documentId: "did:example:123", eventType: "TRANSMIT", timestampSec: 1710000000n, nonce: 42n, status: "OK", }; // Assume signature is computed by the earlier script logic const signature = "0x..."; // 65 bytes const tx = await contract.acceptReceipt( memo.documentId, memo.eventType, memo.timestampSec, memo.nonce, memo.status, signature ); const receipt = await tx.wait(); console.log("Gas used:", receipt.gasUsed.toString()); } run().catch(console.error);

The important part is that acceptReceipt(...) uses the contract’s _buildCanonicalMemoBytes to recreate memo bytes and validate the signature.


Closing thoughts

This weekend experiment taught me something I didn’t appreciate at first: smart contract security isn’t only about arithmetic and authorization—it’s also about byte-level agreement. By making receipt memos deterministic through strict canonicalization (length-prefixed UTF-8 strings + big-endian integers) and verifying signatures over the canonical memo bytes, I eliminated a subtle but damaging trust split between on-chain events and off-chain provenance verification.