Distributed Systems & CryptographyOctober 27, 2026

Building a Threshold-Signed DeFi Escrow for On-Chain Provenance Tags

C

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:

  • chainId
  • vaultAddress
  • positionId
  • tag (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

  1. 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
  2. A Solidity contract that:

    • stores operator public keys in an allowlist
    • verifies m valid 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:

  1. Deploy verifier contract
  2. Deploy ProvenanceThresholdEscrow with operator pubkeys and threshold
  3. 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:
    • vaultAddress
    • positionId
    • 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.