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
Original file line number Diff line number Diff line change
Expand Up @@ -354,48 +354,47 @@ mod state_machine_tests {
/// balance over a correct canonical head.
#[test]
fn current_state_always_reflects_the_canonical_head_never_an_override() {
use crate::types::device_state::{BalanceDelta, BalanceDirection};
use crate::types::operations::Operation;

let devid = [0x42u8; 32];
let head0 =
crate::types::device_state::DeviceState::new(devid, devid, vec![0xAAu8; 32], 64);

// Mint 275 to self so the head carries a real balance, exactly like 8XK.
// Install a real ERA balance on the head by RESTORE, not by minting.
//
// The subject here is that `current_state` reflects the canonical head rather
// than an override — how the head came to hold a balance is incidental. Minting
// is no longer a way to get one: `advance` refuses builtin issuance (ERA/dBTC
// are not self-authorizable), and only builtins survive the compat projection
// that `token_balances` is read from, so a synthetic asset would project as
// empty. `restore` is the honest fixture: it is exactly the shape a reloaded
// device has.
let policy = crate::core::token::builtin_policy_commit_for_token("ERA").unwrap();
let rel = crate::core::bilateral_transaction_manager::compute_smt_key(&devid, &devid);
let init = crate::core::bilateral_transaction_manager::initial_chain_tip_from_device_ids(
&devid, &devid,
);
let outcome = head0
.advance(
let mut balances = std::collections::BTreeMap::new();
balances.insert(policy, 275u64);
let restored = crate::types::device_state::DeviceState::restore(
devid,
devid,
vec![0xAAu8; 32],
None,
balances,
vec![(
rel,
devid,
Operation::Mint {
amount: crate::types::token_types::Balance::from_state(275, [0u8; 32]),
token_id: b"ERA".to_vec(),
policy_commit: crate::core::token::builtin_policy_commit_for_token("ERA")
.unwrap(),
authorized_by: b"self".to_vec(),
proof_of_authorization: Vec::new(),
message: "mint".to_string(),
crate::types::device_state::RelChainTip {
chain_tip: init,
counterparty_devid: devid,
tip_entropy: vec![0x11u8; 32],
value_capability: crate::types::device_state::ValueCapability::Yes,
},
vec![0x11u8; 32],
None,
&[BalanceDelta {
policy_commit: policy,
direction: BalanceDirection::Credit,
amount: 275,
}],
Some(init),
None,
None,
None,
)
.expect("mint advance");
)],
std::collections::BTreeMap::new(),
std::collections::BTreeMap::new(),
std::collections::BTreeMap::new(),
64,
)
.expect("restore a head carrying a real balance");

let mut sm = StateMachine::new();
sm.set_device_head(outcome.new_device_state.clone());
sm.set_device_head(restored.clone());

let cs = sm.current_state().expect("state from head");
let era = cs
Expand All @@ -408,11 +407,7 @@ mod state_machine_tests {
era, 275,
"current_state must reflect the canonical head's balance"
);
assert_eq!(
cs.hash,
outcome.new_device_state.root(),
"hash is the canonical SMT root"
);
assert_eq!(cs.hash, restored.root(), "hash is the canonical SMT root");
}
use crate::types::state_types::DeviceInfo;
use crate::types::token_types::Balance;
Expand Down
138 changes: 138 additions & 0 deletions dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1383,6 +1383,51 @@ impl DeviceState {
)?;
}

// BUILTIN ISSUANCE IS NOT SELF-AUTHORIZABLE.
//
// A `Mint` naming a builtin policy commit (ERA, dBTC) creates units of a
// supply nobody may unilaterally expand. Every check that used to stand
// between a caller and that credit was satisfiable by the caller alone:
// the route builds its own authorization and stamps `authorized_by` with
// the caller's own device id; ERA's preloaded policy carries zero
// conditions and zero roles, so the enforcer iterates nothing and
// returns "allowed"; dBTC has no registered policy at all and takes the
// builtin escape hatch; and `validate_conservation` only checks that the
// single credit delta matches the amount and asset the same caller
// signed. Nothing anywhere established a right to issue.
//
// The gate lives HERE, at the accepting transition, and not on the route,
// because a route guard binds only the callers that go through it: any
// future route, or any direct `advance` caller, would silently reopen the
// hole. This is the chokepoint every mint must cross.
//
// Fail-closed with no exemption: `EXCEPT through an explicit issuance
// predicate whose evidence THIS verifier validates` is the intended
// shape, and no such predicate exists yet — so there is no admissible
// builtin issuance today rather than a placeholder one. A `SupplyCap`
// condition would NOT be that predicate: it reads `circulating_le` from
// caller-supplied enforcement context, and no canonical producer
// authenticates that number.
// Keyed on `policy_commit`, which is the identity that actually moves
// value: `validate_conservation` binds the credit delta to it, `balances`
// is keyed by it, and the compat projection resolves a ticker FROM it.
// The `token_id` string is metadata — a mint carrying the ticker "ERA"
// with a non-builtin commit credits that non-builtin asset and can never
// project as ERA, so rejecting on the string would refuse honest mints
// without closing anything.
if let Operation::Mint { policy_commit, .. } = &operation {
if let Some(name) =
crate::core::token::token_state_manager::builtin_token_id_for_policy_commit(
policy_commit,
)
{
return Err(DsmError::invalid_operation(format!(
"advance: refusing to mint the builtin token {name} — builtin issuance is not \
self-authorizable, and no authenticated issuance predicate is defined for it"
)));
}
}

// Offline-bearer spend: draw the value from the device-bound offline-cash allocation instead of
// the online balance. Requires the anchor-state advance (a bearer transfer always advances
// the anchor leaf), so the allocation debit and the transition land in ONE atomic device root.
Expand Down Expand Up @@ -2966,6 +3011,99 @@ mod tests {
);
}

