1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
// Copyright 2026 The Tari Project
// SPDX-License-Identifier: BSD-3-Clause
use std::num::NonZeroU64;
use ootle_rs::{
ToAccountAddress,
TransactionRequest,
address,
builtin_templates::{UnsignedTransactionBuilder, faucet::IFaucet},
const_nonzero_u64,
default_indexer_url,
displayable::Displayable,
key_provider::PrivateKeyProvider,
provider::{PendingTransaction, Provider, ProviderBuilder, WalletProvider},
stealth::{Output, StealthTransfer},
template_types::{
UtxoAddress,
constants::{TARI, TARI_TOKEN},
},
transaction::TransactionSigner,
wallet::OotleWallet,
};
use tari_ootle_common_types::engine_types::transaction_receipt::TransactionReceipt;
use tari_ootle_transaction::{Epoch, Transaction};
#[tokio::main]
#[allow(clippy::too_many_lines)]
async fn main() {
// env_logger::builder()
// .filter_level(tracing::log::LevelFilter::Debug)
// .init();
// This is the address that we will transfer to (Feel free to change this another address!)
let recipient = address!( "otl_loc_162dtv4375eg54pn2g7c3tgu7j89e96hes5hvrxac4qxex6g4v3q7fsantdmgrs7mlg3hc9v4kdaktkp5l8t495fmkdvgpyz4whe6qvckjl8v6" );
let indexer_api_url = default_indexer_url(recipient.network());
let sender_secret = PrivateKeyProvider::random(recipient.network());
let sender_address = sender_secret.address().clone();
println!("Sender address: {sender_address}");
// Don't print secrets in production code!
println!(
"Sender secrets: {} | {}",
sender_secret.credentials().account_secret().reveal(),
sender_secret.credentials().view_only_secret().reveal()
);
let account_component_addr = sender_address.to_account_address();
println!("Sender account address: {account_component_addr}");
let wallet = OotleWallet::from(sender_secret.clone());
let mut provider = ProviderBuilder::new()
.wallet(wallet)
.connect(indexer_api_url)
.await
.unwrap();
// Get the network from the indexer (must be the same as the network specified in the builder).
let network = provider.get_network().await.unwrap();
println!("Provider network ID: {network}");
assert_eq!(network, provider.network());
// Get the latest block number.
let latest_epoch = provider.get_epoch().await.unwrap();
// Every transaction declares the last epoch it may be sequenced in; past it the transaction can
// never land. Ten epochs is a comfortable window for an example — the network caps how far
// ahead this may be set.
let max_epoch = Epoch(provider.get_epoch().await.unwrap().as_u64() + 10);
println!("Latest epoch: {latest_epoch}");
// Send some TARI to another address. You can replace TARI with any other fungible token resource address.
let tari_token = TARI_TOKEN; // resource_address!("resource_0123456789abcdef...");
// The faucet funds are split across two outputs because the transfer below is split across two statements: a
// small one in the fee intent that sources the fee, and the sixteen-output one in the main intent that the fee
// then pays for. Each statement spends its own input. One of the two inputs seals that transaction and the other
// attaches an authorization signature committing to the seal signer's one-time public key.
// The revealed amount each transaction reserves to pay its fee. It is deliberately generous rather than fitted to
// a dry-run estimate: the budget is part of the transfer statement, so spending an estimate would change the
// transaction it was estimated from. Whatever is not charged is refunded (see the receipt's overcharge line).
//
// It also has to fund the *compute allowance* the sixteen-output statement verifies under, not just the fee that
// statement is charged: the allowance is what the payment buys, at the fee table's point rate.
const FEE_BUDGET: u64 = 250_000;
// Spent by the fee-intent statement: the budget it reveals to pay the fee, plus a stealth output so the statement
// has somewhere to put the remainder.
const FEE_INPUT_AMOUNT: u64 = FEE_BUDGET + TARI;
// Spent by the main-intent statement, which fans it out into sixteen outputs.
const TRANSFER_INPUT_AMOUNT: u64 = 10 * TARI + FEE_BUDGET - FEE_INPUT_AMOUNT;
// // This builder creates a stealth transfer statement (spend proof). This is added to the transaction later.
let (faucet_transfer, required_signers) = StealthTransfer::new(tari_token, &provider)
// Tell the transfer to expect 10 TARI (plus a fee budget for this transaction and the transfer below) as revealed funds from a bucket (the faucet looks at this value and automatically provides the bucket).
.spend_revealed_input(10 * TARI + 2 * FEE_BUDGET)
// The transfer will output the fee budget as revealed funds to pay for the fee.
.to_revealed_output(FEE_BUDGET)
// Spend the remaining value (10 TARI - fee) into outputs for the sender address. NOTE: the sender address is not actually included in the output (privacy!),
// but a supporting wallet that holds the secret key would be able to spend the output.
// You can specify any address here and split up into many outputs as needed, as long as ∑inputs == ∑outputs.
.to_stealth_output(
Output::new(sender_address.clone(), tari_token, const_nonzero_u64!(FEE_INPUT_AMOUNT))
)
.to_stealth_output(
Output::new(sender_address.clone(), tari_token, const_nonzero_u64!(TRANSFER_INPUT_AMOUNT))
)
.prepare()
.await
.unwrap();
// Keep track of the input commitments to spend later.
let inputs_to_spend = faucet_transfer.stealth_outputs().to_vec();
// First let's transfer some faucet TARI to our account to have funds for fees and transfers.
let unsigned_tx = IFaucet::new(&provider, max_epoch)
.take_faucet_funds()
.into_stealth_transfer(faucet_transfer)
.and_pay_fee_from_revealed_output()
.prepare()
.await
.expect("Failed to prepare faucet transaction");
// This authorizer adds the required (stealth) signatures to spend inputs
let authorizer = provider.wallet().stealth_authorizer(required_signers);
let transaction = TransactionRequest::default()
.with_transaction(unsigned_tx)
.build(&authorizer)
.await
.unwrap();
let pending_tx = provider.send_transaction(transaction).await.unwrap();
print_fancy_results("Faucet transfer", &pending_tx).await;
// Then we'll send it to the recipient, across two statements.
//
// The fee intent runs on a fixed credit of compute before anything has been paid — enough to source a fee, and no
// more. Verifying sixteen outputs costs several times that, so a statement that size cannot live there: it is the
// main intent that the fee, once paid, buys the compute allowance for. The engine allows the fee intent exactly
// one transfer statement for this reason, and this is the shape it expects — a small statement that reveals the
// fee, then the real transfer.
// The fee-sourcing statement: reveal the budget to pay the fee, and put the remainder in a stealth output so the
// statement balances. Two outputs verify well inside the pre-payment credit.
let (fee_transfer, fee_signers) = StealthTransfer::new(tari_token, &provider)
.spend_stealth_input(sender_address.clone(), inputs_to_spend[0].commitment())
.to_revealed_output(FEE_BUDGET)
.to_stealth_output(Output::new(
sender_address.clone(),
tari_token,
const_nonzero_u64!(FEE_INPUT_AMOUNT - FEE_BUDGET),
))
.prepare()
.await
.unwrap();
// The transfer itself. One statement may carry up to 16 stealth outputs, and all of them share a single
// aggregated range proof. That is what makes fanning out inside one statement cheaper than splitting the same
// outputs across several transfers, each of which would pay the fixed per-statement cost and need its own change
// output. Here the input goes to two outputs worth spending plus fourteen dust ones.
const DUST_OUTPUT_COUNT: u64 = 14;
// Dust in the literal sense: each of these holds 1 µT while costing ~6,000 µT of range-proof verification to
// create. Fine for showing the fan-out, ruinous as a spending habit.
const DUST_AMOUNT: u64 = 1;
const RECIPIENT_AMOUNT: u64 = 8 * TARI;
// The change absorbs the dust so that ∑inputs == ∑outputs still holds.
const CHANGE_AMOUNT: u64 = TRANSFER_INPUT_AMOUNT - RECIPIENT_AMOUNT - DUST_OUTPUT_COUNT * DUST_AMOUNT;
let transfer_builder = StealthTransfer::new(tari_token, &provider)
// Spend the other stealth input controlled by the sender address. This statement pays no fee of its own — the
// fee-sourcing statement above covers the whole transaction.
.spend_stealth_input(sender_address.clone(), inputs_to_spend[1].commitment())
// Spend to a new output (8 TARI) that we'll generate for the recipient address.
.to_stealth_output(
Output::new(recipient.clone(), tari_token, const_nonzero_u64!(RECIPIENT_AMOUNT))
// NOTE: this memo is stored on-chain, and longer memos increase fees. It is encrypted so that only the recipient can read it.
.with_memo_message("transfer from ootle-rs!")
)
// Send the change back to ourselves (NOTE once this example exits, we'll lose the keys for this output!)
.to_stealth_output(Output::new(sender_address.clone(), tari_token, const_nonzero_u64!(CHANGE_AMOUNT)));
// Fan the rest of the statement out into dust, taking the output count up to the per-statement maximum.
let transfer_builder = (0..DUST_OUTPUT_COUNT).fold(transfer_builder, |builder, _| {
builder.to_stealth_output(Output::new(
recipient.clone(),
tari_token,
NonZeroU64::new(DUST_AMOUNT).expect("DUST_AMOUNT is non-zero"),
))
});
// Load the inputs from the provider to build the transfer statement. NOTE: this will error if the total input
// amounts != total output amounts.
let (transfer, transfer_signers) = transfer_builder.prepare().await.unwrap();
// We'll generate an unsigned transaction directly using the Transaction builder. In future, we may make this
// easier.
let unsigned_tx = Transaction::builder(provider.network(), max_epoch)
.with_fee_instructions_builder(|builder| {
builder
.stealth_transfer(tari_token, fee_transfer)
.put_last_instruction_output_on_workspace("fees")
.pay_fee_from_bucket("fees")
})
// The sixteen-output statement, funded by the fee the instructions above just paid. It reveals nothing, so it
// leaves no bucket behind to account for.
.stealth_transfer(tari_token, transfer)
// This isn't necessary because all transactions implicitly use TARI for fees, but you'd need to include this if other resources are being used
.add_input(tari_token)
// Add the UTXO substates as inputs. These will be DOWNed (destroyed) if the transaction is successful.
.add_input(UtxoAddress::new(tari_token, inputs_to_spend[0].commitment().into()))
.add_input(UtxoAddress::new(tari_token, inputs_to_spend[1].commitment().into()))
.build_unsigned();
// Both statements are spent by one transaction, which has a single seal: merging their requirements settles which
// input seals it and leaves the other to authorize against that seal signer's one-time public key.
let authorizer = provider
.wallet()
.stealth_authorizer(fee_signers.merge(transfer_signers));
let result = provider
.sign_and_send_dry_run_with(&authorizer, unsigned_tx.clone())
.await
.unwrap();
let _diff = result.expect_success();
println!("Dry run successful!");
// Informational: the fee budget above is not derived from this number. Spending the estimate would mean changing
// the transfer statement, which would change the transaction the estimate was taken from.
println!(
"Estimated fees for transfer: {}",
result.finalize.fee_receipt.total_fees_charged()
);
// One of the two stealth inputs seals the transaction; `build` asks the authorizer for the authorization signature
// the other one needs, which commits to the seal signer's one-time public key.
let transaction = TransactionRequest::default()
.with_transaction(unsigned_tx)
.build(&authorizer)
.await
.unwrap();
let pending_tx = provider.send_transaction(transaction).await.unwrap();
print_fancy_results("Stealth transfer", &pending_tx).await;
}
async fn print_fancy_results(label: &str, pending_tx: &PendingTransaction) -> TransactionReceipt {
println!("⌛️ {label} transaction pending... {}", pending_tx.tx_id());
let outcome = pending_tx.watch().await.unwrap();
println!("🏁 Transaction Finalized {}", pending_tx.tx_id());
println!("✅ Outcome: {:?}", outcome);
// Wait for the transaction to be finalized and get the receipt.
let receipt = pending_tx.get_receipt().await.unwrap();
println!("-------------------------------------------");
println!(" Transaction Receipt");
println!("-------------------------------------------");
println!("🔹 Epoch: {}", receipt.epoch);
println!("🔹 Transaction ID: {}", pending_tx.tx_id());
println!("🔹 Outcome: {:?}", receipt.outcome);
let fee_receipt = &receipt.fee_receipt;
println!("🔹 Fees Paid: {}", fee_receipt.total_fees_paid());
println!(
"🔹 Fees Overcharge: {} = {} (paid) - {} (charged) - {} (refunded)",
fee_receipt.total_fee_overcharge(),
fee_receipt.total_fees_paid(),
fee_receipt.total_fees_charged(),
fee_receipt.total_refunded()
);
if !receipt.events.is_empty() {
println!("\n🎉 Events:");
for event in receipt.events() {
println!(" - Substate ID: {}", event.substate_id().display());
println!(" Template Address: {}", event.template_address());
println!(" Topic: {}", event.topic());
println!(" Payload: {{{}}}", event.payload());
println!();
}
}
println!("-------------------------------------------");
receipt
}