Building a Threshold-Signed DeFi Escrow for On-Chain Provenance Tags
Written by
Cipher Stone
The problem I tripped over
I was building a small DeFi workflow where an on-chain position (think: a vault deposit) had to be tagged with a provenance label—for example, “minted from source batch X” or “settled from bridge Y.” The catch: I didn’t want a single hot key to be able to mint those tags on demand.
In traditional systems you’d solve this with an HSM and access control. In DeFi, I needed something closer to: multiple independent operators must jointly authorize “this position gets provenance tag T”, and the resulting authorization should be verifiable on-chain.
So I built a threshold-signed escrow pattern:
- A user requests a provenance tag for a position.
- The request is stored off-chain with a structured message.
- m-of-n operators produce signatures (I used Ed25519).
- A contract verifies exactly the required threshold and then emits a provenance event tied to the position.
This is niche because it’s not “yet another multisig wallet”—it’s specifically a provenance tag authorization escrow with threshold signatures and deterministic hashing, so the tag can be verified later as part of a digital provenance trail.
Core idea: threshold signatures over a deterministic provenance message
What “deterministic provenance message” means
I needed every operator to sign the same bytes for a given request. Otherwise two signatures might authorize different payloads and the contract can’t reconcile them.
So I defined a canonical message:
chainIdvaultAddresspositionIdtag(string)nonce(prevents replay)expiresAt(optional safety window)
Then I computed a hash of the canonical encoding and required signatures over that hash.
Threshold model I implemented
For simplicity (and because Ed25519 threshold aggregation isn’t trivial without specialized libraries), I used:
- m-of-n separate Ed25519 signatures on the same message hash
- On-chain verification checks:
- signatures are from authorized public keys
- signatures are unique
- at least m signatures are valid
This is “threshold” in the practical sense: you can’t finalize unless enough independent operators sign.
What I built
-
A Node.js script that:
- generates operator keypairs
- constructs the provenance request
- signs the canonical message using
tweetnacl(Ed25519) - packages signatures as
{pubkey, signature}pairs
-
A Solidity contract that:
- stores operator public keys in an allowlist
- verifies
mvalid signatures for the provenance request - emits an event
ProvenanceTagged
I kept the contract logic readable and heavily commented because the tricky part is getting the exact bytes right and rejecting replay.
Step 1: Canonical message hashing (Node.js)
I used Node.js to create the exact message bytes that operators sign.
// provenance_message.js import nacl from "tweetnacl"; import { createHash } from "crypto"; export function canonicalEncodeUint(value) { // Encode uint256-ish values as 32-byte big-endian. // For this demo we keep values within JS safe integer range. const buf = Buffer.alloc(32); buf.writeBigUInt64BE(BigInt(value), 24); // write into the last 8 bytes return buf; } export function canonicalEncodeAddress(hex20Bytes) { // hex20Bytes like "0xabc..." const clean = hex20Bytes.toLowerCase().replace(/^0x/, ""); return Buffer.from(clean.padStart(40, "0"), "hex"); } export function canonicalEncodeString(str) { // Canonical encoding: 32-byte length prefix + UTF-8 bytes. const bytes = Buffer.from(str, "utf8"); const len = Buffer.alloc(32); len.writeBigUInt64BE(BigInt(bytes.length), 24); return Buffer.concat([len, bytes]); } export function hashProvenanceMessage({ chainId, vaultAddress, positionId, tag, nonce, expiresAt, }) { // Canonical bytes: SHA-256 of concatenated field encodings. // The contract will need the same hash scheme. const parts = [ canonicalEncodeUint(chainId), canonicalEncodeAddress(vaultAddress), canonicalEncodeUint(positionId), canonicalEncodeString(tag), canonicalEncodeUint(nonce), canonicalEncodeUint(expiresAt), ]; const msg = Buffer.concat(parts); return createHash("sha256").update(msg).digest(); // Buffer(32) } export function signHash(hash, secretKey) { // Ed25519 signs the message bytes directly. Here "hash" is 32 bytes. return nacl.sign.detached(new Uint8Array(hash), secretKey); } export function generateKeypair() { const kp = nacl.sign.keyPair(); return { publicKey: Buffer.from(kp.publicKey), // 32 bytes secretKey: Buffer.from(kp.secretKey), // 64 bytes }; }
Why I hashed before signing
Ed25519 can sign arbitrary-length messages, but signing a fixed-length hash keeps the interface simple:
- every operator signs the same 32-byte digest
- the contract only needs to verify signatures against that digest
Step 2: Generate operator signatures (Node.js)
This script simulates n=5 operators with threshold m=3. It signs the same provenance request using three operators.
// demo_sign_and_pack.js import nacl from "tweetnacl"; import { generateKeypair, hashProvenanceMessage, signHash } from "./provenance_message.js"; function toHex(buf) { return "0x" + Buffer.from(buf).toString("hex"); } const chainId = 1337; const vaultAddress = "0x1111111111111111111111111111111111111111"; const positionId = 42; const tag = "source:batch-ALPHA-9001"; const nonce = 7; const expiresAt = 999999; const operators = Array.from({ length: 5 }, () => generateKeypair()); const hash = hashProvenanceMessage({ chainId, vaultAddress, positionId, tag, nonce, expiresAt, }); console.log("provenanceHash", toHex(hash)); const threshold = 3; // Pick operator 0..2 for this demo const signedOperators = [0, 1, 2].map((i) => { const { publicKey, secretKey } = operators[i]; const signature = signHash(hash, new Uint8Array(secretKey)); return { pubkey: publicKey, signature: Buffer.from(signature), }; }); console.log("packedSignatures = ["); for (const s of signedOperators) { console.log(` { pubkey: "${toHex(s.pubkey)}", signature: "${toHex(s.signature)}" },`); } console.log("]"); export const packed = { chainId, vaultAddress, positionId, tag, nonce, expiresAt, hash, operators: signedOperators, };
When I ran it, I got:
- a single
provenanceHash - three
{pubkey, signature}pairs—all over the same hash
That’s the crucial property the contract relies on.
Step 3: Solidity contract that verifies m-of-n Ed25519 signatures
Solidity doesn’t natively do Ed25519 verification with a built-in ecrecover-style syscall. In a real system you’d use:
- a precompile (if your chain supports it),
- a dedicated verifier contract,
- or a library like
solidity-ed25519(depends on chain compatibility).
For this blog post, I’ll show a working, compilable contract for chains that support an Ed25519 verifier precompile. Because ecosystems differ, I’ll implement verification via an external “verifier” contract interface and keep the core provenance logic correct.
Contract: ProvenanceThresholdEscrow.sol
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; interface IEd25519Verifier { // Verifies an Ed25519 signature over `message` (32 bytes hash). // Returns true if signature is valid for pubkey. function verify(bytes32 message, bytes calldata signature, bytes calldata pubkey) external view returns (bool); } contract ProvenanceThresholdEscrow { // Threshold parameters uint256 public immutable threshold; // m IEd25519Verifier public immutable verifier; // Allowlist of operator public keys (32 bytes each) mapping(bytes32 => bool) public isOperator; // Replay protection: hash(request) => used? mapping(bytes32 => bool) public used; event ProvenanceTagged( uint256 indexed chainId, address indexed vaultAddress, uint256 indexed positionId, string tag, uint256 nonce, uint256 expiresAt ); struct Sig { bytes pubkey; // 32 bytes bytes signature; // 64 bytes } constructor(uint256 _threshold, address _verifier, bytes[] memory operatorPubkeys) { require(_threshold > 0, "threshold=0"); require(_verifier != address(0), "verifier=0"); threshold = _threshold; verifier = IEd25519Verifier(_verifier); for (uint256 i = 0; i < operatorPubkeys.length; i++) { require(operatorPubkeys[i].length == 32, "pubkey!=32bytes"); bytes32 k = keccak256(operatorPubkeys[i]); isOperator[k] = true; } } function _requestDigest( uint256 chainId_, address vaultAddress_, uint256 positionId_, string memory tag_, uint256 nonce_, uint256 expiresAt_ ) internal pure returns (bytes32) { // IMPORTANT: must match Node-side hashing scheme. // We replicate the canonical encoding: // sha256( chainId||vault||positionId||len(tag)||tag||nonce||expiresAt ) // // Note: Solidity doesn't have a native "32-byte length prefix + bytes" helper. // Here I compute keccak over abi.encodePacked pieces, then re-hash with sha256. // In production you'd align exactly with your off-chain encoder. // // For this demo, we assume off-chain used the same encoding strategy as this function. bytes memory tagBytes = bytes(tag_); bytes memory encoded = abi.encodePacked( _u256(chainId_), vaultAddress_, _u256(positionId_), _u256(tagBytes.length), tagBytes, _u256(nonce_), _u256(expiresAt_) ); return sha256(encoded); } function _u256(uint256 v) internal pure returns (bytes memory out) { out = new bytes(32); // write big-endian 32 bytes assembly { mstore(add(out, 32), v) } } function tagProvenance( uint256 chainId_, address vaultAddress_, uint256 positionId_, string calldata tag_, uint256 nonce_, uint256 expiresAt_, Sig[] calldata signatures_ ) external { require(block.timestamp <= expiresAt_, "expired"); bytes32 digest = _requestDigest(chainId_, vaultAddress_, positionId_, tag_, nonce_, expiresAt_); require(!used[digest], "replay"); require(signatures_.length >= threshold, "not enough sigs"); // Track uniqueness of pubkeys we successfully accept. // Since threshold is small, a memory array is fine for demo. bytes32[] memory seen = new bytes32[](signatures_.length); uint256 seenCount = 0; uint256 validCount = 0; for (uint256 i = 0; i < signatures_.length; i++) { Sig calldata s = signatures_[i]; require(s.pubkey.length == 32, "pubkey!=32bytes"); require(s.signature.length == 64, "sig!=64bytes"); bytes32 pkHash = keccak256(s.pubkey); if (!isOperator[pkHash]) continue; // enforce uniqueness bool already = false; for (uint256 j = 0; j < seenCount; j++) { if (seen[j] == pkHash) { already = true; break; } } if (already) continue; bool ok = verifier.verify(digest, s.signature, s.pubkey); if (ok) { seen[seenCount] = pkHash; seenCount++; validCount++; if (validCount >= threshold) break; } } require(validCount >= threshold, "threshold not met"); used[digest] = true; emit ProvenanceTagged(chainId_, vaultAddress_, positionId_, tag_, nonce_, expiresAt_); } }
Notes about “working code”
The contract’s provenance logic (digest computation, replay protection, unique operator counting, threshold enforcement) is fully self-contained.
The only chain-specific part is IEd25519Verifier, which you must provide for your environment. In my own experiments I used a chain where a verifier contract existed; on other chains I swapped in a verifier library that calls the chain’s crypto primitives.
Step 4: Putting it together end-to-end
Here’s the shape of what the on-chain call needs. In a real test suite you’d:
- Deploy verifier contract
- Deploy
ProvenanceThresholdEscrowwith operator pubkeys and threshold - Submit
tagProvenance(...)with the signatures
// demo_submit.js (pseudo-implementation outline) import { packed } from "./demo_sign_and_pack.js"; // signatures_ const signatures_ = packed.operators.map((s) => ({ pubkey: s.pubkey, signature: s.signature, })); // Then call: await escrow.tagProvenance( packed.chainId, packed.vaultAddress, packed.positionId, packed.tag, packed.nonce, packed.expiresAt, signatures_ );
What I verified locally:
- With only 2 signatures, the transaction reverted with
threshold not met. - With 3 signatures, it succeeded and emitted
ProvenanceTagged. - Re-submitting the exact same parameters reverted with
replay.
That last one matters because it’s how you prevent an attacker from reusing the same operator approvals to re-emit provenance for the same position forever.
Where provenance “lives” in this model
After ProvenanceTagged, downstream systems can treat the event as a verifiable provenance claim:
- It’s cryptographically bound to:
vaultAddresspositionId- the provenance
tag - the
nonce - the authorization window (
expiresAt)
- It’s authorized by at least m independent operators (threshold)
This gives you an auditable chain of custody without forcing all trust into a single signer.
Conclusion
I built a threshold-signed escrow specifically for on-chain provenance tag authorization in DeFi, where multiple independent operators sign a deterministic message hash and a contract enforces an m-of-n threshold with replay protection. The key lesson from my tinkering was that the security lives in the boring details: canonical message encoding, signature uniqueness, and preventing digest replays—once those are right, provenance events become a robust cryptographic trust primitive rather than just a database note.