ClaimID formulas

Purpose: claim_id is the global, collision-resistant, route-agnostic identifier for a single monetizable fact. It guarantees one fact → one claim → one cashflow across all EDMA routes and mirrors.

A. Invariants (what the id must guarantee)

  • Uniqueness by evidence and identity: Same physical event with a different dossier (files, corrections) must not collide.

  • Determinism: Any honest client/server computing the id from the same inputs yields the same 32-byte value.

  • Domain separation: IDs from different lanes/schemas cannot collide.

  • Append-only lineage: Corrections/replacements mint a new claim_id and link replaces: old_claim_id. No mutation.

  • Cross-route safety: The same evidence cannot be used in Trade and Tokens at once.

B. Canonical definition (bytes and function)

We derive claim_id by hashing a domain-tagged, length-delimited tuple of typed fields.

claim_id = keccak256(
  abi.encodePacked(
    DOMAIN_TAG,        // bytes8 = "EDMA/CLM"
    VERSION_TAG,       // bytes4 = 0x00000001
    chainId_u256,      // EVM chainId (uint256, ABI encoding)
    laneTag,           // bytes4: "TRAD" | "TOKN"
    schemaHash,        // bytes32 = keccak256(utf8(schema_id)), e.g. "TRADE.ON_BOARD.v1"
    keyHash,           // bytes32 = keccak256( key_blob )  (see Section D)
    povHash            // bytes32 = sha256(canonical_json_bytes)  (Section 12)
  )
)

Why include povHash? Two dossiers with the same identity fields but different evidence (e.g., corrected BL/PSI) produce different claim_ids, preventing “same BL, different PDFs” abuse.

All strings are UTF-8. All hashes are 32 bytes. We rely on Solidity’s abi.encodePacked typing for length-delimited packing; do not hand-concatenate raw strings.

C. Normalization rules (before computing keyHash)

  • Schemas (Sec. 12.3): decide what fields compose the identity; these rules decide how to normalize them prior to hashing:

  • string_id fields (BL numbers, serials, ports, project IDs): Trim ASCII whitespace; collapse internal spaces; uppercase A–Z; NFC normalize; forbid control chars.

  • Reject if not matching schema’s pattern: (^[A-Z0-9._-]+$ unless tighter).

  • Timestamps: RFC-3339 UTC "YYYY-MM-DDTHH:MM:SSZ" (no milliseconds unless schema mandates).

  • Decimals: numbers encoded as strings with schema precision (e.g., "396000" Wh; "39.600" if precision=3). No leading +, no exponent.

  • Arrays: SET → sort ascending (binary compare) before hashing (e.g., container_ids). SEQ → preserve order (e.g., temperature log intervals).

  • Programs/regions/enums: canonical enumerations defined by schema.

  • If any normalization fails: clients must surface an error and the Gate will return E_FORMAT_INVALID or E_SET_NOT_SORTED.

D. keyHash construction (per schema)

We hash a key blob—the minimal identity for the event/unit—after normalization. Each schema specifies its fields.

General form
keyHash = keccak256( abi.encode(
  KEY_DOMAIN,    // bytes8 e.g. "CLAIMKEY"
  schema_id,     // utf8 string exactly as used to compute schemaHash
  key_fields...  // normalized, typed values
))

D.1 Trade — On-Board & Sealed (TRADE.ON_BOARD.v1)

Identity = BL number, seal number, and the set of container IDs.
key_fields = (
  bl_number: string_id,
  seal_number: string_id,
  sha256( join_sorted(container_ids_SET, "\n") )  // file-like digest of list
)

D.2 Trade — Customs Cleared (TRADE.CUSTOMS.v1)

Identity = customs entry no., country code, importer tax id.

key_fields = ( customs_entry_number, country_code, importer_tax_id )

D.3 Trade — Arrival & QA (TRADE.ARRIVAL_QA.v1)

Identity = ASN, DC id, time bucket (to the hour, unless schema says otherwise).

key_fields = ( asn_number, dc_id, window_bucket(received_at, 3600s) )

D.4 Tokens — Energy (1 MWh) (TOKENS.ENERGY.MWH.v1)

Identity = device id, start_ts, end_ts, quantity_Wh.

key_fields = ( device_id, start_ts, end_ts, quantity_Wh )

D.5 Tokens — Carbon (VCM program serial) (TOKENS.CARBON.VCM.v1)

Identity = program, project_id, vintage, unit_serial.

key_fields = ( program, project_id, vintage, unit_serial )

D.6 Registry Mirror (REG.MIRROR.v1) (mapping, not a second claim)

Mirrors bind an external serial to an existing claim_id (they do not mint a new one). For the mirror record we store:

mirrorId = keccak256( abi.encodePacked("EDMA/MIR", schemaHash, program, serial_hash) )
and link: mirrorId → claim_id.

E. Splits, merges, and replacements (lineage rules)

  • Split shipments (Trade): each sub-lot mints an EMT with its own claim_id. key_fields := ( bl_number, seal_number, sha256(join_sorted(subset_container_ids)) ) Note: include the subset; do not reuse the full list hash, or One-Claim will block the second child.

  • Merge (downstream gates): a later gate may consume multiple child claim_ids; it lists them in the dossier; the downstream claim_id is derived from its own identity (e.g., ASN/DC window). Children are marked consumed.

  • Replacement (revocation): a corrective dossier computes a new claim_id (new povHash and often new key fields) and stores replaces: old_claim_id. Proof pages show both entries; we never mutate the old claim.

