Let's jump back into our MultiSig project again!
cd ../../multisig-predicate/predicate
Icon ClipboardText
Again follow these steps with cargo-generate
in the predicate project directory like we did previously:
Install cargo-generate
:
cargo install cargo-generate --locked
Icon ClipboardText
Generate the template:
cargo generate --init fuellabs/sway templates/sway-test-rs --name sway-store
Icon ClipboardText
Delete the templated code and copy the following imports into your harness file. It's important to pay attention to two main imports: predicates
, for obvious reasons, and the ScriptTransactionBuilder
, which we'll use to create transactions. These transactions must be signed before being broadcasted to our local network.
use fuels :: {
accounts :: {
fuel_crypto :: SecretKey ,
predicate :: Predicate ,
wallet :: WalletUnlocked ,
Account ,
},
prelude ::* ,
types :: transaction_builders :: { NetworkInfo , ScriptTransactionBuilder , BuildableTransaction },
};
Icon ClipboardText
Similar to Rust testing for contracts, we'll import the predicate ABI (Application Binary Interface) to interact with it. Ensure the name of your predicate matches the one you're working with.
abigen! ( Predicate (
name = "MultiSig" ,
abi = "./out/debug/predicate-abi.json"
));
Icon ClipboardText
If you're familiar with Rust testing for Sway projects, much of the setup will be similar. Copy and paste the setup_wallets_and_network
function into your harness file.
async fn setup_wallets_and_network () -> ( Vec < WalletUnlocked >, Provider , NetworkInfo , AssetId ) {
// WALLETS
let private_key_0 : SecretKey =
"0xc2620849458064e8f1eb2bc4c459f473695b443ac3134c82ddd4fd992bd138fd"
. parse ()
. unwrap ();
let private_key_1 : SecretKey =
"0x37fa81c84ccd547c30c176b118d5cb892bdb113e8e80141f266519422ef9eefd"
. parse ()
. unwrap ();
let private_key_2 : SecretKey =
"0x976e5c3fa620092c718d852ca703b6da9e3075b9f2ecb8ed42d9f746bf26aafb"
. parse ()
. unwrap ();
let mut wallet_0 : WalletUnlocked = WalletUnlocked :: new_from_private_key (private_key_0, None );
let mut wallet_1 : WalletUnlocked = WalletUnlocked :: new_from_private_key (private_key_1, None );
let mut wallet_2 : WalletUnlocked = WalletUnlocked :: new_from_private_key (private_key_2, None );
// TOKENS
let asset_id = AssetId :: default ();
let all_coins = [ & wallet_0, & wallet_1, & wallet_2]
. iter ()
. flat_map ( | wallet | {
setup_single_asset_coins (wallet . address (), AssetId :: default (), 10 , 1_000_000 )
})
. collect :: < Vec <_>>();
// NETWORKS
let node_config = Config :: default ();
let provider = setup_test_provider (all_coins, vec! [], Some (node_config), None ) .await. unwrap ();
let network_info = provider . network_info () .await. unwrap ();
[ &mut wallet_0, &mut wallet_1, &mut wallet_2]
. iter_mut ()
. for_each ( | wallet | {
wallet . set_provider (provider . clone ());
});
return (
vec! [wallet_0, wallet_1, wallet_2],
provider,
network_info,
asset_id,
);
}
Expand Icon ClipboardText
The three key setup steps include:
Configuring the wallets that will act as owners of our multisig, through the configurables you'll see later in the tests.
// WALLETS
let private_key_0 : SecretKey =
"0xc2620849458064e8f1eb2bc4c459f473695b443ac3134c82ddd4fd992bd138fd"
. parse ()
. unwrap ();
let private_key_1 : SecretKey =
"0x37fa81c84ccd547c30c176b118d5cb892bdb113e8e80141f266519422ef9eefd"
. parse ()
. unwrap ();
let private_key_2 : SecretKey =
"0x976e5c3fa620092c718d852ca703b6da9e3075b9f2ecb8ed42d9f746bf26aafb"
. parse ()
. unwrap ();
let mut wallet_0 : WalletUnlocked = WalletUnlocked :: new_from_private_key (private_key_0, None );
let mut wallet_1 : WalletUnlocked = WalletUnlocked :: new_from_private_key (private_key_1, None );
let mut wallet_2 : WalletUnlocked = WalletUnlocked :: new_from_private_key (private_key_2, None );
Icon ClipboardText
Setting up the default token (zeroth address) and loading some tokens into each wallet.
// TOKENS
let asset_id = AssetId :: default ();
let all_coins = [ & wallet_0, & wallet_1, & wallet_2]
. iter ()
. flat_map ( | wallet | {
setup_single_asset_coins (wallet . address (), AssetId :: default (), 10 , 1_000_000 )
})
. collect :: < Vec <_>>();
Icon ClipboardText
Preparing the network to broadcast our transaction, enabling us to successfully unlock the tokens from the predicate later.
// NETWORKS
let node_config = Config :: default ();
let provider = setup_test_provider (all_coins, vec! [], Some (node_config), None ) .await. unwrap ();
let network_info = provider . network_info () .await. unwrap ();
Icon ClipboardText
Since the predicate address is deterministic, we don't need to copy it as we do with smart contracts, which are deployed with a different address each time. We can leverage SDKs to build the predicate, ensuring we're working with the correct address without error!
Now, let's review the sequence of actions we'll take to simulate a real-world scenario, copy and paste the first test below and let's break it down step by step:
#[tokio :: test]
async fn multisig_two_of_three () -> Result <()> {
let (wallets, provider, network_info, asset_id) = setup_wallets_and_network () .await ;
// CONFIGURABLES
let required_signatures = 2 ;
let signers : [ Address ; 3 ] = [
wallets[ 0 ] . address () . into (),
wallets[ 1 ] . address () . into (),
wallets[ 2 ] . address () . into (),
];
let configurables = MultiSigConfigurables :: new ()
. with_REQUIRED_SIGNATURES (required_signatures)
. with_SIGNERS (signers);
// PREDICATE
let predicate_binary_path = "./out/debug/predicate.bin" ;
let predicate : Predicate = Predicate :: load_from (predicate_binary_path) ?
. with_provider (provider . clone ())
. with_configurables (configurables);
// FUND PREDICATE
let multisig_amount = 100 ;
let wallet_0_amount = provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? ;
wallets[ 0 ]
. transfer (predicate . address (), multisig_amount, asset_id, TxPolicies :: default ())
.await? ;
// BUILD TRANSACTION
let mut tb : ScriptTransactionBuilder = {
let input_coin = predicate . get_asset_inputs_for_amount (asset_id, 1 ) .await? ;
let output_coin =
predicate . get_asset_outputs_for_amount (wallets[ 0 ] . address () . into (), asset_id, multisig_amount);
ScriptTransactionBuilder :: prepare_transfer (
input_coin,
output_coin,
TxPolicies :: default (),
network_info . clone (),
)
};
// SIGN TRANSACTION
wallets[ 0 ] . sign_transaction ( &mut tb);
wallets[ 1 ] . sign_transaction ( &mut tb);
assert_eq! (provider . get_asset_balance (predicate . address (), asset_id) .await? , multisig_amount);
assert_eq! (provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? , wallet_0_amount - multisig_amount);
// SPEND PREDICATE
let tx : ScriptTransaction = tb . build (provider . clone ()) .await? ;
provider . send_transaction_and_await_commit (tx) .await? ;
assert_eq! (provider . get_asset_balance (predicate . address (), asset_id) .await? , 0 );
assert_eq! (provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? , wallet_0_amount);
Ok (())
}
Expand Icon ClipboardText
A group or individuals create their multisig by specifying the wallets that will safeguard the funds. Funding the predicate. Extracting the tokens when needed by building a transaction and getting the original wallets to sign it. Broadcasting the transaction to unlock the funds from the predicate.
For step 1, as mentioned earlier, when we configure the number of required signatures (up to 3) and the 3 addresses that will safeguard our funds. Importing the ABI will automatically load a PredicateNameConfigurable
type. In our case, that will be MultiSigConfigurables
. There will be a corresponding with_configurable function to help you load each configurable. In our case, with_REQUIRED_SIGNATURES
and with_SIGNERS
are both loaded in!
How convenient!
// CONFIGURABLES
let required_signatures = 2 ;
let signers : [ Address ; 3 ] = [
wallets[ 0 ] . address () . into (),
wallets[ 1 ] . address () . into (),
wallets[ 2 ] . address () . into (),
];
let configurables = MultiSigConfigurables :: new ()
. with_REQUIRED_SIGNATURES (required_signatures)
. with_SIGNERS (signers);
Icon ClipboardText
Next, we'll load our original predicate binary with our new configurables to generate our personalized predicate instance. Simply input your configurables using the with_configurables
function, and this will give us a unique predicate root based on our inputs.
// PREDICATE
let predicate_binary_path = "./out/debug/predicate.bin" ;
let predicate : Predicate = Predicate :: load_from (predicate_binary_path) ?
. with_provider (provider . clone ())
. with_configurables (configurables);
Icon ClipboardText
For step 2, transferring funds to our newly generated predicate root is as straightforward as any other blockchain transfer.
// FUND PREDICATE
let multisig_amount = 100 ;
let wallet_0_amount = provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? ;
wallets[ 0 ]
. transfer (predicate . address (), multisig_amount, asset_id, TxPolicies :: default ())
.await? ;
Icon ClipboardText
In step 3, when the multisig holders decide to use the locked funds, we build a transaction specifying the inputs and outputs. Pay close attention to the outputs; we need to specify where the tokens from the predicate are going, which native asset they involve, and the amount. We're essentially extracting a portion of the original base asset sent into the predicate.
// BUILD TRANSACTION
let mut tb : ScriptTransactionBuilder = {
let input_coin = predicate . get_asset_inputs_for_amount (asset_id, 1 ) .await? ;
let output_coin =
predicate . get_asset_outputs_for_amount (wallets[ 0 ] . address () . into (), asset_id, multisig_amount);
ScriptTransactionBuilder :: prepare_transfer (
input_coin,
output_coin,
TxPolicies :: default (),
network_info . clone (),
)
};
Icon ClipboardText
The correct wallet addresses configured in the configurables must sign the transactions. This information, loaded as witness data, will evaluate our predicate to true. It's crucial to provide enough correct, unique signatures; otherwise, the transaction will fail, as demonstrated in later tests. Since our test only requires 2 signatures, we need to provide just those.
// SIGN TRANSACTION
wallets[ 0 ] . sign_transaction ( &mut tb);
wallets[ 1 ] . sign_transaction ( &mut tb);
Icon ClipboardText
After the evaluation is correctly done, all we need to do is broadcast the transaction, and the requested funds should return to wallet 1.
// SPEND PREDICATE
let tx : ScriptTransaction = tb . build (provider . clone ()) .await? ;
provider . send_transaction_and_await_commit (tx) .await? ;
Icon ClipboardText
The setup for the second test, multisig_mixed_three_of_three
, follows the same scheme, showcasing that the transaction signing can be done in any order by valid wallets.
#[tokio :: test]
async fn multisig_mixed_three_of_three () -> Result <()> {
let (wallets, provider, network_info, asset_id) = setup_wallets_and_network () .await ;
// CONFIGURABLES
let required_signatures = 3 ;
let signers : [ Address ; 3 ] = [
wallets[ 0 ] . address () . into (),
wallets[ 1 ] . address () . into (),
wallets[ 2 ] . address () . into (),
];
let configurables = MultiSigConfigurables :: new ()
. with_REQUIRED_SIGNATURES (required_signatures)
. with_SIGNERS (signers);
// PREDICATE
let predicate_binary_path = "./out/debug/predicate.bin" ;
let predicate : Predicate = Predicate :: load_from (predicate_binary_path) ?
. with_provider (provider . clone ())
. with_configurables (configurables);
let multisig_amount = 100 ;
let wallet_0_amount = provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? ;
wallets[ 0 ]
. transfer (predicate . address (), multisig_amount, asset_id, TxPolicies :: default ())
.await? ;
let mut tb : ScriptTransactionBuilder = {
let input_coin = predicate . get_asset_inputs_for_amount (asset_id, 1 ) .await? ;
let output_coin =
predicate . get_asset_outputs_for_amount (wallets[ 0 ] . address () . into (), asset_id, multisig_amount);
ScriptTransactionBuilder :: prepare_transfer (
input_coin,
output_coin,
TxPolicies :: default (),
network_info . clone (),
)
};
// NOTE Cannot be signed in any order
wallets[ 2 ] . sign_transaction ( &mut tb);
wallets[ 0 ] . sign_transaction ( &mut tb);
wallets[ 1 ] . sign_transaction ( &mut tb);
assert_eq! (provider . get_asset_balance (predicate . address (), asset_id) .await? , multisig_amount);
assert_eq! (provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? , wallet_0_amount - multisig_amount);
// SPEND PREDICATE
let tx : ScriptTransaction = tb . build (provider . clone ()) .await? ;
provider . send_transaction_and_await_commit (tx) .await? ;
assert_eq! (provider . get_asset_balance (predicate . address (), asset_id) .await? , 0 );
assert_eq! (provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? , wallet_0_amount);
Ok (())
}
Expand Icon ClipboardText
The same principle applies to the third test, multisig_not_enough_signatures_fails
, where the transaction will fail if there aren't enough signatures.
#[tokio :: test]
async fn multisig_not_enough_signatures_fails () -> Result <()> {
let (wallets, provider, network_info, asset_id) = setup_wallets_and_network () .await ;
// CONFIGURABLES
let required_signatures = 2 ;
let signers : [ Address ; 3 ] = [
wallets[ 0 ] . address () . into (),
wallets[ 1 ] . address () . into (),
wallets[ 2 ] . address () . into (),
];
let configurables = MultiSigConfigurables :: new ()
. with_REQUIRED_SIGNATURES (required_signatures)
. with_SIGNERS (signers);
// PREDICATE
let predicate_binary_path = "./out/debug/predicate.bin" ;
let predicate : Predicate = Predicate :: load_from (predicate_binary_path) ?
. with_provider (provider . clone ())
. with_configurables (configurables);
let multisig_amount = 100 ;
let wallet_0_amount = provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? ;
wallets[ 0 ]
. transfer (predicate . address (), multisig_amount, asset_id, TxPolicies :: default ())
.await? ;
let mut tb : ScriptTransactionBuilder = {
let input_coin = predicate . get_asset_inputs_for_amount (asset_id, 1 ) .await? ;
let output_coin =
predicate . get_asset_outputs_for_amount (wallets[ 0 ] . address () . into (), asset_id, multisig_amount);
ScriptTransactionBuilder :: prepare_transfer (
input_coin,
output_coin,
TxPolicies :: default (),
network_info . clone (),
)
};
wallets[ 0 ] . sign_transaction ( &mut tb);
assert_eq! (provider . get_asset_balance (predicate . address (), asset_id) .await? , multisig_amount);
assert_eq! (provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? , wallet_0_amount - multisig_amount);
// SPEND PREDICATE
let tx : ScriptTransaction = tb . build (provider . clone ()) .await? ;
let _ = provider . send_transaction_and_await_commit (tx) .await. is_err ();
Ok (())
}
Expand Icon ClipboardText
If you have followed the previous steps correctly, your harness.rs
test file should look like this:
use fuels :: {
accounts :: {
fuel_crypto :: SecretKey ,
predicate :: Predicate ,
wallet :: WalletUnlocked ,
Account ,
},
prelude ::* ,
types :: transaction_builders :: { NetworkInfo , ScriptTransactionBuilder , BuildableTransaction },
};
abigen! ( Predicate (
name = "MultiSig" ,
abi = "./out/debug/predicate-abi.json"
));
async fn setup_wallets_and_network () -> ( Vec < WalletUnlocked >, Provider , NetworkInfo , AssetId ) {
// WALLETS
let private_key_0 : SecretKey =
"0xc2620849458064e8f1eb2bc4c459f473695b443ac3134c82ddd4fd992bd138fd"
. parse ()
. unwrap ();
let private_key_1 : SecretKey =
"0x37fa81c84ccd547c30c176b118d5cb892bdb113e8e80141f266519422ef9eefd"
. parse ()
. unwrap ();
let private_key_2 : SecretKey =
"0x976e5c3fa620092c718d852ca703b6da9e3075b9f2ecb8ed42d9f746bf26aafb"
. parse ()
. unwrap ();
let mut wallet_0 : WalletUnlocked = WalletUnlocked :: new_from_private_key (private_key_0, None );
let mut wallet_1 : WalletUnlocked = WalletUnlocked :: new_from_private_key (private_key_1, None );
let mut wallet_2 : WalletUnlocked = WalletUnlocked :: new_from_private_key (private_key_2, None );
// TOKENS
let asset_id = AssetId :: default ();
let all_coins = [ & wallet_0, & wallet_1, & wallet_2]
. iter ()
. flat_map ( | wallet | {
setup_single_asset_coins (wallet . address (), AssetId :: default (), 10 , 1_000_000 )
})
. collect :: < Vec <_>>();
// NETWORKS
let node_config = Config :: default ();
let provider = setup_test_provider (all_coins, vec! [], Some (node_config), None ) .await. unwrap ();
let network_info = provider . network_info () .await. unwrap ();
[ &mut wallet_0, &mut wallet_1, &mut wallet_2]
. iter_mut ()
. for_each ( | wallet | {
wallet . set_provider (provider . clone ());
});
return (
vec! [wallet_0, wallet_1, wallet_2],
provider,
network_info,
asset_id,
);
}
#[tokio :: test]
async fn multisig_two_of_three () -> Result <()> {
let (wallets, provider, network_info, asset_id) = setup_wallets_and_network () .await ;
// CONFIGURABLES
let required_signatures = 2 ;
let signers : [ Address ; 3 ] = [
wallets[ 0 ] . address () . into (),
wallets[ 1 ] . address () . into (),
wallets[ 2 ] . address () . into (),
];
let configurables = MultiSigConfigurables :: new ()
. with_REQUIRED_SIGNATURES (required_signatures)
. with_SIGNERS (signers);
// PREDICATE
let predicate_binary_path = "./out/debug/predicate.bin" ;
let predicate : Predicate = Predicate :: load_from (predicate_binary_path) ?
. with_provider (provider . clone ())
. with_configurables (configurables);
// FUND PREDICATE
let multisig_amount = 100 ;
let wallet_0_amount = provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? ;
wallets[ 0 ]
. transfer (predicate . address (), multisig_amount, asset_id, TxPolicies :: default ())
.await? ;
// BUILD TRANSACTION
let mut tb : ScriptTransactionBuilder = {
let input_coin = predicate . get_asset_inputs_for_amount (asset_id, 1 ) .await? ;
let output_coin =
predicate . get_asset_outputs_for_amount (wallets[ 0 ] . address () . into (), asset_id, multisig_amount);
ScriptTransactionBuilder :: prepare_transfer (
input_coin,
output_coin,
TxPolicies :: default (),
network_info . clone (),
)
};
// SIGN TRANSACTION
wallets[ 0 ] . sign_transaction ( &mut tb);
wallets[ 1 ] . sign_transaction ( &mut tb);
assert_eq! (provider . get_asset_balance (predicate . address (), asset_id) .await? , multisig_amount);
assert_eq! (provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? , wallet_0_amount - multisig_amount);
// SPEND PREDICATE
let tx : ScriptTransaction = tb . build (provider . clone ()) .await? ;
provider . send_transaction_and_await_commit (tx) .await? ;
assert_eq! (provider . get_asset_balance (predicate . address (), asset_id) .await? , 0 );
assert_eq! (provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? , wallet_0_amount);
Ok (())
}
#[tokio :: test]
async fn multisig_mixed_three_of_three () -> Result <()> {
let (wallets, provider, network_info, asset_id) = setup_wallets_and_network () .await ;
// CONFIGURABLES
let required_signatures = 3 ;
let signers : [ Address ; 3 ] = [
wallets[ 0 ] . address () . into (),
wallets[ 1 ] . address () . into (),
wallets[ 2 ] . address () . into (),
];
let configurables = MultiSigConfigurables :: new ()
. with_REQUIRED_SIGNATURES (required_signatures)
. with_SIGNERS (signers);
// PREDICATE
let predicate_binary_path = "./out/debug/predicate.bin" ;
let predicate : Predicate = Predicate :: load_from (predicate_binary_path) ?
. with_provider (provider . clone ())
. with_configurables (configurables);
let multisig_amount = 100 ;
let wallet_0_amount = provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? ;
wallets[ 0 ]
. transfer (predicate . address (), multisig_amount, asset_id, TxPolicies :: default ())
.await? ;
let mut tb : ScriptTransactionBuilder = {
let input_coin = predicate . get_asset_inputs_for_amount (asset_id, 1 ) .await? ;
let output_coin =
predicate . get_asset_outputs_for_amount (wallets[ 0 ] . address () . into (), asset_id, multisig_amount);
ScriptTransactionBuilder :: prepare_transfer (
input_coin,
output_coin,
TxPolicies :: default (),
network_info . clone (),
)
};
// NOTE Cannot be signed in any order
wallets[ 2 ] . sign_transaction ( &mut tb);
wallets[ 0 ] . sign_transaction ( &mut tb);
wallets[ 1 ] . sign_transaction ( &mut tb);
assert_eq! (provider . get_asset_balance (predicate . address (), asset_id) .await? , multisig_amount);
assert_eq! (provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? , wallet_0_amount - multisig_amount);
// SPEND PREDICATE
let tx : ScriptTransaction = tb . build (provider . clone ()) .await? ;
provider . send_transaction_and_await_commit (tx) .await? ;
assert_eq! (provider . get_asset_balance (predicate . address (), asset_id) .await? , 0 );
assert_eq! (provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? , wallet_0_amount);
Ok (())
}
#[tokio :: test]
async fn multisig_not_enough_signatures_fails () -> Result <()> {
let (wallets, provider, network_info, asset_id) = setup_wallets_and_network () .await ;
// CONFIGURABLES
let required_signatures = 2 ;
let signers : [ Address ; 3 ] = [
wallets[ 0 ] . address () . into (),
wallets[ 1 ] . address () . into (),
wallets[ 2 ] . address () . into (),
];
let configurables = MultiSigConfigurables :: new ()
. with_REQUIRED_SIGNATURES (required_signatures)
. with_SIGNERS (signers);
// PREDICATE
let predicate_binary_path = "./out/debug/predicate.bin" ;
let predicate : Predicate = Predicate :: load_from (predicate_binary_path) ?
. with_provider (provider . clone ())
. with_configurables (configurables);
let multisig_amount = 100 ;
let wallet_0_amount = provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? ;
wallets[ 0 ]
. transfer (predicate . address (), multisig_amount, asset_id, TxPolicies :: default ())
.await? ;
let mut tb : ScriptTransactionBuilder = {
let input_coin = predicate . get_asset_inputs_for_amount (asset_id, 1 ) .await? ;
let output_coin =
predicate . get_asset_outputs_for_amount (wallets[ 0 ] . address () . into (), asset_id, multisig_amount);
ScriptTransactionBuilder :: prepare_transfer (
input_coin,
output_coin,
TxPolicies :: default (),
network_info . clone (),
)
};
wallets[ 0 ] . sign_transaction ( &mut tb);
assert_eq! (provider . get_asset_balance (predicate . address (), asset_id) .await? , multisig_amount);
assert_eq! (provider . get_asset_balance (wallets[ 0 ] . address (), asset_id) .await? , wallet_0_amount - multisig_amount);
// SPEND PREDICATE
let tx : ScriptTransaction = tb . build (provider . clone ()) .await? ;
let _ = provider . send_transaction_and_await_commit (tx) .await. is_err ();
Ok (())
}
Expand Icon ClipboardText
To run the test located in tests/harness.rs
, use:
If you want to print outputs to the console during tests, use the nocapture
flag:
cargo test -- --nocapture
Icon ClipboardText
Congratulations on making it this far! We've confirmed that our Multisig works.
Predicates aren't meant to be intimidating. State-minimized DeFi applications should be the standard, rather than resorting to gas golfing or writing assembly code for these optimizations. Now that you have predicates in your toolbox, go out and explore what other state-minimized DeFi applications you can build!