Skip to content

Commit df7f670

Browse files
committed
feat(anchor-providers): real implementations for OTS, WORM, Ethereum, Rekor
Each provider now does the actual external publish via either pure HTTP (OTS, Rekor) or a dynamic SDK import (@aws-sdk/client-s3 for WORM, ethers v6 for Ethereum). All four return structured failures instead of throwing; the previous `notImplemented: true` flag is gone now that the implementations are real. OpenTimestampsProvider — POSTs the SHA-256 digest to one or more public OTS calendars and stores each calendar's binary attestation as base64 inside the externalRef. New `requireAllCalendars` flag toggles between quorum modes. Verify re-POSTs and matches the stored attestation hash (weak verify; full Bitcoin-anchor verification still needs javascript-opentimestamps). WormSnapshotProvider — PutObject with Object Lock retention (GOVERNANCE or COMPLIANCE), recording the SHA-256 in object metadata so verify can do a constant-time integrity check via HeadObject without re-reading the body. Supports MinIO / LocalStack via the optional `endpoint` + forcePathStyle. EthereumProvider — sends the anchor hash as transaction calldata. Two modes: raw-calldata (self-transfer with 0x + hash) or contract-call against `anchor(bytes32)`. Verify recovers tx.data (or decodes the contract input) and asserts equality with the recomputed canonical anchor hash. ethers v6. RekorProvider — submits a hashedrekord v0.0.1 entry. The caller must provide `publicKeyPem` and a `signArtifact(bytes) => signature` callback that signs the raw hash bytes (NOT the merkleRoot string AgentKeyManager defaults to). This is the only honest way to make Rekor's hashedrekord verifier accept the entry. Verify GETs the entry by UUID and compares the stored hash with the recomputed digest; inclusion-proof verification against the signed tree head is left to sigstore.verify(bundle) for callers who need it. Tests rewritten to assert against the real success/failure paths with vi.stubGlobal('fetch', ...) mocks. 19 tests total, all green.
1 parent 0842a1d commit df7f670

6 files changed

Lines changed: 948 additions & 170 deletions

File tree

registry/curated/provenance/anchor-providers/README.md

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -304,35 +304,38 @@ Computes SHA-256 hex digest of the canonical anchor representation.
304304

305305
## Implementation Status
306306

307-
| Provider | Status | Notes |
308-
|----------|--------|-------|
309-
| WormSnapshotProvider | Stub | Requires `@aws-sdk/client-s3` implementation |
310-
| RekorProvider | Stub | Requires `sigstore` SDK implementation |
311-
| OpenTimestampsProvider | Stub | Requires `opentimestamps` implementation |
312-
| EthereumProvider | Stub | Requires `ethers` implementation |
313-
| SolanaProvider | Implemented | Requires `@solana/web3.js` (and a funded signer) |
307+
| Provider | Status | Runtime requirements |
308+
|----------|--------|----------------------|
309+
| OpenTimestampsProvider | Implemented (weak verify) | None — uses global `fetch` against public OTS calendars |
310+
| WormSnapshotProvider | Implemented | `@aws-sdk/client-s3` (dynamic import) + S3 bucket with Object Lock enabled |
311+
| EthereumProvider | Implemented | `ethers` v6 (dynamic import) + funded signer + RPC endpoint |
312+
| RekorProvider | Implemented (weak verify) | `publicKeyPem` + `signArtifact` callback (Ed25519 sig over raw hash bytes) |
313+
| SolanaProvider | Implemented | `@solana/web3.js` (dynamic import) + funded signer |
314314

315-
All providers **except SolanaProvider** currently return `{ success: false }` from `publish()` until their respective SDK integrations are implemented. SolanaProvider is functional when its optional peer dependencies are installed and the configured signer is funded.
315+
All five providers return `{ success: true, externalRef, metadata: {...} }` on a successful publish. Failed publishes return `{ success: false, error, metadata }` — never throw — so a composite anchor strategy can fall through cleanly.
316316

