diff --git a/scripts/console.withdraw.mjs b/scripts/console.withdraw.mjs new file mode 100755 index 000000000..47515f933 --- /dev/null +++ b/scripts/console.withdraw.mjs @@ -0,0 +1,13 @@ +#!/usr/bin/env node + +import { consoleActorLocal } from './actor.mjs'; + +try { + const { withdraw_payments } = await consoleActorLocal(); + + await withdraw_payments(); + + console.log('✅ Payments successfully withdrawn.'); +} catch (error) { + console.error('❌ Payments cannot be withdrawn', error); +} diff --git a/src/console/console.did b/src/console/console.did index 53d49c3a4..a7a68b638 100644 --- a/src/console/console.did +++ b/src/console/console.did @@ -224,4 +224,5 @@ service : () -> { update_rate_config : (SegmentType, RateConfig) -> (); upload_asset_chunk : (UploadChunk) -> (UploadChunkResult); version : () -> (text) query; + withdraw_payments : () -> (nat64); } diff --git a/src/console/src/lib.rs b/src/console/src/lib.rs index fcfc2e584..f7e92a2bc 100644 --- a/src/console/src/lib.rs +++ b/src/console/src/lib.rs @@ -6,6 +6,7 @@ mod impls; mod memory; mod metadata; mod msg; +mod payments; mod proposals; mod storage; mod store; @@ -17,6 +18,7 @@ use crate::factory::orbiter::create_orbiter as create_orbiter_console; use crate::factory::satellite::create_satellite as create_satellite_console; use crate::guards::{caller_is_admin_controller, caller_is_observatory}; use crate::memory::{init_storage_heap_state, STATE}; +use crate::payments::payments::withdraw_balance; use crate::proposals::{ commit_proposal as make_commit_proposal, delete_proposal_assets as delete_proposal_assets_proposal, init_proposal as make_init_proposal, @@ -51,7 +53,7 @@ use ic_cdk::api::call::ManualReply; use ic_cdk::api::caller; use ic_cdk::{id, trap}; use ic_cdk_macros::{export_candid, init, post_upgrade, pre_upgrade, query, update}; -use ic_ledger_types::Tokens; +use ic_ledger_types::{BlockIndex, Tokens}; use junobuild_collections::types::core::CollectionKey; use junobuild_shared::controllers::init_controllers; use junobuild_shared::types::core::DomainName; @@ -171,6 +173,11 @@ fn list_payments() -> Payments { list_payments_state() } +#[update(guard = "caller_is_admin_controller")] +async fn withdraw_payments() -> BlockIndex { + withdraw_balance().await.unwrap_or_else(|e| trap(&e)) +} + /// Satellites #[update] diff --git a/src/console/src/payments/mod.rs b/src/console/src/payments/mod.rs new file mode 100644 index 000000000..ca98e4ba2 --- /dev/null +++ b/src/console/src/payments/mod.rs @@ -0,0 +1 @@ +pub mod payments; diff --git a/src/console/src/payments/payments.rs b/src/console/src/payments/payments.rs new file mode 100644 index 000000000..97f7498c6 --- /dev/null +++ b/src/console/src/payments/payments.rs @@ -0,0 +1,67 @@ +use candid::Principal; +use ic_cdk::id; +use ic_ledger_types::{ + account_balance, AccountBalanceArgs, AccountIdentifier, BlockIndex, Memo, Tokens, +}; +use junobuild_shared::constants::IC_TRANSACTION_FEE_ICP; +use junobuild_shared::env::LEDGER; +use junobuild_shared::ledger::{principal_to_account_identifier, transfer_token, SUB_ACCOUNT}; + +/// Withdraws the entire balance of the Console — i.e., withdraws the payments for the additional +/// Satellites and Orbiters that have been made. +/// +/// The destination account for the withdrawal is one of mine (David here). +/// +/// # Returns +/// - `Ok(BlockIndex)`: If the transfer was successful, it returns the block index of the transaction. +/// - `Err(String)`: If an error occurs during the process, it returns a descriptive error message. +/// +/// # Errors +/// This function can return errors in the following cases: +/// - If the account balance retrieval fails. +/// - If the transfer to the ledger fails due to insufficient balance or other issues. +/// +/// # Example +/// ```rust +/// let result = withdraw_balance().await; +/// match result { +/// Ok(block_index) => println!("Withdrawal successful! Block index: {}", block_index), +/// Err(e) => println!("Error during withdrawal: {}", e), +/// } +/// ``` +pub async fn withdraw_balance() -> Result { + let account_identifier: AccountIdentifier = AccountIdentifier::from_hex( + "e4aaed31b1cbf2dfaaca8ef9862a51b04fc4a314e2c054bae8f28d501c57068b", + )?; + + let balance = console_balance().await?; + + let block_index = transfer_token( + account_identifier, + Memo(0), + balance - IC_TRANSACTION_FEE_ICP, + IC_TRANSACTION_FEE_ICP, + ) + .await + .map_err(|e| format!("failed to call ledger: {:?}", e))? + .map_err(|e| format!("ledger transfer error {:?}", e))?; + + Ok(block_index) +} + +async fn console_balance() -> Result { + let ledger = Principal::from_text(LEDGER).unwrap(); + + let console_account_identifier: AccountIdentifier = + principal_to_account_identifier(&id(), &SUB_ACCOUNT); + + let args: AccountBalanceArgs = AccountBalanceArgs { + account: console_account_identifier, + }; + + let tokens = account_balance(ledger, args) + .await + .map_err(|e| format!("failed to call ledger balance: {:?}", e))?; + + Ok(tokens) +} diff --git a/src/declarations/console/console.did.d.ts b/src/declarations/console/console.did.d.ts index 47f50838f..ba64aeaa2 100644 --- a/src/declarations/console/console.did.d.ts +++ b/src/declarations/console/console.did.d.ts @@ -261,6 +261,7 @@ export interface _SERVICE { update_rate_config: ActorMethod<[SegmentType, RateConfig], undefined>; upload_asset_chunk: ActorMethod<[UploadChunk], UploadChunkResult>; version: ActorMethod<[], string>; + withdraw_payments: ActorMethod<[], bigint>; } export declare const idlFactory: IDL.InterfaceFactory; export declare const init: (args: { IDL: typeof IDL }) => IDL.Type[]; diff --git a/src/declarations/console/console.factory.did.js b/src/declarations/console/console.factory.did.js index ae0242f13..932f0f47d 100644 --- a/src/declarations/console/console.factory.did.js +++ b/src/declarations/console/console.factory.did.js @@ -275,7 +275,8 @@ export const idlFactory = ({ IDL }) => { submit_proposal: IDL.Func([IDL.Nat], [IDL.Nat, Proposal], []), update_rate_config: IDL.Func([SegmentType, RateConfig], [], []), upload_asset_chunk: IDL.Func([UploadChunk], [UploadChunkResult], []), - version: IDL.Func([], [IDL.Text], ['query']) + version: IDL.Func([], [IDL.Text], ['query']), + withdraw_payments: IDL.Func([], [IDL.Nat64], []) }); }; // @ts-ignore diff --git a/src/declarations/console/console.factory.did.mjs b/src/declarations/console/console.factory.did.mjs index ae0242f13..932f0f47d 100644 --- a/src/declarations/console/console.factory.did.mjs +++ b/src/declarations/console/console.factory.did.mjs @@ -275,7 +275,8 @@ export const idlFactory = ({ IDL }) => { submit_proposal: IDL.Func([IDL.Nat], [IDL.Nat, Proposal], []), update_rate_config: IDL.Func([SegmentType, RateConfig], [], []), upload_asset_chunk: IDL.Func([UploadChunk], [UploadChunkResult], []), - version: IDL.Func([], [IDL.Text], ['query']) + version: IDL.Func([], [IDL.Text], ['query']), + withdraw_payments: IDL.Func([], [IDL.Nat64], []) }); }; // @ts-ignore diff --git a/src/libs/shared/src/ledger.rs b/src/libs/shared/src/ledger.rs index 4166f63c9..d4b8e883f 100644 --- a/src/libs/shared/src/ledger.rs +++ b/src/libs/shared/src/ledger.rs @@ -46,13 +46,34 @@ pub async fn transfer_payment( memo: Memo, amount: Tokens, fee: Tokens, +) -> CallResult { + let account_identifier: AccountIdentifier = principal_to_account_identifier(to, to_sub_account); + + transfer_token(account_identifier, memo, amount, fee).await +} + +/// Transfers tokens to a specified account identified. +/// +/// # Arguments +/// * `account_identifier` - The account identifier of the destination. +/// * `memo` - A memo for the transaction. +/// * `amount` - The amount of tokens to transfer. +/// * `fee` - The transaction fee. +/// +/// # Returns +/// A result containing the transfer result or an error message. +pub async fn transfer_token( + account_identifier: AccountIdentifier, + memo: Memo, + amount: Tokens, + fee: Tokens, ) -> CallResult { let args = TransferArgs { memo, amount, fee, from_subaccount: Some(SUB_ACCOUNT), - to: principal_to_account_identifier(to, to_sub_account), + to: account_identifier, created_at_time: None, }; diff --git a/src/tests/console.spec.ts b/src/tests/console.spec.ts index dc35df4e7..1642e28c0 100644 --- a/src/tests/console.spec.ts +++ b/src/tests/console.spec.ts @@ -1,8 +1,10 @@ import type { _SERVICE as ConsoleActor } from '$declarations/console/console.did'; import { idlFactory as idlFactorConsole } from '$declarations/console/console.factory.did'; +import { AnonymousIdentity } from '@dfinity/agent'; import { Ed25519KeyIdentity } from '@dfinity/identity'; import { PocketIc, type Actor } from '@hadronous/pic'; import { afterEach, beforeEach, describe, expect, inject } from 'vitest'; +import { CONTROLLER_ERROR_MSG } from './constants/console-tests.constants'; import { deploySegments, initMissionControls } from './utils/console-tests.utils'; import { CONSOLE_WASM_PATH } from './utils/setup-tests.utils'; @@ -31,9 +33,49 @@ describe('Console', () => { await pic?.tearDown(); }); - it('should throw errors if too many users are created quickly', async () => { - await expect( - async () => await initMissionControls({ actor, pic, length: 2 }) - ).rejects.toThrowError(new RegExp('Rate limit reached, try again later', 'i')); + describe('owner', () => { + it('should throw errors if too many users are created quickly', async () => { + await expect( + async () => await initMissionControls({ actor, pic, length: 2 }) + ).rejects.toThrowError(new RegExp('Rate limit reached, try again later', 'i')); + }); + }); + + describe('anonymous', () => { + beforeEach(() => { + actor.setIdentity(new AnonymousIdentity()); + }); + + it('should throw errors on list payments', async () => { + const { list_payments } = actor; + + await expect(list_payments()).rejects.toThrow(CONTROLLER_ERROR_MSG); + }); + + it('should throw errors on withdraw payments', async () => { + const { withdraw_payments } = actor; + + await expect(withdraw_payments()).rejects.toThrow(CONTROLLER_ERROR_MSG); + }); + }); + + describe('random', () => { + const randomCaller = Ed25519KeyIdentity.generate(); + + beforeEach(() => { + actor.setIdentity(randomCaller); + }); + + it('should throw errors on list payments', async () => { + const { list_payments } = actor; + + await expect(list_payments()).rejects.toThrow(CONTROLLER_ERROR_MSG); + }); + + it('should throw errors on withdraw payments', async () => { + const { withdraw_payments } = actor; + + await expect(withdraw_payments()).rejects.toThrow(CONTROLLER_ERROR_MSG); + }); }); });