Contracts: deterministic self-spend id so an interrupted revoke resumes

revoke builds the self-spend via new(), which used a random slate id, so a crash
between new() and sign() left an orphaned self-spend and a retry created another
one. Derive the self-spend slate id deterministically from the revoked slate
(blake2b, already a dependency) and let contract::new take an optional slate id;
a retry then reuses the in-progress context via get_or_create instead of
orphaning a fresh self-spend. The other new() caller passes None (random id).

Adds a unit test that the derived id is deterministic and distinct.
This commit is contained in:
jwinterm
2026-06-10 16:34:52 -04:00
parent c7acc8ef4a
commit 55a0d43d57
3 changed files with 50 additions and 4 deletions
+1 -1
View File
@@ -1529,7 +1529,7 @@ where
C: NodeClient,
K: Keychain,
{
contract::new(&mut *w, keychain_mask, &args.setup_args)
contract::new(&mut *w, keychain_mask, &args.setup_args, None)
}
/// Sign transaction contract
+11 -2
View File
@@ -23,19 +23,23 @@ use crate::grin_util::secp::key::SecretKey;
use crate::slate::Slate;
use crate::types::{Context, NodeClient};
use crate::backend::WalletBackend;
use uuid::Uuid;
/// Create a new contract with initial setup done by the initiator
/// Create a new contract with initial setup done by the initiator. `slate_id`, when
/// provided, fixes the slate id (rather than a random one) so the caller can make a
/// retried creation idempotent: get_or_create reuses the existing context for that id.
pub fn new<C, K>(
w: &mut WalletBackend<C, K>,
keychain_mask: Option<&SecretKey>,
setup_args: &ContractSetupArgsAPI,
slate_id: Option<Uuid>,
) -> Result<Slate, Error>
where
C: NodeClient,
K: Keychain,
{
// Compute state for 'new'
let (slate, mut context) = compute(w, keychain_mask, setup_args)?;
let (slate, mut context) = compute(w, keychain_mask, setup_args, slate_id)?;
// Atomically commit state
contract::utils::save_step(
@@ -55,6 +59,7 @@ pub fn compute<C, K>(
w: &mut WalletBackend<C, K>,
keychain_mask: Option<&SecretKey>,
setup_args: &ContractSetupArgsAPI,
slate_id: Option<Uuid>,
) -> Result<(Slate, Context), Error>
where
C: NodeClient,
@@ -68,6 +73,10 @@ where
// Initialize a new contract (if net_change is positive, I'm the receiver meaning this is invoice flow)
let num_participants = setup_args.num_participants;
let mut slate = Slate::blank(num_participants, net_change > 0);
// Use a caller-supplied id when given, so a retried creation reuses the same context.
if let Some(id) = slate_id {
slate.id = id;
}
// We set slate.amount to contain the _positive_ net_change for the other party so they can derive expectations.
// unsigned_abs avoids the i64::MIN overflow panic of abs().
slate.amount = net_change.unsigned_abs();
+38 -1
View File
@@ -22,6 +22,19 @@ use crate::internal::tx;
use crate::slate::Slate;
use crate::types::{NodeClient, OutputData, OutputStatus, TxLogEntryType};
use crate::backend::WalletBackend;
use crate::blake2::blake2b::blake2b;
use uuid::Uuid;
/// Deterministic slate id for the self-spend that revokes a given contract slate.
/// Derived from the revoked slate id so a revoke interrupted between creating and
/// signing the self-spend resumes by reusing the same context (get_or_create), rather
/// than orphaning a fresh self-spend on each retry.
fn self_spend_slate_id(revoked_slate_id: Uuid) -> Uuid {
let hash = blake2b(16, b"grin-contract-revoke", revoked_slate_id.as_bytes());
let mut bytes = [0u8; 16];
bytes.copy_from_slice(hash.as_bytes());
Uuid::from_bytes(bytes)
}
/// Contract revocation is done by double-spending the input
pub fn revoke<C, K>(
@@ -67,6 +80,7 @@ where
// it is already a *Cancelled type (and the inputs are Unspent), so we skip straight to
// re-spending them.
let revoked = w.get_tx_log_entry(parent_key_id.clone(), tx_id)?;
let revoked_slate_id = revoked.as_ref().and_then(|e| e.tx_slate_id);
let needs_cancel = match revoked.as_ref() {
Some(e) => matches!(
e.tx_type,
@@ -82,7 +96,7 @@ where
tx::cancel_tx(&mut *w, keychain_mask, &parent_key_id, Some(tx_id), None)?;
// Drop the canceled slate's private context if one still exists (signing already
// deletes it).
if let Some(slate_id) = revoked.and_then(|e| e.tx_slate_id) {
if let Some(slate_id) = revoked_slate_id {
if w
.get_private_context(keychain_mask, slate_id.as_bytes())
.is_ok()
@@ -107,6 +121,10 @@ where
.acct_path_iter()?
.find(|m| m.path == parent_key_id)
.map(|m| m.label);
// Deterministic self-spend slate id (when we know the revoked slate) so a crash
// between new() and sign() is resumed by reusing the same context rather than
// orphaning a fresh self-spend on the retry.
let self_spend_id = revoked_slate_id.map(self_spend_slate_id);
// 2. Create a 1-1 self-spend transaction using this input
let ct_slate = new(
w,
@@ -122,6 +140,7 @@ where
},
proof_args: Default::default(),
},
self_spend_id,
)?;
let finished_slate = sign(
w,
@@ -142,3 +161,21 @@ where
Ok(Some(finished_slate))
}
#[cfg(test)]
mod tests {
use super::self_spend_slate_id;
use uuid::Uuid;
#[test]
fn self_spend_id_is_deterministic_and_distinct() {
let revoked = Uuid::parse_str("936da01f-9abd-4d9d-80c7-02af85c822a8").unwrap();
// Same revoked slate -> same self-spend id, so a retried revoke reuses the context.
assert_eq!(self_spend_slate_id(revoked), self_spend_slate_id(revoked));
// Distinct from the revoked id (the self-spend is a different tx).
assert_ne!(self_spend_slate_id(revoked), revoked);
// Different revoked slates -> different self-spend ids.
let other = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
assert_ne!(self_spend_slate_id(revoked), self_spend_slate_id(other));
}
}