317-
### Detecting stubbed providers programmatically
317+
### Verification strength
318318

319-
Stub providers set `metadata.notImplemented: true` on the failure result so callers can distinguish "configured provider is not yet implemented" from "implemented provider failed at runtime". Use this when composing fallback strategies:
319+
| Provider | Verify behaviour |
320+
|----------|------------------|
321+
| OpenTimestampsProvider | Weak — re-POSTs the digest to a stored calendar URL and confirms the returned bytes hash to the stored attestation hash. For full Bitcoin-block proof, parse the stored base64 with `javascript-opentimestamps` and run its `verify()` against Bitcoin. |
322+
| WormSnapshotProvider | Strong — `HeadObjectCommand` confirms the object still exists, the Object Lock retention is still active, and the stored SHA-256 metadata matches the recomputed canonical anchor hash. |
323+
| EthereumProvider | Strong — fetches the transaction + receipt by hash, decodes calldata (or contract input via the `anchor(bytes32)` ABI), and asserts equality with the recomputed canonical anchor hash. |
324+
| RekorProvider | Weak — GETs the entry by UUID, decodes the body, and compares the stored hash with the recomputed canonical anchor hash. The inclusion proof against the signed tree head is not verified here; wire `sigstore.verify(bundle)` afterwards for compliance workflows. |
325+
| SolanaProvider | Strong — reads the on-chain account, decodes the manifest hash + content hash, and compares both against the recomputed canonical values. |
326+
327+
### Failure detection
328+
329+
Failed publishes set `metadata` with provider-specific diagnostics (bucket / region / chainId / serverUrl etc.). For SDK-missing failures the WORM provider sets `metadata.sdkMissing: true` so callers composing fallback strategies can distinguish "this provider can't run on this machine" from "transient runtime error":
320330

321331
```ts
322332
const result = await provider.publish(anchor);
323333
if (!result.success) {
324-
if (result.metadata?.notImplemented === true) {
325-
// Skip this provider; fall through to the next one in the
326-
// composite without surfacing the failure as a real error.
327-
continue;
328-
}
329-
// Real failure — log, alert, retry.
334+
if (result.metadata?.sdkMissing === true) continue; // fall through
330335
throw new Error(`Anchor publish failed: ${result.error}`);
331336
}
332337
```
333338

334-
The error message also names the missing dependency / configuration in plain text so operator-facing logs are immediately actionable.
335-
336339
## Testing
337340

