Help Needed: Unattended Asset Transfer After One Year

Hello everyone,

I need assistance with setting up an unattended asset transfer that will automatically execute one year after scheduling. I won’t be logged in at that time, so the transfer needs to happen without any manual intervention. The assets are currently stored in a well-known wallet where I normally keep my wealth.

I understand there might already be some information or discussions on this topic in the forum, so I apologize if this has been covered before. Could someone please point me to relevant threads or provide guidance on how to achieve this?

Thank you in advance for your help!

:warning: That’s a scam attempt! @Matiki do not anwser above message, there is no such things as an “ICP OFFICIAL” ticketing system.

Regarding your question, I don’t really have the answer as I understand you want to solve this with a service, not via coding, and not sure if such a service exists.

1 Like

Thank you for the warning, Peter.
Actually I’d like to code such a service within a canister
Matiki.

You probably will want to use a timer. If it’s tokens that you want to transfer, then have a look at ICRC-2 as well

1 Like

Got it. I haven’t implemented this myself, so I can’t provide a definitive solution, only some ideas. From what I understand, you’re aiming to implement a canister that is authorized by a spender to execute a transfer one year later. If that’s correct, one possible approach might be to implement an ICRC-2 approve function. This function would allow the user to authorize the canister to spend X tokens until a specific date Y. The canister could then use a timer to periodically check and execute the transfer. However, as I think through this idea, I realize there might be a downside: the user authorizes the spender (the canister) to spend the amount over a time span, rather than on a precise date.

UPDATE: I answered at the same time of Severin. This matches his answer but, he provides links which is way better.

1 Like

Thank you guys,
I’m wondering if there’s a hard limit on the duration of this approval?
Matiki

expires_at : opt nat64;
so it’s up to you if you want to set an expiry or not

OK. thank you guys.
I modified a dfinity example ‘token_transfer_from’ slightly:

Into:

#[ic_cdk::update]
async fn transfer(args: TransferArgs) -> Result<BlockIndex, String> {
    ic_cdk::println!(
        "Transferring {} tokens to account {} \n from account {}  after {} seconds",
        &args.amount,
        &args.to_account,
        &args.from_account,
        &args.delay_in_seconds
    );

    let secs = Duration::from_secs(args.delay_in_seconds);
    let amount = args.amount.clone();
    let to_account = args.to_account.clone();
    let from_account = args.from_account.clone();

    ic_cdk::println!("Timer canister: Starting a new timer with {secs:?} interval...");
    // Schedule a new periodic task to increment the counter.
    let _timer_id = ic_cdk_timers::set_timer(secs, move || {
        ic_cdk::println!("Timer canister: in set_timer closure");
        // To drive an async function to completion inside the timer handler,
        // use `ic_cdk::spawn()`, for example:
        // ic_cdk_timers::set_timer_interval(interval, || ic_cdk::spawn(async_function()));


        let transfer_from_args = TransferFromArgs {
            // the account we want to transfer tokens from (in this case we assume the caller approved the canister to spend funds on their behalf)
            from: Account::from(from_account.clone()),
            // can be used to distinguish between transactions
            memo: None,
            // the amount we want to transfer
            amount: amount.clone(),
            // the subaccount we want to spend the tokens from (in this case we assume the default subaccount has been approved)
            spender_subaccount: None,
            // if not specified, the default fee for the canister is used
            fee: None,
            // the account we want to transfer tokens to
            to: to_account.clone(),
            // a timestamp indicating when the transaction was created by the caller; if it is not specified by the caller then this is set to the current ICP time
            created_at_time: None,
        };  

    

        ic_cdk::spawn(async {
            // Your async code here
            ic_cdk::println!("Timer canister: in spawned async block");
        
            // 1. Asynchronously call another canister function using `ic_cdk::call`.
            let result =ic_cdk::call::<(TransferFromArgs,), (Result<BlockIndex, TransferFromError>,)>(
                // 2. Convert a textual representation of a Principal into an actual `Principal` object. The principal is the one we specified in `dfx.json`.
                //    `expect` will panic if the conversion fails, ensuring the code does not proceed with an invalid principal.
                Principal::from_text(LEDGER_CANISTER_ID)
                    .expect("Could not decode the principal."),
                // 3. Specify the method name on the target canister to be called, in this case, "icrc1_transfer".
                "icrc2_transfer_from",
                // 4. Provide the arguments for the call in a tuple, here `transfer_args` is encapsulated as a single-element tuple.
                (transfer_from_args,),
            )
            .await // 5. Await the completion of the asynchronous call, pausing the execution until the future is resolved.
            // 6. Apply `map_err` to transform any network or system errors encountered during the call into a more readable string format.
            //    The `?` operator is then used to propagate errors: if the result is an `Err`, it returns from the function with that error,
            //    otherwise, it unwraps the `Ok` value, allowing the chain to continue.
            .map_err(|e| format!("failed to call ledger: {:?}", e))
            .and_then(|response| response.0.map_err(|e| format!("ledger transfer error {:?}", e)));
        
                        // 7. Access the first element of the tuple, which is the `Result<BlockIndex, TransferError>`, for further processing.
            // 8. Use `map_err` again to transform any specific ledger transfer errors into a readable string format, facilitating error handling and debugging.
            ic_cdk::println!("Timer canister: in spawned async block after call");
    
            match result {
                Ok(block_index) => {
                    ic_cdk::println!("Transfer successful. Block index: {}", block_index);
                }
                Err(e) => {
                    ic_cdk::println!("Transfer failed: {:?}", e);
                }
            }

        });
    });

    ic_cdk::println!("Timer canister: returning from transfer");

    Ok(BlockIndex::from(0 as u32))
}