/// BUILTIN ISSUANCE IS REFUSED AT THE ACCEPTING TRANSITION.
///
/// Not at the route — at `advance`, the chokepoint every mint must cross.
/// Before this gate, `token.mint {token_id: "ERA", amount: <any>}` was a live
/// production route that credited the caller: the handler signs its own
/// authorization and stamps `authorized_by` with the caller's own device id;
/// ERA's preloaded policy has zero conditions and zero roles, so enforcement
/// returns "allowed"; dBTC has no policy at all and takes the builtin escape
/// hatch; and conservation only checks that the single credit matches the
/// amount and asset the same caller signed.
///
/// MUTATION CONTROL: delete the builtin-issuance block in `advance` and this
/// test goes green by minting ERA from air — which is precisely the defect.
#[test]
fn a_builtin_token_cannot_be_minted_from_air_at_the_accepting_transition() {
for ticker in ["ERA", "dBTC"] {
let pc = crate::core::token::builtin_policy_commit_for_token(ticker)
.expect("builtin commit");
let dev = DeviceState::new(devid(0xA1), devid(0xA1), vec![0x01; 32], 64);
let rk =
crate::core::bilateral_transaction_manager::compute_smt_key(&dev.devid, &dev.devid);
let tip = crate::core::bilateral_transaction_manager::initial_chain_tip_from_device_ids(
&dev.devid, &dev.devid,
);
let outcome = dev.advance(
rk,
dev.devid,
mint_op_for(u64::MAX, pc),
entropy(7),
None,
&[BalanceDelta {
policy_commit: pc,
direction: BalanceDirection::Credit,
amount: u64::MAX,
}],
Some(tip),
None,
None,
None,
);
// Fail for the RIGHT reason — an `is_err()` assertion would pass just
// as happily on an unrelated error.
let err = format!(
"{}",
outcome.expect_err("minting a builtin token from air must be refused")
);
assert!(
err.contains("builtin issuance is not self-authorizable") && err.contains(ticker),
"must fail as unauthorized builtin issuance naming {ticker}, got: {err}"
);
}
}

/// The gate is keyed on the ASSET, not the ticker string. A mint carrying a
/// builtin ticker with a non-builtin `policy_commit` credits that non-builtin
/// asset and can never project as ERA, so refusing it would reject honest
/// issuance without closing anything. This pins that the gate stays narrow.
#[test]
fn a_non_builtin_asset_still_mints_even_under_a_builtin_ticker() {
let pc = [0x5Au8; 32];
assert!(
crate::core::token::token_state_manager::builtin_token_id_for_policy_commit(&pc)
.is_none(),
"fixture must not accidentally name a builtin"
);
let dev = DeviceState::new(devid(0xA2), devid(0xA2), vec![0x02; 32], 64);
let rk =
crate::core::bilateral_transaction_manager::compute_smt_key(&dev.devid, &dev.devid);
let tip = crate::core::bilateral_transaction_manager::initial_chain_tip_from_device_ids(
&dev.devid, &dev.devid,
);
// `mint_op_for` hard-codes the ticker "ERA" while naming this commit.
let out = dev
.advance(
rk,
dev.devid,
mint_op_for(1_000, pc),
entropy(8),
None,
&[BalanceDelta {
policy_commit: pc,
direction: BalanceDirection::Credit,
amount: 1_000,
}],
Some(tip),
None,
None,
None,
)
.expect("a non-builtin asset is unaffected by the builtin-issuance gate");
assert_eq!(out.new_device_state.balance(&pc), 1_000);
}

