|
2 | 2 | /** |
3 | 3 | * @file EthereumProvider.ts |
4 | 4 | * @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. |
6 | 20 | * |
7 | 21 | * Proof level: `publicly-timestamped` |
8 | | - * Required peer dependency: `ethers` |
| 22 | + * Required peer dependency: `ethers` (loaded via dynamic import). |
9 | 23 | * |
10 | 24 | * @module @framers/agentos-ext-anchor-providers |
11 | 25 | */ |
12 | 26 |
|
13 | 27 | import type { AnchorProvider, AnchorRecord, AnchorProviderResult, ProofLevel } from '@framers/agentos'; |
14 | 28 | import type { BaseProviderConfig } from '../types.js'; |
15 | 29 | import { resolveBaseConfig } from '../types.js'; |
| 30 | +import { hashCanonicalAnchor } from '../utils/serialization.js'; |
16 | 31 |
|
17 | 32 | export interface EthereumProviderConfig extends BaseProviderConfig { |
18 | 33 | /** JSON-RPC endpoint URL. */ |
19 | 34 | 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. */ |
21 | 36 | contractAddress?: string; |
22 | | - /** Private key hex for signing transactions. */ |
| 37 | + /** Private key hex (0x-prefixed) for signing transactions. */ |
23 | 38 | signerPrivateKey?: string; |
24 | 39 | /** Chain ID. Default: 1 (mainnet). */ |
25 | 40 | chainId?: number; |
26 | 41 | /** Gas limit override. Default: auto-estimate. */ |
27 | 42 | gasLimit?: number; |
| 43 | + /** Number of confirmations to wait for. Default: 1. */ |
| 44 | + confirmations?: number; |
28 | 45 | } |
29 | 46 |
|
| 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 | + |
30 | 54 | export class EthereumProvider implements AnchorProvider { |
31 | 55 | readonly id = 'ethereum'; |
32 | 56 | readonly name = 'Ethereum On-Chain Anchor'; |
33 | 57 | readonly proofLevel: ProofLevel = 'publicly-timestamped'; |
34 | 58 |
|
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 | + }; |
36 | 64 | private readonly baseConfig: Required<BaseProviderConfig>; |
37 | 65 |
|
| 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 | + |
38 | 71 | constructor(config: EthereumProviderConfig) { |
39 | 72 | 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, |
42 | 82 | }; |
43 | 83 | this.baseConfig = resolveBaseConfig(config); |
44 | 84 | } |
45 | 85 |
|
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 | + } |
71 | 169 | } |
72 | 170 |
|
| 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 | + */ |
73 | 177 | 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 | + } |
80 | 219 | } |
81 | 220 |
|
82 | 221 | 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; |
84 | 263 | } |
85 | 264 | } |
0 commit comments