Skip to Content
Smart ContractsSoft binding

Soft binding

A C2PA manifest is only useful if you can still find it. Soft binding is the part of the standard that answers “here is a file — where is its provenance?”, and the contract is Monolith’s implementation of it.

Hard binding vs soft binding

A hard binding ties a manifest to exact bytes. In Monolith’s manifests that’s the c2pa.hash.data assertion: a SHA-256 over the asset, checked byte for byte. It is precise, and it is brittle by design — re-encode the file, strip a frame, or recompress it, and the binding no longer matches. Worse, if the manifest is removed from the file altogether, there is nothing left to check.

A soft binding goes the other way. It is an identifier derived from the content and published in a registry, so that a bare file with no embedded manifest can still be traced back to one. The manifest can be stripped; the registry entry remains.

Monolith uses both. The hard binding is what validation_state reports on when you attach the asset bytes. The soft binding is what makes a stripped file recoverable.

The relevant specification is C2PA 2.2 — Decoupled soft binding resolution , the same URL the contract itself returns from getContractMetadata().

The assertion Monolith writes

Every manifest Monolith signs carries a c2pa.soft-binding assertion:

{ "label": "c2pa.soft-binding", "data": { "alg": "com.joinmonolith.sha256", "value": "S2ooekArWklc1o8EtozqRkyLUA9ktH8HfLGNCj/Zak8=" } }

value is base64 of the raw 32 fingerprint bytes — not the 0x-prefixed hex string you see everywhere else in the API. Converting between the two forms is the single most common mistake when integrating against soft bindings.

import { hexlify, getBytes, encodeBase64, decodeBase64 } from 'ethers' // 0x-hex → base64 (what goes in the assertion) const value = encodeBase64(getBytes(fingerprint)) // base64 → 0x-hex (what the contract expects) const fingerprint = hexlify(decodeBase64(value))

The algorithm identifier is com.joinmonolith.sha256 — a vendor-prefixed name, as the spec requires for algorithms outside its registry. The value underneath is a plain SHA-256 of the asset bytes, identical to the fingerprint used throughout the API.

The contract as a resolution store

The spec describes a soft binding resolver as a key-value store mapping a binding value to the manifests that reference it. The contract is exactly that, and it advertises itself as one. describe() returns a JSON document listing the resolution methods it supports:

[ { "resolutionMethod": "smartContract", "querySmartContract": { "smartContractAddress": "0xfe9dafc133ed3e9726d4b2c24b8cc3c3aadd8225", "byBindingFunctionName": "getManifestByBinding", "byBindingInputSchema": { }, "byBindingOutputSchema": { } } }, { "resolutionMethod": "eventLog", "queryEventLog": { "smartContractAddress": "0xfe9dafc133ed3e9726d4b2c24b8cc3c3aadd8225", "keyTopic": "SoftBindingRegistered(bytes32,string[],string)", "byBindingOutputSchema": { } } } ]

Two companion reads round out the discovery surface:

FunctionReturns
c2paSpecVersion()"2.2.0"
supportedAlgorithms()["sha256"]
getContractMetadata()name, version, description, and the spec URL

describe() currently advertises only the curated registry’s function and event. It does not mention the public registry. Use resolveByBindingDetailed rather than getManifestByBinding if you want a lookup that covers both.

Resolving a fingerprint

The direct method: hash the bytes you have, ask the contract, get back the manifest identifier and where to fetch it from.

import { readFile } from 'node:fs/promises' import { createHash } from 'node:crypto' import { JsonRpcProvider, Contract } from 'ethers' const RPC = 'https://rpc.stabilityprotocol.com/zgt/try-it-out' const { address, abi } = await fetch( 'https://api.joinmonolith.com/c2pa/contract' ).then(r => r.json()) const bytes = await readFile('found-image.png') const fingerprint = '0x' + createHash('sha256').update(bytes).digest('hex') const registry = new Contract( address, abi, new JsonRpcProvider(RPC, 101010) ) const [source, endpoints, manifestId, manifestHash, registeredBy, ts] = await registry.resolveByBindingDetailed(fingerprint) console.log(source === 1n ? 'curated' : 'public') console.log(manifestId) // e.g. '0f9a4c1e-…-b7d2' console.log(endpoints) // [ 'https://api.joinmonolith.com/api/c2pa' ]

An unregistered fingerprint reverts with ManifestNotFound.

For curated entries endpoints resolves to https://api.joinmonolith.com/api/c2pa, the Soft Binding Resolution API that serves the manifest itself.

Both halves are open to anyone. The contract read needs no key, account, or signature; the HTTP resolver at that endpoint is mounted ahead of the subsystem-token gate precisely so a third-party verifier with no credentials can use it. To go from a fingerprint to manifest bytes over HTTP, call GET /api/c2pa/matches/byBinding and then GET /api/c2pa/manifests/{manifestId}. Signing a new manifest is the one authenticated route on that surface.

Resolving from the event log

The second advertised method reads the log rather than storage. It is the right tool when you want to follow registrations over time — building an index, watching for new entries — rather than answering one lookup.

The two registries emit deliberately distinct events, so permissionless entries never merge into the curated stream:

EventRegistry
SoftBindingRegistered(bytes32,string[],string)Curated
PublicSoftBindingRegistered(bytes32,address,string[],string)Public
const filter = registry.filters.PublicSoftBindingRegistered() const latest = await provider.getBlockNumber() // Query in chunks — the RPC caps how many blocks one filter may span for (let from = latest - 50_000; from <= latest; from += 10_000) { const to = Math.min(from + 9_999, latest) for (const log of await registry.queryFilter(filter, from, to)) { console.log(log.args.fingerprint, log.args.registeredBy) } }

Keep log queries to about 10,000 blocks per request. Wider spans are rejected by the RPC, and the failure surfaces as a generic request error rather than a range-specific one.

The curated SoftBindingRegistered event has no registeredBy field — for those entries, use the registeredBy returned by resolveByBindingDetailed.

Where to go next

Last updated on