// ── reserve authority: the PRODUCTION funding path ─────────────────────
//
// The tests above fund through `fund_vault_reserves`, a test-only shim that
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ use prost::Message;
use crate::bridge::{AppInvoke, AppQuery, AppResult};
use super::app_router_impl::{AppRouterImpl, FaucetState, build_testnet_faucet_policy};
use super::response_helpers::{pack_envelope_ok, err};
use crate::util::deterministic_time as dt;

impl AppRouterImpl {
/// Dispatch handler for `faucet.check_nearby` query route.
Expand All @@ -35,12 +34,15 @@ impl AppRouterImpl {
return err("faucet.check_nearby: device_id must be 32 bytes".into());
}

// Testnet faucet - always available
// Builtin faucet issuance is unavailable (see `faucet.claim`): report
// it here rather than advertising a faucet that must refuse.
let resp = generated::FaucetClaimResponse {
success: true,
success: false,
tokens_received: 0,
next_available_index: 0,
message: "Testnet faucet available".to_string(),
message: "builtin faucet issuance unavailable — no authenticated issuance \
predicate is defined for ERA/dBTC"
.to_string(),
};
// Return as Envelope.faucetClaimResponse (field 24)
pack_envelope_ok(generated::envelope::Payload::FaucetClaimResponse(resp))
Expand Down Expand Up @@ -74,58 +76,29 @@ impl AppRouterImpl {
return err("faucet.claim: device_id must be 32 bytes".into());
}

log::info!(
"[faucet.claim] device_id_b32={}",
crate::util::text_id::encode_base32_crockford(&dev)
);

let identity = crate::util::text_id::encode_base32_crockford(&dev);
let now = dt::tick();

let (amount, next_available) = {
let mut faucet = self.faucet_state.lock().await;
match faucet.claim(&identity, now) {
Ok(v) => v,
Err(msg) => return err(format!("faucet.claim: {msg}")),
}
};

log::info!(
"[faucet.claim] granted amount={} next_available_index={}",
amount,
next_available
);

match self.wallet.mint_for_self(amount, Some("ERA")).await {
Ok(_) => {
log::error!(
"[faucet.claim] ❗ mint_for_self succeeded, amount={}",
amount
);

// Verify the canonical projection row was updated.
let device_id_txt =
crate::util::text_id::encode_base32_crockford(&self.device_id_bytes);
if let Ok(Some(record)) =
crate::storage::client_db::get_balance_projection(&device_id_txt, "ERA")
{
log::error!("[faucet.claim] ❗ Post-mint ERA projection verification: device_id={} available={} locked={}",
device_id_txt, record.available, record.locked);
} else {
log::error!("[faucet.claim] ❌ Post-mint ERA projection verification FAILED: projection not found");
}

let resp = generated::FaucetClaimResponse {
success: true,
tokens_received: amount,
next_available_index: next_available,
message: "Faucet claim successful".to_string(),
};
// NEW: Return as Envelope.faucetClaimResponse (field 24)
pack_envelope_ok(generated::envelope::Payload::FaucetClaimResponse(resp))
}
Err(e) => err(format!("faucet.claim failed: {e}")),
}
// BUILTIN FAUCET ISSUANCE IS UNAVAILABLE.
//
// This route minted builtin ERA on the strength of a caller-supplied
// `device_id` plus a local cooldown — no independently verifiable right
// to issue, which is the same defect class the accepting-layer gate
// exists to close. `DeviceState::advance` now refuses builtin issuance
// outright, so the mint would fail regardless; refusing here names the
// reason instead of surfacing a confusing lower-layer error.
//
// This refusal is a MESSAGE, not the control. The authoritative gate is
// the one at the accepting transition — delete this block and nothing can
// be minted anyway. Do NOT turn it into an exemption, a dev flag that
// bypasses `advance`, or a magic faucet key: any of those would make the
// repair a disguised backdoor. Restoring a faucet means defining a real
// issuance predicate whose evidence the accepting layer validates.
//
// The minting body is DELETED rather than left unreachable: a dead path
// that still knows how to mint is the thing a later edit resurrects.
err(
"faucet.claim: builtin faucet issuance unavailable — no authenticated \
issuance predicate is defined for ERA/dBTC"
.into(),
)
}

// -------- faucet.clean (InvokeOp) --------
Expand Down
Loading
Loading