338341
```bash

registry/curated/provenance/anchor-providers/src/providers/EthereumProvider.ts

Lines changed: 218 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,84 +2,263 @@
22
/**
33
* @file EthereumProvider.ts
44
* @description Ethereum on-chain anchor provider.
5-
* Publishes anchor Merkle roots as calldata in Ethereum transactions.
5+
*
6+
* `publish()` writes the SHA-256 of the canonical anchor as calldata on
7+
* an Ethereum transaction so the anchor becomes inseparable from a
8+
* specific block (and via the block, a specific point in time). Two
9+
* modes:
10+
*
11+
* 1. **Self-transfer with calldata** (default when `contractAddress`
12+
* is unset): the signer sends 0 wei to itself with the anchor hash
13+
* packed into the `data` field. Cheap, no contract needed.
14+
* 2. **Contract anchor** (when `contractAddress` is set): an ABI call
15+
* to `anchor(bytes32 merkleRoot)` so on-chain consumers can filter
16+
* / index anchor events.
17+
*
18+
* `verify()` fetches the transaction's receipt, recovers the calldata,
19+
* and confirms it matches the recomputed canonical anchor hash.
620
*
721
* Proof level: `publicly-timestamped`
8-
* Required peer dependency: `ethers`
22+
* Required peer dependency: `ethers` (loaded via dynamic import).
923
*
1024
* @module @framers/agentos-ext-anchor-providers
1125
*/
1226

1327
import type { AnchorProvider, AnchorRecord, AnchorProviderResult, ProofLevel } from '@framers/agentos';
1428
import type { BaseProviderConfig } from '../types.js';
1529
import { resolveBaseConfig } from '../types.js';
30+
import { hashCanonicalAnchor } from '../utils/serialization.js';
1631

1732
export interface EthereumProviderConfig extends BaseProviderConfig {
1833
/** JSON-RPC endpoint URL. */
1934
rpcUrl: string;
20-
/** Contract address for anchor storage (optional — can use raw calldata tx). */
35+
/** Contract address for anchor storage. Omit for raw-calldata mode. */
2136
contractAddress?: string;
22-
/** Private key hex for signing transactions. */
37+
/** Private key hex (0x-prefixed) for signing transactions. */
2338
signerPrivateKey?: string;
2439
/** Chain ID. Default: 1 (mainnet). */
2540
chainId?: number;
2641
/** Gas limit override. Default: auto-estimate. */
2742
gasLimit?: number;
43+
/** Number of confirmations to wait for. Default: 1. */
44+
confirmations?: number;
2845
}
2946

47+
/**
48+
* Minimal ABI fragment for an `anchor(bytes32)` contract. Callers can
49+
* deploy any contract with this method signature (e.g. logging the
50+
* merkleRoot to an event) and point `contractAddress` at it.
51+
*/
52+
const ANCHOR_CONTRACT_ABI = ['function anchor(bytes32 merkleRoot)'];
53+
3054
export class EthereumProvider implements AnchorProvider {
3155
readonly id = 'ethereum';
3256
readonly name = 'Ethereum On-Chain Anchor';
3357
readonly proofLevel: ProofLevel = 'publicly-timestamped';
3458

35-
private readonly config: EthereumProviderConfig;
59+
private readonly config: Required<Omit<EthereumProviderConfig, 'contractAddress' | 'signerPrivateKey' | 'gasLimit'>> & {
60+
contractAddress?: string;
61+
signerPrivateKey?: string;
62+
gasLimit?: number;
63+
};
3664
private readonly baseConfig: Required<BaseProviderConfig>;
3765

66+
/** Cached ethers Provider — reused across calls. */
67+
private cachedProvider: any = null;
68+
/** Cached ethers Wallet — only present when signing is configured. */
69+
private cachedWallet: any = null;
70+
3871
constructor(config: EthereumProviderConfig) {
3972
this.config = {
40-
chainId: 1,
41-
...config,
73+
rpcUrl: config.rpcUrl,
74+
chainId: config.chainId ?? 1,
75+
confirmations: config.confirmations ?? 1,
76+
timeoutMs: config.timeoutMs ?? 30_000,
77+
retries: config.retries ?? 3,
78+
retryDelayMs: config.retryDelayMs ?? 1_000,
79+
contractAddress: config.contractAddress,
80+
signerPrivateKey: config.signerPrivateKey,
81+
gasLimit: config.gasLimit,
4282
};
4383
this.baseConfig = resolveBaseConfig(config);
4484
}
4585

46-
async publish(_anchor: AnchorRecord): Promise<AnchorProviderResult> {
47-
// Stubbed pending ethers/viem wiring + funded signer. Required steps:
48-
// 1. Compute SHA-256 of canonical anchor: hashCanonicalAnchor(anchor)
49-
// 2. `new ethers.JsonRpcProvider(this.config.rpcUrl)`
50-
// 3. `new ethers.Wallet(this.config.signerPrivateKey, provider)`
51-
// 4. If `contractAddress` is set: ABI-encode `anchor(bytes32)` and
52-
// `wallet.sendTransaction` to it.
53-
// Otherwise: self-transfer with `data: '0x' + hash` as calldata.
54-
// 5. Await receipt (1 confirmation by default; configurable).
55-
// 6. Return `externalRef: 'eth:${chainId}:${txHash}'` plus
56-
// blockNumber/blockHash/gasUsed in metadata.
57-
// Verify path: fetch receipt by tx hash, decode calldata or read the
58-
// contract event log, compare against `hashCanonicalAnchor(anchor)`.
59-
return {
60-
providerId: this.id,
61-
success: false,
62-
error:
63-
'EthereumProvider not implemented. Pending: `ethers` (or `viem`) + funded signer + RPC endpoint. See provider source for the implementation outline.',
64-
metadata: {
65-
notImplemented: true,
66-
chainId: this.config.chainId,
67-
rpcUrl: this.config.rpcUrl,
68-
hasContract: Boolean(this.config.contractAddress),
69-
},
70-
};
86+
async publish(anchor: AnchorRecord): Promise<AnchorProviderResult> {
87+
if (!this.config.signerPrivateKey) {
88+
return {
89+
providerId: this.id,
90+
success: false,
91+
error: 'EthereumProvider.publish requires signerPrivateKey to send transactions.',
92+
metadata: { chainId: this.config.chainId },
93+
};
94+
}
95+
96+
let ethers: any;
97+
try {
98+
ethers = await this.loadEthers();
99+
} catch (e) {
100+
return {
101+
providerId: this.id,
102+
success: false,
103+
error: e instanceof Error ? e.message : String(e),
104+
metadata: { sdkMissing: true },
105+
};
106+
}
107+
108+
const digestHex = await hashCanonicalAnchor(anchor);
109+
const dataHex = `0x${digestHex}`;
110+
111+
try {
112+
const wallet = await this.getWallet(ethers);
113+
let txResponse: any;
114+
115+
if (this.config.contractAddress) {
116+
// Contract-call mode: invoke anchor(bytes32) so on-chain
117+
// listeners can index anchor commits by event.
118+
const contract = new ethers.Contract(this.config.contractAddress, ANCHOR_CONTRACT_ABI, wallet);
119+
const overrides: Record<string, unknown> = {};
120+
if (this.config.gasLimit !== undefined) overrides.gasLimit = this.config.gasLimit;
121+
txResponse = await contract.anchor(dataHex, overrides);
122+
} else {
123+
// Raw-calldata mode: self-transfer with the digest packed into
124+
// the data field. No contract required; the proof is just the
125+
// existence of a tx with this calldata in a finalised block.
126+
const tx: Record<string, unknown> = {
127+
to: await wallet.getAddress(),
128+
value: 0n,
129+
data: dataHex,
130+
};
131+
if (this.config.gasLimit !== undefined) tx.gasLimit = this.config.gasLimit;
132+
txResponse = await wallet.sendTransaction(tx);
133+
}
134+
135+
const receipt = await txResponse.wait(this.config.confirmations);
136+
if (!receipt) {
137+
return {
138+
providerId: this.id,
139+
success: false,
140+
error: 'Transaction sent but receipt was null',
141+
metadata: { txHash: txResponse.hash, chainId: this.config.chainId },
142+
};
143+
}
144+
145+
return {
146+
providerId: this.id,
147+
success: true,
148+
externalRef: `eth:${this.config.chainId}:${receipt.hash ?? txResponse.hash}`,
149+
publishedAt: new Date().toISOString(),
150+
metadata: {
151+
chainId: this.config.chainId,
152+
txHash: receipt.hash ?? txResponse.hash,
153+
blockNumber: receipt.blockNumber,
154+
blockHash: receipt.blockHash,
155+
gasUsed: receipt.gasUsed?.toString(),
156+
contractAddress: this.config.contractAddress,
157+
mode: this.config.contractAddress ? 'contract' : 'calldata',
158+
},
159+
};
160+
} catch (e: unknown) {
161+
const message = e instanceof Error ? e.message : String(e);
162+
return {
163+
providerId: this.id,
164+
success: false,
165+
error: `Ethereum transaction failed: ${message}`,
166+
metadata: { chainId: this.config.chainId },
167+
};
168+
}
71169
}
72170

171+
/**
172+
* Confirm the anchor's `eth:` externalRef points at a finalised tx
173+
* whose calldata (or contract input) matches the recomputed canonical
174+
* anchor digest. Does not re-execute the contract — just compares the
175+
* stored calldata against the expected hash.
176+
*/
73177
async verify(anchor: AnchorRecord): Promise<boolean> {
74-
// Pending implementation. Parse the `eth:${chainId}:${txHash}` ref,
75-
// fetch the receipt via the configured RPC, decode calldata (or read
76-
// the contract event), and compare against the recomputed canonical
77-
// anchor hash.
78-
if (!anchor.externalRef) return false;
79-
return false;
178+
if (!anchor.externalRef?.startsWith('eth:')) return false;
179+
let ethers: any;
180+
try {
181+
ethers = await this.loadEthers();
182+
} catch {
183+
return false;
184+
}
185+
186+
// Parse eth:${chainId}:${txHash}
187+
const parts = anchor.externalRef.split(':');
188+
if (parts.length < 3) return false;
189+
const txHash = parts.slice(2).join(':'); // tolerate 0x... that already has colons (defensive)
190+
191+
try {
192+
const provider = this.getProvider(ethers);
193+
const tx = await provider.getTransaction(txHash);
194+
if (!tx) return false;
195+
const receipt = await provider.getTransactionReceipt(txHash);
196+
if (!receipt || receipt.status === 0) return false;
197+
198+
const expectedHex = `0x${await hashCanonicalAnchor(anchor)}`;
199+
200+
if (this.config.contractAddress) {
201+
// For contract calls, the calldata is the ABI-encoded anchor()
202+
// function call. Decode and compare the bytes32 arg.
203+
const iface = new ethers.Interface(ANCHOR_CONTRACT_ABI);
204+
try {
205+
const parsed = iface.parseTransaction({ data: tx.data, value: tx.value });
206+
if (!parsed || parsed.name !== 'anchor') return false;
207+
const argHex = (parsed.args[0] as string).toLowerCase();
208+
return argHex === expectedHex;
209+
} catch {
210+
return false;
211+
}
212+
}
213+
214+
// Raw-calldata mode: tx.data is the digest hex directly.
215+
return tx.data?.toLowerCase() === expectedHex;
216+
} catch {
217+
return false;
218+
}
80219
}
81220

82221
async dispose(): Promise<void> {
83-
// TODO: Disconnect provider if persistent connection was established
222+
if (this.cachedProvider && typeof this.cachedProvider.destroy === 'function') {
223+
try {
224+
this.cachedProvider.destroy();
225+
} catch {
226+
// Best-effort cleanup.
227+
}
228+
}
229+
this.cachedProvider = null;
230+
this.cachedWallet = null;
231+
}
232+
233+
private async loadEthers(): Promise<any> {
234+
const moduleName = 'ethers';
235+
try {
236+
const mod = await import(moduleName);
237+
// ethers v6 exports a namespace under default; v5 exports flat.
238+
// We use namespace-style access (`ethers.Wallet`, `ethers.Contract`)
239+
// which works for v6 root export.
240+
return (mod as any).ethers ?? mod;
241+
} catch {
242+
throw new Error(
243+
'EthereumProvider requires ethers (v6) at runtime. ' +
244+
'Install it in your project to enable Ethereum anchoring.',
245+
);
246+
}
247+
}
248+
249+
private getProvider(ethers: any): any {
250+
if (this.cachedProvider) return this.cachedProvider;
251+
this.cachedProvider = new ethers.JsonRpcProvider(this.config.rpcUrl, this.config.chainId);
252+
return this.cachedProvider;
253+
}
254+
255+
private async getWallet(ethers: any): Promise<any> {
256+
if (this.cachedWallet) return this.cachedWallet;
257+
if (!this.config.signerPrivateKey) {
258+
throw new Error('signerPrivateKey is required to obtain a wallet for publishing.');
259+
}
260+
const provider = this.getProvider(ethers);
261+
this.cachedWallet = new ethers.Wallet(this.config.signerPrivateKey, provider);
262+
return this.cachedWallet;
84263
}
85264
}

0 commit comments

Comments
 (0)