Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for dependent transfers #1253

Closed
wants to merge 26 commits into from
Closed
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
129af74
feat(accounts): add split_dependable_output fn
Br1ght0ne Jan 15, 2024
51835e4
feat(coin-cache): add dependent cache
Br1ght0ne Jan 22, 2024
0f55cd2
feat(coin-cache): save Coins intead of UtxoIds (for amount)
Br1ght0ne Jan 22, 2024
e571ce6
feat(provider): extend coin filtering with dependents
Br1ght0ne Jan 22, 2024
5dbbebe
fix(utils): return index of split output
Br1ght0ne Jan 22, 2024
ec66970
WIP: feat(accounts): add dependent_transfer function
Br1ght0ne Jan 22, 2024
c897696
Merge branch 'master' into oleksii/dependent-transactions
Br1ght0ne Jan 22, 2024
210998f
fix: gate functions behind coin-cache flag
Br1ght0ne Jan 22, 2024
414a09e
fix: add previous output to inputs
Br1ght0ne Jan 22, 2024
ca64b43
feat: cache outputs
Br1ght0ne Jan 25, 2024
633a83c
fix: don't split if no remaining assets
Br1ght0ne Jan 25, 2024
3fa41de
Merge branch 'master' into oleksii/dependent-transactions
Br1ght0ne Jan 25, 2024
9460d7a
fix: feature gating
Br1ght0ne Jan 29, 2024
565a5f6
fix: more feature gating
Br1ght0ne Jan 29, 2024
d466e45
fix: clippy
Br1ght0ne Jan 29, 2024
8b269b3
add test
Br1ght0ne Jan 29, 2024
a027ff6
Merge branch 'master' into oleksii/dependent-transactions
Br1ght0ne Jan 30, 2024
0f4a781
Merge branch 'master' into oleksii/dependent-transactions
Br1ght0ne Feb 12, 2024
03b441d
refactor: split cache read/write into functions
Br1ght0ne Feb 12, 2024
983ae91
Merge branch 'master' into oleksii/dependent-transactions
Br1ght0ne Feb 12, 2024
43d22d3
Merge branch 'master' into oleksii/dependent-transactions
segfault-magnet Feb 12, 2024
ea4541e
Merge branch 'master' into oleksii/dependent-transactions
segfault-magnet Feb 19, 2024
3a7002d
Merge branch 'master' into oleksii/dependent-transactions
Br1ght0ne Apr 8, 2024
d4640c4
Remove VecDeque::iter
Br1ght0ne Apr 8, 2024
962ee48
Merge branch 'master' into oleksii/dependent-transactions
Br1ght0ne Apr 8, 2024
9327eb2
Merge branch 'master' into oleksii/dependent-transactions
Br1ght0ne May 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
137 changes: 137 additions & 0 deletions packages/fuels-accounts/src/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,136 @@ pub trait Account: ViewOnlyAccount {
Ok((tx_id, receipts))
}

#[cfg(feature = "coin-cache")]
/// Transfer funds from this account to multiple other `Address`es.
/// See [Account::transfer] for more information.
async fn dependent_transfers(
&self,
initial_amount: u64,
transfers: &[(&Bech32Address, u64)],
asset_id: AssetId,
tx_policies: TxPolicies,
) -> Result<Vec<(TxId, Vec<Receipt>)>> {
use crate::accounts_utils::split_dependable_output;
use fuels_core::error;

let total_amount = transfers.iter().map(|(_, amount)| *amount).sum::<u64>();
if total_amount > initial_amount {
return Err(error!(InvalidData, "The sum of dependent transfer amounts cannot be more than the initial input amount"));
}

let provider = self.try_provider()?;
let cache_key = (self.address().clone(), asset_id);

let mut results = Vec::with_capacity(transfers.len());
for (i, (to, amount)) in transfers.iter().enumerate() {
let inputs = if i != 0 {
self.maybe_get_cached_dependable_input(&cache_key, *amount, asset_id)
.await?
} else {
self.get_asset_inputs_for_amount(asset_id, *amount).await?
};

let outputs = self.get_asset_outputs_for_amount(to, asset_id, *amount);

let mut tx_builder =
ScriptTransactionBuilder::prepare_transfer(inputs, outputs, tx_policies);

self.add_witnesses(&mut tx_builder)?;

let (dependable_output_index, dependable_output_amount) =
split_dependable_output(&mut tx_builder, *amount, self.address(), provider).await?;

let used_base_amount = if asset_id == AssetId::BASE {
*amount
} else {
0
};
self.adjust_for_fee(&mut tx_builder, used_base_amount)
.await?;

let tx = tx_builder.build(provider).await?;
let tx_id = tx.id(provider.chain_id());

if i != transfers.len() - 1 {
self.cache_dependent_output(
&cache_key,
UtxoId::new(tx_id, dependable_output_index),
dependable_output_amount,
asset_id,
self.address().clone(),
tx.maturity(),
)
.await?;
}

let tx_status = provider.send_transaction_and_await_commit(tx).await?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we wait for each tx to be finalized there is no need to keep track of the output of previous tx. We can just ask the node to provide spendable coins at that point.


let receipts = tx_status.take_receipts_checked(None)?;

results.push((tx_id, receipts));
}

Ok(results)
}

