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.
ethers
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:
| Function | Returns |
|---|---|
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.
ethers
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:
| Event | Registry |
|---|---|
SoftBindingRegistered(bytes32,string[],string) | Curated |
PublicSoftBindingRegistered(bytes32,address,string[],string) | Public |
ethers
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
- Public records — register your own binding
- Interface — every function, event, and revert
- Core concepts — manifests, reports, and sidecars
- Soft binding API — the HTTP resolver these records point at