I have a shell script that transfers ICP from Alice identity to Bob identity
It works both in the local mode and in a playground mode , the latter operating with real ICP tokens.

#!/usr/bin/env bash
#  If you run the script without any arguments, `MODE` will be set to `local`.
# can be run with `local` or `nonlocal` as a script argument
MODE=${1:-local}

# in nonllocal mode feed Alice with some ICP first:
# dfx ledger --ic transfer --identity Matiki --amount 0.01 --memo 9 $(dfx ledger account-id  --identity Alice)

if [ "$MODE" = "local" ]; then
  echo "Building in local mode"
else
  echo "Building in non-local mode"
fi

dfx killall
set -e

echo "===========SETUP========="
dfx start --background --clean
sleep 5
dfx identity list

if [ "$MODE" == "local" ]; then
  export LEDGER_CANISTER_ID=mxzaz-hqaaa-aaaar-qaada-cai
  export LEDGER=icrc1_ledger_canister
  export NETWORK=
  export PLAYGROUND=
else
  export LEDGER_CANISTER_ID=ryjl3-tyaaa-aaaaa-aaaba-cai
  export LEDGER=$LEDGER_CANISTER_ID
  export NETWORK=--ic
  export PLAYGROUND=--playground
fi

function balance() {
  local identity=$1
  # --identity Matiki is only to silence a warning about the identity 'default'
  dfx canister $NETWORK call --identity Matiki $LEDGER icrc1_balance_of "(record {
      owner = principal \"$(dfx identity --identity $identity get-principal)\";
    })"
}

function balance_of_canister() {
  local canister_name=$1
  dfx canister $NETWORK call $LEDGER icrc1_balance_of "(record {
      owner = principal \"$(dfx canister id $canister_name)\";
    })"
}
PUSH_RED="\e[31m"
PUSH_GREEN="\e[32m"
PUSH_YELLOW="\e[33m"
POP="\e[0m"

