contracts between local accounts

This commit is contained in:
wiesche
2026-10-17 08:22:41 +02:00
parent f080323ec5
commit 08fb43144c
11 changed files with 500 additions and 57 deletions
+278
View File
@@ -0,0 +1,278 @@
// Copyright 2022 The Grin Developers
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Contracts between accounts in one wallet
extern crate grin_wallet_controller as wallet;
extern crate grin_wallet_impls as impls;
use grin_core::core::{Input, OutputFeatures};
use grin_wallet_libwallet as libwallet;
use libwallet::contract::my_fee_contribution;
use libwallet::contract::types::{ContractNewArgsAPI, ContractRevokeArgsAPI};
use libwallet::{Error, SlateState};
use std::path::PathBuf;
use std::sync::atomic::Ordering;
#[macro_use]
mod common;
fn local_transfer(
test_dir: &'static str,
invoice: bool,
early_lock: bool,
revoke: bool,
) -> Result<(), Error> {
common::setup(test_dir);
let (wallets, _chain, stopper, _) = common::create_wallets(
vec![vec![("default", 4), ("receiver", 4), ("unrelated", 4)]],
test_dir,
)
.unwrap();
let wallet = wallets[0].0.clone();
let mask = wallets[0].1.as_ref();
let result =
common::owner_single_use(wallet.clone(), mask, PathBuf::from(test_dir), |api, m| {
let amount = 5_000_000_000;
let (initiator, counterparty) = if invoice {
("receiver", "default")
} else {
("default", "receiver")
};
let change = if invoice { amount } else { -amount };
api.set_active_account(m, "unrelated")?;
let (_, unrelated) = api.retrieve_outputs(m, false, true, None)?;
let unrelated = unrelated.first().unwrap().clone();
api.set_active_account(m, initiator)?;
let mut args = common::contract_setup_args(Some(change));
args.add_outputs = early_lock;
let first = api.contract_new(
m,
&ContractNewArgsAPI {
setup_args: args,
ttl_blocks: Some(20),
},
)?;
// Preserve the legacy context key
let initiator_parent = {
wallet_inst!(wallet, w);
let ctx = w.get_private_context(m, first.id.as_bytes())?;
ctx.parent_key_id
};
// Setup retries stay with the original account
api.set_active_account(m, "unrelated")?;
{
wallet_inst!(wallet, w);
let retried = libwallet::contract::setup(
w,
m,
&first,
&common::contract_setup_args(Some(change)),
)?;
assert_eq!(retried.participant_data.len(), 1);
assert_eq!(w.private_context_iter()?.count(), 1);
assert_eq!(
w.get_private_context(m, first.id.as_bytes())?.parent_key_id,
initiator_parent
);
}
let mut sign_args = common::contract_setup_args(Some(-change));
api.set_active_account(m, if invoice { "unrelated" } else { counterparty })?;
if invoice {
sign_args.src_acct_name = Some(counterparty.into());
}
let second = common::roundtrip_slate(&api.contract_sign(m, &first, &sign_args)?, 4)?;
{
wallet_inst!(wallet, w);
assert_eq!(
w.get_private_context(m, first.id.as_bytes())?.parent_key_id,
initiator_parent
);
assert_eq!(w.private_context_iter()?.count(), 2);
}
api.set_active_account(m, counterparty)?;
let counterparty_tx = common::tx_log_for_slate(api, m, &second)?;
let progress = common::wallet_progress(api, m)?;
// Reject replay of both messages
for slate in [&first, &second] {
let err = api.contract_sign(m, slate, &sign_args).unwrap_err();
assert!(
matches!(err, Error::GenericError(ref msg) if msg.contains("already been signed"))
);
}
assert_eq!(common::wallet_progress(api, m)?, progress);
assert_eq!(
api.contract_view(m, &second)?.agreed_net_change,
Some(-change)
);
api.set_active_account(m, "unrelated")?;
assert!(api.contract_view(m, &second).is_err());
assert!(api
.contract_sign(m, &second, &common::contract_setup_args(None))
.is_err());
api.set_active_account(m, initiator)?;
assert_eq!(
api.contract_view(m, &second)?.agreed_net_change,
Some(change)
);
let progress = common::wallet_progress(api, m)?;
// Reject inputs from unrelated accounts
let mut tampered = second.clone();
tampered.tx = Some(
tampered
.tx_or_err()?
.clone()
.with_input(Input::new(OutputFeatures::Coinbase, unrelated.commit)),
);
let err = api
.contract_sign(m, &tampered, &common::contract_setup_args(None))
.unwrap_err();
assert!(
matches!(err, Error::GenericError(ref msg) if msg.contains("unexpected input commitment"))
);
assert_eq!(common::wallet_progress(api, m)?, progress);
// Require the counterparty's signature
let mut unsigned = second.clone();
for participant in &mut unsigned.participant_data {
participant.part_sig = None;
}
let err = api
.contract_sign(m, &unsigned, &common::contract_setup_args(None))
.unwrap_err();
assert!(
matches!(err, Error::GenericError(ref msg) if msg.contains("unexpected input"))
);
assert_eq!(common::wallet_progress(api, m)?, progress);
if revoke {
let initiator_tx = common::tx_log_for_slate(api, m, &second)?;
// Revoking one party preserves the other context
let replacement = api
.contract_revoke(
m,
&ContractRevokeArgsAPI {
tx_id: counterparty_tx.id,
src_acct_name: Some(counterparty.into()),
},
)?
.unwrap();
{
wallet_inst!(wallet, w);
assert_eq!(
w.get_private_context(m, first.id.as_bytes())?.parent_key_id,
initiator_parent
);
assert_eq!(w.private_context_iter()?.count(), 1);
}
assert!(api
.contract_revoke(
m,
&ContractRevokeArgsAPI {
tx_id: counterparty_tx.id,
src_acct_name: Some(counterparty.into()),
}
)?
.is_none());
// Revoked contributions are no longer authorized
assert!(api
.contract_sign(m, &second, &common::contract_setup_args(None))
.is_err());
let other_replacement = api
.contract_revoke(
m,
&ContractRevokeArgsAPI {
tx_id: initiator_tx.id,
src_acct_name: Some(initiator.into()),
},
)?
.unwrap();
assert_ne!(replacement.id, other_replacement.id);
wallet_inst!(wallet, w);
assert_eq!(w.private_context_iter()?.count(), 0);
return Ok(());
}
let final_slate = api.contract_sign(m, &second, &common::contract_setup_args(None))?;
assert_eq!(
final_slate.state,
if invoice {
SlateState::Invoice3
} else {
SlateState::Standard3
}
);
{
wallet_inst!(wallet, w);
assert!(matches!(
w.get_private_context(m, first.id.as_bytes()),
Err(Error::NotFoundErr(_))
));
assert_eq!(w.private_context_iter()?.count(), 1);
}
// Reject signing again even after context deletion
for (account, net) in [(initiator, change), (counterparty, -change)] {
api.set_active_account(m, account)?;
for slate in [&first, &second] {
assert!(api
.contract_sign(m, slate, &common::contract_setup_args(Some(net)))
.is_err());
}
let view = api.contract_view(m, &final_slate)?;
assert_eq!(view.agreed_net_change, Some(net));
assert_eq!(
view.own_commitment_status,
libwallet::contract::types::OwnCommitmentStatus::Unknown
);
}
// Mine rewards into a third account to isolate transfer balances
api.set_active_account(m, "unrelated")?;
assert!(api.contract_view(m, &final_slate).is_err());
api.post_tx(m, &final_slate, false)?;
let fee = my_fee_contribution(1, 1, 1, 2)?.fee();
for (account, net) in [("default", -amount), ("receiver", amount)] {
api.set_active_account(m, account)?;
let (refreshed, info) = api.retrieve_summary_info(m, true, 1)?;
assert!(refreshed);
assert_eq!(
info.total,
(4 * grin_core::consensus::REWARD as i64 + net) as u64 - fee
);
assert!(common::tx_log_for_slate(api, m, &final_slate)?.confirmed);
}
Ok(())
});
stopper.store(false, Ordering::Relaxed);
result?;
common::clean_output_dir(test_dir);
Ok(())
}
#[test]
fn contracts_between_local_accounts() -> Result<(), Error> {
for (dir, invoice, early) in [
("test_output/contract_local_srs", false, false),
("test_output/contract_local_srs_early", false, true),
("test_output/contract_local_rsr", true, false),
("test_output/contract_local_rsr_early", true, true),
] {
local_transfer(dir, invoice, early, false)?;
}
Ok(())
}
#[test]
fn revoke_local_counterparty() -> Result<(), Error> {
local_transfer("test_output/contract_local_revoke", false, true, true)
}
+2
View File
@@ -53,6 +53,8 @@ does not, revoke the contract and create a new one.
Each side's account is fixed when it first joins the contract. Changing the active account
later does not affect setup or signing; they use the account stored in the context.
Encrypted Slatepacks still need their account selected with `--account`; the error names it.
For contracts between accounts in the same wallet, select the party with `--account`
for each step.
## View and revoke
+1 -1
View File
@@ -463,7 +463,7 @@ where
/// Retrieve the private context associated with a given slate id.
pub fn get_private_context(
&mut self,
&self,
keychain_mask: Option<&SecretKey>,
slate_id: &[u8],
) -> Result<Context, Error> {
+8 -7
View File
@@ -79,13 +79,14 @@ where
let mut existing_context = None;
if let Some(id) = slate_id {
slate.id = id;
match w.get_private_context(keychain_mask, slate.id.as_bytes()) {
Ok(context) => {
slate.ttl_cutoff_height = context.contract_ttl_cutoff_height.unwrap_or(0);
existing_context = Some(context);
}
Err(Error::NotFoundErr(_)) => {}
Err(e) => return Err(e),
let parent = contract::utils::parent_key_for(w, setup_args.src_acct_name.as_deref())?;
contract::utils::verify_not_signed(w, slate.id, &parent)?;
existing_context = contract::context::stored(w, keychain_mask, slate.id)?
.into_iter()
.find(|(_, ctx)| ctx.parent_key_id == parent)
.map(|(_, ctx)| ctx);
if let Some(context) = &existing_context {
slate.ttl_cutoff_height = context.contract_ttl_cutoff_height.unwrap_or(0);
}
}
if existing_context.is_none() {
+7 -8
View File
@@ -93,14 +93,13 @@ where
// Drop the canceled slate's private context if one still exists (signing already
// deletes it).
if let Some(slate_id) = revoked_slate_id {
match w.get_private_context(keychain_mask, slate_id.as_bytes()) {
Ok(_) => {
let mut batch = w.batch(keychain_mask)?;
batch.delete_private_context(slate_id.as_bytes())?;
batch.commit()?;
}
Err(Error::NotFoundErr(_)) => {}
Err(e) => return Err(e),
if let Some((key, _)) = crate::contract::context::stored(w, keychain_mask, slate_id)?
.into_iter()
.find(|(_, ctx)| ctx.parent_key_id == parent_key_id)
{
let mut batch = w.batch(keychain_mask)?;
batch.delete_private_context(&key)?;
batch.commit()?;
}
}
}
+9 -2
View File
@@ -56,9 +56,16 @@ where
slate.num_participants, setup_args.num_participants
)));
}
contract::utils::verify_not_signed(w, slate.id)?;
let parent = contract::utils::parent_key_for(w, setup_args.src_acct_name.as_deref())?;
let context =
contract::context::resolve(w, keychain_mask, slate, &parent, setup_args.net_change)?;
let parent = context
.as_ref()
.map(|ctx| &ctx.parent_key_id)
.unwrap_or(&parent);
contract::utils::verify_not_signed(w, slate.id, parent)?;
// Compute state for 'setup'
let (slate, context) = compute(w, keychain_mask, slate, setup_args, None)?;
let (slate, context) = compute(w, keychain_mask, slate, setup_args, context)?;
// Atomically commit state
contract::utils::save_step(w, keychain_mask, &slate, context, setup_args.add_outputs)?;
+8 -6
View File
@@ -62,11 +62,9 @@ where
contract::utils::verify_fee_rate(setup_args.fee_rate)?;
// Compute if we will add outputs at this step. Only a missing context means we
// still need to add them; propagate any other error.
let existing_context = match w.get_private_context(keychain_mask, slate.id.as_bytes()) {
Ok(context) => Some(context),
Err(Error::NotFoundErr(_)) => None,
Err(e) => return Err(e),
};
let parent = contract::utils::parent_key_for(w, setup_args.src_acct_name.as_deref())?;
let existing_context =
contract::context::resolve(w, keychain_mask, slate, &parent, setup_args.net_change)?;
let will_add_outputs = existing_context
.as_ref()
.map(|context| !context.has_inputs_or_outputs())
@@ -92,7 +90,11 @@ where
C: NodeClient,
K: Keychain,
{
contract::utils::verify_not_signed(w, slate.id)?;
let parent = match &existing_context {
Some(context) => context.parent_key_id.clone(),
None => contract::utils::parent_key_for(w, setup_args.src_acct_name.as_deref())?,
};
contract::utils::verify_not_signed(w, slate.id, &parent)?;
// Reject terminal and unknown states before setup reserves keys.
contract::slate::next_state(&slate.state)?;
+33 -16
View File
@@ -76,16 +76,32 @@ where
));
}
contract::utils::verify_num_participants(slate.num_participants)?;
let context = match w.get_private_context(keychain_mask, slate.id.as_bytes()) {
Ok(context) => Some(context),
Err(Error::NotFoundErr(_)) => None,
Err(e) => return Err(e),
};
let parent = w.parent_key_id();
let context = contract::context::resolve(w, keychain_mask, slate, &parent, None)?;
let suggested_net_change = initial_net_change(&slate.state, slate.amount)?;
// A contract is executed once the transaction it produced has confirmed. That is
// recorded on our own tx log entry for this slate, so no chain lookup is needed; a
// slate we have never signed simply has no entry and is not executed.
let txs = updater::retrieve_txs(w, None, Some(slate.id), None, None, false)?;
let mut txs = updater::retrieve_txs(w, None, Some(slate.id), None, None, false)?;
let contexts = contract::context::stored(w, keychain_mask, slate.id)?;
let missing_signed_context = txs.iter().any(|tx| {
contract::utils::is_signed_tx(tx, slate.id)
&& !contexts
.iter()
.any(|(_, ctx)| ctx.parent_key_id == tx.parent_key_id)
});
let parent = context
.as_ref()
.map(|ctx| ctx.parent_key_id.clone())
.unwrap_or(parent);
// Show only the selected party when both accounts are local
if txs.iter().any(|tx| tx.parent_key_id == parent)
|| txs
.windows(2)
.any(|pair| pair[0].parent_key_id != pair[1].parent_key_id)
{
txs.retain(|tx| tx.parent_key_id == parent);
}
let is_executed = txs.iter().any(|tx| tx.confirmed);
let signed_tx = txs
.iter()
@@ -101,16 +117,17 @@ where
}
// Once we have signed, the context identifies the commitments we added. If it has
// already been removed, the slate no longer carries enough information to do that.
let own_commitment_status = if slate.tx.is_none() || (context.is_none() && !txs.is_empty()) {
OwnCommitmentStatus::Unknown
} else {
contract::slate::own_commitment_status(
w,
keychain_mask,
slate,
if has_signed { context.as_ref() } else { None },
)?
};
let own_commitment_status =
if slate.tx.is_none() || missing_signed_context || (context.is_none() && !txs.is_empty()) {
OwnCommitmentStatus::Unknown
} else {
contract::slate::own_commitment_status(
w,
keychain_mask,
slate,
if has_signed { context.as_ref() } else { None },
)?
};
// Count signatures present (a participant is "complete" once it has a partial sig).
let num_sigs = slate
.participant_data
+112 -11
View File
@@ -26,6 +26,108 @@ use grin_core::core::FeeFields;
use grin_keychain::{Identifier, Keychain};
use grin_util::secp::key::SecretKey;
// Keep the legacy key for the first party and qualify the second by account
fn account_key(slate_id: uuid::Uuid, parent: &Identifier) -> Vec<u8> {
let mut key = slate_id.as_bytes().to_vec();
key.extend_from_slice(&parent.to_bytes());
key
}
pub(super) fn stored<C: NodeClient, K: Keychain>(
w: &WalletBackend<C, K>,
mask: Option<&SecretKey>,
slate_id: uuid::Uuid,
) -> Result<Vec<(Vec<u8>, Context)>, Error> {
let mut keys = vec![slate_id.as_bytes().to_vec()];
keys.extend(
w.acct_path_iter()?
.map(|account| account_key(slate_id, &account.path)),
);
let mut contexts = Vec::new();
for key in keys {
match w.get_private_context(mask, &key) {
Ok(context) => contexts.push((key, context)),
Err(Error::NotFoundErr(_)) => {}
Err(e) => return Err(e),
}
}
Ok(contexts)
}
pub(super) fn storage_key<C: NodeClient, K: Keychain>(
w: &WalletBackend<C, K>,
mask: Option<&SecretKey>,
slate_id: uuid::Uuid,
parent: &Identifier,
) -> Result<Vec<u8>, Error> {
let contexts = stored(w, mask, slate_id)?;
if let Some((key, _)) = contexts
.iter()
.find(|(_, ctx)| &ctx.parent_key_id == parent)
{
return Ok(key.clone());
}
if contexts.iter().any(|(key, _)| key == slate_id.as_bytes()) {
Ok(account_key(slate_id, parent))
} else {
Ok(slate_id.as_bytes().to_vec())
}
}
/// Resolve the selected party, preserving retries after account switches
pub(super) fn resolve<C: NodeClient, K: Keychain>(
w: &WalletBackend<C, K>,
mask: Option<&SecretKey>,
slate: &Slate,
parent: &Identifier,
requested_change: Option<i64>,
) -> Result<Option<Context>, Error> {
let mut contexts = stored(w, mask, slate.id)?;
if let Some(index) = contexts
.iter()
.position(|(_, ctx)| &ctx.parent_key_id == parent)
{
return Ok(Some(contexts.swap_remove(index).1));
}
// Never fall back to another party after context deletion
let mut parents = Vec::new();
for tx in w.tx_log_iter()? {
let tx = tx?;
if tx.tx_slate_id == Some(slate.id) {
if &tx.parent_key_id == parent {
return Ok(None);
}
if !parents.contains(&tx.parent_key_id) {
parents.push(tx.parent_key_id);
}
}
}
if parents.len() > 1 {
return Err(Error::GenericError(
"Select an account participating in this contract".into(),
));
}
match contexts.len() {
0 => Ok(None),
1 => {
let (_, context) = contexts.pop().unwrap();
if slate.num_participants == 2 {
if let Some(change) = super::initial_net_change(&slate.state, slate.amount)? {
if change.signum() != context.get_net_change()?.signum()
&& requested_change.is_none_or(|net| net.signum() == change.signum())
{
return Ok(None);
}
}
}
Ok(Some(context))
}
_ => Err(Error::GenericError(
"Select an account participating in this contract".into(),
)),
}
}
/// Get or create transaction Context for the given slate
pub fn get_or_create<C, K>(
w: &mut WalletBackend<C, K>,
@@ -38,28 +140,27 @@ where
K: Keychain,
{
debug!("contract::context::get_or_create => called");
let maybe_context = w.get_private_context(keychain_mask, slate.id.as_bytes());
let context = match maybe_context {
// Only create a new context when there genuinely isn't one; propagate other errors.
Err(Error::NotFoundErr(_)) => {
// Get data required for creating a context
let parent_key_id = contract_utils::parent_key_for(w, setup_args.src_acct_name.as_deref())?;
let context = match resolve(
w,
keychain_mask,
slate,
&parent_key_id,
setup_args.net_change,
)? {
Some(context) => context,
None => {
let height = w.w2n_client().get_chain_tip()?.0;
let parent_key_id =
contract_utils::parent_key_for(w, setup_args.src_acct_name.as_deref())?;
self::create(
w,
keychain_mask,
slate,
height,
// &args,
setup_args,
&parent_key_id,
false,
)?
}
Err(e) => return Err(e),
Ok(ctx) => ctx,
};
Ok(context)
}
+32 -1
View File
@@ -247,7 +247,7 @@ where
Inputs::CommitOnly(_) => return Ok(OwnCommitmentStatus::Unknown),
Inputs::FeaturesAndCommit(inputs) => inputs,
};
let (expected_inputs, expected_outputs) = match signed_context {
let (mut expected_inputs, mut expected_outputs) = match signed_context {
Some(context) => {
let (inputs, outputs) = expected_identifiers(w, &keychain, context)?;
(
@@ -263,6 +263,37 @@ where
}
None => (BTreeSet::new(), BTreeSet::new()),
};
// Allow only stored local contributions backed by a valid partial signature
for (_, context) in super::context::stored(w, keychain_mask, slate.id)? {
let Some(log_id) = context.log_id else {
continue;
};
let Some(tx_log) = w.get_tx_log_entry_by_id(&context.parent_key_id, log_id)? else {
continue;
};
if !super::utils::is_signed_tx(&tx_log, slate.id)
|| !matches!(
tx_log.tx_type,
crate::TxLogEntryType::TxSent
| crate::TxLogEntryType::TxReceived
| crate::TxLogEntryType::TxSelfSpend
) {
continue;
}
let index = match slate.find_index_matching_context(&keychain, &context) {
Ok(index) => index,
Err(Error::ContextToIndex) => continue,
Err(e) => return Err(e),
};
if !slate.participant_data[index].is_complete() {
continue;
}
slate.verify_part_sigs(keychain.secp())?;
verify_own_commitments(w, keychain_mask, slate, &context)?;
let (inputs, outputs) = expected_identifiers(w, &keychain, &context)?;
expected_inputs.extend(inputs.into_iter().map(|input| input.commitment()));
expected_outputs.extend(outputs.into_iter().map(|output| output.commitment()));
}
let incoming_inputs = inputs
.into_iter()
.map(|input| input.commitment())
+10 -5
View File
@@ -271,6 +271,7 @@ where
};
// Phase 2 - atomically update Context, OutputData and TxLogEntry
let context_key = super::context::storage_key(w, keychain_mask, slate.id, parent_key_id)?;
let mut batch = w.batch(keychain_mask)?;
// Update TxLogEntry
@@ -300,17 +301,17 @@ where
// Update context
if is_signed && !is_step2 {
// Forget the signing context in the same batch as the transaction log update
batch.delete_private_context(slate.id.as_bytes())?;
batch.delete_private_context(&context_key)?;
} else {
// Keep unsigned contexts for retries and the step2 context for contract view
batch.save_private_context(slate.id.as_bytes(), &context)?;
batch.save_private_context(&context_key, &context)?;
}
batch.commit()?;
// Confirm sec_key/sec_nonce are gone after signing, except for step2
if is_signed && !is_step2 {
match w.get_private_context(keychain_mask, slate.id.as_bytes()) {
match w.get_private_context(keychain_mask, &context_key) {
Err(Error::NotFoundErr(_)) => {}
Err(e) => return Err(e),
Ok(_) => {
@@ -424,7 +425,11 @@ pub(super) fn is_signed_tx(tx: &TxLogEntry, slate_id: Uuid) -> bool {
/// Returns an error if the slate has already been signed (in our local database). Even if the
/// result is Ok, it's still possible it was signed but we don't have the data about it locally.
pub fn verify_not_signed<C, K>(w: &mut WalletBackend<C, K>, slate_id: Uuid) -> Result<(), Error>
pub fn verify_not_signed<C, K>(
w: &mut WalletBackend<C, K>,
slate_id: Uuid,
parent: &Identifier,
) -> Result<(), Error>
where
C: NodeClient,
K: Keychain,
@@ -433,7 +438,7 @@ where
// we have already signed this slate.
for tx in w.tx_log_iter()? {
let tx = tx?;
if is_signed_tx(&tx, slate_id) {
if &tx.parent_key_id == parent && is_signed_tx(&tx, slate_id) {
debug!("contract::utils::verify_not_signed => The slate has already been signed.");
return Err(Error::GenericError(format!(
"Slate with id:{} has already been signed.",