Spaces:
Building
Building
| //! BTTC royalty distribution β RoyaltyDistributor.sol via ethers-rs. | |
| //! | |
| //! Production path: | |
| //! - Builds typed `distribute()` calldata via ethers-rs ABI encoding | |
| //! - Signs with Ledger hardware wallet (LedgerWallet provider) | |
| //! - Sends via `eth_sendRawTransaction` | |
| //! - ZK proof passed as ABI-encoded `bytes` argument | |
| //! | |
| //! Dev path (BTTC_DEV_MODE=1): | |
| //! - Returns stub tx hash, no network calls | |
| //! | |
| //! Value cap: MAX_DISTRIBUTION_BTT enforced before ABI encoding. | |
| //! The same cap is enforced in Solidity (defence-in-depth). | |
| use ethers_core::{ | |
| abi::{encode, Token}, | |
| types::{Address, Bytes, U256}, | |
| utils::keccak256, | |
| }; | |
| use shared::types::{BtfsCid, RoyaltySplit}; | |
| use tracing::{info, instrument, warn}; | |
| /// 1 million BTT (18 decimals) β matches MAX_DISTRIBUTION_BTT in Solidity. | |
| pub const MAX_DISTRIBUTION_BTT: u128 = 1_000_000 * 10u128.pow(18); | |
| /// 4-byte selector for `distribute(address[],uint256[],uint8,uint256,bytes)` | |
| fn distribute_selector() -> [u8; 4] { | |
| let sig = "distribute(address[],uint256[],uint8,uint256,bytes)"; | |
| let hash = keccak256(sig.as_bytes()); | |
| [hash[0], hash[1], hash[2], hash[3]] | |
| } | |
| /// ABI-encodes the `distribute()` calldata. | |
| /// Equivalent to `abi.encodeWithSelector(distribute.selector, recipients, amounts, band, bpSum, proof)`. | |
| fn encode_distribute_calldata( | |
| recipients: &[Address], | |
| amounts: &[U256], | |
| band: u8, | |
| bp_sum: u64, | |
| proof: &[u8], | |
| ) -> Bytes { | |
| let selector = distribute_selector(); | |
| let tokens = vec![ | |
| Token::Array(recipients.iter().map(|a| Token::Address(*a)).collect()), | |
| Token::Array(amounts.iter().map(|v| Token::Uint(*v)).collect()), | |
| Token::Uint(U256::from(band)), | |
| Token::Uint(U256::from(bp_sum)), | |
| Token::Bytes(proof.to_vec()), | |
| ]; | |
| let mut calldata = selector.to_vec(); | |
| calldata.extend_from_slice(&encode(&tokens)); | |
| Bytes::from(calldata) | |
| } | |
| pub struct SubmitResult { | |
| pub tx_hash: String, | |
| // band included for callers and future API responses | |
| pub band: u8, | |
| } | |
| pub async fn submit_distribution( | |
| cid: &BtfsCid, | |
| splits: &[RoyaltySplit], | |
| band: u8, | |
| proof: Option<&[u8]>, | |
| ) -> anyhow::Result<SubmitResult> { | |
| let rpc = std::env::var("BTTC_RPC_URL").unwrap_or_else(|_| "http://127.0.0.1:8545".into()); | |
| let contract = std::env::var("ROYALTY_CONTRACT_ADDR") | |
| .unwrap_or_else(|_| "0x0000000000000000000000000000000000000001".into()); | |
| info!(cid=%cid.0, band=%band, rpc=%rpc, "Submitting to BTTC"); | |
| // ββ Dev mode ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if std::env::var("BTTC_DEV_MODE").unwrap_or_default() == "1" { | |
| warn!("BTTC_DEV_MODE=1 β returning stub tx hash"); | |
| return Ok(SubmitResult { | |
| tx_hash: format!("0x{}", "ab".repeat(32)), | |
| band, | |
| }); | |
| } | |
| // ββ Value cap (Rust layer β Solidity enforces the same) βββββββββββββ | |
| let total_btt: u128 = splits.iter().map(|s| s.amount_btt).sum(); | |
| if total_btt > MAX_DISTRIBUTION_BTT { | |
| anyhow::bail!( | |
| "Distribution of {} BTT exceeds MAX_DISTRIBUTION_BTT ({} BTT). \ | |
| Use the timelock queue for large distributions.", | |
| total_btt / 10u128.pow(18), | |
| MAX_DISTRIBUTION_BTT / 10u128.pow(18), | |
| ); | |
| } | |
| // ββ Parse recipients + amounts βββββββββββββββββββββββββββββββββββββββ | |
| let mut recipients: Vec<Address> = Vec::with_capacity(splits.len()); | |
| let mut amounts: Vec<U256> = Vec::with_capacity(splits.len()); | |
| for split in splits { | |
| let addr: Address = split | |
| .address | |
| .0 | |
| .parse() | |
| .map_err(|e| anyhow::anyhow!("Invalid EVM address in split: {e}"))?; | |
| recipients.push(addr); | |
| amounts.push(U256::from(split.amount_btt)); | |
| } | |
| let bp_sum: u64 = splits.iter().map(|s| s.bps as u64).sum(); | |
| anyhow::ensure!( | |
| bp_sum == 10_000, | |
| "Basis points must sum to 10,000, got {}", | |
| bp_sum | |
| ); | |
| let proof_bytes = proof.unwrap_or(&[]); | |
| let calldata = encode_distribute_calldata(&recipients, &amounts, band, bp_sum, proof_bytes); | |
| let contract_addr: Address = contract | |
| .parse() | |
| .map_err(|e| anyhow::anyhow!("Invalid ROYALTY_CONTRACT_ADDR: {e}"))?; | |
| // ββ Sign via Ledger and send βββββββββββββββββββββββββββββββββββββββββ | |
| let tx_hash = send_via_ledger(&rpc, contract_addr, calldata).await?; | |
| // Validate returned hash through LangSec recognizer | |
| shared::parsers::recognize_tx_hash(&tx_hash) | |
| .map_err(|e| anyhow::anyhow!("RPC returned invalid tx hash: {e}"))?; | |
| info!(tx_hash=%tx_hash, cid=%cid.0, band=%band, "BTTC distribution submitted"); | |
| Ok(SubmitResult { tx_hash, band }) | |
| } | |
| /// Signs and broadcasts a transaction using the Ledger hardware wallet. | |
| /// | |
| /// Uses ethers-rs `LedgerWallet` with HDPath `m/44'/60'/0'/0/0`. | |
| /// The Ledger must be connected, unlocked, and the Ethereum app open. | |
| /// Signing is performed directly via `Signer::sign_transaction` β no | |
| /// `SignerMiddleware` (and therefore no ethers-middleware / reqwest 0.11) | |
| /// is required. | |
| async fn send_via_ledger(rpc_url: &str, to: Address, calldata: Bytes) -> anyhow::Result<String> { | |
| use ethers_core::types::{transaction::eip2718::TypedTransaction, TransactionRequest}; | |
| use ethers_providers::{Http, Middleware, Provider}; | |
| use ethers_signers::{HDPath, Ledger, Signer}; | |
| let provider = Provider::<Http>::try_from(rpc_url) | |
| .map_err(|e| anyhow::anyhow!("Cannot connect to RPC {rpc_url}: {e}"))?; | |
| let chain_id = provider.get_chainid().await?.as_u64(); | |
| let ledger = Ledger::new(HDPath::LedgerLive(0), chain_id) | |
| .await | |
| .map_err(|e| { | |
| anyhow::anyhow!( | |
| "Ledger connection failed: {e}. \ | |
| Ensure device is connected, unlocked, and Ethereum app is open." | |
| ) | |
| })?; | |
| let from = ledger.address(); | |
| let nonce = provider.get_transaction_count(from, None).await?; | |
| let mut typed_tx = TypedTransaction::Legacy( | |
| TransactionRequest::new() | |
| .from(from) | |
| .to(to) | |
| .data(calldata) | |
| .nonce(nonce) | |
| .chain_id(chain_id), | |
| ); | |
| let gas_est = provider | |
| .estimate_gas(&typed_tx, None) | |
| .await | |
| .unwrap_or(U256::from(300_000u64)); | |
| // 20% gas buffer | |
| typed_tx.set_gas(gas_est * 120u64 / 100u64); | |
| // Sign with Ledger hardware wallet (no middleware needed) | |
| let signature = ledger | |
| .sign_transaction(&typed_tx) | |
| .await | |
| .map_err(|e| anyhow::anyhow!("Transaction rejected by Ledger: {e}"))?; | |
| // Broadcast signed raw transaction via provider | |
| let raw = typed_tx.rlp_signed(&signature); | |
| let pending = provider | |
| .send_raw_transaction(raw) | |
| .await | |
| .map_err(|e| anyhow::anyhow!("RPC rejected transaction: {e}"))?; | |
| // Wait for 1 confirmation | |
| let receipt = pending | |
| .confirmations(1) | |
| .await? | |
| .ok_or_else(|| anyhow::anyhow!("Transaction dropped from mempool"))?; | |
| Ok(format!("{:#x}", receipt.transaction_hash)) | |
| } | |
| mod tests { | |
| use super::*; | |
| fn selector_is_stable() { | |
| // The 4-byte selector for distribute() must never change β | |
| // it's what the Solidity ABI expects. | |
| let sel = distribute_selector(); | |
| // Verify it's non-zero (actual value depends on full sig hash) | |
| assert!(sel.iter().any(|b| *b != 0), "selector must be non-zero"); | |
| } | |
| fn value_cap_enforced() { | |
| // total > MAX should be caught before any network call | |
| let splits = vec![shared::types::RoyaltySplit { | |
| address: shared::types::EvmAddress("0x0000000000000000000000000000000000000001".into()), | |
| bps: 10_000, | |
| amount_btt: MAX_DISTRIBUTION_BTT + 1, | |
| }]; | |
| // We can't call the async fn in a sync test, but we verify the cap constant | |
| assert!(splits.iter().map(|s| s.amount_btt).sum::<u128>() > MAX_DISTRIBUTION_BTT); | |
| } | |
| fn calldata_encodes_without_panic() { | |
| let recipients = vec!["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" | |
| .parse::<Address>() | |
| .unwrap()]; | |
| let amounts = vec![U256::from(1000u64)]; | |
| let proof = vec![0x01u8, 0x02, 0x03]; | |
| let data = encode_distribute_calldata(&recipients, &amounts, 0, 10_000, &proof); | |
| // 4 selector bytes + at least 5 ABI words | |
| assert!(data.len() >= 4 + 5 * 32); | |
| } | |
| } | |