if [ "$MODE" == "local" ]; then
  dfx deploy $LEDGER --argument "(variant {
    Init = record {
      token_symbol = \"ICRC1\";
      token_name = \"L-ICRC1\";
      minting_account = record {
        owner = principal \"$(dfx identity --identity anonymous get-principal)\"
      };
      transfer_fee = 10_000;
      metadata = vec {};
      initial_balances = vec {
        record {
          record {
            owner = principal \"$(dfx identity --identity Alice get-principal)\";
          };
          10_000_000_000;
        };
      };
      archive_options = record {
        num_blocks_to_archive = 1000;
        trigger_threshold = 2000;
        controller_id = principal \"$(dfx identity --identity anonymous get-principal)\";
      };
      feature_flags = opt record {
        icrc2 = true;
      };
    }
  })"

fi

balance Matiki
MatikiBalanceBefore=$(balance Matiki)

balance Alice
balance Bob
BobBalanceBefore=$(balance Bob)

echo "===========SETUP DONE========="

balance Alice

dfx deploy $PLAYGROUND token_transfer_from_backend
export BACKEND_CANISTER_ID=$(dfx canister $PLAYGROUND id token_transfer_from_backend)

echo -e "${PUSH_YELLOW}BACKEND_CANISTER_ID: $BACKEND_CANISTER_ID$POP"

echo "===========APPROVE========="
# approve the token_transfer_from_backend canister to spend 300 + transfer fee tokens
dfx canister --verbose $NETWORK call --identity Alice $LEDGER icrc2_approve "(
  record {
    spender= record {
      owner = principal \"$BACKEND_CANISTER_ID\";
    };
    amount = 10_300: nat;
  }
)"

echo =========ALLOWANCE is what is approved
dfx canister $NETWORK call --identity Alice $LEDGER icrc2_allowance "(
  record {
    account = record {
      owner = principal \"$(dfx identity --identity Alice get-principal)\";
    };
    spender = record {
      owner = principal \"$BACKEND_CANISTER_ID\";
  };
})"

balance Alice

echo "===========TRANSFER========="
dfx canister $NETWORK call $BACKEND_CANISTER_ID transfer "(record {
  amount = 300;
  to_account = record {
    owner = principal \"$(dfx identity $NETWORK --identity Bob get-principal)\";
  };
  from_account = record {
    owner = principal \"$(dfx identity $NETWORK --identity Alice get-principal)\";
  };
  delay_in_seconds = 20;
})"
balance Alice
balance Bob

dfx canister $NETWORK call $LEDGER icrc1_balance_of "(record {
  owner = principal \"$(dfx canister $PLAYGROUND id token_transfer_from_backend)\";
})"

function check_bob_balance_increase {
  local BobBalanceAfter=$(balance Bob)

  # BoB balances are in the form (3_300 : nat), so we need to parse them before subtraction
  BobBalanceBefore=$(echo $BobBalanceBefore | tr -d '_' | sed 's/.*(\([0-9]*\).*/\1/')
  BobBalanceAfter=$(echo $BobBalanceAfter | tr -d '_' | sed 's/.*(\([0-9]*\).*/\1/')
  if [ $((BobBalanceAfter - BobBalanceBefore)) -ne 300 ]; then
    echo -e "${PUSH_RED}Error: Bob's balance hasn't increased by 300$POP"
  else
    echo -e "${PUSH_GREEN}OK: Bob's balance has increased by 300$POP"
  fi
}

check_bob_balance_increase

sleep 20

balance Alice
balance Bob
balance Matiki

MatikiBalanceAfter=$(balance Matiki)
if [ "$MatikiBalanceBefore" == "$MatikiBalanceAfter" ]; then
  echo "Matiki balance hasn't changed."
else
  echo "${PUSH_RED}Error: Matiki balance has changed !!!$POP"
fi

check_bob_balance_increase

echo "DONE"

IN either case the output looks like this:

So far so good.

