icrc1_test_suite/
lib.rs

1use anyhow::{bail, Context};
2use candid::Nat;
3use futures::StreamExt;
4use icrc1_test_env::icrc1::{
5    balance_of, metadata, minting_account, supported_standards, token_decimals, token_name,
6    token_symbol, transfer, transfer_fee,
7};
8use icrc1_test_env::icrc2::{allowance, approve, transfer_from};
9use icrc1_test_env::ApproveArgs;
10use icrc1_test_env::TransferFromArgs;
11use icrc1_test_env::{Account, LedgerEnv, Transfer, TransferError, Value};
12use icrc1_test_env::{AllowanceArgs, ApproveError, TransferFromError};
13use std::future::Future;
14use std::pin::Pin;
15use std::time::SystemTime;
16
17pub enum Outcome {
18    Passed,
19    Skipped { reason: String },
20}
21
22pub type TestResult = anyhow::Result<Outcome>;
23
24pub struct Test {
25    name: String,
26    action: Pin<Box<dyn Future<Output = TestResult>>>,
27}
28
29pub fn test(name: impl Into<String>, body: impl Future<Output = TestResult> + 'static) -> Test {
30    Test {
31        name: name.into(),
32        action: Box::pin(body),
33    }
34}
35
36fn lookup<'a, K, V, U>(meta: &'a [(K, V)], key: &U) -> Option<&'a V>
37where
38    K: PartialEq<U>,
39    U: ?Sized,
40{
41    meta.iter().find_map(|(k, v)| (k == key).then_some(v))
42}
43
44fn assert_equal<T: PartialEq + std::fmt::Debug>(lhs: T, rhs: T) -> anyhow::Result<()> {
45    if lhs != rhs {
46        bail!("{:?} ≠ {:?}", lhs, rhs)
47    }
48    Ok(())
49}
50
51fn assert_not_equal<T: PartialEq + std::fmt::Debug>(lhs: T, rhs: T) -> anyhow::Result<()> {
52    if lhs == rhs {
53        bail!("{:?} = {:?}", lhs, rhs)
54    }
55    Ok(())
56}
57
58async fn time_nanos(ledger_env: &impl LedgerEnv) -> u64 {
59    ledger_env
60        .time()
61        .await
62        .duration_since(SystemTime::UNIX_EPOCH)
63        .unwrap()
64        .as_nanos() as u64
65}
66
67async fn assert_balance(
68    ledger: &impl LedgerEnv,
69    account: impl Into<Account>,
70    expected: impl Into<Nat>,
71) -> anyhow::Result<()> {
72    let account = account.into();
73    let actual = balance_of(ledger, account.clone()).await?;
74    let expected = expected.into();
75
76    if expected != actual {
77        bail!(
78            "Expected the balance of account {:?} to be {}, got {}",
79            account,
80            expected,
81            actual
82        )
83    }
84    Ok(())
85}
86
87async fn assert_allowance(
88    ledger_env: &impl LedgerEnv,
89    from: impl Into<Account>,
90    spender: impl Into<Account>,
91    expected_allowance: impl Into<Nat>,
92    expires_at: Option<u64>,
93) -> anyhow::Result<()> {
94    let from: Account = from.into();
95    let spender: Account = spender.into();
96    let expected_allowance: Nat = expected_allowance.into();
97    let allowance = allowance(
98        ledger_env,
99        AllowanceArgs {
100            account: from.clone(),
101            spender: spender.clone(),
102        },
103    )
104    .await?;
105    if allowance.allowance != expected_allowance {
106        bail!(
107            "Expected the {:?} -> {:?} allowance to be {}, got {}",
108            from,
109            spender,
110            expected_allowance,
111            allowance.allowance
112        );
113    }
114    if allowance.expires_at != expires_at {
115        bail!("Approval {:?} -> {:?} , wrong expiration", from, spender,);
116    }
117    Ok(())
118}
119
120async fn setup_test_account(
121    ledger_env: &impl LedgerEnv,
122    amount: Nat,
123) -> anyhow::Result<impl LedgerEnv> {
124    let balance = balance_of(ledger_env, ledger_env.principal()).await?;
125    assert!(balance >= amount.clone() + transfer_fee(ledger_env).await?);
126    let receiver_env = ledger_env.fork();
127    let receiver = receiver_env.principal();
128    assert_balance(&receiver_env, receiver, 0u8).await?;
129    let _tx = transfer(ledger_env, Transfer::amount_to(amount.clone(), receiver)).await??;
130    assert_balance(
131        &receiver_env,
132        Account {
133            owner: receiver,
134            subaccount: None,
135        },
136        amount.clone(),
137    )
138    .await?;
139    Ok(receiver_env)
140}
141
142/// Checks whether the ledger supports token transfers and handles
143/// default sub accounts correctly.
144pub async fn icrc1_test_transfer(ledger_env: impl LedgerEnv) -> TestResult {
145    let fee = transfer_fee(&ledger_env).await?;
146    let transfer_amount = Nat::from(10_000u16);
147    let initial_balance: Nat = transfer_amount.clone() + fee.clone();
148    let p1_env = setup_test_account(&ledger_env, initial_balance).await?;
149    let p2_env = ledger_env.fork();
150
151    let balance_p1 = balance_of(&p1_env, p1_env.principal()).await?;
152    let balance_p2 = balance_of(&p2_env, p2_env.principal()).await?;
153
154    let _tx = transfer(
155        &p1_env,
156        Transfer::amount_to(transfer_amount.clone(), p2_env.principal()),
157    )
158    .await??;
159
160    assert_balance(
161        &ledger_env,
162        Account {
163            owner: p2_env.principal(),
164            subaccount: None,
165        },
166        balance_p2.clone() + transfer_amount.clone(),
167    )
168    .await?;
169
170    assert_balance(
171        &ledger_env,
172        Account {
173            owner: p2_env.principal(),
174            subaccount: Some([0; 32]),
175        },
176        balance_p2 + transfer_amount.clone(),
177    )
178    .await?;
179
180    assert_balance(
181        &ledger_env,
182        p1_env.principal(),
183        balance_p1 - transfer_amount.clone() - fee,
184    )
185    .await?;
186
187    Ok(Outcome::Passed)
188}
189
190/// Checks whether the ledger supports token burns.
191/// Skips the checks if the ledger does not have a minting account.
192pub async fn icrc1_test_burn(ledger_env: impl LedgerEnv) -> TestResult {
193    let minting_account = match minting_account(&ledger_env).await? {
194        Some(account) => account,
195        None => {
196            return Ok(Outcome::Skipped {
197                reason: "the ledger does not support burn transactions".to_string(),
198            });
199        }
200    };
201
202    assert_balance(&ledger_env, minting_account.clone(), 0u8)
203        .await
204        .context("minting account cannot hold any funds")?;
205
206    let burn_amount = Nat::from(10_000u16);
207    let p1_env = setup_test_account(&ledger_env, burn_amount.clone()).await?;
208
209    // Burning tokens is done by sending the burned amount to the minting account
210    let _tx = transfer(
211        &p1_env,
212        Transfer::amount_to(burn_amount.clone(), minting_account.clone()),
213    )
214    .await?
215    .with_context(|| {
216        format!(
217            "failed to transfer {} tokens to {:?}",
218            burn_amount,
219            minting_account.clone()
220        )
221    })?;
222
223    assert_balance(&p1_env, p1_env.principal(), 0u8).await?;
224    assert_balance(&ledger_env, minting_account, 0u8).await?;
225
226    Ok(Outcome::Passed)
227}
228
229/// Checks whether the ledger metadata entries agree with named methods.
230pub async fn icrc1_test_metadata(ledger: impl LedgerEnv) -> TestResult {
231    let mut metadata = metadata(&ledger).await?;
232    metadata.sort_by(|l, r| l.0.cmp(&r.0));
233
234    for ((k1, _), (k2, _)) in metadata.iter().zip(metadata.iter().skip(1)) {
235        if k1 == k2 {
236            bail!("Key {} is duplicated in the metadata", k1);
237        }
238    }
239
240    if let Some(name) = lookup(&metadata, "icrc1:name") {
241        assert_equal(&Value::Text(token_name(&ledger).await?), name)
242            .context("icrc1:name metadata entry does not match the icrc1_name endpoint")?;
243    }
244    if let Some(sym) = lookup(&metadata, "icrc1:symbol") {
245        assert_equal(&Value::Text(token_symbol(&ledger).await?), sym)
246            .context("icrc1:symol metadata entry does not match the icrc1_symbol endpoint")?;
247    }
248    if let Some(meta_decimals) = lookup(&metadata, "icrc1:decimals") {
249        let decimals = token_decimals(&ledger).await?;
250        assert_equal(&Value::Nat(Nat::from(decimals)), meta_decimals)
251            .context("icrc1:decimals metadata entry does not match the icrc1_decimals endpoint")?;
252    }
253    if let Some(fee) = lookup(&metadata, "icrc1:fee") {
254        assert_equal(&Value::Nat(transfer_fee(&ledger).await?), fee)
255            .context("icrc1:fee metadata entry does not match the icrc1_fee endpoint")?;
256    }
257    Ok(Outcome::Passed)
258}
259
260/// Checks whether the ledger advertizes support for ICRC-1 standard.
261pub async fn icrc1_test_supported_standards(ledger: impl LedgerEnv) -> anyhow::Result<Outcome> {
262    let stds = supported_standards(&ledger).await?;
263    if !stds.iter().any(|std| std.name == "ICRC-1") {
264        bail!("The ledger does not claim support for ICRC-1: {:?}", stds);
265    }
266
267    Ok(Outcome::Passed)
268}
269
270/// Checks whether the ledger advertizes support for ICRC-2 standard.
271pub async fn icrc2_test_supported_standards(ledger: impl LedgerEnv) -> anyhow::Result<Outcome> {
272    let stds = supported_standards(&ledger).await?;
273    // If the ledger claims to support ICRC-2 it also needs to support ICRC-1
274    if !(stds.iter().any(|std| std.name == "ICRC-2") && stds.iter().any(|std| std.name == "ICRC-1"))
275    {
276        bail!(
277            "The ledger does not claim support for ICRC-1 and ICRC-2: {:?}",
278            stds
279        );
280    }
281
282    Ok(Outcome::Passed)
283}
284
285/// Checks basic functionality of the ICRC-2 approve endpoint.
286pub async fn icrc2_test_approve(ledger_env: impl LedgerEnv) -> anyhow::Result<Outcome> {
287    let fee = transfer_fee(&ledger_env).await?;
288    let initial_balance: Nat = fee.clone() * 2u8;
289    let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?;
290    let p2_env = ledger_env.fork();
291    let p2_subaccount = Account {
292        owner: p2_env.principal(),
293        subaccount: Some([1; 32]),
294    };
295    let approve_amount = fee.clone();
296
297    approve(
298        &p1_env,
299        ApproveArgs::approve_amount(approve_amount.clone(), p2_env.principal()),
300    )
301    .await??;
302
303    assert_allowance(
304        &p1_env,
305        p1_env.principal(),
306        p2_env.principal(),
307        approve_amount.clone(),
308        None,
309    )
310    .await?;
311
312    assert_allowance(
313        &p1_env,
314        p1_env.principal(),
315        p2_subaccount.clone(),
316        0u8,
317        None,
318    )
319    .await?;
320
321    assert_balance(&ledger_env, p1_env.principal(), fee.clone()).await?;
322    assert_balance(&ledger_env, p2_env.principal(), 0u8).await?;
323    assert_balance(&ledger_env, p2_subaccount.clone(), 0u8).await?;
324
325    // Approval for a subaccount.
326    approve(
327        &p1_env,
328        ApproveArgs::approve_amount(approve_amount.clone() * 2u8, p2_subaccount.clone()),
329    )
330    .await??;
331
332    assert_allowance(
333        &p1_env,
334        p1_env.principal(),
335        p2_env.principal(),
336        approve_amount.clone(),
337        None,
338    )
339    .await?;
340
341    assert_allowance(
342        &p1_env,
343        p1_env.principal(),
344        p2_subaccount.clone(),
345        approve_amount.clone() * 2u8,
346        None,
347    )
348    .await?;
349
350    assert_balance(&ledger_env, p1_env.principal(), 0u8).await?;
351    assert_balance(&ledger_env, p2_env.principal(), 0u8).await?;
352    assert_balance(&ledger_env, p2_subaccount, 0u8).await?;
353
354    // Insufficient funds to pay the fee for a second approval
355    match approve(
356        &p1_env,
357        ApproveArgs::approve_amount(fee.clone() * 3u8, p2_env.principal()),
358    )
359    .await?
360    {
361        Ok(_) => bail!("expected ApproveError::InsufficientFunds, got Ok result"),
362        Err(e) => match e {
363            ApproveError::InsufficientFunds { balance } => {
364                if balance != 0u8 {
365                    bail!("wrong balance, expected 0, got: {}", balance);
366                }
367            }
368            _ => return Err(e).context("expected ApproveError::InsufficientFunds"),
369        },
370    }
371
372    Ok(Outcome::Passed)
373}
374
375/// Checks the ICRC-2 approve endpoint for correct handling of the expiration functionality.
376pub async fn icrc2_test_approve_expiration(ledger_env: impl LedgerEnv) -> anyhow::Result<Outcome> {
377    let fee = transfer_fee(&ledger_env).await?;
378    let initial_balance: Nat = fee.clone() * 2u8;
379    let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?;
380    let p2_env = ledger_env.fork();
381    let approve_amount = fee.clone();
382    let now = time_nanos(&ledger_env).await;
383
384    // Expiration in the past
385    match approve(
386        &p1_env,
387        ApproveArgs::approve_amount(approve_amount.clone(), p2_env.principal()).expires_at(now - 1),
388    )
389    .await?
390    {
391        Ok(_) => bail!("expected ApproveError::Expired, got Ok result"),
392        Err(e) => match e {
393            ApproveError::Expired { .. } => {}
394            _ => return Err(e).context("expected ApproveError::Expired"),
395        },
396    }
397
398    assert_allowance(&p1_env, p1_env.principal(), p2_env.principal(), 0u8, None).await?;
399
400    assert_balance(&ledger_env, p1_env.principal(), initial_balance.clone()).await?;
401    assert_balance(&ledger_env, p2_env.principal(), 0u8).await?;
402
403    // Correct expiration in the future
404    let expiration = u64::MAX;
405    approve(
406        &p1_env,
407        ApproveArgs::approve_amount(approve_amount.clone(), p2_env.principal())
408            .expires_at(expiration),
409    )
410    .await??;
411
412    assert_allowance(
413        &p1_env,
414        p1_env.principal(),
415        p2_env.principal(),
416        approve_amount.clone(),
417        Some(expiration),
418    )
419    .await?;
420
421    assert_balance(&ledger_env, p1_env.principal(), fee).await?;
422    assert_balance(&ledger_env, p2_env.principal(), 0u8).await?;
423
424    // Change expiration
425    let new_expiration = expiration - 1;
426    approve(
427        &p1_env,
428        ApproveArgs::approve_amount(approve_amount.clone(), p2_env.principal())
429            .expires_at(new_expiration),
430    )
431    .await??;
432
433    assert_allowance(
434        &p1_env,
435        p1_env.principal(),
436        p2_env.principal(),
437        approve_amount,
438        Some(new_expiration),
439    )
440    .await?;
441
442    assert_balance(&ledger_env, p1_env.principal(), 0u8).await?;
443    assert_balance(&ledger_env, p2_env.principal(), 0u8).await?;
444
445    Ok(Outcome::Passed)
446}
447
448/// Checks the ICRC-2 approve endpoint for correct handling of the expected allowance functionality.
449pub async fn icrc2_test_approve_expected_allowance(
450    ledger_env: impl LedgerEnv,
451) -> anyhow::Result<Outcome> {
452    let fee = transfer_fee(&ledger_env).await?;
453    let initial_balance: Nat = fee.clone() * 2u8;
454    let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?;
455    let p2_env = ledger_env.fork();
456    let approve_amount = fee.clone();
457
458    approve(
459        &p1_env,
460        ApproveArgs::approve_amount(approve_amount.clone(), p2_env.principal()),
461    )
462    .await??;
463
464    // Wrong expected allowance
465    let new_approve_amount: Nat = fee.clone() * 2u8;
466    match approve(
467        &p1_env,
468        ApproveArgs::approve_amount(new_approve_amount.clone(), p2_env.principal())
469            .expected_allowance(fee.clone() * 2u8),
470    )
471    .await?
472    {
473        Ok(_) => bail!("expected ApproveError::AllowanceChanged, got Ok result"),
474        Err(e) => match e {
475            ApproveError::AllowanceChanged { current_allowance } => {
476                if current_allowance != approve_amount {
477                    bail!(
478                        "wrong current_allowance, expected {}, got: {}",
479                        approve_amount,
480                        current_allowance
481                    );
482                }
483            }
484            _ => return Err(e).context("expected ApproveError::AllowanceChanged"),
485        },
486    }
487
488    // Correct expected allowance
489    approve(
490        &p1_env,
491        ApproveArgs::approve_amount(new_approve_amount.clone(), p2_env.principal())
492            .expected_allowance(approve_amount),
493    )
494    .await??;
495
496    assert_allowance(
497        &p1_env,
498        p1_env.principal(),
499        p2_env.principal(),
500        new_approve_amount,
501        None,
502    )
503    .await?;
504
505    assert_balance(&ledger_env, p1_env.principal(), 0u8).await?;
506    assert_balance(&ledger_env, p2_env.principal(), 0u8).await?;
507
508    Ok(Outcome::Passed)
509}
510
511/// Checks the basic functionality of the ICRC-2 transfer from endpoint.
512pub async fn icrc2_test_transfer_from(ledger_env: impl LedgerEnv) -> anyhow::Result<Outcome> {
513    let fee = transfer_fee(&ledger_env).await?;
514    // Charge account with some tokens plus two times the transfer fee, once for approving and once for transferring
515    let transfer_amount = fee.clone();
516    let initial_balance: Nat = transfer_amount.clone() * 2u8 + fee.clone() * 2u8;
517    let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?;
518    let p2_env = ledger_env.fork();
519    let p3_env = ledger_env.fork();
520
521    // Approve amount needs to be the transferred amount + the fee for transferring
522    let approve_amount: Nat = transfer_amount.clone() + fee.clone();
523
524    approve(
525        &p1_env,
526        ApproveArgs::approve_amount(approve_amount.clone(), p2_env.principal()),
527    )
528    .await??;
529
530    // Transferred amount has to be smaller than the approved amount minus the fee for transfering tokens
531    let transfer_amount = approve_amount - fee.clone() - Nat::from(1u8);
532    transfer_from(
533        &p2_env,
534        TransferFromArgs::transfer_from(
535            transfer_amount.clone(),
536            p3_env.principal(),
537            p1_env.principal(),
538        ),
539    )
540    .await??;
541
542    assert_balance(
543        &ledger_env,
544        p1_env.principal(),
545        // Balance should be the initial balance minus two times the fee, once for the approve and once for the transfer, and the transferred amount
546        initial_balance - fee.clone() - fee - transfer_amount.clone(),
547    )
548    .await?;
549    // Balance of spender should not change
550    assert_balance(&ledger_env, p2_env.principal(), 0u8).await?;
551    // Beneficiary should get the amount transferred
552    assert_balance(&ledger_env, p3_env.principal(), transfer_amount).await?;
553
554    assert_allowance(
555        &p1_env,
556        p1_env.principal(),
557        p2_env.principal(),
558        Nat::from(1u8),
559        None,
560    )
561    .await?;
562    Ok(Outcome::Passed)
563}
564
565/// Checks the ICRC-2 transfer from endpoint for correct handling of the insufficient funds error.
566pub async fn icrc2_test_transfer_from_insufficient_funds(
567    ledger_env: impl LedgerEnv,
568) -> anyhow::Result<Outcome> {
569    let fee = transfer_fee(&ledger_env).await?;
570    let transfer_amount = fee.clone();
571    // The initial balance is not enough to cover the fee for approval and transfer_from.
572    let initial_balance: Nat = transfer_amount.clone() + fee.clone();
573    let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?;
574    let p2_env = ledger_env.fork();
575    let p3_env = ledger_env.fork();
576
577    // Approve sufficient amount.
578    let approve_amount: Nat = transfer_amount.clone() + fee.clone();
579    approve(
580        &p1_env,
581        ApproveArgs::approve_amount(approve_amount.clone(), p2_env.principal()),
582    )
583    .await??;
584
585    match transfer_from(
586        &p2_env,
587        TransferFromArgs::transfer_from(
588            transfer_amount.clone(),
589            p3_env.principal(),
590            p1_env.principal(),
591        ),
592    )
593    .await?
594    {
595        Ok(_) => bail!("expected TransferFromError::InsufficientFunds, got Ok result"),
596        Err(e) => match e {
597            TransferFromError::InsufficientFunds { balance } => {
598                if balance != transfer_amount {
599                    bail!(
600                        "wrong balance, expected {}, got: {}",
601                        transfer_amount,
602                        balance
603                    );
604                }
605            }
606            _ => return Err(e).context("expected TransferFromError::InsufficientFunds"),
607        },
608    }
609
610    // p1_env balance was reduced by the approval fee.
611    assert_balance(&ledger_env, p1_env.principal(), transfer_amount).await?;
612    assert_balance(&ledger_env, p2_env.principal(), 0u8).await?;
613    assert_balance(&ledger_env, p3_env.principal(), 0u8).await?;
614
615    // Allowance is not changed.
616    assert_allowance(
617        &p1_env,
618        p1_env.principal(),
619        p2_env.principal(),
620        approve_amount,
621        None,
622    )
623    .await?;
624
625    Ok(Outcome::Passed)
626}
627
628/// Checks the ICRC-2 transfer from endpoint for correct handling of the insufficient allowance error.
629pub async fn icrc2_test_transfer_from_insufficient_allowance(
630    ledger_env: impl LedgerEnv,
631) -> anyhow::Result<Outcome> {
632    let fee = transfer_fee(&ledger_env).await?;
633    let transfer_amount = fee.clone();
634    let initial_balance: Nat = transfer_amount.clone() + fee.clone();
635    let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?;
636    let p2_env = ledger_env.fork();
637    let p3_env = ledger_env.fork();
638
639    match transfer_from(
640        &p2_env,
641        TransferFromArgs::transfer_from(
642            transfer_amount.clone(),
643            p3_env.principal(),
644            p1_env.principal(),
645        ),
646    )
647    .await?
648    {
649        Ok(_) => bail!("expected TransferFromError::InsufficientAllowance, got Ok result"),
650        Err(e) => match e {
651            TransferFromError::InsufficientAllowance { allowance } => {
652                if allowance != 0u8 {
653                    bail!("wrong allowance, expected 0, got: {}", allowance);
654                }
655            }
656            _ => return Err(e).context("expected TransferFromError::InsufficientAllowance"),
657        },
658    }
659
660    // Balances are not changed.
661    assert_balance(&ledger_env, p1_env.principal(), initial_balance).await?;
662    assert_balance(&ledger_env, p2_env.principal(), 0u8).await?;
663    assert_balance(&ledger_env, p3_env.principal(), 0u8).await?;
664
665    Ok(Outcome::Passed)
666}
667
668/// Checks the ICRC-2 transfer from endpoint for correct handling of self transfers.
669pub async fn icrc2_test_transfer_from_self(ledger_env: impl LedgerEnv) -> anyhow::Result<Outcome> {
670    let fee = transfer_fee(&ledger_env).await?;
671    let transfer_amount = fee.clone();
672    let initial_balance: Nat = transfer_amount.clone() + fee.clone();
673    let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?;
674    let p2_env = ledger_env.fork();
675
676    // icrc2_transfer_from does not require approval if spender == from
677    transfer_from(
678        &p1_env,
679        TransferFromArgs::transfer_from(
680            transfer_amount.clone(),
681            p2_env.principal(),
682            p1_env.principal(),
683        ),
684    )
685    .await??;
686
687    // Transferred the transfer_amount and paid fee; the balance is now 0.
688    assert_balance(&ledger_env, p1_env.principal(), 0u8).await?;
689    // Beneficiary should get the amount transferred.
690    assert_balance(&ledger_env, p2_env.principal(), transfer_amount).await?;
691
692    Ok(Outcome::Passed)
693}
694
695/// Checks whether the ledger applies deduplication of transactions correctly
696pub async fn icrc1_test_tx_deduplication(ledger_env: impl LedgerEnv) -> anyhow::Result<Outcome> {
697    let fee = transfer_fee(&ledger_env).await?;
698    let transfer_amount = Nat::from(10_000u64);
699    let initial_balance: Nat = transfer_amount.clone() * 7u8 + fee.clone() * 7u8;
700    // Create two test accounts and transfer some tokens to the first account. Also charge them with enough tokens so they can pay the transfer fees
701    let p1_env = setup_test_account(&ledger_env, initial_balance.clone()).await?;
702    let p2_env = p1_env.fork();
703
704    // Deduplication should not happen if the created_at_time field is unset.
705    let transfer_args = Transfer::amount_to(transfer_amount.clone(), p2_env.principal());
706    transfer(&p1_env, transfer_args.clone())
707        .await?
708        .context("failed to execute the first no-dedup transfer")?;
709
710    assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone()).await?;
711
712    transfer(&p1_env, transfer_args.clone())
713        .await?
714        .context("failed to execute the second no-dedup transfer")?;
715
716    assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 2u8).await?;
717
718    // Setting the created_at_time field changes the transaction
719    // identity, so the transfer should succeed.
720    let transfer_args = transfer_args.created_at_time(time_nanos(&ledger_env).await);
721
722    let txid = match transfer(&p1_env, transfer_args.clone()).await? {
723        Ok(txid) => txid,
724        Err(TransferError::TooOld) => {
725            return Ok(Outcome::Skipped {
726                reason: "the ledger does not support deduplication".to_string(),
727            })
728        }
729        Err(e) => return Err(e).context("failed to execute the first dedup transfer"),
730    };
731
732    assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 3u8).await?;
733
734    // Sending the same transfer again should trigger deduplication.
735    assert_equal(
736        Err(TransferError::Duplicate {
737            duplicate_of: txid.clone(),
738        }),
739        transfer(&p1_env, transfer_args.clone()).await?,
740    )?;
741
742    assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 3u8).await?;
743
744    // Explicitly setting the fee field changes the transaction
745    // identity, so the transfer should succeed.
746    let transfer_args = transfer_args.fee(fee.clone());
747
748    let txid_2 = transfer(&p1_env, transfer_args.clone())
749        .await?
750        .context("failed to execute the transfer with an explicitly set fee field")?;
751
752    assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 4u8).await?;
753
754    assert_not_equal(&txid, &txid_2).context("duplicate txid")?;
755
756    // Sending the same transfer again should trigger deduplication.
757    assert_equal(
758        Err(TransferError::Duplicate {
759            duplicate_of: txid_2.clone(),
760        }),
761        transfer(&p1_env, transfer_args.clone()).await?,
762    )?;
763
764    assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 4u8).await?;
765
766    // A custom memo changes the transaction identity, so the transfer
767    // should succeed.
768    let transfer_args = transfer_args.memo(vec![1, 2, 3]);
769
770    let txid_3 = transfer(&p1_env, transfer_args.clone())
771        .await?
772        .context("failed to execute the transfer with an explicitly set memo field")?;
773
774    assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 5u8).await?;
775
776    assert_not_equal(&txid, &txid_3).context("duplicate txid")?;
777    assert_not_equal(&txid_2, &txid_3).context("duplicate txid")?;
778
779    // Sending the same transfer again should trigger deduplication.
780    assert_equal(
781        Err(TransferError::Duplicate {
782            duplicate_of: txid_3,
783        }),
784        transfer(&p1_env, transfer_args.clone()).await?,
785    )?;
786
787    assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 5u8).await?;
788
789    let now = time_nanos(&ledger_env).await;
790
791    // Transactions with different subaccounts (even if it's None and
792    // Some([0; 32])) should not be considered duplicates.
793
794    transfer(
795        &p1_env,
796        Transfer::amount_to(
797            transfer_amount.clone(),
798            Account {
799                owner: p2_env.principal(),
800                subaccount: None,
801            },
802        )
803        .memo(vec![0])
804        .created_at_time(now),
805    )
806    .await?
807    .context("failed to execute the transfer with an empty subaccount")?;
808
809    assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 6u8).await?;
810
811    transfer(
812        &p1_env,
813        Transfer::amount_to(
814            transfer_amount.clone(),
815            Account {
816                owner: p2_env.principal(),
817                subaccount: Some([0; 32]),
818            },
819        )
820        .memo(vec![0])
821        .created_at_time(now),
822    )
823    .await?
824    .context("failed to execute the transfer with the default subaccount")?;
825
826    assert_balance(&p1_env, p2_env.principal(), transfer_amount.clone() * 7u8).await?;
827
828    Ok(Outcome::Passed)
829}
830
831/// Checks the ICRC-2 transfer from endpoint for correct handling of the insufficient bad fee error.
832pub async fn icrc1_test_bad_fee(ledger_env: impl LedgerEnv) -> anyhow::Result<Outcome> {
833    let fee = transfer_fee(&ledger_env).await?;
834    let transfer_amount = Nat::from(10_000u16);
835    let initial_balance: Nat = transfer_amount.clone() + fee.clone();
836    // Create two test accounts and transfer some tokens to the first account
837    let p1_env = setup_test_account(&ledger_env, initial_balance).await?;
838    let p2_env = p1_env.fork();
839
840    let mut transfer_args = Transfer::amount_to(transfer_amount.clone(), p2_env.principal());
841    // Set incorrect fee
842    transfer_args = transfer_args.fee(fee.clone() + Nat::from(1u8));
843    match transfer(&ledger_env, transfer_args.clone()).await? {
844        Ok(_) => return Err(anyhow::Error::msg("Expected Bad Fee Error")),
845        Err(err) => match err {
846            TransferError::BadFee { expected_fee } => {
847                if expected_fee != transfer_fee(&ledger_env).await? {
848                    return Err(anyhow::Error::msg(format!(
849                        "Expected BadFee argument to be {}, got {}",
850                        fee, expected_fee
851                    )));
852                }
853            }
854            _ => return Err(anyhow::Error::msg("Expected BadFee error")),
855        },
856    }
857    Ok(Outcome::Passed)
858}
859
860/// Checks the ICRC-2 transfer from endpoint for correct handling of the future transfer error.
861pub async fn icrc1_test_future_transfer(ledger_env: impl LedgerEnv) -> anyhow::Result<Outcome> {
862    let fee = transfer_fee(&ledger_env).await?;
863    let transfer_amount = Nat::from(10_000u16);
864    let initial_balance: Nat = transfer_amount.clone() + fee.clone();
865    // Create two test accounts and transfer some tokens to the first account
866    let p1_env = setup_test_account(&ledger_env, initial_balance).await?;
867    let p2_env = p1_env.fork();
868
869    let mut transfer_args = Transfer::amount_to(transfer_amount, p2_env.principal());
870
871    // Set created time in the future
872    transfer_args = transfer_args.created_at_time(u64::MAX);
873    match transfer(&ledger_env, transfer_args).await? {
874        Err(TransferError::CreatedInFuture { ledger_time: _ }) => Ok(Outcome::Passed),
875        other => bail!("expected CreatedInFuture error, got: {:?}", other),
876    }
877}
878
879/// Checks the ICRC-2 transfer from endpoint for correct handling of the length of the memo.
880pub async fn icrc1_test_memo_bytes_length(ledger_env: impl LedgerEnv) -> anyhow::Result<Outcome> {
881    let fee = transfer_fee(&ledger_env).await?;
882    let transfer_amount = Nat::from(10_000u16);
883    let initial_balance: Nat = transfer_amount.clone() + fee.clone();
884    // Create two test accounts and transfer some tokens to the first account
885    let p1_env = setup_test_account(&ledger_env, initial_balance).await?;
886    let p2_env = p1_env.fork();
887
888    let transfer_args = Transfer::amount_to(transfer_amount, p2_env.principal()).memo([1u8; 32]);
889    // Ledger should accept memos of at least 32 bytes;
890    match transfer(&ledger_env, transfer_args.clone()).await? {
891        Ok(_) => Ok(Outcome::Passed),
892        Err(err) => bail!(
893            "Expected memo with 32 bytes to succeed but got error: {:?}",
894            err
895        ),
896    }
897}
898
899/// Returns the entire list of icrc1 tests.
900pub fn icrc1_test_suite(env: impl LedgerEnv + 'static + Clone) -> Vec<Test> {
901    vec![
902        test("icrc1:transfer", icrc1_test_transfer(env.clone())),
903        test("icrc1:burn", icrc1_test_burn(env.clone())),
904        test("icrc1:metadata", icrc1_test_metadata(env.clone())),
905        test(
906            "icrc1:supported_standards",
907            icrc1_test_supported_standards(env.clone()),
908        ),
909        test(
910            "icrc1:tx_deduplication",
911            icrc1_test_tx_deduplication(env.clone()),
912        ),
913        test(
914            "icrc1:memo_bytes_length",
915            icrc1_test_memo_bytes_length(env.clone()),
916        ),
917        test(
918            "icrc1:future_transfers",
919            icrc1_test_future_transfer(env.clone()),
920        ),
921        test("icrc1:bad_fee", icrc1_test_bad_fee(env)),
922    ]
923}
924
925/// Returns the entire list of icrc2 tests.
926pub fn icrc2_test_suite(env: impl LedgerEnv + 'static + Clone) -> Vec<Test> {
927    vec![
928        test(
929            "icrc2:supported_standards",
930            icrc2_test_supported_standards(env.clone()),
931        ),
932        test("icrc2:approve", icrc2_test_approve(env.clone())),
933        test(
934            "icrc2:approve_expiration",
935            icrc2_test_approve_expiration(env.clone()),
936        ),
937        test(
938            "icrc2:approve_expected_allowance",
939            icrc2_test_approve_expected_allowance(env.clone()),
940        ),
941        test("icrc2:transfer_from", icrc2_test_transfer_from(env.clone())),
942        test(
943            "icrc2:transfer_from_insufficient_funds",
944            icrc2_test_transfer_from_insufficient_funds(env.clone()),
945        ),
946        test(
947            "icrc2:transfer_from_insufficient_allowance",
948            icrc2_test_transfer_from_insufficient_allowance(env.clone()),
949        ),
950        test(
951            "icrc2:transfer_from_self",
952            icrc2_test_transfer_from_self(env.clone()),
953        ),
954    ]
955}
956
957pub async fn test_suite(env: impl LedgerEnv + 'static + Clone) -> Vec<Test> {
958    match supported_standards(&env).await {
959        Ok(standard) => {
960            let mut tests = vec![];
961            if standard.iter().any(|std| std.name == "ICRC-1") {
962                tests.append(&mut icrc1_test_suite(env.clone()));
963            }
964            if standard.iter().any(|std| std.name == "ICRC-2") {
965                tests.append(&mut icrc2_test_suite(env));
966            }
967            tests
968        }
969        Err(_) => {
970            println!("No standard is supported by the given ledger: Is the endpoint 'icrc1_supported_standards' implemented correctly?");
971            vec![]
972        }
973    }
974}
975/// Executes the list of tests concurrently and prints results using
976/// the TAP protocol (https://testanything.org/).
977pub async fn execute_tests(tests: Vec<Test>) -> bool {
978    use futures::stream::FuturesOrdered;
979
980    let mut names = Vec::new();
981    let mut futures = FuturesOrdered::new();
982
983    for test in tests.into_iter() {
984        names.push(test.name);
985        futures.push_back(test.action);
986    }
987
988    println!("TAP version 14");
989    println!("1..{}", futures.len());
990
991    let mut idx = 0;
992    let mut success = true;
993    while let Some(result) = futures.next().await {
994        match result {
995            Ok(Outcome::Passed) => {
996                println!("ok {} - {}", idx + 1, names[idx]);
997            }
998            Ok(Outcome::Skipped { reason }) => {
999                println!("ok {} - {} # SKIP {}", idx + 1, names[idx], reason);
1000            }
1001            Err(err) => {
1002                success = false;
1003
1004                for line in format!("{:?}", err).lines() {
1005                    println!("# {}", line);
1006                }
1007
1008                println!("not ok {} - {}", idx + 1, names[idx]);
1009            }
1010        }
1011        idx += 1;
1012    }
1013
1014    success
1015}