Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ $ ceramic-one daemon --store-dir ./custom-store-dir
The process honors RUST_LOG env variable for controlling its logging output.
For example, to enable debug logging for code from this repo but error logging for all other code use:

## Self-Anchoring

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.

## License

Fully open source and dual-licensed under MIT and Apache 2.
Expand Down
1 change: 0 additions & 1 deletion anchor-evm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,3 @@ ceramic-sql.workspace = true
cid.workspace = true
expect-test.workspace = true
test-log.workspace = true
tracing-subscriber.workspace = true
74 changes: 73 additions & 1 deletion anchor-evm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,79 @@ let config = EvmConfig {
};
```

## Integration with Ceramic
## Usage with Ceramic One

The simplest way to enable EVM self-anchoring is via CLI options when running ceramic-one:

### CLI Options

```bash
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" \
--evm-confirmations 4 \
--anchor-interval 3600
```

### Environment Variables

For production deployments, use environment variables to avoid exposing secrets:

```bash
# Required EVM options
export CERAMIC_ONE_EVM_RPC_URL="https://gnosis-mainnet.g.alchemy.com/v2/YOUR_KEY"
export CERAMIC_ONE_EVM_PRIVATE_KEY="your_private_key_hex_without_0x"
export CERAMIC_ONE_EVM_CHAIN_ID="100"
export CERAMIC_ONE_EVM_CONTRACT_ADDRESS="0x231055A0852D67C7107Ad0d0DFeab60278fE6AdC"

# Optional tuning
export CERAMIC_ONE_EVM_CONFIRMATIONS="4" # Block confirmations (default: 4)
export CERAMIC_ONE_ANCHOR_INTERVAL="3600" # Seconds between anchors (default: 3600)
export CERAMIC_ONE_ANCHOR_BATCH_SIZE="1000000" # Max events per batch

# Optional: Additional RPC URLs for validating anchors from other chains
# (e.g., historical anchors or synced events anchored on different chains)
export CERAMIC_ONE_ADDITIONAL_CHAIN_RPC_URLS="https://ethereum-rpc.publicnode.com,https://sepolia-rpc.publicnode.com"

# Run daemon
ceramic-one daemon
```

**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.

### Available CLI Options

| Option | Environment Variable | Description | Default |
|--------|---------------------|-------------|---------|
| `--evm-rpc-url` | `CERAMIC_ONE_EVM_RPC_URL` | RPC endpoint for EVM chain (used for both anchoring and validation) | Required |
| `--evm-private-key` | `CERAMIC_ONE_EVM_PRIVATE_KEY` | Private key for signing (hex, no 0x) | Required |
| `--evm-chain-id` | `CERAMIC_ONE_EVM_CHAIN_ID` | EVM chain ID (e.g., 100 for Gnosis) | Required |
| `--evm-contract-address` | `CERAMIC_ONE_EVM_CONTRACT_ADDRESS` | Anchor contract address | Required |
| `--evm-confirmations` | `CERAMIC_ONE_EVM_CONFIRMATIONS` | Block confirmations to wait | 4 |
| `--anchor-interval` | `CERAMIC_ONE_ANCHOR_INTERVAL` | Seconds between anchor batches | 3600 |
| `--additional-chain-rpc-urls` | `CERAMIC_ONE_ADDITIONAL_CHAIN_RPC_URLS` | Additional RPC URLs for validating anchors from other chains | None |

All four EVM options must be provided together for self-anchoring.

### Example: Gnosis Chain Setup

```bash
# 1. Fund a wallet with xDAI (even 0.1 xDAI is sufficient for years of anchoring)
# 2. Export your private key (hex format, no 0x prefix)
# 3. Run ceramic-one:

ceramic-one daemon \
--network mainnet \
--evm-rpc-url "https://gnosis-mainnet.g.alchemy.com/v2/YOUR_KEY" \
--evm-private-key "55e16063c21943ad9d70fa10b0b9713c7dc42d4119ca6b83f36056d6188f4c70" \
--evm-chain-id 100 \
--evm-contract-address "0x231055A0852D67C7107Ad0d0DFeab60278fE6AdC" \
--anchor-interval 3600
```

## Programmatic Integration

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

Expand Down
130 changes: 90 additions & 40 deletions anchor-evm/src/evm_transaction_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,19 +94,19 @@ impl EvmTransactionManager {
/// Validate the configuration
pub fn validate_config(config: &EvmConfig) -> Result<()> {
if config.private_key.is_empty() {
return Err(anyhow!("Private key cannot be empty"));
anyhow::bail!("Private key cannot be empty");
}

if config.contract_address.is_empty() {
return Err(anyhow!("Contract address cannot be empty"));
anyhow::bail!("Contract address cannot be empty");
}

if config.confirmations == 0 {
return Err(anyhow!("Confirmations must be greater than 0"));
anyhow::bail!("Confirmations must be greater than 0");
}

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

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

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

if actual_chain_id != self.config.chain_id {
return Err(anyhow!(
anyhow::bail!(
"Chain ID mismatch: configured {} but connected to {}",
self.config.chain_id,
actual_chain_id
));
);
}

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

// Wait for any pending transactions from previous runs to clear
Self::wait_for_pending_transactions(&provider, wallet_address).await;

// Create contract instance
let contract = AnchorContract::new(contract_address, provider.clone());

Expand Down Expand Up @@ -229,10 +230,10 @@ impl EvmTransactionManager {

// Check if transaction reverted
if !receipt.status() {
return Err(anyhow!(
anyhow::bail!(
"Transaction {} reverted - anchor contract rejected the call",
tx_hash
));
);
}

// Get block number from receipt - a mined transaction should always have this
Expand Down Expand Up @@ -272,38 +273,29 @@ impl EvmTransactionManager {
.get_balance(wallet_address)
.await
.unwrap_or(U256::ZERO);
return Err(anyhow!(
anyhow::bail!(
"Insufficient funds for transaction. Current balance: {} wei. Error: {}",
current_balance, e
));
);
}

if error_str.contains("nonce") && error_str.contains("expired")
|| error_str.contains("nonce too low")
|| error_str.contains("replacement transaction underpriced")
// Check if a previous transaction from THIS run was mined
if self.config.retry_config.check_previous_success
&& (error_str.contains("nonce too low")
|| error_str.contains("replacement transaction underpriced"))
{
// Nonce error - check if a previous transaction was mined
if self.config.retry_config.check_previous_success
&& !previous_tx_hashes.is_empty()
{
info!("Nonce error detected, checking if previous transaction was mined...");
for prev_tx in previous_tx_hashes.iter().rev() {
if let Ok(Some(_)) = provider
.get_transaction_receipt(prev_tx.parse().unwrap_or_default())
.await
for prev_tx in previous_tx_hashes.iter().rev() {
if let Ok(Some(_)) = provider
.get_transaction_receipt(prev_tx.parse().unwrap_or_default())
.await
{
info!("Previous transaction {} was mined successfully", prev_tx);
if let Ok(ending_balance) =
provider.get_balance(wallet_address).await
{
info!(
"Previous transaction {} was mined successfully",
prev_tx
);
// Log ending wallet balance
if let Ok(ending_balance) =
provider.get_balance(wallet_address).await
{
info!("Ending wallet balance: {} wei", ending_balance);
}
return Ok(prev_tx.clone());
info!("Ending wallet balance: {} wei", ending_balance);
}
return Ok(prev_tx.clone());
}
}
}
Expand All @@ -325,6 +317,67 @@ impl EvmTransactionManager {
}))
}

/// Wait for any pending transactions from this wallet to be mined.
/// This prevents "could not replace existing tx" errors from previous runs.
async fn wait_for_pending_transactions<P: Provider<Http<Client>>>(
provider: &P,
wallet_address: Address,
) {
const MAX_WAIT: Duration = Duration::from_secs(120);
const POLL_INTERVAL: Duration = Duration::from_secs(5);

let pending_nonce = match provider
.get_transaction_count(wallet_address)
.pending()
.await
{
Ok(n) => n,
Err(_) => return, // Can't check, proceed anyway
};

let confirmed_nonce = match provider.get_transaction_count(wallet_address).await {
Ok(n) => n,
Err(_) => return,
};

if pending_nonce == confirmed_nonce {
return; // No pending transactions
}

info!(
pending_nonce,
confirmed_nonce, "Waiting for pending transactions to be mined"
);

let start = std::time::Instant::now();
while start.elapsed() < MAX_WAIT {
sleep(POLL_INTERVAL).await;

let current_pending = provider
.get_transaction_count(wallet_address)
.pending()
.await
.unwrap_or(pending_nonce);
let current_confirmed = provider
.get_transaction_count(wallet_address)
.await
.unwrap_or(confirmed_nonce);

if current_pending == current_confirmed {
info!("Pending transactions cleared, proceeding");
return;
}

debug!(
pending = current_pending,
confirmed = current_confirmed,
"Still waiting for pending transactions"
);
}

warn!("Timed out waiting for pending transactions, proceeding anyway");
}

/// Wait for the required number of confirmations
async fn wait_for_confirmations<P: Provider<Http<Client>>>(
&self,
Expand All @@ -337,10 +390,7 @@ impl EvmTransactionManager {

loop {
if start_time.elapsed() > self.config.confirmation_timeout {
return Err(anyhow!(
"Timeout waiting for confirmations for tx: {}",
tx_hash
));
anyhow::bail!("Timeout waiting for confirmations for tx: {}", tx_hash);
}

interval.tick().await;
Expand Down
Loading
Loading