Skip to content

Commit 5a0b0b1

Browse files
chore(wip): salvage round-2 work in progress for #1702
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 053a640 commit 5a0b0b1

12 files changed

Lines changed: 690 additions & 197 deletions

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ chia-protocol = "0.26"
2929
chia-puzzle-types = "0.26"
3030
chia-wallet-sdk = { version = "0.30", features = ["chip-0035"] }
3131
clvmr = "0.14"
32+
# Asset ids and puzzle hashes are rendered as lowercase hex in a summary line.
33+
hex = "0.4"
3234
thiserror = "2"
3335
async-trait = "0.1"
3436
serde = { version = "1", features = ["derive"] }

src/error.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ pub enum AccountError {
3838
#[error("spend refused: {0}")]
3939
Spend(String),
4040

41+
/// The USER declined the spend at the confirm ceremony.
42+
///
43+
/// Distinct from [`PolicyDenied`](Self::PolicyDenied) because the two are different facts about
44+
/// different deciders, and a host reports them differently: "you said no" is an ordinary outcome,
45+
/// while "the rules say no" may mean a misconfiguration. Merging them would also make the normative
46+
/// wire mapping (`SPEC.md` §6.3.1) ambiguous — one crate outcome cannot map to two codes — and
47+
/// collapsing outcomes at a boundary is the defect this crate's 0.2.0 shape exists to remove.
48+
///
49+
/// Terminal: no further ceremony may permit a spend the user has already refused.
50+
#[error("the user declined the spend: {0}")]
51+
UserDeclined(String),
52+
4153
/// The spend is FORBIDDEN by a structural custody rule, or the user declined it — no (further)
4254
/// confirmation ceremony can permit it.
4355
///

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ pub mod keys;
4141
pub mod model;
4242
pub mod profile_mint;
4343
pub mod session;
44+
pub mod session_residency;
4445
pub mod signer;
4546
pub mod store;
4647
pub mod unlocked;
@@ -57,6 +58,7 @@ pub use keys::wallet_key::WalletKey;
5758
pub use model::{Account, AccountRecord, Profile};
5859
pub use profile_mint::ProfileMinter;
5960
pub use session::AccountSession;
61+
pub use session_residency::Residency;
6062
pub use signer::ProfileSigner;
6163
pub use store::{AccountStore, AccountStoreError};
6264
pub use unlocked::UnlockedAccount;

src/session_residency.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//! [`Residency`] — the shared liveness token that makes `lock()` authoritative.
2+
//!
3+
//! # Why a token rather than dropping the seed
4+
//!
5+
//! The live seed sits behind an `Arc<UnlockedMasterSeed>`, and every capability handle
6+
//! ([`WalletOps`](crate::wallet::authorizer::WalletOps), the money signer) holds a clone of it. So
7+
//! dropping the [`UnlockedAccount`](crate::unlocked::UnlockedAccount) drops only ONE reference: while
8+
//! any capability handle survives, the seed is neither dropped nor zeroized, and anything built from it
9+
//! keeps working. `lock()` looked like a revocation and was really a hint.
10+
//!
11+
//! A `Residency` closes that. Every capability derived from one unlock shares the same token, and
12+
//! [`revoke`](Residency::revoke) flips it once for all of them. A signer therefore OBSERVES the session
13+
//! rather than owning a snapshot of it: after `lock()`, after a password change, after a profile switch,
14+
//! signing fails with [`Locked`](crate::error::AccountError::Locked) — even though the seed bytes may
15+
//! still be resident because some other handle is alive.
16+
//!
17+
//! This is deliberately an ENFORCEMENT rather than a documented obligation. The previous design's
18+
//! answer to "what stops a stale signer signing?" was a note asking hosts to rebuild the signer after
19+
//! the ceremony — the same unenforced-convention shape as the `SpendAuthorizer` trait this crate
20+
//! removed. A host cannot forget to check a flag it does not own.
21+
22+
use std::sync::atomic::{AtomicBool, Ordering};
23+
24+
/// The liveness of ONE unlock, shared by every capability derived from it.
25+
///
26+
/// Starts live and becomes revoked at most once; there is deliberately no way back. A relock is a new
27+
/// unlock, which mints a new token — so a revoked `Residency` can never be resurrected by holding a
28+
/// reference to it.
29+
#[derive(Debug)]
30+
pub struct Residency {
31+
live: AtomicBool,
32+
}
33+
34+
impl Residency {
35+
/// A live residency for a freshly-unlocked account.
36+
pub(crate) fn new() -> Self {
37+
Self {
38+
live: AtomicBool::new(true),
39+
}
40+
}
41+
42+
/// Whether the unlock this token belongs to is still live.
43+
///
44+
/// `Acquire`/`Release` ordering pairs with [`revoke`](Self::revoke): a thread that observes the
45+
/// revocation also observes everything the revoking thread did before it, so a relock cannot be
46+
/// seen half-applied.
47+
pub fn is_live(&self) -> bool {
48+
self.live.load(Ordering::Acquire)
49+
}
50+
51+
/// Revoke the unlock. Idempotent, and irreversible.
52+
pub(crate) fn revoke(&self) {
53+
self.live.store(false, Ordering::Release);
54+
}
55+
}
56+
57+
#[cfg(test)]
58+
mod tests {
59+
use super::*;
60+
use std::sync::Arc;
61+
62+
#[test]
63+
fn a_fresh_residency_is_live_and_revocation_is_visible_through_every_clone() {
64+
let residency = Arc::new(Residency::new());
65+
let capability = residency.clone();
66+
assert!(capability.is_live());
67+
68+
residency.revoke();
69+
assert!(
70+
!capability.is_live(),
71+
"a capability holding its own reference must observe the revocation"
72+
);
73+
}
74+
75+
#[test]
76+
fn revocation_is_idempotent_and_has_no_way_back() {
77+
let residency = Residency::new();
78+
residency.revoke();
79+
residency.revoke();
80+
assert!(!residency.is_live());
81+
}
82+
}

src/unlocked.rs

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use zeroize::Zeroizing;
1212

1313
use crate::id::{AccountId, ProfileIx};
1414
use crate::keys::dek::profile_dek;
15+
use crate::session_residency::Residency;
1516
use crate::signer::ProfileSigner;
1617
use crate::wallet::authorizer::WalletOps;
1718

@@ -33,6 +34,9 @@ pub struct UnlockedAccount {
3334
account: AccountId,
3435
seed: Arc<UnlockedMasterSeed>,
3536
default_profile_ix: ProfileIx,
37+
/// The liveness token every capability derived from this unlock shares, so
38+
/// [`lock`](Self::lock) revokes them all rather than merely dropping one reference to the seed.
39+
residency: Arc<Residency>,
3640
}
3741

3842
impl UnlockedAccount {
@@ -46,9 +50,18 @@ impl UnlockedAccount {
4650
account,
4751
seed,
4852
default_profile_ix,
53+
residency: Arc::new(Residency::new()),
4954
}
5055
}
5156

57+
/// The liveness token for this unlock — live until [`lock`](Self::lock).
58+
///
59+
/// Exposed so a host can ask whether the capabilities it holds are still valid without having to
60+
/// attempt an operation. It cannot be revoked through this handle.
61+
pub fn residency(&self) -> Arc<Residency> {
62+
self.residency.clone()
63+
}
64+
5265
/// The account this handle unlocked.
5366
pub fn account_id(&self) -> &AccountId {
5467
&self.account
@@ -65,8 +78,16 @@ impl UnlockedAccount {
6578
}
6679

6780
/// The wallet-ops handle for the default profile (money-path derivations + signing seam).
81+
///
82+
/// The handle observes this unlock's [`Residency`], so a money signer built from it stops signing
83+
/// the moment [`lock`](Self::lock) is called — it does not hold a snapshot that outlives the
84+
/// session.
6885
pub fn wallet_ops(&self) -> WalletOps {
69-
WalletOps::new(self.seed.clone(), self.default_profile_ix)
86+
WalletOps::new(
87+
self.seed.clone(),
88+
self.default_profile_ix,
89+
self.residency.clone(),
90+
)
7091
}
7192

7293
/// The per-profile data-encryption key (DEK) for profile `ix` — 32 bytes, derived from the seed
@@ -82,10 +103,16 @@ impl UnlockedAccount {
82103
self.seed.master_seed()
83104
}
84105

85-
/// Relock immediately, dropping the live seed.
106+
/// Relock immediately: revoke every capability derived from this unlock, and drop the seed.
107+
///
108+
/// Revoking is what makes this authoritative. Consuming `self` drops only ONE reference to the
109+
/// seed, so a surviving [`WalletOps`] would otherwise keep the bytes resident AND keep signing;
110+
/// after this call such a handle refuses with [`Locked`](crate::error::AccountError::Locked)
111+
/// regardless of who else still holds the seed.
86112
pub fn lock(self) {
87-
// Consuming `self` drops the `Arc<UnlockedMasterSeed>`; when the last handle drops, the seed
88-
// is zeroized.
113+
self.residency.revoke();
114+
// Dropping `self` releases this handle's `Arc<UnlockedMasterSeed>`; the bytes are zeroized
115+
// once the last surviving handle drops.
89116
}
90117
}
91118

src/wallet/approval.rs

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@
3434
use chia_protocol::CoinSpend;
3535
use dig_wallet_backend::types::TransactionSummary;
3636

37-
use crate::auth::provider::SpendDecision;
37+
use crate::auth::provider::{AuthProvider, SpendConfirmRequest, SpendDecision};
3838
use crate::error::{AccountError, Result};
39+
use crate::id::{AccountId, ProfileIx};
3940
use crate::wallet::summary::SpendSummary;
4041

4142
/// The spends a ruling was made about, together with the single derivation made from them.
@@ -48,9 +49,14 @@ struct AuthorizedSpend {
4849
coin_spends: Vec<CoinSpend>,
4950
/// This crate's tiered, human-renderable view of those spends.
5051
summary: SpendSummary,
51-
/// `dig-wallet-backend`'s own verified re-parse of the same spends, carried so the signer can
52-
/// hand it back for the pre-signing cross-check without re-deriving it (one derivation, one
53-
/// answer — two derivations could disagree).
52+
/// The hinted-only summary `dig-wallet-backend`'s signer takes as a required PARAMETER, derived by
53+
/// the gate alongside `summary` so the signer never has to re-parse the spend.
54+
///
55+
/// The signer does compare it against its own re-derivation, but this crate does not treat that as
56+
/// a check and does not rely on it: both sides come from these same bytes, so it can only agree. A
57+
/// genuine second opinion would need an INDEPENDENT derivation — exactly the two-answers-can-
58+
/// disagree shape this whole type exists to remove. What protects the caller is that `coin_spends`
59+
/// below is the same `Vec` the gate judged.
5460
verified: TransactionSummary,
5561
}
5662

@@ -129,29 +135,61 @@ impl PendingApproval {
129135
&self.inner.summary
130136
}
131137

132-
/// Convert the user's ruling into a signable approval.
138+
/// Run the confirm ceremony through `provider`, and convert the user's ruling into a signable
139+
/// approval.
140+
///
141+
/// **This is the ONLY route from "needs a human" to a signature**, and it is a route THROUGH the
142+
/// consent seam rather than past it. A host cannot mint an approval by asserting consent it never
143+
/// obtained: it must implement [`AuthProvider::confirm_spend`], which is the seam that exists to
144+
/// render the ceremony. A host that cannot render one MUST return
145+
/// [`Decline`](SpendDecision::Decline) — never `Approve` — and `SPEC.md` §6.3 states that as a MUST.
133146
///
134-
/// Consumes `self`, so a ceremony cannot be run once and converted twice.
147+
/// Consumes `self`, so one prompt yields at most one approval.
135148
///
136149
/// # A decline is terminal, not a retry
137150
///
138-
/// [`Decline`](SpendDecision::Decline) yields [`PolicyDenied`](AccountError::PolicyDenied) rather
139-
/// than an escalatable refusal: the human has already been asked, so no further ceremony may
140-
/// permit this spend, and a caller that treated the decline as "ask again" would turn a refusal
141-
/// into a prompt-until-mis-click.
151+
/// [`Decline`](SpendDecision::Decline) yields [`UserDeclined`](AccountError::UserDeclined) — a
152+
/// distinct variant from the structural [`PolicyDenied`](AccountError::PolicyDenied), so a host can
153+
/// report "you said no" separately from "the rules say no" instead of collapsing them. Either way no
154+
/// further ceremony may permit this spend: a caller that treated a decline as "ask again" would turn
155+
/// a refusal into a prompt-until-mis-click.
142156
///
143157
/// # A confirmed spend does not consume the auto-send allowance
144158
///
145-
/// The rolling period cap bounds what may move *unattended*. This spend moved because a human
146-
/// said so, so it is charged to nothing — and, symmetrically, a declined spend leaves the
147-
/// allowance untouched (a refusal must never cost the user their allowance). That is why this
148-
/// method holds no reference to the gate's ledger: it structurally cannot charge it.
149-
pub fn confirmed(self, decision: SpendDecision) -> Result<SpendApproval> {
159+
/// The rolling period cap bounds what may move *unattended*. This spend moves because a human said
160+
/// so, so it is charged to nothing — and, symmetrically, a declined spend leaves the allowance
161+
/// untouched. Charging a confirmed spend would let anything that can raise a prompt drain the user's
162+
/// unattended allowance without a single approval, turning the cap into a weapon against them.
163+
/// `SPEC.md` §6.4 records the reasoning; this method holds no reference to the gate's ledger, so it
164+
/// structurally cannot charge it either way.
165+
pub async fn confirm_with(
166+
self,
167+
provider: &dyn AuthProvider,
168+
account: AccountId,
169+
profile: ProfileIx,
170+
) -> Result<SpendApproval> {
171+
let request = SpendConfirmRequest::new(account, profile, self.inner.summary.clone());
172+
let decision = provider.confirm_spend(request).await?;
173+
self.decided(decision)
174+
}
175+
176+
/// Convert an already-collected decision.
177+
///
178+
/// `pub(crate)`: this is [`confirm_with`](Self::confirm_with)'s tail, and it is deliberately not a
179+
/// public door. A public `confirmed(SpendDecision)` would let a host write
180+
/// `RequiresConfirmation(p) => p.confirmed(Approve)` — one line, no user asked, no cap charged, no
181+
/// limit re-checked. That is the `Ok(())` authorizer this crate removed, wearing a different name,
182+
/// and with less accounting than the thing it replaced. Consent must come from the seam that exists
183+
/// to obtain it.
184+
fn decided(self, decision: SpendDecision) -> Result<SpendApproval> {
150185
match decision {
151186
SpendDecision::Approve => Ok(SpendApproval { inner: self.inner }),
152-
SpendDecision::Decline(reason) => Err(AccountError::PolicyDenied(format!(
187+
// `{:?}` deliberately: the reason is host-supplied text that may quote a dapp, and an
188+
// error string ends up in logs. Debug-escaping it keeps a newline or a control character
189+
// from forging a log line.
190+
SpendDecision::Decline(reason) => Err(AccountError::UserDeclined(format!(
153191
"the user declined this spend{}",
154-
reason.map(|r| format!(": {r}")).unwrap_or_default()
192+
reason.map(|r| format!(": {r:?}")).unwrap_or_default()
155193
))),
156194
}
157195
}

src/wallet/authorizer.rs

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use dig_wallet_backend::types::Network;
2323
use crate::error::Result;
2424
use crate::id::ProfileIx;
2525
use crate::keys::wallet_key::WalletKey;
26+
use crate::session_residency::Residency;
2627
use crate::wallet::money_signer::LocalMoneySigner;
2728
use crate::wallet::policy::CustodyPolicy;
2829
use crate::wallet::summary::SpendSummary;
@@ -32,12 +33,23 @@ use crate::wallet::summary::SpendSummary;
3233
pub struct WalletOps {
3334
seed: Arc<UnlockedMasterSeed>,
3435
profile_ix: ProfileIx,
36+
/// The unlock this handle belongs to. Passed to every money signer it builds, so a signer cannot
37+
/// outlive the session that authorized it.
38+
residency: Arc<Residency>,
3539
}
3640

3741
impl WalletOps {
38-
/// Build the wallet-ops handle for `profile_ix`, backed by `seed`.
39-
pub(crate) fn new(seed: Arc<UnlockedMasterSeed>, profile_ix: ProfileIx) -> Self {
40-
Self { seed, profile_ix }
42+
/// Build the wallet-ops handle for `profile_ix`, backed by `seed` and scoped to `residency`.
43+
pub(crate) fn new(
44+
seed: Arc<UnlockedMasterSeed>,
45+
profile_ix: ProfileIx,
46+
residency: Arc<Residency>,
47+
) -> Self {
48+
Self {
49+
seed,
50+
profile_ix,
51+
residency,
52+
}
4153
}
4254

4355
/// The profile's wallet (money) key, derived from the master seed at the profile index.
@@ -68,16 +80,26 @@ impl WalletOps {
6880

6981
/// Build the profile's money signer for `network` — the concrete, canonical-wallet spend signer.
7082
///
71-
/// The signer is derived from the master seed over the CANONICAL
72-
/// `master_to_wallet_unhardened(seed, ix).derive_synthetic()` money-key scheme (via
83+
/// The signer signs over the CANONICAL `master_to_wallet_unhardened(seed, ix).derive_synthetic()`
84+
/// money-key scheme (via
7385
/// [`LocalSigner::new_canonical`](dig_wallet_backend::client::LocalSigner::new_canonical)), so it
7486
/// controls the coins this profile's funds actually live at (byte-identical to
7587
/// [`public_key`](Self::public_key) / [`address`](Self::address)). The raw seed/key never leaves
7688
/// dig-account — the returned [`LocalMoneySigner`] exposes only signing.
77-
pub fn money_signer(&self, network: Network) -> Result<LocalMoneySigner> {
89+
///
90+
/// **The signer OBSERVES this unlock rather than copying it.** It holds the same
91+
/// `Arc<UnlockedMasterSeed>` and the same [`Residency`] as this handle and derives the money key
92+
/// per signature, so after
93+
/// [`UnlockedAccount::lock`](crate::unlocked::UnlockedAccount::lock) it refuses. Holding a signer is
94+
/// therefore not a way to keep a relocked account spendable.
95+
///
96+
/// Infallible: building the signer defers every derivation to signing time, so there is nothing
97+
/// here that can fail.
98+
pub fn money_signer(&self, network: Network) -> LocalMoneySigner {
7899
LocalMoneySigner::new_canonical(
79-
self.seed.master_seed().to_vec(),
80-
self.profile_ix.0,
100+
self.seed.clone(),
101+
self.residency.clone(),
102+
self.profile_ix,
81103
network,
82104
)
83105
}

0 commit comments

Comments
 (0)