Now I’m trying to implement the approval in a frontend canister

    async login() {
      const authClient = await AuthClient.create();
      await authClient.login({
        //identityProvider: import.meta.env.VITE_APP_IDENTITY_PROVIDER,
        identityProvider: "https://identity.ic0.app/#authorize",
        //identityProvider: "http://be2us-64aaa-aaaaa-qaabq-cai.localhost:4943/",
        onSuccess: async () => {
          const identity = authClient.getIdentity();
          console.log('authClient.getIdentity():', identity);
          
          const principal = identity.getPrincipal();
          this.principal = principal;
          this.identity = identity;
        }
      })
    },


    async transfer() {
      
      console.log('mtlk Matiki here 0');


       console.log('identity:', this.identity);
       console.log('identity principal:', this.identity?.getPrincipal());
       console.log('identity principal toText:', this.identity?.getPrincipal().toText());

       console.log('mtlk Matiki here 2');

        if (!this.identity) {
          throw new Error("mtlk No identity")
        }

        const identity = this.identity;

        const AUTH_PROVIDER_URL = 'https://icp-api.io';

        console.log('identity principal toText:', identity.getPrincipal().toText());

        console.log('mtlk Matiki here 2f');
        console.log('identity:', identity);
        console.log('host:', AUTH_PROVIDER_URL); 
        console.log('dfx netowrk:', process.env.DFX_NETWORK);
        console.log('fetchRootKey:', process.env.DFX_NETWORK === "local");

        console.log('canisterId:', canisterId);
        
        const agent = await createAgent({
          identity: identity,
          host: AUTH_PROVIDER_URL,
          fetchRootKey: process.env.DFX_NETWORK === "local",
        });

        console.log('agent:', agent); 
        console.log('mtlk Matiki here 3');


        console.log('icrc1_ledger_canister_id :', LEDGER_CANISTER_ID);

        // Create an instance of the icrc1_ledger_canister
        const icrc1_ledger_canister = Actor.createActor(icrc1_ledger_canister_idl, { agent, canisterId: LEDGER_CANISTER_ID });

        console.log('mtlk Matiki here 4');
        console.log('icrc1_ledger_canister:', icrc1_ledger_canister);

        // Now you can call functions on the icrc1_ledger_canister instance. For example:

        console.log('mtlk Matiki here 5');


        const name = await icrc1_ledger_canister.icrc1_name();
        console.log('icrc1_ledger_canister.icrc1_name():', name);
        
        // //'icrc1_balance_of' : IDL.Func([Account], [Tokens], ['query']),
        // balance = await this.actor.icrc1_balance_of({
        // owner: Principal.fromText(address),


        const identity_balance = await icrc1_ledger_canister.icrc1_balance_of({
          owner: Principal.fromText('zgcr3-w3e7h-6okfu-e5dke-k66xm-kadri-lvnuw-kipko-mysl7-r6p53-xae'),   subaccount: [],
        });
        console.log('identity_balance:', identity_balance);

        const metadata = await icrc1_ledger_canister.icrc1_metadata();
        console.log('metadata:', metadata);




        const approveResult = await icrc1_ledger_canister.icrc2_approve({
          fee: [],
          memo: [],
          from_subaccount:  [],
          created_at_time:  [],
          amount: BigInt(3),
          expected_allowance:  [],
          expires_at:  [],
          spender: {
            owner: Principal.fromText('dxzul-dqaaa-aaaan-qmvuq-cai'),
            subaccount:  []
          }
        });

        console.log('approveResult:', approveResult);

        console.log('mtlk Matiki here 5f');
     
    ...

I’m logging in with Internet Identity
‘zgcr3-w3e7h-6okfu-e5dke-k66xm-kadri-lvnuw-kipko-mysl7-r6p53-xae’ is my principal in nns-app
But I get another principal here and my approve is not successful

App.vue:82 authClient.getIdentity(): Pu {_inner: Ms, _delegation: ks}
App.vue:87 Zalogowano Proxy(Yi) {_arr: Uint8Array(29), _isPrincipal: true}
App.vue:116 User data Proxy(Object) {nickname: 'qaqa', avatar_url: Array(0)}
App.vue:125 mtlk Matiki here 0
App.vue:128 identity: Proxy(Pu) {_inner: Ms, _delegation: ks, _principal: Yi}
App.vue:129 identity principal: Proxy(Yi) {_arr: Uint8Array(29), _isPrincipal: true}
App.vue:130 identity principal toText: 22hlo-vukku-nwwrv-hndxd-p6vwz-thrwl-udox2-gchyj-kbtpm-7watc-jqe
App.vue:132 mtlk Matiki here 2
App.vue:142 identity principal toText: 22hlo-vukku-nwwrv-hndxd-p6vwz-thrwl-udox2-gchyj-kbtpm-7watc-jqe
App.vue:144 mtlk Matiki here 2f
App.vue:145 identity: Proxy(Pu) {_inner: Ms, _delegation: ks, _principal: Yi}
App.vue:146 host: https://icp-api.io
App.vue:147 dfx netowrk: ic
App.vue:148 fetchRootKey: false
App.vue:150 canisterId: dxzul-dqaaa-aaaan-qmvuq-cai
App.vue:158 agent: yi {rootKey: ArrayBuffer(133), _timeDiffMsecs: 0, _rootKeyFetched: false, _isAgent: true, log: cm, …}
App.vue:159 mtlk Matiki here 3
App.vue:162 icrc1_ledger_canister_id : ryjl3-tyaaa-aaaaa-aaaba-cai
App.vue:167 mtlk Matiki here 4
App.vue:168 icrc1_ledger_canister: i {get_blocks: ƒ, get_data_certificate: ƒ, get_transactions: ƒ, icrc1_balance_of: ƒ, icrc1_decimals: ƒ, …}
App.vue:172 mtlk Matiki here 5
App.vue:176 icrc1_ledger_canister.icrc1_name(): Internet Computer
App.vue:186 identity_balance: 600250000n
App.vue:189 metadata: (4) [Array(2), Array(2), Array(2), Array(2)]0: (2) ['icrc1:decimals', {…}]1: (2) ['icrc1:name', {…}]2: (2) ['icrc1:symbol', {…}]3: (2) ['icrc1:fee', {…}]length: 4[[Prototype]]: Array(0)
App.vue:226 approveResult: {Err: {…}}Err: {InsufficientFunds: {…}}InsufficientFunds: {balance: 0n}[[Prototype]]: Object[[Prototype]]: Object
App.vue:228 mtlk Matiki here 5f

Matiki

For anonymity and to prevent tracking, II provides a different principal to the caller dApps per domain. If you sign in on domain A with identity 123, you get principal 456. Then, if you sign in on domain B with identity 123, you get principal 789. This includes localhost as a specific domain (including port).

OK, so how can I fix the mistake ?

Not sure what’s the mistake, nor exactly what you try to accomplish and what’s your full use case. Just sharing why you get a different principal.

OK , I see, thank you, Peter.

My use case is as follows:

Alice sends to Bob ICP tokens via an unattended transfer . The transfer is executed after a year.

Alice should allow the ICRC-2 approval - i.e. this approval should be executed by the caller for which the transfer will be ultimately executed, in that case Alice. So I don’t know where is Alice but, it’s where Alice is that this should happen. Hope that helps a bit.

Thank you for your response. I appreciate your assistance.

As mentioned, I have provided the code above in Bash, which is functioning correctly using DFX. I am unsure if I can more clearly describe in words what I have already demonstrated through the code utilizing ICRC2 and a timer. What I am kindly seeking is help in translating this exact functionality into the canister.

Thank you for your support.

Matiki

I cannot help more neither, just provided few hints. Hope someone else can.

Fair enough, Thank you, Peter.
Do you have somebody in particular in mind ?

Matiki

Not really. Wait a bit to see if someone responds here. If no one does, then you could try refining the scope of your question. Provide a shorter code snippet and clearly phrase your issue in another thread.