#[cfg(feature = "coin-cache")]
async fn maybe_get_cached_dependable_input(
&self,
cache_key: &(Bech32Address, AssetId),
amount: u64,
asset_id: AssetId,
) -> Result<Vec<Input>> {
use fuels_core::error;
let previous_coin_output = self
.try_provider()?
.cache_mut()
.await
.pop_dependent(cache_key)
.ok_or(error!(InvalidData, "Expected a cached output"))?;
let previous_coin_amount = previous_coin_output.amount;
let previous_coin_input = Input::ResourceSigned {
resource: CoinType::Coin(previous_coin_output),
};
if amount > previous_coin_amount {
let mut extra_inputs = self
.get_asset_inputs_for_amount(asset_id, amount - previous_coin_amount)
.await?;
extra_inputs.push(previous_coin_input);
Ok(extra_inputs)
} else {
Ok(vec![previous_coin_input])
}
}

#[cfg(feature = "coin-cache")]
async fn cache_dependent_output(
&self,
cache_key: &(Bech32Address, AssetId),
utxo_id: UtxoId,
amount: u64,
asset_id: AssetId,
owner: Bech32Address,
maturity: u32,
) -> Result<()> {
use fuels_core::types::coin::CoinStatus;

let coin = Coin {
utxo_id,
block_created: 0,
amount,
asset_id,
owner,
maturity,
status: CoinStatus::Unspent,
};
self.try_provider()?
.cache_mut()
.await
.push_dependent(cache_key, coin);
Ok(())
}

/// Unconditionally transfers `balance` of type `asset_id` to
/// the contract at `to`.
/// Fails if balance for `asset_id` is larger than this account's spendable balance.
Expand Down Expand Up @@ -306,6 +436,13 @@ pub trait Account: ViewOnlyAccount {

Ok((tx_id, nonce, receipts))
}

#[cfg(feature = "coin-cache")]
async fn clear_dependent(&self) -> Result<()> {
let provider = self.try_provider()?;
provider.cache_mut().await.clear_dependent();
Ok(())
}
}

#[cfg(test)]
Expand Down
35 changes: 35 additions & 0 deletions packages/fuels-accounts/src/accounts_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,38 @@ pub fn adjust_inputs_outputs(
.push(Output::change(address.into(), 0, BASE_ASSET_ID));
}
}

#[cfg(feature = "coin-cache")]
pub async fn split_dependable_output(
tb: &mut impl TransactionBuilder,
used_base_amount: u64,
address: &Bech32Address,
provider: &Provider,
) -> Result<(u8, u64)> {
let transaction_fee = tb
.fee_checked_from_tx(provider)
.await?
.ok_or(error!(InvalidData, "Error calculating TransactionFee"))?;

let available_amount = available_base_amount(tb);
let remaining_amount = available_amount - used_base_amount - transaction_fee.max_fee();

if remaining_amount == 0 {
return Err(error!(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a low level problem that we reveal to a high level API call.
The user is not explicitly aware that inputs are being chopped up to make this work under the hood.
If I used dependent_transfers and this error were to come up, It's not obvious how to fix it.

Also, I doubt there is a way to fix it. If the selected coin(s) fit so perfectly that they can't be split up I either have more coins to cover for the next tx or I don't, it's not a matter dependant transfers.

Maybe we should attempt to execute dependant txs on a best effort basis but not be explicit about it.

InvalidData,
"No unused amount left to split into a dependable output"
));
}

let outputs_len = tb.outputs().len();
let index = outputs_len - 1;
tb.outputs_mut().insert(
index,
Output::Coin {
to: address.into(),
amount: remaining_amount,
asset_id: BASE_ASSET_ID,
},
);
Ok((index as u8, remaining_amount))
}
29 changes: 27 additions & 2 deletions packages/fuels-accounts/src/coin_cache.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use std::{
collections::{HashMap, HashSet},
collections::{HashMap, HashSet, VecDeque},
hash::{Hash, Hasher},
};

use fuel_types::AssetId;
use fuels_core::types::{bech32::Bech32Address, coin_type_id::CoinTypeId};
use fuels_core::types::{bech32::Bech32Address, coin::Coin, coin_type_id::CoinTypeId};
use tokio::time::{Duration, Instant};

