Skip to content

Commit c928b00

Browse files
committed
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
1 parent 0a7b433 commit c928b00

8 files changed

Lines changed: 333 additions & 132 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: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,72 @@ 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+
# Run daemon
169+
ceramic-one daemon
170+
```
171+
172+
### Available CLI Options
173+
174+
| Option | Environment Variable | Description | Default |
175+
|--------|---------------------|-------------|---------|
176+
| `--evm-rpc-url` | `CERAMIC_ONE_EVM_RPC_URL` | RPC endpoint for EVM chain | Required |
177+
| `--evm-private-key` | `CERAMIC_ONE_EVM_PRIVATE_KEY` | Private key for signing (hex, no 0x) | Required |
178+
| `--evm-chain-id` | `CERAMIC_ONE_EVM_CHAIN_ID` | EVM chain ID (e.g., 100 for Gnosis) | Required |
179+
| `--evm-contract-address` | `CERAMIC_ONE_EVM_CONTRACT_ADDRESS` | Anchor contract address | Required |
180+
| `--evm-confirmations` | `CERAMIC_ONE_EVM_CONFIRMATIONS` | Block confirmations to wait | 4 |
181+
| `--anchor-interval` | `CERAMIC_ONE_ANCHOR_INTERVAL` | Seconds between anchor batches | 3600 |
182+
183+
All four EVM options must be provided together.
184+
185+
### Example: Gnosis Chain Setup
186+
187+
```bash
188+
# 1. Fund a wallet with xDAI (even 0.1 xDAI is sufficient for years of anchoring)
189+
# 2. Export your private key (hex format, no 0x prefix)
190+
# 3. Run ceramic-one:
191+
192+
ceramic-one daemon \
193+
--network mainnet \
194+
--evm-rpc-url "https://gnosis-mainnet.g.alchemy.com/v2/YOUR_KEY" \
195+
--evm-private-key "55e16063c21943ad9d70fa10b0b9713c7dc42d4119ca6b83f36056d6188f4c70" \
196+
--evm-chain-id 100 \
197+
--evm-contract-address "0x231055A0852D67C7107Ad0d0DFeab60278fE6AdC" \
198+
--anchor-interval 3600
199+
```
200+
201+
## Programmatic Integration
137202

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

anchor-evm/src/evm_transaction_manager.rs

Lines changed: 95 additions & 37 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,9 @@ 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!(
124+
anyhow::bail!(
125125
"CID too short: need at least 36 bytes (4 prefix + 32 hash)"
126-
));
126+
);
127127
}
128128

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

173173
if actual_chain_id != self.config.chain_id {
174-
return Err(anyhow!(
174+
anyhow::bail!(
175175
"Chain ID mismatch: configured {} but connected to {}",
176176
self.config.chain_id,
177177
actual_chain_id
178-
));
178+
);
179179
}
180180

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

190+
// Wait for any pending transactions from previous runs to clear
191+
Self::wait_for_pending_transactions(&provider, wallet_address).await;
192+
190193
// Create contract instance
191194
let contract = AnchorContract::new(contract_address, provider.clone());
192195

@@ -229,10 +232,10 @@ impl EvmTransactionManager {
229232

230233
// Check if transaction reverted
231234
if !receipt.status() {
232-
return Err(anyhow!(
235+
anyhow::bail!(
233236
"Transaction {} reverted - anchor contract rejected the call",
234237
tx_hash
235-
));
238+
);
236239
}
237240

238241
// Get block number from receipt - a mined transaction should always have this
@@ -272,38 +275,32 @@ impl EvmTransactionManager {
272275
.get_balance(wallet_address)
273276
.await
274277
.unwrap_or(U256::ZERO);
275-
return Err(anyhow!(
278+
anyhow::bail!(
276279
"Insufficient funds for transaction. Current balance: {} wei. Error: {}",
277280
current_balance, e
278-
));
281+
);
279282
}
280283

281-
if error_str.contains("nonce") && error_str.contains("expired")
282-
|| error_str.contains("nonce too low")
283-
|| error_str.contains("replacement transaction underpriced")
284+
// Check if a previous transaction from THIS run was mined
285+
if self.config.retry_config.check_previous_success
286+
&& (error_str.contains("nonce too low")
287+
|| error_str.contains("replacement transaction underpriced"))
284288
{
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
289+
for prev_tx in previous_tx_hashes.iter().rev() {
290+
if let Ok(Some(_)) = provider
291+
.get_transaction_receipt(prev_tx.parse().unwrap_or_default())
292+
.await
293+
{
294+
info!(
295+
"Previous transaction {} was mined successfully",
296+
prev_tx
297+
);
298+
if let Ok(ending_balance) =
299+
provider.get_balance(wallet_address).await
294300
{
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());
301+
info!("Ending wallet balance: {} wei", ending_balance);
306302
}
303+
return Ok(prev_tx.clone());
307304
}
308305
}
309306
}
@@ -325,6 +322,67 @@ impl EvmTransactionManager {
325322
}))
326323
}
327324

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

338396
loop {
339397
if start_time.elapsed() > self.config.confirmation_timeout {
340-
return Err(anyhow!(
398+
anyhow::bail!(
341399
"Timeout waiting for confirmations for tx: {}",
342400
tx_hash
343-
));
401+
);
344402
}
345403

346404
interval.tick().await;

0 commit comments

Comments
 (0)