Smart Contract I Built For Verifiable Commitments Via Ecdsa-Signed Storage Receipts
Written by
Cipher Stone
The problem I wanted to solve
I got tired of the same pattern repeating in smart-contract prototypes: off-chain code computes something, then on-chain code “blindly” trusts that computation because only the final value is recorded on-chain. That’s fine for simple apps, but for digital provenance and advanced cryptographic trust models, I wanted a more concrete guarantee:
Every on-chain state change should be backed by a cryptographic receipt produced off-chain—an artifact I can independently verify later.
Specifically, I built a niche mechanism I’ve never seen “standardized” anywhere: ECDSA-signed storage receipts where an off-chain agent signs a canonical hash of a set of key/value updates, and the smart contract verifies that signature before accepting the updates.
This is not about cryptocurrency balances. It’s about making state transitions auditable and independently verifiable—especially useful for provenance pipelines.
What I built (high level)
The system has two pieces:
-
Off-chain “receipt signer”
Takes a batch of updates (e.g.,{assetId -> newOwner}), canonicalizes them, hashes them, and signs that hash with a private key (ECDSA). -
On-chain smart contract
Verifies the signature, and only then stores the updates and records the receipt hash.
Later, anyone can prove:
- what exact updates were claimed,
- which authorized signer attested to them,
- and that the contract stored only those attested values.
Key idea: canonical hashing of updates
Smart contracts don’t like ambiguity. If the same batch can serialize in multiple ways, signatures become unreliable.
So I used a strict encoding rule:
- Each update is
(key, value)as UTF-8 strings (in a real system you’d use bytes or fixed-size types). - For hashing, I build an array of
keccak256(abi.encodePacked(key, value)). - I sort those per update to make ordering irrelevant.
- Then I hash the sorted list into a single “batch hash”.
The off-chain code must produce the exact same batch hash as the on-chain code.
Smart contract: ReceiptBackedStorage.sol
Below is a minimal but fully working example using Solidity 0.8.20 and OpenZeppelin’s ECDSA helpers.
What the contract does
setSigner(address)sets who is allowed to sign receipts.applyBatch(...):- recomputes the batch hash from inputs,
- verifies the ECDSA signature,
- ensures replay protection via a
nonce, - writes each key/value update,
- and records
receiptHash => true.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; contract ReceiptBackedStorage { using ECDSA for bytes32; address public signer; uint256 public nextNonce; // Simple key/value storage for demonstration. mapping(bytes32 => string) public storedValue; // Receipt hashes that have been used (replay protection). mapping(bytes32 => bool) public usedReceipt; event SignerUpdated(address indexed oldSigner, address indexed newSigner); event BatchApplied( uint256 indexed nonce, bytes32 indexed receiptHash, uint256 updatesCount ); constructor(address initialSigner) { require(initialSigner != address(0), "signer is zero"); signer = initialSigner; nextNonce = 1; } function setSigner(address newSigner) external { // For demo simplicity: allow only current signer to rotate signer. require(msg.sender == signer, "not authorized"); require(newSigner != address(0), "newSigner is zero"); emit SignerUpdated(signer, newSigner); signer = newSigner; } /// @notice Apply a batch of updates that has an ECDSA-signed receipt. /// @dev keys and values must align by index. function applyBatch( uint256 nonce, string[] calldata keys, string[] calldata values, bytes calldata signature ) external { require(nonce == nextNonce, "bad nonce"); require(keys.length == values.length, "length mismatch"); require(keys.length > 0, "empty batch"); // 1) Compute batch hash deterministically. bytes32 batchHash = computeBatchHash(keys, values, nonce); // 2) Receipt hash binds signer intent + contract domain. // EIP-191 style domain separation is handled by ECDSA.toEthSignedMessageHash. bytes32 receiptHash = batchHash; // 3) Replay protection. require(!usedReceipt[receiptHash], "receipt already used"); // 4) Verify signature over receiptHash. bytes32 ethHash = receiptHash.toEthSignedMessageHash(); address recovered = ethHash.recover(signature); require(recovered == signer, "invalid signature"); usedReceipt[receiptHash] = true; nextNonce = nonce + 1; // 5) Apply updates. for (uint256 i = 0; i < keys.length; i++) { bytes32 k = keccak256(bytes(keys[i])); storedValue[k] = values[i]; } emit BatchApplied(nonce, receiptHash, keys.length); } /// @dev Computes batch hash with order-insensitivity by hashing and sorting update items. function computeBatchHash( string[] calldata keys, string[] calldata values, uint256 nonce ) public pure returns (bytes32) { require(keys.length == values.length, "length mismatch"); bytes32[] memory itemHashes = new bytes32[](keys.length); for (uint256 i = 0; i < keys.length; i++) { // Hash each (key,value) pair. bytes32 item = keccak256(abi.encodePacked(keys[i], values[i])); itemHashes[i] = item; } // Sort item hashes to make the final batch hash independent of input ordering. // Solidity doesn't have a built-in sort, so we do a simple in-place bubble sort // (fine for small demo batches; replace with a gas-optimized approach for production). for (uint256 a = 0; a < itemHashes.length; a++) { for (uint256 b = a + 1; b < itemHashes.length; b++) { if (itemHashes[b] < itemHashes[a]) { bytes32 tmp = itemHashes[a]; itemHashes[a] = itemHashes[b]; itemHashes[b] = tmp; } } } // Bind nonce to prevent replays with the same updates. // Then hash the ordered list of item hashes. bytes32 listHash = keccak256(abi.encodePacked(itemHashes)); return keccak256(abi.encodePacked(nonce, listHash)); } }
Notes on the nonce and replay protection
noncestarts at 1.applyBatchrequiresnonce == nextNonce.- That means the same signed receipt can’t be reused in a later call because
nextNoncewill have advanced.
This is crucial when using off-chain signatures: otherwise, a valid signature could be replayed indefinitely.
Off-chain signer: generating a compatible signature in Node.js
To sign the receipt hash the contract expects, the off-chain code must reproduce the same computeBatchHash.
In practice, I did this with ethers by:
- hashing each
(key,value)withkeccak256(abi.encodePacked(key,value)), - sorting those hashes,
- computing
listHash = keccak256(abi.encodePacked(sortedItemHashes)), - then
receiptHash = keccak256(nonce, listHash)(the contract’s exact concatenation), - and finally signing
toEthSignedMessageHash(receiptHash).
Full working script: signAndSubmit.mjs
import { ethers } from "ethers"; import fs from "fs"; const RPC_URL = "http://127.0.0.1:8545"; // match your local dev chain const PRIVATE_KEY = process.env.PRIVATE_KEY; // signer private key const CONTRACT_ADDRESS = process.env.CONTRACT_ADDRESS; const ABI = JSON.parse(fs.readFileSync("./ReceiptBackedStorage.abi.json", "utf8")); function keccak256Hex(hex) { return ethers.keccak256(hex); } function utf8Bytes(s) { return ethers.toUtf8Bytes(s); } // Mimic Solidity's abi.encodePacked(keys[i], values[i]) for strings: // abi.encodePacked(string,string) == packed UTF-8 bytes concatenation. function abiEncodePackedTwoStrings(a, b) { return ethers.concat([utf8Bytes(a), utf8Bytes(b)]); } function solidityKeccakPacked(typesAndValues) { // ethers.solidityPacked does abi.encodePacked with explicit types. // We'll mostly use it for the final nonce binding. return ethers.solidityPacked(typesAndValues.map(([t, _]) => t), typesAndValues.map(([, v]) => v)); } function computeBatchHashOffchain(keys, values, nonce) { if (keys.length !== values.length) throw new Error("length mismatch"); if (keys.length === 0) throw new Error("empty batch"); // Hash each (key,value) pair like Solidity: // bytes32 item = keccak256(abi.encodePacked(keys[i], values[i])); const itemHashes = keys.map((k, i) => { const packed = abiEncodePackedTwoStrings(k, values[i]); return ethers.keccak256(packed); }); // Sort hashes as Solidity would compare bytes32. itemHashes.sort((x, y) => (x < y ? -1 : x > y ? 1 : 0)); // listHash = keccak256(abi.encodePacked(itemHashes)); // Here we pack the bytes32 array tightly. const listPacked = ethers.concat(itemHashes.map(h => ethers.getBytes(h))); const listHash = ethers.keccak256(listPacked); // return keccak256(abi.encodePacked(nonce, listHash)); const nonceBytes = ethers.zeroPadValue(ethers.toBeHex(nonce), 32); const receiptPacked = ethers.concat([nonceBytes, ethers.getBytes(listHash)]); return ethers.keccak256(receiptPacked); } async function main() { if (!PRIVATE_KEY) throw new Error("Missing PRIVATE_KEY env var"); if (!CONTRACT_ADDRESS) throw new Error("Missing CONTRACT_ADDRESS env var"); const provider = new ethers.JsonRpcProvider(RPC_URL); const wallet = new ethers.Wallet(PRIVATE_KEY, provider); const contract = new ethers.Contract(CONTRACT_ADDRESS, ABI, wallet); // Read the required next nonce from the contract. const nonce = await contract.nextNonce(); // Example updates. // In a provenance pipeline, these could represent "who owns this artifact" // or "what fingerprint is associated with this batch". const keys = ["asset:readme.txt", "asset:dataset.csv"]; const values = ["owner:alice", "sha256:9f3a..."]; const receiptHash = computeBatchHashOffchain(keys, values, nonce); // Contract verifies signature over receiptHash.toEthSignedMessageHash() const ethSignedMessage = ethers.hashMessage(ethers.getBytes(receiptHash)); // ethers.hashMessage does the same EIP-191 prefixing as toEthSignedMessageHash. const signature = await wallet.signMessage(ethers.getBytes(receiptHash)); console.log("Signer address:", await wallet.getAddress()); console.log("Nonce:", nonce.toString()); console.log("receiptHash:", receiptHash); console.log("signature:", signature); // Submit the batch. const tx = await contract.applyBatch(nonce, keys, values, signature); console.log("tx:", tx.hash); await tx.wait(); // Verify state update. const k0 = ethers.keccak256(ethers.toUtf8Bytes(keys[0])); const stored0 = await contract.storedValue(k0); console.log("storedValue(asset:readme.txt):", stored0); const k1 = ethers.keccak256(ethers.toUtf8Bytes(keys[1])); const stored1 = await contract.storedValue(k1); console.log("storedValue(asset:dataset.csv):", stored1); } main().catch((e) => { console.error(e); process.exit(1); });
What happens when I ran it
When I executed the script against my local chain:
- The script pulled
nextNoncefrom the contract. - It computed
receiptHashlocally using the exact same rules as the contract. - It produced an ECDSA signature.
- The contract recomputed the hash again, recovered the signer from the signature, and applied updates.
The “sanity check moment” was when I intentionally changed one value (e.g., changed owner:alice to owner:bob). The contract immediately reverted with invalid signature because the recomputed hash no longer matched what was signed.
That’s the core win: the on-chain state never accepts anything not cryptographically attested.
Hardening details I added after the first prototype
I learned quickly that prototypes fail in boring places:
1) Sorting item hashes made signatures stable
Without sorting, the same set of updates in different order would require different signatures.
Sorting made the receipt represent a set rather than a sequence.
2) Nonce prevented receipt reuse
Even with valid signatures, replay attacks are trivial unless you bind signatures to call-specific context. Here, nonce does the job.
3) usedReceipt gives a second layer of safety
Even if the nonce logic is correct, recording receipt usage helps catch accidental replays in edge cases (like transaction reorg behavior patterns during testing).
Practical takeaway: provenance receipts as first-class state
This niche pattern—verifiable, off-chain receipts that authorize on-chain state transitions—turns smart contracts into more than “recorders.” They become enforcement points for cryptographic trust.
I used it to model provenance-like claims (“this content maps to that metadata under an authorized signer”), but the same structure works for:
- cross-system attestation,
- decentralized indexing receipts,
- or any pipeline where off-chain computation needs auditable, replay-resistant authorization.
In summary, I built a smart-contract-backed storage mechanism where batches of updates are accepted only when an ECDSA-signed, deterministically hashed receipt matches what the contract recomputes, and I validated it end-to-end with working Node.js code that intentionally fails when the signed inputs don’t match.