Skip to content

Commit 148ccef

Browse files
authored
feat(anchor-evm): add CLI options for EVM self-anchoring (#748)
* feat(anchor-evm): add CLI options for EVM self-anchoring Add command-line options to ceramic-one daemon for configuring EVM-based self-anchoring, eliminating the need for a centralized anchor service. CLI Options: --evm-rpc-url RPC endpoint for EVM chain --evm-private-key Private key for signing (hex, no 0x prefix) --evm-chain-id EVM chain ID (e.g., 100 for Gnosis) --evm-contract-address Anchor contract address --evm-confirmations Block confirmations to wait (default: 4) All four required options must be provided together. Example: ceramic-one daemon \ --evm-rpc-url "https://gnosis-mainnet.g.alchemy.com/v2/YOUR_KEY" \ --evm-private-key "your_private_key_hex_without_0x" \ --evm-chain-id 100 \ --evm-contract-address "0x231055A0852D67C7107Ad0d0DFeab60278fE6AdC" \ --anchor-interval 3600 Environment variables are also supported (recommended for production): CERAMIC_ONE_EVM_RPC_URL, CERAMIC_ONE_EVM_PRIVATE_KEY, etc. Additional changes: - Add wait_for_pending_transactions() to handle tx from previous runs - Deprecate --remote-anchor-service-url in favor of EVM options - Use test_log::test(tokio::test) instead of manual tracing setup - Add comprehensive CLI documentation to anchor-evm/README.md - Add self-anchoring section to main README.md * fix: resolve CI failures with formatting and deprecated field warning - Run cargo fmt to fix formatting issues in daemon.rs and evm_transaction_manager.rs - Add #[allow(deprecated)] for intentional use of deprecated remote_anchor_service_url field * refactor(anchor-evm): simplify EVM RPC configuration for self-anchoring Consolidate RPC URL configuration so that --evm-rpc-url is automatically used for both submitting anchors AND validating anchor proofs on that chain. Changes: - Add --additional-chain-rpc-urls for validating anchors from other chains - Deprecate --ethereum-rpc-urls (keep as hidden alias with warning) * test fixes
1 parent 0a7b433 commit 148ccef

10 files changed

Lines changed: 413 additions & 118 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ $ ceramic-one daemon --store-dir ./custom-store-dir
114114
The process honors RUST_LOG env variable for controlling its logging output.
115115
For example, to enable debug logging for code from this repo but error logging for all other code use:
116116

117+
## Self-Anchoring
118+
119+
Ceramic One supports self-anchoring directly to EVM-compatible blockchains (Gnosis, Ethereum, Polygon, etc.), eliminating the need for a centralized anchor service. See [anchor-evm/README.md](./anchor-evm/README.md) for setup instructions.
120+
117121
## License
118122

119123
Fully open source and dual-licensed under MIT and Apache 2.

anchor-evm/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,3 @@ ceramic-sql.workspace = true
2626
cid.workspace = true
2727
expect-test.workspace = true
2828
test-log.workspace = true
29-
tracing-subscriber.workspace = true

anchor-evm/README.md

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,79 @@ let config = EvmConfig {
133133
};
134134
```
135135

136-
## Integration with Ceramic
136+
## Usage with Ceramic One
137+
138+
The simplest way to enable EVM self-anchoring is via CLI options when running ceramic-one:
139+
140+
### CLI Options
141+
142+
```bash
143+
ceramic-one daemon \
144+
--evm-rpc-url "https://gnosis-mainnet.g.alchemy.com/v2/YOUR_KEY" \
145+
--evm-private-key "your_private_key_hex_without_0x" \
146+
--evm-chain-id 100 \
147+
--evm-contract-address "0x231055A0852D67C7107Ad0d0DFeab60278fE6AdC" \
148+
--evm-confirmations 4 \
149+
--anchor-interval 3600
150+
```
151+
152+
### Environment Variables
153+
154+
For production deployments, use environment variables to avoid exposing secrets:
155+
156+
```bash
157+
# Required EVM options
158+
export CERAMIC_ONE_EVM_RPC_URL="https://gnosis-mainnet.g.alchemy.com/v2/YOUR_KEY"
159+
export CERAMIC_ONE_EVM_PRIVATE_KEY="your_private_key_hex_without_0x"
160+
export CERAMIC_ONE_EVM_CHAIN_ID="100"
161+
export CERAMIC_ONE_EVM_CONTRACT_ADDRESS="0x231055A0852D67C7107Ad0d0DFeab60278fE6AdC"
162+
163+
# Optional tuning
164+
export CERAMIC_ONE_EVM_CONFIRMATIONS="4" # Block confirmations (default: 4)
165+
export CERAMIC_ONE_ANCHOR_INTERVAL="3600" # Seconds between anchors (default: 3600)
166+
export CERAMIC_ONE_ANCHOR_BATCH_SIZE="1000000" # Max events per batch
167+
168+
# Optional: Additional RPC URLs for validating anchors from other chains
169+
# (e.g., historical anchors or synced events anchored on different chains)
170+
export CERAMIC_ONE_ADDITIONAL_CHAIN_RPC_URLS="https://ethereum-rpc.publicnode.com,https://sepolia-rpc.publicnode.com"
171+
172+
# Run daemon
173+
ceramic-one daemon
174+
```
175+
176+
**Note:** The `CERAMIC_ONE_EVM_RPC_URL` is automatically used for both submitting anchor transactions AND validating anchor proofs on that chain. Use `CERAMIC_ONE_ADDITIONAL_CHAIN_RPC_URLS` only if you need to validate anchors from other chains.
177+
178+
### Available CLI Options
179+
180+
| Option | Environment Variable | Description | Default |
181+
|--------|---------------------|-------------|---------|
182+
| `--evm-rpc-url` | `CERAMIC_ONE_EVM_RPC_URL` | RPC endpoint for EVM chain (used for both anchoring and validation) | Required |
183+
| `--evm-private-key` | `CERAMIC_ONE_EVM_PRIVATE_KEY` | Private key for signing (hex, no 0x) | Required |
184+
| `--evm-chain-id` | `CERAMIC_ONE_EVM_CHAIN_ID` | EVM chain ID (e.g., 100 for Gnosis) | Required |
185+
| `--evm-contract-address` | `CERAMIC_ONE_EVM_CONTRACT_ADDRESS` | Anchor contract address | Required |
186+
| `--evm-confirmations` | `CERAMIC_ONE_EVM_CONFIRMATIONS` | Block confirmations to wait | 4 |
187+
| `--anchor-interval` | `CERAMIC_ONE_ANCHOR_INTERVAL` | Seconds between anchor batches | 3600 |
188+
| `--additional-chain-rpc-urls` | `CERAMIC_ONE_ADDITIONAL_CHAIN_RPC_URLS` | Additional RPC URLs for validating anchors from other chains | None |
189+
190+
All four EVM options must be provided together for self-anchoring.
191+
192+
### Example: Gnosis Chain Setup
193+
194+
```bash
195+
# 1. Fund a wallet with xDAI (even 0.1 xDAI is sufficient for years of anchoring)
196+
# 2. Export your private key (hex format, no 0x prefix)
197+
# 3. Run ceramic-one:
198+
199+
ceramic-one daemon \
200+
--network mainnet \
201+
--evm-rpc-url "https://gnosis-mainnet.g.alchemy.com/v2/YOUR_KEY" \
202+
--evm-private-key "55e16063c21943ad9d70fa10b0b9713c7dc42d4119ca6b83f36056d6188f4c70" \
203+
--evm-chain-id 100 \
204+
--evm-contract-address "0x231055A0852D67C7107Ad0d0DFeab60278fE6AdC" \
205+
--anchor-interval 3600
206+
```
207+
208+
## Programmatic Integration
137209

138210
The `EvmTransactionManager` implements the `TransactionManager` trait and can be used as a drop-in replacement for the remote CAS:
139211

anchor-evm/src/evm_transaction_manager.rs

Lines changed: 90 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -94,19 +94,19 @@ impl EvmTransactionManager {
9494
/// Validate the configuration
9595
pub fn validate_config(config: &EvmConfig) -> Result<()> {
9696
if config.private_key.is_empty() {
97-
return Err(anyhow!("Private key cannot be empty"));
97+
anyhow::bail!("Private key cannot be empty");
9898
}
9999

100100
if config.contract_address.is_empty() {
101-
return Err(anyhow!("Contract address cannot be empty"));
101+
anyhow::bail!("Contract address cannot be empty");
102102
}
103103

104104
if config.confirmations == 0 {
105-
return Err(anyhow!("Confirmations must be greater than 0"));
105+
anyhow::bail!("Confirmations must be greater than 0");
106106
}
107107

108108
if config.retry_config.max_retries == 0 {
109-
return Err(anyhow!("Max retries must be greater than 0"));
109+
anyhow::bail!("Max retries must be greater than 0");
110110
}
111111

112112
Ok(())
@@ -121,9 +121,7 @@ impl EvmTransactionManager {
121121
// This matches: uint8arrays.toString(rootCid.bytes.slice(4), 'base16')
122122
if cid_bytes.len() < 36 {
123123
// 4 prefix + 32 hash bytes
124-
return Err(anyhow!(
125-
"CID too short: need at least 36 bytes (4 prefix + 32 hash)"
126-
));
124+
anyhow::bail!("CID too short: need at least 36 bytes (4 prefix + 32 hash)");
127125
}
128126

129127
let hash_bytes = &cid_bytes[4..]; // Skip multicodec prefix
@@ -171,11 +169,11 @@ impl EvmTransactionManager {
171169
.map_err(|e| anyhow!("Failed to connect to EVM node: {}", e))?;
172170

173171
if actual_chain_id != self.config.chain_id {
174-
return Err(anyhow!(
172+
anyhow::bail!(
175173
"Chain ID mismatch: configured {} but connected to {}",
176174
self.config.chain_id,
177175
actual_chain_id
178-
));
176+
);
179177
}
180178

181179
info!("Connected to EVM chain with ID: {}", actual_chain_id);
@@ -187,6 +185,9 @@ impl EvmTransactionManager {
187185
.map_err(|e| anyhow!("Failed to get wallet balance: {}", e))?;
188186
info!("Starting wallet balance: {} wei", starting_balance);
189187

188+
// Wait for any pending transactions from previous runs to clear
189+
Self::wait_for_pending_transactions(&provider, wallet_address).await;
190+
190191
// Create contract instance
191192
let contract = AnchorContract::new(contract_address, provider.clone());
192193

@@ -229,10 +230,10 @@ impl EvmTransactionManager {
229230

230231
// Check if transaction reverted
231232
if !receipt.status() {
232-
return Err(anyhow!(
233+
anyhow::bail!(
233234
"Transaction {} reverted - anchor contract rejected the call",
234235
tx_hash
235-
));
236+
);
236237
}
237238

238239
// Get block number from receipt - a mined transaction should always have this
@@ -272,38 +273,29 @@ impl EvmTransactionManager {
272273
.get_balance(wallet_address)
273274
.await
274275
.unwrap_or(U256::ZERO);
275-
return Err(anyhow!(
276+
anyhow::bail!(
276277
"Insufficient funds for transaction. Current balance: {} wei. Error: {}",
277278
current_balance, e
278-
));
279+
);
279280
}
280281

281-
if error_str.contains("nonce") && error_str.contains("expired")
282-
|| error_str.contains("nonce too low")
283-
|| error_str.contains("replacement transaction underpriced")
282+
// Check if a previous transaction from THIS run was mined
283+
if self.config.retry_config.check_previous_success
284+
&& (error_str.contains("nonce too low")
285+
|| error_str.contains("replacement transaction underpriced"))
284286
{
285-
// Nonce error - check if a previous transaction was mined
286-
if self.config.retry_config.check_previous_success
287-
&& !previous_tx_hashes.is_empty()
288-
{
289-
info!("Nonce error detected, checking if previous transaction was mined...");
290-
for prev_tx in previous_tx_hashes.iter().rev() {
291-
if let Ok(Some(_)) = provider
292-
.get_transaction_receipt(prev_tx.parse().unwrap_or_default())
293-
.await
287+
for prev_tx in previous_tx_hashes.iter().rev() {
288+
if let Ok(Some(_)) = provider
289+
.get_transaction_receipt(prev_tx.parse().unwrap_or_default())
290+
.await
291+
{
292+
info!("Previous transaction {} was mined successfully", prev_tx);
293+
if let Ok(ending_balance) =
294+
provider.get_balance(wallet_address).await
294295
{
295-
info!(
296-
"Previous transaction {} was mined successfully",
297-
prev_tx
298-
);
299-
// Log ending wallet balance
300-
if let Ok(ending_balance) =
301-
provider.get_balance(wallet_address).await
302-
{
303-
info!("Ending wallet balance: {} wei", ending_balance);
304-
}
305-
return Ok(prev_tx.clone());
296+
info!("Ending wallet balance: {} wei", ending_balance);
306297
}
298+
return Ok(prev_tx.clone());
307299
}
308300
}
309301
}
@@ -325,6 +317,67 @@ impl EvmTransactionManager {
325317
}))
326318
}
327319

320+
/// Wait for any pending transactions from this wallet to be mined.
321+
/// This prevents "could not replace existing tx" errors from previous runs.
322+
async fn wait_for_pending_transactions<P: Provider<Http<Client>>>(
323+
provider: &P,
324+
wallet_address: Address,
325+
) {
326+
const MAX_WAIT: Duration = Duration::from_secs(120);
327+
const POLL_INTERVAL: Duration = Duration::from_secs(5);
328+
329+
let pending_nonce = match provider
330+
.get_transaction_count(wallet_address)
331+
.pending()
332+
.await
333+
{
334+
Ok(n) => n,
335+
Err(_) => return, // Can't check, proceed anyway
336+
};
337+
338+
let confirmed_nonce = match provider.get_transaction_count(wallet_address).await {
339+
Ok(n) => n,
340+
Err(_) => return,
341+
};
342+
343+
if pending_nonce == confirmed_nonce {
344+
return; // No pending transactions
345+
}
346+
347+
info!(
348+
pending_nonce,
349+
confirmed_nonce, "Waiting for pending transactions to be mined"
350+
);
351+
352+
let start = std::time::Instant::now();
353+
while start.elapsed() < MAX_WAIT {
354+
sleep(POLL_INTERVAL).await;
355+
356+
let current_pending = provider
357+
.get_transaction_count(wallet_address)
358+
.pending()
359+
.await
360+
.unwrap_or(pending_nonce);
361+
let current_confirmed = provider
362+
.get_transaction_count(wallet_address)
363+
.await
364+
.unwrap_or(confirmed_nonce);
365+
366+
if current_pending == current_confirmed {
367+
info!("Pending transactions cleared, proceeding");
368+
return;
369+
}
370+
371+
debug!(
372+
pending = current_pending,
373+
confirmed = current_confirmed,
374+
"Still waiting for pending transactions"
375+
);
376+
}
377+
378+
warn!("Timed out waiting for pending transactions, proceeding anyway");
379+
}
380+
328381
/// Wait for the required number of confirmations
329382
async fn wait_for_confirmations<P: Provider<Http<Client>>>(
330383
&self,
@@ -337,10 +390,7 @@ impl EvmTransactionManager {
337390

338391
loop {
339392
if start_time.elapsed() > self.config.confirmation_timeout {
340-
return Err(anyhow!(
341-
"Timeout waiting for confirmations for tx: {}",
342-
tx_hash
343-
));
393+
anyhow::bail!("Timeout waiting for confirmations for tx: {}", tx_hash);
344394
}
345395

346396
interval.tick().await;

0 commit comments

Comments
 (0)