diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/mod.rs index f7b5da6d3..941dcf26c 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/mod.rs @@ -246,7 +246,8 @@ mod state_machine_tests { .max() .unwrap_or(0); assert_eq!( - era, 300, + era, + 3 * crate::economic::native_reserve::ERA_FAUCET_PAYOUT, "current_state must reflect the canonical head's balance" ); assert_eq!(cs.hash, head.root(), "hash is the canonical SMT root"); diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/era_policy.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/era_policy.rs index cb33682c4..f90b8738d 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/token/era_policy.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/token/era_policy.rs @@ -29,11 +29,12 @@ const ERA_POLICY_PROTO: [u8; 40] = [ 0x03, b'E', b'R', b'A', // // alias "ERA". 0x00, 0x03, b'E', b'R', b'A', // - // decimals: whole ERA. - 0x00, // - // genesis supply: 80,000,000,000 (u128, big-endian; owner, 2026-09-26). + // decimals: two (SoFi Amendment S18, owner 2026-10-01). + 0x02, // + // genesis supply: 80,000,000,000.00 ERA, which is 8,000,000,000,000 base + // units (u128, big-endian; owner 2026-09-26, in base units since S18). 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // - 0x00, 0x00, 0x00, 0x12, 0xA0, 0x5F, 0x20, 0x00, // + 0x00, 0x00, 0x07, 0x46, 0xA5, 0x28, 0x80, 0x00, // // description: none; icon: none. 0x00, 0x00, 0x00, 0x00, // // recipient allowlist: none (kind NONE, count 0). @@ -84,7 +85,7 @@ mod tests { ); assert_eq!( crate::utils::text_id::encode_base32_crockford(&era_policy_commit()), - "JXPMPGJH45HDTE0ARWE2CTB9E9BWTQZ3T78CE5RFF1RXMR9VKK80" + "NNG176RZ6ACTWCDPRNYHXZK2DCZ72SPA9Q6XWGRGQ9JGKZYTESG0" ); } @@ -97,7 +98,7 @@ mod tests { assert_eq!(decoded.encode_to_vec(), era_policy_bytes()); } - /// Every field of ERA's policy, as SoFi Amendment S11 fixes it. + /// Every field of ERA's policy, as SoFi Amendments S11 and S18 fix it. #[test] fn eras_policy_states_what_the_specification_fixes() { assert_eq!( @@ -105,8 +106,8 @@ mod tests { &TokenPolicy { ticker: "ERA".into(), alias: "ERA".into(), - decimals: 0, - genesis_supply: 80_000_000_000, + decimals: 2, + genesis_supply: 8_000_000_000_000, release: Release::Faucet, description: None, icon_url: None, diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs index fa8b72868..2dc850d47 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs @@ -19,8 +19,9 @@ use crate::types::error::DsmError; /// conservation guard must be able to validate it. The guard is a pure function /// over `(operation, deltas)`; a fee it cannot see is a fee it cannot enforce, /// and a fee that a runtime map could change is not a protocol rule. The SDK's -/// schedule now READS this value, so there is exactly one authority. -pub const TOKEN_CREATION_FEE_ERA: u64 = 10; +/// schedule now READS this value, so there is exactly one authority. In base +/// units: 10.00 ERA at ERA's two decimals (SoFi Amendment S18). +pub const TOKEN_CREATION_FEE_ERA: u64 = 1_000; /// Display-only ticker resolution for non-builtin (CPTA-anchored) tokens. /// diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/native_reserve.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/native_reserve.rs index 74affbedc..d15d461ad 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/native_reserve.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/native_reserve.rs @@ -73,14 +73,15 @@ use crate::types::proto as generated; type D32 = [u8; 32]; -/// The whole distributable ERA supply of one network at genesis. Nothing is -/// minted after it; every unit in circulation was released from it. -pub const ERA_RESERVE_GENESIS_SUPPLY: u64 = 80_000_000_000; +/// The whole distributable ERA supply of one network at genesis, in base +/// units: 80,000,000,000.00 ERA at ERA's two decimals (SoFi Amendment S18). +/// Nothing is minted after it; every unit in circulation was released from it. +pub const ERA_RESERVE_GENESIS_SUPPLY: u64 = 8_000_000_000_000; -/// What one beta faucet claim releases. ERA is whole-unit (`decimals = 0`), -/// so this is literally 100 ERA. The claim names no amount: the beta claim -/// policy fixes it, and the accepting transition refuses any other delta. -pub const ERA_FAUCET_PAYOUT: u64 = 100; +/// What one beta faucet claim releases, in base units: 100.00 ERA (SoFi +/// Amendment S18). The claim names no amount: the beta claim policy fixes it, +/// and the accepting transition refuses any other delta. +pub const ERA_FAUCET_PAYOUT: u64 = 10_000; /// Matches the proto's `dsm_max_len`; prost does not enforce it, so this /// module does. @@ -849,7 +850,7 @@ mod tests { #[test] fn the_envelope_round_trips_and_is_strict() { - let release = signed(&genesis(), 100); + let release = signed(&genesis(), ERA_FAUCET_PAYOUT); let bytes = release.envelope_bytes.clone(); // Decodable-but-non-canonical: unknown field, silently skipped by // prost, caught only by the re-encode comparison. @@ -1041,7 +1042,7 @@ mod tests { release_constructible(&r0, &zero), Err(ReleaseRefusal::ZeroRelease) ); - let (release, pk) = signed_with_key(&r0, 100); + let (release, pk) = signed_with_key(&r0, ERA_FAUCET_PAYOUT); let r1 = release_constructible(&r0, &release).expect("constructible"); // Every unit that left the reserve is accounted to the recipient the // body names, and that recipient is the signer. @@ -1057,7 +1058,7 @@ mod tests { fn a_release_must_succeed_exactly_its_parent() { let r0 = genesis(); let (pk, sk) = keypair(); - let mut wrong_root = body(&r0, 100, &pk); + let mut wrong_root = body(&r0, ERA_FAUCET_PAYOUT, &pk); wrong_root.parent_root = [0xEE; 32]; let wrong_root = decode_and_verify_release(&sign_release(&wrong_root, &sk).unwrap()).unwrap(); @@ -1065,21 +1066,21 @@ mod tests { release_constructible(&r0, &wrong_root), Err(ReleaseRefusal::ParentRootMismatch) ); - let mut wrong_gen = body(&r0, 100, &pk); + let mut wrong_gen = body(&r0, ERA_FAUCET_PAYOUT, &pk); wrong_gen.generation = 2; let wrong_gen = decode_and_verify_release(&sign_release(&wrong_gen, &sk).unwrap()).unwrap(); assert_eq!( release_constructible(&r0, &wrong_gen), Err(ReleaseRefusal::GenerationIsNotSuccessor) ); - let mut other = body(&r0, 100, &pk); + let mut other = body(&r0, ERA_FAUCET_PAYOUT, &pk); other.reserve_id = era_reserve_id(b"othernet"); let other = decode_and_verify_release(&sign_release(&other, &sk).unwrap()).unwrap(); assert_eq!( release_constructible(&r0, &other), Err(ReleaseRefusal::NamesAnotherReserve) ); - let mut foreign = body(&r0, 100, &pk); + let mut foreign = body(&r0, ERA_FAUCET_PAYOUT, &pk); foreign.storage_set_id = [0x77; 32]; let foreign = decode_and_verify_release(&sign_release(&foreign, &sk).unwrap()).unwrap(); assert_eq!( @@ -1097,7 +1098,7 @@ mod tests { #[test] fn finality_without_the_deterministic_leader_is_impossible() { let r0 = genesis(); - let release = signed(&r0, 100); + let release = signed(&r0, ERA_FAUCET_PAYOUT); let x = release.envelope_bytes.clone(); let (at, mut skipped_leader) = successor_cell(&r0); skipped_leader.write(&x, ROUTE_LEN - 1, &[0]); @@ -1130,7 +1131,7 @@ mod tests { #[test] fn unrecognized_bytes_never_occupy_the_cell() { let r0 = genesis(); - let release = signed(&r0, 100); + let release = signed(&r0, ERA_FAUCET_PAYOUT); // Signed, canonical, succeeding R_0 — and releasing more than exists. let too_much = signed(&r0, ERA_RESERVE_GENESIS_SUPPLY + 1); let (at, mut cell) = successor_cell(&r0); @@ -1153,8 +1154,8 @@ mod tests { #[test] fn additional_replicas_do_not_alter_the_winner() { let r0 = genesis(); - let a = signed(&r0, 100); - let b = signed(&r0, 100); + let a = signed(&r0, ERA_FAUCET_PAYOUT); + let b = signed(&r0, ERA_FAUCET_PAYOUT); let child_a = release_constructible(&r0, &a).unwrap(); let (at, mut cell) = successor_cell(&r0); cell.write(&a.envelope_bytes, 0, &[]); @@ -1183,7 +1184,7 @@ mod tests { #[test] fn a_final_release_has_a_completion_proof_that_checks() { let r0 = genesis(); - let release = signed(&r0, 100); + let release = signed(&r0, ERA_FAUCET_PAYOUT); let (at, mut cell) = successor_cell(&r0); cell.write(&release.envelope_bytes, 1, &[]); assert_eq!(successor_completion(&at, &cell.evidence()), Ok(None)); @@ -1206,7 +1207,7 @@ mod tests { #[test] fn the_walk_advances_through_final_releases_and_stops_at_the_head() { let r0 = genesis(); - let rel1 = signed(&r0, 100); + let rel1 = signed(&r0, ERA_FAUCET_PAYOUT); let r1 = release_constructible(&r0, &rel1).unwrap(); let rel2 = signed(&r1, ERA_FAUCET_PAYOUT); let r2 = release_constructible(&r1, &rel2).unwrap(); @@ -1267,7 +1268,7 @@ mod tests { }) ); assert_eq!(visited, 0, "nothing final was read"); - let rel1 = signed(&r0, 100); + let rel1 = signed(&r0, ERA_FAUCET_PAYOUT); let r1 = release_constructible(&r0, &rel1).unwrap(); let stop = walk_lineage( r0, diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/offline_step_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/offline_step_tests.rs index fb2d66721..45d38b90a 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/offline_step_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/bluetooth/offline_step_tests.rs @@ -748,7 +748,7 @@ async fn bearer_pair() -> ( "wallet.loadOffline", &crate::generated::OfflineCashRequest { token_id: "ERA".to_string(), - amount: "20".to_string(), + amount: "20.00".to_string(), }, ) .await; @@ -789,7 +789,7 @@ async fn a_bearer_step_proves_its_whole_write_set_and_commits_on_both() { assert!(kinds.contains(&ReceiptLeaf::AnchorState), "{kinds:?}"); assert!( kinds.contains(&ReceiptLeaf::OfflineAllocation { - pre_amount: 20, + pre_amount: crate::economic_fixtures::whole_era(20), pre_sequence: 1, }), "the allocation's pre-state is the load: {kinds:?}" @@ -1868,7 +1868,11 @@ async fn an_online_send_waits_for_the_offline_step_in_flight() { !refused.success, "A sent online with its offline step in flight" ); - assert_eq!(pair.a.era_balance(), 1_000, "a refused send debited"); + assert_eq!( + pair.a.era_balance(), + crate::economic_fixtures::whole_era(1_000), + "a refused send debited" + ); b.device.enter(); b.handler @@ -1880,7 +1884,11 @@ async fn an_online_send_waits_for_the_offline_step_in_flight() { !refused.success, "B sent online while it held A's offline proposal" ); - assert_eq!(pair.b.era_balance(), 1_000, "a refused send debited"); + assert_eq!( + pair.b.era_balance(), + crate::economic_fixtures::whole_era(1_000), + "a refused send debited" + ); a.device.enter(); let cancellation = a diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/economic_fixtures.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/economic_fixtures.rs index fa771a383..af12709c5 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/economic_fixtures.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/economic_fixtures.rs @@ -62,6 +62,18 @@ pub fn use_test_storage_dir() { /// else can never produce an admitted position. pub const NETWORK: &[u8] = b"dsm-testnet"; +/// `whole` ERA in base units, at ERA's committed decimals (SoFi Amendment +/// S18): a test that speaks in ERA states its amounts through this, so it +/// keeps its meaning whatever ERA's decimals are. +pub fn whole_era(whole: u64) -> u64 { + whole + * 10u64.pow( + dsm::core::token::era_policy::era_policy() + .expect("ERA's policy") + .decimals, + ) +} + /// The device's environment config, pointed at one node set. Dropping it /// removes the config, so a later test that forgets to point the SDK at its /// own nodes fails to load a config rather than reaching this set's. diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/balance_list_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/balance_list_tests.rs index 03f0fc953..910a2f7e4 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/balance_list_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/balance_list_tests.rs @@ -124,18 +124,18 @@ async fn decimals_survive_a_restart_for_a_held_token() { assert_eq!(t.display_amount, "500.00"); } -/// Builtins keep their exact values: ERA whole units, dBTC in satoshis, no -/// policy icon, and each its protocol-defined anchor. +/// Builtins keep their exact values: ERA at two decimals (SoFi Amendment S18), +/// dBTC in satoshis, no policy icon, and each its protocol-defined anchor. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[serial] async fn builtin_tokens_keep_their_metadata() { let d = Device::funded(0x83).await; let rows = wire_rows(&d.router).await; let era = row(&rows, "ERA"); - assert_eq!(era.decimals, 0); + assert_eq!(era.decimals, 2); assert_eq!(era.symbol, "ERA"); - assert_eq!(era.available, 100); - assert_eq!(era.display_amount, "100"); + assert_eq!(era.available, crate::economic_fixtures::whole_era(100)); + assert_eq!(era.display_amount, "100.00"); let dbtc = row(&rows, "dBTC"); assert_eq!(dbtc.decimals, 8); assert_eq!(dbtc.symbol, "dBTC"); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bilateral_finality_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bilateral_finality_tests.rs index 71c4c0c73..37af18d9b 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bilateral_finality_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bilateral_finality_tests.rs @@ -17,6 +17,7 @@ //! release, R7 byte-identical checkpoint replay, R11 one deleter for the gate. //! R5/R6 live beside the code they pin (`storage_routes` / `core_sdk`). +use crate::economic_fixtures::whole_era; use crate::storage::client_db as cdb; use crate::test_support::two_device::{assert_incomplete, Pair, TestDevice}; use dsm::types::proto as generated; @@ -340,7 +341,7 @@ async fn r1_role_reversal_applies_once_on_a_and_finalizes_on_b() { let rel = p.a.rel_key_with(&p.b); generation(&p.a, &p.b, 10).await; generation(&p.a, &p.b, 10).await; - assert_eq!(p.b.era_balance(), 120); + assert_eq!(p.b.era_balance(), whole_era(100) + 20); // A pins B's head at exactly the child B journaled on its second apply — // learned from B's delta, authenticated by sig_b — and that is the parent @@ -372,8 +373,12 @@ async fn r1_role_reversal_applies_once_on_a_and_finalizes_on_b() { generation(&p.b, &p.a, 5).await; - assert_eq!(p.a.era_balance(), 985, "A credited exactly once"); - assert_eq!(p.b.era_balance(), 115); + assert_eq!( + p.a.era_balance(), + whole_era(1_000) - 20 + 5, + "A credited exactly once" + ); + assert_eq!(p.b.era_balance(), whole_era(100) + 20 - 5); p.a.enter(); assert_eq!(rows_for_relationship("canonical_apply_identity", &rel), 1); assert_eq!( @@ -466,7 +471,7 @@ async fn r2b_the_certificate_releases_the_recipient() { "B released by the certificate: {status:?}" ); generation(&p.b, &p.a, 5).await; - assert_eq!(p.a.era_balance(), 995); + assert_eq!(p.a.era_balance(), whole_era(1_000) - 10 + 5); } // ===================================================================== @@ -507,7 +512,7 @@ async fn r3_sender_stays_gated_until_the_checkpoint_reaches_quorum() { assert_eq!(status.send_block_reason, pending_catchup()); let refused = p.a.send(&p.b, 1).await; assert!(!refused.success, "second A->B must be refused"); - assert_eq!(p.a.era_balance(), 990, "no second debit"); + assert_eq!(p.a.era_balance(), whole_era(1_000) - 10, "no second debit"); // Fleet back: the sweep replays the exact certificate, quorum, release. p.nodes.restore_spools(&down).await; @@ -558,7 +563,7 @@ async fn a_send_before_the_previous_step_finalizes_is_gated_never_marked_for_res !cdb::cert_resync_blocks_send(&rel).expect("resync state"), "the relationship is not marked for resync" ); - assert_eq!(p.a.era_balance(), 990, "no second debit"); + assert_eq!(p.a.era_balance(), whole_era(1_000) - 10, "no second debit"); // The step finalizes; the relationship sends again. let b_sync = p.b.sync().await; @@ -825,7 +830,7 @@ async fn r7_a_frozen_checkpoint_is_replayed_byte_identically_after_the_fleet_ret vec![cdb::OUTBOX_GC_PENDING.to_string()] ); assert_eq!(proposal_statuses(&rel).len(), 1, "no second proposal"); - assert_eq!(p.a.era_balance(), 990, "no second debit"); + assert_eq!(p.a.era_balance(), whole_era(1_000) - 10, "no second debit"); // The replay re-posts to members that already held the first delivery; // a node deduplicates nothing (storage spec §8), so those hold it twice. // What matters is that every copy anywhere is the frozen envelope under diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bilateral_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bilateral_routes.rs index 729d692aa..3432eb26e 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bilateral_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/bilateral_routes.rs @@ -300,7 +300,7 @@ mod pending_list_tests { ); assert_eq!(prepared.recipient_id, peer.to_vec()); assert_eq!((prepared.amount, prepared.token_id.as_str()), (3, "ERA")); - assert_eq!(prepared.display_amount.as_deref(), Some("3")); + assert_eq!(prepared.display_amount.as_deref(), Some("0.03")); assert!(prepared.cancellable, "an unconfirmed proposal is offered"); let confirmed = step([0xA2; 32]); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/faucet_flow_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/faucet_flow_tests.rs index 61fd36463..10393e1b5 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/faucet_flow_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/faucet_flow_tests.rs @@ -18,7 +18,7 @@ use dsm::economic::native_reserve::{ }; use crate::bridge::{AppQuery, AppRouter}; -use crate::economic_fixtures::NETWORK; +use crate::economic_fixtures::{whole_era, NETWORK}; use crate::generated; use crate::sdk::faucet_claim_flow::claim_era_faucet; use crate::sdk::storage_set::{as_ccb_members, canonical_set}; @@ -91,11 +91,15 @@ async fn a_full_claim_credits_100_era_and_admits_position_1() { let outcome = claim_era_faucet(d.core(), NETWORK) .await .expect("claim succeeds"); - assert_eq!(outcome.tokens_received, 100); + assert_eq!(outcome.tokens_received, whole_era(100)); assert_eq!(outcome.economic_position, 1); let head = d.core().device_head().expect("head"); - assert_eq!(head.balance(&era()), 100, "exactly +100, conservation"); + assert_eq!( + head.balance(&era()), + whole_era(100), + "exactly +100 ERA, conservation" + ); assert!( head.pending_economic_admission().is_none(), "admitted ⇒ unfenced" @@ -120,8 +124,8 @@ async fn a_full_claim_credits_100_era_and_admits_position_1() { claim.tx_type, generated::TransactionType::TxTypeFaucet as i32 ); - assert_eq!(claim.amount_signed, 100); - assert_eq!(claim.display_amount, "100"); + assert_eq!(claim.amount_signed, whole_era(100) as i64); + assert_eq!(claim.display_amount, "100.00"); assert_eq!(claim.token_id, "ERA"); assert!(claim.from_device_id.is_empty(), "the source is the reserve"); assert_eq!(claim.to_device_id, d.identity.device_id.to_vec()); @@ -206,7 +210,7 @@ async fn a_release_of_the_whole_supply_holds_nothing_and_the_claim_lands() { let outcome = claim_era_faucet(d.core(), NETWORK) .await .expect("the claim lands"); - assert_eq!(outcome.tokens_received, 100); + assert_eq!(outcome.tokens_received, whole_era(100)); assert_eq!( recipient_of(&release_at(1).await), d.identity.device_id, @@ -214,7 +218,10 @@ async fn a_release_of_the_whole_supply_holds_nothing_and_the_claim_lands() { ); let head = reserve_head().await; assert_eq!(head.generation, 1); - assert_eq!(head.remaining_supply, ERA_RESERVE_GENESIS_SUPPLY - 100); + assert_eq!( + head.remaining_supply, + ERA_RESERVE_GENESIS_SUPPLY - whole_era(100) + ); } /// Owner ruling 2026-09-25: a faucet release occupies a reserve cell only @@ -256,7 +263,7 @@ async fn a_release_naming_a_device_its_key_does_not_derive_holds_nothing() { let outcome = claim_era_faucet(d.core(), NETWORK) .await .expect("the claim lands"); - assert_eq!(outcome.tokens_received, 100); + assert_eq!(outcome.tokens_received, whole_era(100)); let won = release_at(1).await; assert_ne!( won.envelope_bytes, impostor, @@ -331,7 +338,11 @@ async fn a_repeat_claimant_succeeds_on_the_next_generation() { .await .expect("second claim"); assert_eq!(two.economic_position, 2, "each claim advances the position"); - assert_eq!(d.era_balance(), 200, "two claims, exactly 200"); + assert_eq!( + d.era_balance(), + whole_era(200), + "two claims, exactly 200 ERA" + ); assert_eq!(reserve_head().await.generation, 2); } @@ -348,15 +359,18 @@ async fn another_claimants_release_moves_the_head_and_the_next_claim_takes_the_g let outcome = claim_era_faucet(&p.a.router().core_sdk, NETWORK) .await .expect("A claims the next generation"); - assert_eq!(outcome.tokens_received, 100); + assert_eq!(outcome.tokens_received, whole_era(100)); assert_eq!(outcome.economic_position, 1, "A's own first position"); - assert_eq!(p.a.era_balance(), 100); + assert_eq!(p.a.era_balance(), whole_era(100)); assert_eq!(recipient_of(&release_at(1).await), p.b.device_id); assert_eq!(recipient_of(&release_at(2).await), p.a.device_id); let head = reserve_head().await; assert_eq!(head.generation, 2); - assert_eq!(head.remaining_supply, ERA_RESERVE_GENESIS_SUPPLY - 200); + assert_eq!( + head.remaining_supply, + ERA_RESERVE_GENESIS_SUPPLY - whole_era(200) + ); } /// A claim whose release reached only the leader and one more seat leaves a @@ -398,7 +412,7 @@ async fn a_release_cut_short_after_the_leader_is_carried_by_the_next_claimant_an .await .expect("B's claim is not bricked by A's short chain"); assert_eq!(b_claim.economic_position, 1); - assert_eq!(p.b.era_balance(), 100); + assert_eq!(p.b.era_balance(), whole_era(100)); let first = release_at(1).await; assert_eq!(first.envelope_bytes, frozen, "generation 1 is A's release"); assert_eq!(recipient_of(&release_at(2).await), p.b.device_id); @@ -407,12 +421,15 @@ async fn a_release_cut_short_after_the_leader_is_carried_by_the_next_claimant_an let resumed = claim_era_faucet(&p.a.router().core_sdk, NETWORK) .await .expect("A finishes on its own generation"); - assert_eq!(resumed.tokens_received, 100); + assert_eq!(resumed.tokens_received, whole_era(100)); assert_eq!(resumed.economic_position, 1); - assert_eq!(p.a.era_balance(), 100); + assert_eq!(p.a.era_balance(), whole_era(100)); let head = reserve_head().await; assert_eq!(head.generation, 2, "A released once, B once"); - assert_eq!(head.remaining_supply, ERA_RESERVE_GENESIS_SUPPLY - 200); + assert_eq!( + head.remaining_supply, + ERA_RESERVE_GENESIS_SUPPLY - whole_era(200) + ); } /// The claimant alone: its release cut short after the leader, its retry @@ -438,7 +455,7 @@ async fn a_claim_cut_short_resumes_on_its_own_release_byte_identically() { let outcome = claim_era_faucet(d.core(), NETWORK) .await .expect("resumed claim"); - assert_eq!(outcome.tokens_received, 100); + assert_eq!(outcome.tokens_received, whole_era(100)); assert_eq!(outcome.economic_position, 1); assert_eq!( release_at(1).await.envelope_bytes, @@ -446,7 +463,7 @@ async fn a_claim_cut_short_resumes_on_its_own_release_byte_identically() { "the resumed claim used the frozen release" ); assert_eq!(reserve_head().await.generation, 1, "released once"); - assert_eq!(d.era_balance(), 100); + assert_eq!(d.era_balance(), whole_era(100)); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -461,7 +478,7 @@ async fn admissions_are_never_double_finished_and_positions_stay_monotonic() { assert_eq!(second.economic_position, 2); let third = claim_era_faucet(d.core(), NETWORK).await.expect("claim 3"); assert_eq!(third.economic_position, 3); - assert_eq!(d.era_balance(), 300); + assert_eq!(d.era_balance(), whole_era(300)); let (position, _root) = client_db::economic_lineage::get_admitted_coordinate() .expect("read admitted") .expect("admitted"); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs index 3fca1054c..56e7ed451 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/frontier_verification_tests.rs @@ -250,7 +250,10 @@ async fn a_credits_source_is_validated_one_hop_back_and_no_further() { assert!(paid.success, "{:?}", paid.error_message); let credited = p.a.sync().await; assert!(credited.success, "{:?}", credited.errors); - assert_eq!(p.a.era_balance(), 130); + assert_eq!( + p.a.era_balance(), + crate::economic_fixtures::whole_era(100) + 30 + ); let sent = p.a.send(&p.b, 20).await; assert!(sent.success, "{:?}", sent.error_message); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs index 54420838d..808e22659 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs @@ -142,7 +142,10 @@ async fn a_transfer_reaches_the_nodes_only_sealed_and_arrives() { assert!(b_sync.success, "{:?}", b_sync.errors); let a_sync = p.a.sync().await; assert!(a_sync.success, "{:?}", a_sync.errors); - assert_eq!(p.a.era_balance(), 90); + assert_eq!( + p.a.era_balance(), + crate::economic_fixtures::whole_era(100) - 10 + ); assert_eq!(p.b.era_balance(), 10); let memo = format!("{}->{} #1", p.a.slot, p.b.slot).into_bytes(); @@ -316,7 +319,8 @@ async fn set_up(d: &TestDevice, vault_id: &[u8; 32]) -> Vec { } } -/// A creates the token and the vault (100 ERA against 1000 TKN at 30 bps); +/// A creates the token and the vault (100 base units of ERA against 1000 of +/// TKN, at 30 bps); /// B adopts the token and sets up. async fn open_market(p: &Pair) -> Market { let tkn = create_token(&p.a, "TKN", 10_000).await; @@ -456,11 +460,15 @@ async fn a_sofi_trade_executes_end_to_end() { let p = Pair::boot(500, 200).await; let m = open_market(&p).await; realized_trade(&p, &m, 10).await; - // The vault priced the trade at its reserves: 10 ERA in against 100 ERA - // and 1000 TKN, at 30 bps. + // The vault priced the trade at its reserves: 10 base units of ERA in + // against 100 of ERA and 1000 of TKN, at 30 bps. let out = dsm::dlv::route_commit::constant_product_output(10, 100, 1_000, 30) .expect("the vault prices the trade"); - assert_eq!(balance(&p.b, &m.era), 190, "the trader paid 10 ERA"); + assert_eq!( + balance(&p.b, &m.era), + crate::economic_fixtures::whole_era(200) - 10, + "the trader paid 10 base units" + ); assert_eq!( balance(&p.b, &m.tkn), out, @@ -864,7 +872,10 @@ async fn a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_byte assert_eq!(next.fulfillment().body.attempts()[0].attempt, 1); let out2 = dsm::dlv::route_commit::constant_product_output(10, 110, 1_000 - out1, 30) .expect("the vault prices the second trade"); - assert_eq!(balance(&p.b, &m.era), 180); + assert_eq!( + balance(&p.b, &m.era), + crate::economic_fixtures::whole_era(200) - 20 + ); assert_eq!(balance(&p.b, &m.tkn), out1 + out2); head_agrees_with_admitted_root(&p.b, &[m.era, m.tkn]); } @@ -916,7 +927,10 @@ async fn an_unsigned_exercise_at_a_successor_key_takes_nothing() { .into_exercise() .expect("B's second exercise holds the first key at R1"); assert_eq!(second.fulfillment().body.position(), q2); - assert_eq!(balance(&p.b, &m.era), 180); + assert_eq!( + balance(&p.b, &m.era), + crate::economic_fixtures::whole_era(200) - 20 + ); head_agrees_with_admitted_root(&p.b, &[m.era, m.tkn]); } @@ -985,7 +999,10 @@ async fn a_trade_cut_short_by_a_refused_write_is_the_network_status_until_it_lan assert_eq!(position_of(&r, "sofi.trade"), (q, exhausted)); assert_eq!(pending_position(&p.b), Some(q)); assert_eq!(admitted_position(&p.b), position); - assert_eq!(balance(&p.b, &m.era), 200); + assert_eq!( + balance(&p.b, &m.era), + crate::economic_fixtures::whole_era(200) + ); assert_eq!( complete(&p).await, Completion::NotTaken { @@ -1011,7 +1028,10 @@ async fn a_trade_cut_short_by_a_refused_write_is_the_network_status_until_it_lan assert_eq!(resolve(&p).await, (q, exhausted)); assert_eq!(pending_position(&p.b), Some(q)); assert_eq!(admitted_position(&p.b), position); - assert_eq!(balance(&p.b, &m.era), 200); + assert_eq!( + balance(&p.b, &m.era), + crate::economic_fixtures::whole_era(200) + ); assert_eq!(balance(&p.b, &m.tkn), 0); // 3. Every write lands: completion is written and the position realizes. @@ -1022,7 +1042,10 @@ async fn a_trade_cut_short_by_a_refused_write_is_the_network_status_until_it_lan assert_eq!(admitted_position(&p.b), q); let out = dsm::dlv::route_commit::constant_product_output(10, 100, 1_000, 30) .expect("the vault prices the trade"); - assert_eq!(balance(&p.b, &m.era), 190); + assert_eq!( + balance(&p.b, &m.era), + crate::economic_fixtures::whole_era(200) - 10 + ); assert_eq!(balance(&p.b, &m.tkn), out); head_agrees_with_admitted_root(&p.b, &[m.era, m.tkn]); @@ -1208,7 +1231,11 @@ async fn every_sofi_route_reaches_its_producer() { }), ) .await; - assert_eq!(balance(&p.b, &era), 190, "the route took 10 ERA"); + assert_eq!( + balance(&p.b, &era), + crate::economic_fixtures::whole_era(200) - 10, + "the route took 10 base units" + ); assert_eq!(balance(&p.b, &tkb), two, "and gave what its hops priced"); assert_eq!(balance(&p.b, &tkn), 0, "and kept nothing on the way"); @@ -1246,7 +1273,11 @@ async fn every_sofi_route_reaches_its_producer() { }), ) .await; - assert_eq!(balance(&p.b, &era), 180, "the trade took 10 ERA"); + assert_eq!( + balance(&p.b, &era), + crate::economic_fixtures::whole_era(200) - 20, + "the trade took 10 more" + ); assert_eq!(balance(&p.b, &tkn), bought, "and gave what it was quoted"); realized_through( diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_dispatch.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_dispatch.rs index 12b546cbd..2af3a1437 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_dispatch.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/recipient_dispatch.rs @@ -1373,7 +1373,7 @@ mod tests { let item = listing(&listed.items, id); assert_eq!( item.preview, - format!("From: {sender} Amount: 10 ERA"), + format!("From: {sender} Amount: 0.10 ERA"), "every copy of the transfer shows the signed terms" ); assert_eq!(item.sender_id.as_deref(), Some(sender.as_str())); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/sender_admission_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/sender_admission_tests.rs index 28c566d1c..d8a80f165 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/sender_admission_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/sender_admission_tests.rs @@ -131,18 +131,19 @@ fn outbox_status(rel: &[u8; 32]) -> String { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[serial] async fn an_admitted_burn_advances_the_lineage_and_is_foreign_walkable() { - // Faucet position 1 (+100), then an ADMITTED burn of 40 at position 2. + // Faucet position 1 (+100.00 ERA), then an ADMITTED burn of 40 base units + // at position 2. // Then the decisive check: a FOREIGN walk of positions 1..2, crossing a // reserve release AND a pure debit in one lineage. let d = Device::funded(0xC1).await; let burned = invoke(&d.router, "token.burn", &burn_request("ERA", 40)).await; match payload(&burned) { crate::generated::envelope::Payload::TokenBurnResponse(r) => { - assert_eq!(r.new_balance, 60) + assert_eq!(r.new_balance, economic_fixtures::whole_era(100) - 40) } other => panic!("expected TokenBurnResponse, got {other:?}"), } - assert_eq!(d.era_balance(), 60); + assert_eq!(d.era_balance(), economic_fixtures::whole_era(100) - 40); let head = d.core().device_head().expect("head"); assert!(head.pending_economic_admission().is_none(), "unfenced"); let (position, admitted_root) = client_db::economic_lineage::get_admitted_coordinate() @@ -213,7 +214,11 @@ async fn a_stale_admission_snapshot_is_refused_not_committed() { head_before, "nothing advanced" ); - assert_eq!(d.era_balance(), 90, "only the overtaking burn debited"); + assert_eq!( + d.era_balance(), + economic_fixtures::whole_era(100) - 10, + "only the overtaking burn debited" + ); } /// A refused advance leaves NOTHING: no head movement, no fence, no admitted @@ -229,7 +234,12 @@ async fn a_refused_advance_leaves_no_trace() { .expect("frozen artifacts") .len(); - let refused = invoke(&d.router, "token.burn", &burn_request("ERA", 101)).await; + let refused = invoke( + &d.router, + "token.burn", + &burn_request("ERA", economic_fixtures::whole_era(100) + 1), + ) + .await; assert!( !refused.success, "a burn beyond the balance must be refused" @@ -242,7 +252,7 @@ async fn a_refused_advance_leaves_no_trace() { "no fence survived" ); assert_eq!(admitted_position(), 1, "no admitted movement"); - assert_eq!(d.era_balance(), 100); + assert_eq!(d.era_balance(), economic_fixtures::whole_era(100)); assert_eq!( client_db::frozen_publication_artifact::list_unpublished_artifacts(u32::MAX) .expect("frozen artifacts") @@ -277,7 +287,10 @@ async fn sequential_admissions_stay_monotonic_across_operation_kinds() { assert_eq!(admitted_position(), 4); let head = d.core().device_head().expect("head"); - assert_eq!(head.balance(&era()), 90 - fee); + assert_eq!( + head.balance(&era()), + economic_fixtures::whole_era(100) - 10 - fee + ); assert_eq!(head.balance(&seq.policy_commit), 480); } @@ -299,11 +312,11 @@ async fn a_transfer_naming_no_token_or_a_misspelled_one_is_refused_and_nothing_m assert!(!unnamed.success, "an omitted token is not ERA"); let msg = unnamed.error_message.unwrap_or_default(); assert!(msg.contains("names no token"), "got: {msg}"); - assert_eq!(p.a.era_balance(), 100); + assert_eq!(p.a.era_balance(), economic_fixtures::whole_era(100)); let folded = p.a.invoke("wallet.sendSmart", &request("era")).await; assert!(!folded.success, "`era` does not name ERA"); - assert_eq!(p.a.era_balance(), 100); + assert_eq!(p.a.era_balance(), economic_fixtures::whole_era(100)); assert_eq!(p.b.era_balance(), 0); } @@ -435,7 +448,11 @@ async fn a_send_to_a_device_that_is_not_a_contact_moves_nothing() { "refused because C is not a contact, not for another reason: {why}" ); p.a.enter(); - assert_eq!(p.a.era_balance(), 100, "nothing was debited"); + assert_eq!( + p.a.era_balance(), + economic_fixtures::whole_era(100), + "nothing was debited" + ); assert_eq!(admitted_position(), position, "no position was admitted"); let head = p.a.router().core_sdk.device_head().expect("head"); assert!( @@ -800,7 +817,11 @@ async fn token_routes_admit_create_and_burn_end_to_end() { crate::generated::envelope::Payload::TokenCreateResponse(t) => t, other => panic!("expected TokenCreateResponse, got {other:?}"), }; - assert_eq!(d.era_balance(), 100 - fee, "exactly the fee, burned"); + assert_eq!( + d.era_balance(), + economic_fixtures::whole_era(100) - fee, + "exactly the fee, burned" + ); assert_eq!( admitted_position(), 2, @@ -846,7 +867,11 @@ async fn token_routes_admit_create_and_burn_end_to_end() { other => panic!("expected TokenCreateResponse, got {other:?}"), }; assert_eq!(resp2.token_id, resp.token_id, "one commitment, one token"); - assert_eq!(d.era_balance(), 100 - fee, "no second fee"); + assert_eq!( + d.era_balance(), + economic_fixtures::whole_era(100) - fee, + "no second fee" + ); assert_eq!( admitted_position(), 2, @@ -856,7 +881,10 @@ async fn token_routes_admit_create_and_burn_end_to_end() { // The admitted burn ROUTE (position 3). let burned = invoke(&d.router, "token.burn", &burn_request("ERA", 25)).await; assert!(burned.success, "{:?}", burned.error_message); - assert_eq!(d.era_balance(), 100 - fee - 25); + assert_eq!( + d.era_balance(), + economic_fixtures::whole_era(100) - fee - 25 + ); assert_eq!( admitted_position(), 3, @@ -880,7 +908,7 @@ async fn a_transfer_registers_the_senders_root_at_the_next_position() { assert!(b_sync.success, "{:?}", b_sync.errors); let a_sync = p.a.sync().await; assert!(a_sync.success, "{:?}", a_sync.errors); - assert_eq!(p.a.era_balance(), 90); + assert_eq!(p.a.era_balance(), economic_fixtures::whole_era(100) - 10); p.a.enter(); let (position, admitted_root) = client_db::economic_lineage::get_admitted_coordinate() @@ -957,7 +985,7 @@ async fn a_failed_finish_holds_the_creation_and_resume_completes_the_same_admiss assert_eq!(admitted_position(), 1, "nothing admitted"); assert_eq!( d.era_balance(), - 100 - fee, + economic_fixtures::whole_era(100) - fee, "forward-only: the committed fee stands" ); let frozen = client_db::frozen_publication_artifact::list_unpublished_artifacts(u32::MAX) @@ -1123,7 +1151,7 @@ async fn a_failed_finish_holds_the_outbox_and_resume_completes_the_same_admissio ); assert_eq!( p.a.era_balance(), - 90, + economic_fixtures::whole_era(100) - 10, "forward-only: the committed debit stands" ); assert_eq!( diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_create_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_create_tests.rs index 52908ee21..e34488dc7 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_create_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_create_tests.rs @@ -263,7 +263,10 @@ async fn a_created_tokens_amounts_render_at_its_own_decimals() { ..Default::default() }; enrich_transaction_display(&mut era).expect("the token's decimals are known"); - assert_eq!(era.display_amount, "-100", "ERA is whole units"); + assert_eq!( + era.display_amount, "-1.00", + "ERA renders at its two decimals" + ); } /// The policy a created token enforces lives in durable storage, not in the diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs index 7d58028d6..57067cc0d 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/wallet_routes.rs @@ -1461,7 +1461,7 @@ mod tests { crate::economic_fixtures::use_test_storage_dir(); crate::storage::client_db::reset_database_for_tests(); crate::storage::client_db::init_database().expect("init db"); - assert_eq!(token_decimals("ERA"), Ok(0)); + assert_eq!(token_decimals("ERA"), Ok(2)); assert_eq!(token_decimals("dbtc"), Ok(8)); let unknown = token_decimals("NOPE").expect_err("no registry entry"); assert!(unknown.contains("no registry entry"), "{unknown}"); @@ -1569,8 +1569,8 @@ mod tests { let mut era = seed("ERA", 264, 0); super::enrich_balance_metadata(&mut era, &|_| None).expect("ERA is named"); assert!(era.protocol_defined); - assert_eq!((era.symbol.as_str(), era.decimals), ("ERA", 0)); - assert_eq!(era.genesis_supply_display, "80000000000"); + assert_eq!((era.symbol.as_str(), era.decimals), ("ERA", 2)); + assert_eq!(era.genesis_supply_display, "80000000000.00"); assert_eq!( era.permissions, Some(generated::TokenPolicyPermissions { @@ -1580,7 +1580,7 @@ mod tests { ); assert_eq!( era.policy_anchor_b32, - "JXPMPGJH45HDTE0ARWE2CTB9E9BWTQZ3T78CE5RFF1RXMR9VKK80" + "NNG176RZ6ACTWCDPRNYHXZK2DCZ72SPA9Q6XWGRGQ9JGKZYTESG0" ); let mut dbtc = seed("dBTC", 0, 0); super::enrich_balance_metadata(&mut dbtc, &|_| None).expect("dBTC is named"); @@ -1738,7 +1738,7 @@ mod history_tests { assert_eq!(claim.to_device_id, device.router.device_id_bytes.to_vec()); assert_eq!(claim.recipient, "ERA reserve (faucet)"); assert_eq!(claim.amount_signed, 100, "incoming"); - assert_eq!(claim.display_amount, "100"); + assert_eq!(claim.display_amount, "1.00"); assert_eq!(claim.token_id, "ERA"); let peer = crate::util::text_id::encode_base32_crockford(&[0x74u8; 32]); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs index 70c42d77f..26b9063a3 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs @@ -3025,7 +3025,10 @@ mod tests { let restored = economic_fixtures::core_sdk_for(&d.identity); let head = restored.device_head().expect("restored head"); assert_eq!(head.root(), live.root()); - assert_eq!(head.balance(&era_commit()), 100); + assert_eq!( + head.balance(&era_commit()), + economic_fixtures::whole_era(100) + ); } fn era_commit() -> [u8; 32] { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs index cdf5fa11d..5a82b0f42 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs @@ -97,10 +97,23 @@ fn read_ca_certs(path: &str) -> Result)>, DsmEr Ok(certs) } +/// How long a member has to accept a connection. +const MEMBER_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// How long one request to a member may take, end to end. A member that +/// accepts a connection and never answers is then a member that did not +/// answer, which every flow already handles, rather than a write that hangs +/// with no error. A transport bound only: nothing in DSM's validity or +/// ordering reads it. +const MEMBER_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + /// Build a client from resolved material: the expensive step (TLS /// configuration, connection pool), run only when the material changes. fn build_client_from(material: &CaMaterial) -> Result { - let mut builder = reqwest::Client::builder().user_agent("DSM-SDK/1.0"); + let mut builder = reqwest::Client::builder() + .user_agent("DSM-SDK/1.0") + .connect_timeout(MEMBER_CONNECT_TIMEOUT) + .timeout(MEMBER_REQUEST_TIMEOUT); for (cert_path, bytes) in &material.certs { let cert = reqwest::Certificate::from_pem(bytes).map_err(|e| { ca_error(format!( diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/projection_repair.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/projection_repair.rs index d173670bf..da32307c4 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/projection_repair.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/projection_repair.rs @@ -378,7 +378,8 @@ mod tests { .unwrap() .expect("projection rebuilt"); assert_eq!( - proj.available, 300, + proj.available, + crate::economic_fixtures::whole_era(300), "projection rebuilt to the head's balance" ); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs index 05885bda8..d1a2671b0 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/test_support/two_device.rs @@ -191,17 +191,24 @@ impl TestDevice { dsm::core::bilateral_transaction_manager::compute_smt_key(&self.device_id, &peer.device_id) } - /// Fund with economic ancestry: `amount / 100` faucet claims (the fixed - /// payout). Amounts must be multiples of 100 — a fixture asking for - /// anything else is asking for value the protocol cannot issue. - pub async fn fund_admitted(&self, amount: u64) { + /// Fund with economic ancestry: `whole_era` ERA, in faucet claims of the + /// fixed payout (100.00 ERA each). Amounts must be multiples of the + /// payout — a fixture asking for anything else is asking for value the + /// protocol cannot issue. Balances read back in base units. + pub async fn fund_admitted(&self, whole_era: u64) { + let scale = 10u64.pow( + dsm::core::token::era_policy::era_policy() + .expect("ERA's policy") + .decimals, + ); + let payout = dsm::economic::native_reserve::ERA_FAUCET_PAYOUT / scale; assert!( - amount.is_multiple_of(100), - "fund_admitted: amounts are multiples of the 100-ERA faucet payout" + whole_era.is_multiple_of(payout), + "fund_admitted: amounts are multiples of the {payout}-ERA faucet payout" ); self.enter(); let core = self.router().core_sdk.clone(); - for claim in 0..(amount / 100) { + for claim in 0..(whole_era / payout) { crate::sdk::faucet_claim_flow::claim_era_faucet(&core, economic_fixtures::NETWORK) .await .unwrap_or_else(|e| panic!("funding claim {claim}: {e}")); diff --git a/dsm_client/frontend/src/components/screens/__tests__/AccountsScreen.tokens.test.tsx b/dsm_client/frontend/src/components/screens/__tests__/AccountsScreen.tokens.test.tsx index 5a9c22ade..f541e727e 100644 --- a/dsm_client/frontend/src/components/screens/__tests__/AccountsScreen.tokens.test.tsx +++ b/dsm_client/frontend/src/components/screens/__tests__/AccountsScreen.tokens.test.tsx @@ -46,10 +46,10 @@ const CREATED = { const balances = [ row({ tokenId: 'ERA', - baseUnits: 264n, - displayAmount: '264', + baseUnits: 26400n, + displayAmount: '264.00', protocolDefined: true, - genesisSupplyDisplay: '80000000000', + genesisSupplyDisplay: '80000000000.00', policyAnchorB32: 'ERAANCHOR0000', anchorFingerprint: 'ERAANCHO', }), @@ -454,7 +454,7 @@ describe('AccountsScreen — the screen TOKENS actually opens', () => { (dsmClient.getAllBalances as jest.Mock).mockResolvedValue(balances); render(); fireEvent.click(await screen.findByText('ERA')); - expect((await screen.findByText('Total Supply')).nextElementSibling).toHaveTextContent('80000000000 ERA'); + expect((await screen.findByText('Total Supply')).nextElementSibling).toHaveTextContent('80000000000.00 ERA'); expect(screen.getByText('Defined By').nextElementSibling).toHaveTextContent('the protocol'); expect(screen.queryByText('Burn')).toBeNull(); expect(screen.queryByText('Transfer')).toBeNull(); diff --git a/specs/SoFi_Settlement_Specification.md b/specs/SoFi_Settlement_Specification.md index 6fd1bd951..f1c12ea49 100644 --- a/specs/SoFi_Settlement_Specification.md +++ b/specs/SoFi_Settlement_Specification.md @@ -2484,6 +2484,8 @@ The ticker is display only; two tokens may share one. > > ERA's policy: version 3, fungible, native; transferable and burnable; no recipient allowlist; release rule the beta faucet, under which units come out of the network's reserve by faucet claims; ticker and alias `ERA`; decimals 0; genesis supply 80,000,000,000 (owner, 2026-09-26); no description and no icon. Every device holds these bytes by construction. Their commitment, `BLAKE3(DSM/policy ‖ 0x00 ‖ TokenPolicyV3 bytes)`, is `JXPMPGJH45HDTE0ARWE2CTB9E9BWTQZ3T78CE5RFF1RXMR9VKK80` (Crockford base32). The faucet's per-claim payout is fixed by Core's beta claim policy and is not committed in the blob. The reserve's accounting is: the reserve starts at the genesis supply, every release is counted against it, the reserve is exhausted exactly when all of it has been released, and at exhaustion a claim is refused before anything is signed or written. ERA's previous commitment was the hash of empty input and committed to no policy; it is replaced. > +> **Amendment S18 (owner, 2026-10-01) — ERA has two decimals.** ERA is divisible to hundredths: its policy's decimals are 2, and every ERA amount is held in base units of 0.01 ERA. The amounts people know are unchanged: the genesis supply is 80,000,000,000.00 ERA, which the policy states in base units as 8,000,000,000,000; one beta faucet claim releases 100.00 ERA (10,000 base units); the token creation fee is 10.00 ERA (1,000 base units). The rest of S11's policy is unchanged. ERA's commitment changes with its bytes, to `NNG176RZ6ACTWCDPRNYHXZK2DCZ72SPA9Q6XWGRGQ9JGKZYTESG0` (Crockford base32), and replaces S11's; by S11's rule this is a different ERA identity, so no balance under the old commitment carries over (a clean cut). Amounts are entered and shown in whole tokens with the token's decimals after a point, `100.00` for ERA, never as base units. +> > Open: join-triggered emission (DJTE) replaces the faucet after beta. That is a different release rule, so a different blob, and by the rule above that any differing field makes a different token, a different ERA identity. **Code** diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 1acd78662..aeef6958a 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1847,11 +1847,11 @@ Known cost: the first time a receiver meets a payer it validates the payer's seg | Spec | Rows | Met | Partial | Missing | Violated | Not code | Deferred | |---|---|---|---|---|---|---|---| | DSM high-level (MR-DSM) | 276 | 94 | 96 | 39 | 0 | 29 | 18 | -| SoFi (MR-SOFI) | 348 | 223 | 86 | 18 | 4 | 17 | 0 | +| SoFi (MR-SOFI) | 349 | 223 | 87 | 18 | 4 | 17 | 0 | | dBTC (MR-DBTC) | 135 | 0 | 0 | 0 | 0 | 0 | 135 | | Storage node (MR-STOR) | 158 | 60 | 18 | 61 | 0 | 18 | 1 | | Storage §14 lines added after the pin (STOR-014) | 11 | 9 | 1 | 1 | 0 | 0 | 0 | -| **All** | **928** | **386** | **201** | **119** | **4** | **64** | **154** | +| **All** | **929** | **386** | **202** | **119** | **4** | **64** | **154** | ## 8 Per-requirement results @@ -2488,6 +2488,7 @@ Known cost: the first time a receiver meets a payer it validates the payer's seg | MR-SOFI-0346 | Partial | `dsm::sofi::resolve::Verifier::peer_position`; `dsm_sdk::sdk::economic_registers::resolve_peer` | `dsm_sdk::handlers::frontier_verification_tests::a_receiver_reads_nothing_behind_its_frontier`; `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay` | SoFi Amendment S15 (wording made exact 2026-09-30: the setup positions the legs name are inside the segment and read through A8). The resolver reads q's position pair, P, the first leg's cell, the vault chains and q's evidence, and the trader's accepted claims at its setup positions through `resolve_peer`, which DSM core's walk bounds to the frontier (that test). Partial: no request-log test of the resolver's own read set yet. | | MR-SOFI-0347 | Met | `dsm_sdk::sdk::economic_registers::resolve_peer`; `dsm::sofi::validation::setup_valid` | `dsm_sdk::handlers::node_e2e_tests::a_trader_who_has_traded_can_pay`; `dsm::sofi::validation::tests::a_setup_on_a_lineage_known_invalid_is_invalid` | SoFi Amendment S15 (`feat/frontier-relative-peer-verification`). `SofiReads::accepted_claim_at` reads another trader's accepted claim at a setup position through `resolve_peer`, DSM core's frontier-relative walk with this resolver; the whole-lineage walk (`resolve_peer_with_cache`) no longer exists. A payee resolving a trader's SoFi position runs SetupValid this way in the P15-9 node test. | | MR-SOFI-0348 | Met | `dsm::sofi::lineage::advance_resolved`; `dsm::sofi::lineage::advance_peer_resolved` | `dsm::sofi::lineage::tests::a_realized_position_crediting_an_unadopted_token_is_refused`; `dsm::sofi::lineage::tests::another_verifier_resolves_the_position_without_the_traders_adoptions`; `dsm::sofi::lineage::tests::a_void_needs_no_adoption` | SoFi Amendment S15. The trader's own advance refuses a realized position crediting a token it has not adopted; another verifier's `advance_peer_resolved` derives the same root and accepted claim from the same facts with no adoption state of the trader's. Mutation: the adoption check run on the peer path → `another_verifier_resolves_the_position_without_the_traders_adoptions` red. | +| MR-SOFI-0349 | Partial | `dsm::core::token::era_policy::era_policy`; `dsm::economic::native_reserve::ERA_FAUCET_PAYOUT`; `dsm::core::token::token_state_manager::TOKEN_CREATION_FEE_ERA`; `dsm_sdk::handlers::wallet_routes::parse_display_amount_to_base_units` | `dsm::core::token::era_policy::tests::eras_policy_states_what_the_specification_fixes`; `dsm::core::token::era_policy::tests::eras_commitment_is_derived_from_its_bytes_and_is_the_specifications` | SoFi Amendment S18 (2026-10-01). ERA's policy carries decimals 2 and its supply in base units, the faucet pays 10,000 base units and the creation fee is 1,000, and sends take amounts in whole tokens with the point. Partial: the SoFi routes' amount fields (createVault, findRoute, trade, route) and their screen still take base units. | ### 8.3 dBTC native specification diff --git a/specs/requirements/MASTER_REQUIREMENTS.md b/specs/requirements/MASTER_REQUIREMENTS.md index 9d6e0efde..79949d373 100644 --- a/specs/requirements/MASTER_REQUIREMENTS.md +++ b/specs/requirements/MASTER_REQUIREMENTS.md @@ -15,11 +15,11 @@ Every extraction in this round is taken against exactly these bytes: | File | `git hash-object` | Lines | |---|---|---| | `specs/DSM_High_Level_Explainer.md` | `fad1940a13b806d53a0925e1bada409e5acc298c` | 4317 | -| `specs/SoFi_Settlement_Specification.md` | `6fd1bd951d5a57e50518749ca8361c07c331d561` | 2656 | +| `specs/SoFi_Settlement_Specification.md` | `f1c12ea496f517e91fcc9ef506432dce7655bdbb` | 2658 | | `specs/dBTC_Native_Specification.md` | `233a3e72a5b16a023af830f4c8ffaad4ba9391a8` | 2160 | | `specs/DSM_Storage_Node_Specification.md` | `415a9b9c67c7a2b4b6df78b0af85fb3bc7282ae8` | 636 | -Pins updated 2026-09-30 after DSM Amendments A8 (frontier-relative verification) and A9 (the relationship-key tag is `DSM/smt-key`) and SoFi Amendments S14 (a final cell whose fulfillment can never register is skipped) and S15 (a trader's position resolved from public objects); before that, 2026-09-29 after SoFi Amendments S12 (the trader's balances before the trade) and S13 (a lineage known invalid); before that, 2026-09-26 after SoFi Amendment S11 (ERA's canonical policy); before that, 2026-09-24 after SoFi Amendments S8, S9 and S10 and the storage §9 rule on the leader first, one chain in route order and the completion proof (#977); before that, 2026-09-23 after storage §14 (#974), the set-identity amendment (SoFi Amendment S6, storage §10), the replication amendment (storage §12.5), the vault-consistency recommendation (SoFi §31), Amendment S7 (SoFi §24) and Amendment A7 (DSM §11, storage §8). The extractions of 2026-09-22 were taken against SoFi `4c62ee78…` (2597 lines) and storage `8d43ac02…` (571 lines); their line-based IDs refer to those bytes. +Pins updated 2026-10-01 after SoFi Amendment S18 (ERA has two decimals); before that, 2026-09-30 after DSM Amendments A8 (frontier-relative verification) and A9 (the relationship-key tag is `DSM/smt-key`) and SoFi Amendments S14 (a final cell whose fulfillment can never register is skipped) and S15 (a trader's position resolved from public objects); before that, 2026-09-29 after SoFi Amendments S12 (the trader's balances before the trade) and S13 (a lineage known invalid); before that, 2026-09-26 after SoFi Amendment S11 (ERA's canonical policy); before that, 2026-09-24 after SoFi Amendments S8, S9 and S10 and the storage §9 rule on the leader first, one chain in route order and the completion proof (#977); before that, 2026-09-23 after storage §14 (#974), the set-identity amendment (SoFi Amendment S6, storage §10), the replication amendment (storage §12.5), the vault-consistency recommendation (SoFi §31), Amendment S7 (SoFi §24) and Amendment A7 (DSM §11, storage §8). The extractions of 2026-09-22 were taken against SoFi `4c62ee78…` (2597 lines) and storage `8d43ac02…` (571 lines); their line-based IDs refer to those bytes. The DSM and SoFi specifications were amended on 2026-09-22 (marked "Amendment" in their text). The storage-node specification was added to the corpus on 2026-09-22, before any other extractor started. The owner accepted it in full the same day. Extract its items marked **Open** with Flags `ambiguous` and a Requirement text that says so, never as settled requirements. @@ -155,6 +155,7 @@ Each extraction also has a Findings table: - **The trader's balances before the trade (2026-09-29).** Conformance finding (CONFORMANCE §6.39): once one trader had traded through a vault, no other reader could classify the trade, because `TraderSideValid` needs the trader's balances before the trade and `T°` states them only as hashes; the owner's close of that vault never resolved. Owner decisions: the exercise carries each such balance as a content-addressed `TraderPreBalance` that `𝒞_E^pre` names, every verifier (the trader included) reads the balances from those objects, and a balance the closure does not name is Invalid, not undecided (SoFi Amendment S12). MR-SOFI-0241 is a different defect and is unchanged. MR-SOFI-0338–0341 added with source `amendment`; §1 re-pinned. - **A lineage known invalid (2026-09-29).** Conformance finding (CONFORMANCE §6.40): under Amendment S9 a trader whose lineage validation finds Invalid never yields an accepted claim, so its setup was never evaluated and its trade held a vault key forever. Owner decision: not yet known waits; known invalid makes the setup, and so the route, Invalid; no accepted claim is synthesized (SoFi Amendment S13). MR-SOFI-0331 rewritten; MR-SOFI-0342 added with source `amendment`; §1 re-pinned. - **A fulfillment that can never register (2026-09-30).** Conformance finding (CONFORMANCE §8, MR-SOFI-0239): `route_impossible` carried a fifth arm, `PositionLost`, which §23.5 does not list and which depends on a particular F, not on P and E. Without it, an exercise whose fulfillment can never register, because another claim holds its position, strands the DLV parent's attempt chain (TLA `DSM_SofiFulfillment`, `LostPosition`). Owner decision: a separate skip, `RejectedFinalInadmissible`, which is not an arm of `RouteImpossible`; `RouteImpossible` stays scoped to P and E (SoFi Amendment S14). MR-SOFI-0343 added with source `amendment`; §1 re-pinned. +- **ERA has two decimals (2026-10-01).** Rig finding: ERA was whole-unit (decimals 0, SoFi Amendment S11), so a 0.30% fee on a 50 ERA trade could not be written as an ERA amount, and amounts were entered as raw base units. Owner ruling: ERA's decimals are 2, with the supply, faucet payout and creation fee unchanged in whole ERA; ERA's commitment changes (a clean cut); amounts are entered and shown in whole tokens with the token's decimals (SoFi Amendment S18). MR-SOFI-0335 rewritten; MR-SOFI-0349 added with source `amendment`; §1 re-pinned. - **Frontier-relative verification (2026-09-30).** Conformance finding (P15-9, and G16): a payee accepted a payer only by walking the payer's whole lineage from its activation root, re-validating every transition from evidence it fetched from the nodes, and a SoFi position in that history stopped the walk, so a trader who had traded could not pay. Owner decision: a receiver verifies forward from its own authenticated frontier: for each position of the segment it authenticates the authoritative root claim and verifies the transition producing that root with that transition's directly required evidence and provenance one hop back, never recursing behind that hop and never reading behind the frontier (DSM Amendment A8, corrected the same day: an authenticated root is not a valid transition, and a counterparty's acceptance does not stand for one); a SoFi position inside that segment is resolved from SoFi's public objects for that position alone, and adoption is not re-run by a later verifier (SoFi Amendment S15). MR-DSM-0273–0276 and MR-SOFI-0344–0348 added with source `amendment`; §1 re-pinned. - **The relationship-key tag (2026-09-30).** Conformance finding (CONFORMANCE §6.14, MR-DSM-0115, MR-DSM-0249): the explainer derived a relationship's SMT key under `DSM/smt-key/v1`, while the code and its golden vector use `DSM/smt-key`. Owner ruling: `DSM/smt-key` is the canonical relationship-SMT domain tag for beta, and `/v1` in the specification was an error (DSM Amendment A9). The formula in §26 and the example in §16 are corrected; no key migrates. MR-DSM-0115 rewritten; §1 re-pinned. - **Post-reconciliation amendment (2026-09-22): route-chain finality.** For finding GPT-4, finality was redefined as a route chain (storage spec §9, §12.6, §14, §22; DSM Amendment A6; SoFi Amendment S4). §1 pins the amended files. Canonical rows restating the old rule were rewritten, and rows for the new rules were added at the end of §8.1, §8.2 and §8.4 with source `amendment`. The extraction files in `extractions/` remain as extracted against the earlier hashes. @@ -171,7 +172,7 @@ Reconciled on 2026-09-22 from two extractions: `claude-chat` (798 rows) and `cha | DSM_Storage_Node_Specification.md | 128 | 122 | 6 | | **Total** | **859** | **652** | **207** | -Added afterwards by amendment (§7.1): DSM 7, SoFi 21, storage 30, for 917 canonical requirements in all. +Added afterwards by amendment (§7.1): DSM 7, SoFi 22, storage 30, for 918 canonical requirements in all. Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sources** are the extraction IDs merged into the row (`cc:` claude-chat, `gpt:` chatgpt); the first source locates the quote. **Flags** carry the findings in §8.7 that bear on the row. @@ -794,7 +795,7 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-SOFI-0332 | authority | explicit | A device-created token's policy blob commits its creator, the genesis G and device id DevID of the creating device, after the release rule (a network-anchored policy names none, Amendment S11); a native token's genesis release is admissible only in a CreateToken of that device, so anyone else holding the same policy bytes releases nothing. | amendment: SoFi Amendment S8 (2026-09-23) | owner | none | | MR-SOFI-0333 | invariant | explicit | The creating transition inserts a creation record for the policy commit into the creator's economic tree from zero (class 0x0060, key H(DSM/economic-token-creation-key/v1; G ‖ DevID ‖ policy_commit)); its presence under a validated root proves the creation, a second creation of the same commit on that lineage cannot build its write set, and so the genesis supply is released exactly once. | amendment: SoFi Amendment S8 (2026-09-23) | owner | none | | MR-SOFI-0334 | invariant | explicit | A network-anchored native policy names no creator and no signer set: its blob omits both after the release rule, and its release rule alone governs every release; a device-created policy's layout is unchanged, and a blob whose shape does not match its release rule does not parse. | amendment: SoFi Amendment S11 (2026-09-26) | owner | none | -| MR-SOFI-0335 | invariant | explicit | ERA's policy is fixed in Core (version 3, fungible, native, transferable and burnable, no recipient allowlist, the beta faucet release rule, ticker and alias ERA, decimals 0, genesis supply 80,000,000,000, no description or icon), and ERA's policy commitment is BLAKE3(DSM/policy ‖ 0x00 ‖ its TokenPolicyV3 bytes); every device holds it by construction, never from storage or a registry. | amendment: SoFi Amendment S11 (2026-09-26) | owner | none | +| MR-SOFI-0335 | invariant | explicit | ERA's policy is fixed in Core (version 3, fungible, native, transferable and burnable, no recipient allowlist, the beta faucet release rule, ticker and alias ERA, decimals 2, genesis supply 8,000,000,000,000 base units (80,000,000,000.00 ERA), no description or icon), and ERA's policy commitment is BLAKE3(DSM/policy ‖ 0x00 ‖ its TokenPolicyV3 bytes); every device holds it by construction, never from storage or a registry. | amendment: SoFi Amendments S11 (2026-09-26) and S18 (2026-10-01) | owner | none | | MR-SOFI-0336 | prohibition | explicit | Exactly one network-anchored policy exists, ERA's; any other network-anchored blob has no reserve, releases nothing, and is never registered, adopted or published. | amendment: SoFi Amendment S11 (2026-09-26) | owner | none | | MR-SOFI-0337 | invariant | explicit | ERA's reserve starts at ERA's committed genesis supply and every release is counted against it: remaining plus released equals the genesis supply at every state, the reserve is exhausted exactly when all of it has been released, and at exhaustion a claim is refused before anything is signed or written. | amendment: SoFi Amendment S11 (2026-09-26) | owner | none | | MR-SOFI-0338 | invariant | explicit | TraderPreBalance is class 0x0061, schema 1: trader_genesis, trader_device_id and policy_commit (digest32 each) and amount (u64, strictly positive; a zero balance is an absent leaf and needs no object). Its address is immutable_addr(DSM/sofi/trader-pre-balance-object/v1, CCB bytes), and 𝒞_E^pre references it as ContentAddr{0x0061, addr}, so E commits it and the exercise carries it with the other closure objects. | amendment: SoFi Amendment S12 (2026-09-29) | owner | none | @@ -808,6 +809,7 @@ Columns: **ID** is the canonical ID (`MR--nnnn`, in document order). **Sou | MR-SOFI-0346 | prohibition | explicit | Resolving another trader's position q reads no private object of the trader's and no position of the trader's outside the verifier's frontier-to-parent segment (within it, only q, the authenticated predecessor root at q − 1, and the accepted claim at each setup position explicitly named by P's legs, each authenticated through DSM Amendment A8); it reads no unrelated trader position and has no fallback lineage walk (owner ruling, 2026-09-30). | amendment: SoFi Amendment S15 (2026-09-30) | owner | none | | MR-SOFI-0347 | obligation | explicit | SetupValid reads the trader's accepted claim at a setup's position by frontier-relative verification of the trader's lineage (DSM Amendment A8), never by replaying the trader's history to genesis or recursing behind a step's one-hop evidence. | amendment: SoFi Amendment S15 (2026-09-30) | owner | none | | MR-SOFI-0348 | invariant | explicit | Adoption is a construction predicate of the trader's own transition, enforced when the trader installs a realized position; a later verifier resolving which root a historical position selected does not re-run it, and that resolution requires no adoption leaf or other private trader state. | amendment: SoFi Amendment S15 (2026-09-30) | owner | none | +| MR-SOFI-0349 | obligation | explicit | ERA has two decimals: a faucet claim releases 100.00 ERA (10,000 base units), the token creation fee is 10.00 ERA (1,000 base units), and every amount is entered and shown in whole tokens with the token's decimals after a point, never as base units. | amendment: SoFi Amendment S18 (2026-10-01) | owner | none | ### 8.3 dBTC native specification