F. Collision resistance & safety notes

  • Including schemaHash + povHash: provides domain separation and second-preimage resilience.

  • Using abi.encode/encodePacked: avoids manual delimiter mistakes; do not raw-concat strings.

  • For sets: hashing the sorted list (as a file digest) prevents order-based collisions.

  • Normalization: eliminates accidental collisions from casing/whitespace/locale.

G. Reference implementations

Solidity (library excerpt)

library ClaimId {
    bytes8  constant DOMAIN = 0x45444d412f434c4d; // "EDMA/CLM"
    bytes4  constant V1     = 0x00000001;

    function schemaHash(string memory schemaId) internal pure returns (bytes32) {
        return keccak256(bytes(schemaId));
    }

    function keyHash_ONBOARD_v1(
        string memory bl,
        string memory seal,
        bytes32 containersSha256
    ) internal pure returns (bytes32) {
        return keccak256(abi.encode("CLAIMKEY", "TRADE.ON_BOARD.v1", bl, seal, containersSha256));
    }

    function claimId(
        uint256 chainId,
        bytes4 laneTag,          // "TRAD" or "TOKN"
        bytes32 schemaHash_,
        bytes32 keyHash_,
        bytes32 povHash
    ) internal pure returns (bytes32) {
        return keccak256(abi.encodePacked(DOMAIN, V1, chainId, laneTag, schemaHash_, keyHash_, povHash));
    }
}

TypeScript (deterministic encoder)

import { keccak256, solidityPackedKeccak256, sha256 } from "ethers";
const DOMAIN = "0x45444d412f434c4d"; // "EDMA/CLM"
const V1     = "0x00000001";

export function schemaHash(id: string): string {
  return keccak256(new TextEncoder().encode(id));
}

export function sha256Hex(bytes: Uint8Array): string {
  return "0x" + Buffer.from(crypto.subtle.digestSync("SHA-256", bytes)).toString("hex");
}

export function keyHashOnBoardV1(bl: string, seal: string, containersSha256: string): string {
  return keccak256(
    new TextEncoder().encode(
      JSON.stringify(["CLAIMKEY","TRADE.ON_BOARD.v1", bl, seal, containersSha256])
    )
  );
}

// canonical claim id
export function claimId(
  chainId: bigint,
  laneTag: "TRAD" | "TOKN",
  schemaHashHex: string,
  keyHashHex: string,
  povHashHex: string
): string {
  return solidityPackedKeccak256(
    ["bytes8","bytes4","uint256","bytes4","bytes32","bytes32","bytes32"],
    [DOMAIN, V1, chainId, ethers.encodeBytes32String(laneTag).slice(0,10), schemaHashHex, keyHashHex, povHashHex]
  );
}

(In production, use the SDK’s canonicalizer and provided helpers; the snippet shows types and packing, not full error handling.)

H. Worked examples (process, not full hex)

H.1 Trade — On-Board (TRADE.ON_BOARD.v1)

  • Normalize

BL = "OOLU1234567890" → "OOLU1234567890"
SEAL = "seal9981" → "SEAL9981"
container_ids = ["CMAU000002","CMAU000001"] → sort → ["CMAU000001","CMAU000002"]
containersSha256 = sha256("CMAU000001\nCMAU000002")
  • keyHash = keccak256("CLAIMKEY","TRADE.ON_BOARD.v1", BL, SEAL, containersSha256)

  • schemaHash = keccak256("TRADE.ON_BOARD.v1")

  • povHash = sha256(canonical_json_bytes) (includes file digests for bl.pdf, seal.jpg, etc.)

  • claim_id = keccak256( DOMAIN, V1, chainId, "TRAD", schemaHash, keyHash, povHash )

H.2 Tokens — Carbon (TOKENS.CARBON.VCM.v1)

  • Normalize: program="VERRA", project_id="VCS-1234", vintage="2024", unit_serial="…-00012345"

  • keyHash = keccak256("CLAIMKEY","TOKENS.CARBON.VCM.v1", program, project_id, vintage, unit_serial)

  • schemaHash = keccak256("TOKENS.CARBON.VCM.v1")

  • povHash = sha256(canonical_json_bytes)

  • claim_id = keccak256( DOMAIN, V1, chainId, "TOKN", schemaHash, keyHash, povHash )

I. Validation & tests (you should pass these)

  • Determinism: recompute claim_id on different clients → identical results.

  • Normalization: casing/whitespace variants yield same id; local-time timestamps are rejected.

  • Set/sequence: changing order of container_ids (SET) doesn’t change id; changing order of a SEQ field must.

  • Evidence-sensitive: same key fields but different povHash → different claim_id.

  • Append-only: replacement dossier yields new claim_id; Explorer shows replaces linkage.

  • Cross-route: attempt to reuse the same key fields in Trade & Tokens → different lanes; One-Claim still blocks duplicates within each lane.

Plain recap

claim_id = keccak256( DOMAIN || VERSION || chainId || laneTag || schemaHash || keyHash || povHash ). keyHash captures the identity (BL/seal/containers, serials, device windows); povHash captures the evidence (canonical JSON + file digests). With domain and version tags, typed ABI encoding, normalization, and One-Claim atomic reserve→finalize, EDMA keeps the promise: one fact → one claim → one cashflow, everywhere.

Last updated