type CoinCacheKey = (Bech32Address, AssetId);
Expand All @@ -13,6 +13,7 @@ type CoinCacheKey = (Bech32Address, AssetId);
pub(crate) struct CoinsCache {
ttl: Duration,
items: HashMap<CoinCacheKey, HashSet<CoinCacheItem>>,
dependent: HashMap<CoinCacheKey, VecDeque<Coin>>,
}

impl Default for CoinsCache {
Expand All @@ -26,6 +27,7 @@ impl CoinsCache {
Self {
ttl,
items: HashMap::default(),
dependent: HashMap::default(),
}
}

Expand All @@ -41,6 +43,13 @@ impl CoinsCache {
}
}

pub fn push_dependent(&mut self, key: &CoinCacheKey, coin: Coin) {
self.dependent
.entry(key.clone())
.or_default()
.push_back(coin);
}

pub fn get_active(&mut self, key: &CoinCacheKey) -> HashSet<CoinTypeId> {
self.remove_expired_entries(key);

Expand All @@ -53,6 +62,18 @@ impl CoinsCache {
.collect()
}

pub fn iter_dependent(&mut self, key: &CoinCacheKey) -> impl Iterator<Item = &Coin> {
self.dependent
.get(key)
.map(VecDeque::iter)
Br1ght0ne marked this conversation as resolved.
Show resolved Hide resolved
.into_iter()
.flatten()
}

pub fn pop_dependent(&mut self, key: &CoinCacheKey) -> Option<Coin> {
self.dependent.get_mut(key).and_then(VecDeque::pop_front)
}

pub fn remove_items(
&mut self,
inputs: impl IntoIterator<Item = (CoinCacheKey, Vec<CoinTypeId>)>,
Expand All @@ -76,6 +97,10 @@ impl CoinsCache {
entry.retain(|item| item.is_valid(self.ttl));
}
}

pub fn clear_dependent(&mut self) {
self.dependent.clear()
}
}

#[derive(Eq, Debug, Clone)]
Expand Down
12 changes: 10 additions & 2 deletions packages/fuels-accounts/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ use supported_versions::{check_fuel_core_version_compatibility, VersionCompatibi
use tai64::Tai64;
use thiserror::Error;
#[cfg(feature = "coin-cache")]
use tokio::sync::Mutex;
use tokio::sync::{Mutex, MutexGuard};

#[cfg(feature = "coin-cache")]
use crate::coin_cache::CoinsCache;
Expand Down Expand Up @@ -439,7 +439,9 @@ impl Provider {
#[cfg(feature = "coin-cache")]
async fn extend_filter_with_cached(&self, filter: &mut ResourceFilter) {
let mut cache = self.cache.lock().await;
let used_coins = cache.get_active(&(filter.from.clone(), filter.asset_id));
let key = (filter.from.clone(), filter.asset_id);
let used_coins = cache.get_active(&key);
let dependents = cache.iter_dependent(&key);

let excluded_utxos = used_coins
.iter()
Expand All @@ -448,6 +450,7 @@ impl Provider {
_ => None,
})
.cloned()
.chain(dependents.map(|coin| coin.utxo_id))
.collect::<Vec<_>>();

let excluded_message_nonces = used_coins
Expand Down Expand Up @@ -724,6 +727,11 @@ impl Provider {
self.client.set_retry_config(retry_config);
self
}

#[cfg(feature = "coin-cache")]
pub(crate) async fn cache_mut(&self) -> MutexGuard<'_, CoinsCache> {
self.cache.lock().await
}
}

#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
Expand Down
43 changes: 43 additions & 0 deletions packages/fuels/tests/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,49 @@ async fn test_cache_invalidation_on_await() -> Result<()> {
Ok(())
}

#[cfg(feature = "coin-cache")]
#[tokio::test]
async fn test_dependent_transfers() -> Result<()> {
let initial_amount = 100;
let transfer_amount = 40;
let wallets = launch_custom_provider_and_get_wallets(
WalletsConfig::new(Some(3), Some(1), Some(initial_amount)),
None,
None,
)
.await?;
let sender = &wallets[0];
let receiver_1 = &wallets[1];
let receiver_2 = &wallets[2];

sender
.dependent_transfers(
initial_amount,
&[
(receiver_1.address(), transfer_amount),
(receiver_2.address(), transfer_amount),
],
BASE_ASSET_ID,
TxPolicies::default(),
)
.await?;

assert_eq!(
sender.get_asset_balance(&BASE_ASSET_ID).await?,
initial_amount - transfer_amount * 2,
);
assert_eq!(
receiver_1.get_asset_balance(&BASE_ASSET_ID).await?,
initial_amount + transfer_amount
);
assert_eq!(
receiver_2.get_asset_balance(&BASE_ASSET_ID).await?,
initial_amount + transfer_amount
);

Ok(())
}

#[tokio::test]
async fn can_fetch_mint_transactions() -> Result<()> {
setup_program_test!(
Expand Down