OpenPay (1 crate, 3751 LOC, 83 tests)
op-crypto-extra Extended crypto rails. Stablecoin catalog (USDC, USDT,
PYUSD, DAI, RLUSD, EURC, FDUSD — 26 deployments). L2
catalog (12 chains incl. Optimism, Arbitrum, Base,
zkSync Era, Polygon zkEVM, Linea, Scroll, Starknet).
CCTP v2 BurnMessage encode/decode + domain ids +
attestation. ERC-4337 UserOperation v0.7 +
PackedUserOperation. EIP-7702 set-EOA-code with RLP
signing preimage. Uniswap Permit2 single + batch
transfer EIP-712 hashes. EIP-2612 ERC-20 permit.
BOLT-11 bech32 invoices + LNURL decode. Bitcoin
PSBT v2 (BIP-370). Atomic-swap HTLC. Solana SPL /
Aleo / Aztec confidential-transfer sketches.
Smart Byte (1 crate, 2304 LOC, 30 tests)
smart-byte-acdc Authentic Chained Data Containers (KERI ecosystem).
Acdc + AcdcBuilder (targeted + untargeted) with SAID
derivation. JSON Schema 2020-12 validator with
oneOf partial-disclosure. TEL (RIP/VRT/ISS/REV)
with hash-chained verification. IPEX (apply/offer/
agree/grant/admit/spurn) chained by SAID. Edge
records (I2I/NI2I/DI2I) + EdgeGraph BFS traversal
with cycle detection. Selective disclosure with
full/none/subset plans + compact packing.
CredentialRegistry trait + InMemoryRegistry over
TEL. Composes with smart-byte-core + smart-byte-keri-witness.
EOC (1 crate, 1764 LOC, 16 tests)
eoc-memory Long-term memory primitives. Memory trait + MemoryKind +
EpisodeId. Episodic event log with TimeIndex range
queries. RDF-style Triple store with S/P/O indices.
Bounded attention-budget Scratchpad with
salience-weighted eviction. Rolling SummaryBuffer +
FIFO SlidingWindow. Dream-cycle consolidate()
(episodic → semantic). Ebbinghaus forgetting curve
with reinforcement. Retriever blending cosine
similarity + recency + saturating frequency.
MemGpt three-tier shape (Main/Recall/Archival).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
280 lines
9.7 KiB
Rust
280 lines
9.7 KiB
Rust
//! ERC-4337 account abstraction: `UserOperation` v0.7 layout.
|
|
//!
|
|
//! v0.7 introduced the `PackedUserOperation` struct: the bundler
|
|
//! still sees fields as separate values, but the EntryPoint receives
|
|
//! them packed (with gas fields concatenated into single 32-byte
|
|
//! slots) for calldata-size reduction.
|
|
//!
|
|
//! This module models both the unpacked `UserOperation` (what
|
|
//! application code constructs) and the `PackedUserOperation`
|
|
//! (what gets handed to the EntryPoint). It does **not** compute
|
|
//! the user-op hash — that requires the EntryPoint address, chain
|
|
//! id, and EIP-712 typed-data hashing, which depends on operator
|
|
//! choice of EntryPoint deployment.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::error::{Error, Result};
|
|
|
|
/// EntryPoint version the user-op targets.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum EntryPointVersion {
|
|
/// EntryPoint v0.6 (legacy).
|
|
V0_6,
|
|
/// EntryPoint v0.7 (current). Uses `PackedUserOperation`.
|
|
V0_7,
|
|
/// EntryPoint v0.8 (forthcoming).
|
|
V0_8,
|
|
}
|
|
|
|
impl EntryPointVersion {
|
|
/// Canonical deployed contract address for the version on
|
|
/// Ethereum mainnet (and most L2s — the EntryPoint is deployed
|
|
/// at the same address everywhere via CREATE2).
|
|
#[must_use]
|
|
pub const fn canonical_address(self) -> &'static str {
|
|
match self {
|
|
Self::V0_6 => "0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789",
|
|
Self::V0_7 => "0x0000000071727de22e5e9d8baf0edac6f37da032",
|
|
Self::V0_8 => "0x4337084d9e255ff0702461cf8895ce9e3b5ff108",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// ERC-4337 v0.7 unpacked UserOperation.
|
|
///
|
|
/// Field names and order match the EntryPoint v0.7 spec
|
|
/// (`PackedUserOperation` Solidity struct, unpacked form). Amounts
|
|
/// in wei. All addresses lowercase hex with `0x` prefix.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct UserOperation {
|
|
/// The smart account the op targets.
|
|
pub sender: String,
|
|
/// Anti-replay nonce. The EntryPoint partitions the nonce space
|
|
/// by the top 192 bits ("key"); same key = strictly increasing
|
|
/// sequence.
|
|
pub nonce: u128,
|
|
/// Account deployment calldata. Empty for already-deployed
|
|
/// accounts; non-empty triggers a CREATE2 via the factory whose
|
|
/// address is the first 20 bytes.
|
|
pub init_code: Vec<u8>,
|
|
/// The actual call the account executes.
|
|
pub call_data: Vec<u8>,
|
|
/// Gas budget for the account's execution phase.
|
|
pub call_gas_limit: u128,
|
|
/// Gas budget for the EntryPoint's verification phase.
|
|
pub verification_gas_limit: u128,
|
|
/// Gas burnt before the EntryPoint is even entered (calldata
|
|
/// cost + intrinsic).
|
|
pub pre_verification_gas: u128,
|
|
/// EIP-1559 max-fee-per-gas.
|
|
pub max_fee_per_gas: u128,
|
|
/// EIP-1559 max-priority-fee-per-gas.
|
|
pub max_priority_fee_per_gas: u128,
|
|
/// Paymaster + paymaster gas limits + paymaster data. Empty for
|
|
/// self-funded ops.
|
|
pub paymaster_and_data: Vec<u8>,
|
|
/// Account-side signature (EOA sig / multisig blob / passkey
|
|
/// assertion — depends on the account's `validateUserOp`).
|
|
pub signature: Vec<u8>,
|
|
}
|
|
|
|
impl UserOperation {
|
|
/// Construct an empty self-funded op for `sender`. Operator
|
|
/// fills in the rest before submission.
|
|
#[must_use]
|
|
pub fn new(sender: impl Into<String>) -> Self {
|
|
Self {
|
|
sender: sender.into(),
|
|
nonce: 0,
|
|
init_code: Vec::new(),
|
|
call_data: Vec::new(),
|
|
call_gas_limit: 0,
|
|
verification_gas_limit: 0,
|
|
pre_verification_gas: 0,
|
|
max_fee_per_gas: 0,
|
|
max_priority_fee_per_gas: 0,
|
|
paymaster_and_data: Vec::new(),
|
|
signature: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Validate enough of the structural envelope that a bundler
|
|
/// won't reject the op on a trivial mistake. Does not validate
|
|
/// the signature.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`Error::Constraint`] when fields violate the spec.
|
|
pub fn validate_structure(&self) -> Result<()> {
|
|
if !self.sender.starts_with("0x") || self.sender.len() != 42 {
|
|
return Err(Error::Constraint {
|
|
field: "sender",
|
|
reason: "must be 0x + 40 hex chars".into(),
|
|
});
|
|
}
|
|
if self.call_gas_limit == 0 {
|
|
return Err(Error::Constraint {
|
|
field: "call_gas_limit",
|
|
reason: "must be positive".into(),
|
|
});
|
|
}
|
|
if self.verification_gas_limit == 0 {
|
|
return Err(Error::Constraint {
|
|
field: "verification_gas_limit",
|
|
reason: "must be positive".into(),
|
|
});
|
|
}
|
|
if self.max_fee_per_gas < self.max_priority_fee_per_gas {
|
|
return Err(Error::Constraint {
|
|
field: "max_fee_per_gas",
|
|
reason: "must be >= max_priority_fee_per_gas".into(),
|
|
});
|
|
}
|
|
if !self.paymaster_and_data.is_empty() && self.paymaster_and_data.len() < 20 {
|
|
return Err(Error::Constraint {
|
|
field: "paymaster_and_data",
|
|
reason: "must be empty or >= 20 bytes (paymaster addr prefix)".into(),
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// ERC-4337 v0.7 `PackedUserOperation` — the calldata layout the
|
|
/// EntryPoint actually receives.
|
|
///
|
|
/// Two fields are packed into single 32-byte slots:
|
|
/// - `account_gas_limits` = (verificationGasLimit << 128) |
|
|
/// callGasLimit
|
|
/// - `gas_fees` = (maxPriorityFeePerGas << 128) | maxFeePerGas
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct PackedUserOperation {
|
|
/// Sender (smart account).
|
|
pub sender: String,
|
|
/// Nonce (192-bit key || 64-bit sequence — packed externally).
|
|
pub nonce: u128,
|
|
/// Init code (factory_addr ++ factory_calldata).
|
|
pub init_code: Vec<u8>,
|
|
/// Account call data.
|
|
pub call_data: Vec<u8>,
|
|
/// Packed gas limits — 32 bytes.
|
|
pub account_gas_limits: [u8; 32],
|
|
/// Pre-verification gas (kept unpacked).
|
|
pub pre_verification_gas: u128,
|
|
/// Packed gas fees — 32 bytes.
|
|
pub gas_fees: [u8; 32],
|
|
/// Paymaster blob.
|
|
pub paymaster_and_data: Vec<u8>,
|
|
/// Signature.
|
|
pub signature: Vec<u8>,
|
|
}
|
|
|
|
/// Pack a [`UserOperation`] into the v0.7 `PackedUserOperation`
|
|
/// layout (the bytes the EntryPoint consumes).
|
|
///
|
|
/// Each of the two packed slots is `(high_field << 128) |
|
|
/// low_field` encoded as 32-byte big-endian.
|
|
#[must_use]
|
|
pub fn pack_user_op(op: &UserOperation) -> PackedUserOperation {
|
|
let account_gas_limits = pack_two_u128(op.verification_gas_limit, op.call_gas_limit);
|
|
let gas_fees = pack_two_u128(op.max_priority_fee_per_gas, op.max_fee_per_gas);
|
|
PackedUserOperation {
|
|
sender: op.sender.clone(),
|
|
nonce: op.nonce,
|
|
init_code: op.init_code.clone(),
|
|
call_data: op.call_data.clone(),
|
|
account_gas_limits,
|
|
pre_verification_gas: op.pre_verification_gas,
|
|
gas_fees,
|
|
paymaster_and_data: op.paymaster_and_data.clone(),
|
|
signature: op.signature.clone(),
|
|
}
|
|
}
|
|
|
|
fn pack_two_u128(high: u128, low: u128) -> [u8; 32] {
|
|
let mut out = [0u8; 32];
|
|
out[..16].copy_from_slice(&high.to_be_bytes());
|
|
out[16..].copy_from_slice(&low.to_be_bytes());
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn sample_op() -> UserOperation {
|
|
UserOperation {
|
|
sender: "0x1111111111111111111111111111111111111111".into(),
|
|
nonce: 42,
|
|
init_code: vec![],
|
|
call_data: vec![0xde, 0xad],
|
|
call_gas_limit: 100_000,
|
|
verification_gas_limit: 60_000,
|
|
pre_verification_gas: 21_000,
|
|
max_fee_per_gas: 10_000_000_000,
|
|
max_priority_fee_per_gas: 2_000_000_000,
|
|
paymaster_and_data: vec![],
|
|
signature: vec![0xbe; 65],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn entrypoint_v07_canonical_address() {
|
|
assert_eq!(
|
|
EntryPointVersion::V0_7.canonical_address(),
|
|
"0x0000000071727de22e5e9d8baf0edac6f37da032"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_accepts_canonical_op() {
|
|
sample_op().validate_structure().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rejects_bad_sender() {
|
|
let mut op = sample_op();
|
|
op.sender = "not-an-address".into();
|
|
let err = op.validate_structure().unwrap_err();
|
|
assert!(matches!(err, Error::Constraint { field: "sender", .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rejects_zero_call_gas() {
|
|
let mut op = sample_op();
|
|
op.call_gas_limit = 0;
|
|
assert!(op.validate_structure().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn validate_rejects_priority_higher_than_max() {
|
|
let mut op = sample_op();
|
|
op.max_priority_fee_per_gas = op.max_fee_per_gas + 1;
|
|
let err = op.validate_structure().unwrap_err();
|
|
assert!(matches!(
|
|
err,
|
|
Error::Constraint {
|
|
field: "max_fee_per_gas",
|
|
..
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn pack_layout_high_low_split() {
|
|
let op = sample_op();
|
|
let packed = pack_user_op(&op);
|
|
|
|
// account_gas_limits: high = 60_000 (vGas), low = 100_000 (cGas)
|
|
let high_v: u128 = u128::from_be_bytes(packed.account_gas_limits[..16].try_into().unwrap());
|
|
let low_c: u128 = u128::from_be_bytes(packed.account_gas_limits[16..].try_into().unwrap());
|
|
assert_eq!(high_v, 60_000);
|
|
assert_eq!(low_c, 100_000);
|
|
|
|
// gas_fees: high = maxPriority, low = maxFee
|
|
let high_p: u128 = u128::from_be_bytes(packed.gas_fees[..16].try_into().unwrap());
|
|
let low_m: u128 = u128::from_be_bytes(packed.gas_fees[16..].try_into().unwrap());
|
|
assert_eq!(high_p, 2_000_000_000);
|
|
assert_eq!(low_m, 10_000_000_000);
|
|
}
|
|
}
|