diff --git a/Cargo.lock b/Cargo.lock index 93f120902..d858c058d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,6 +53,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + [[package]] name = "base64" version = "0.13.1" @@ -546,11 +552,20 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] [[package]] name = "http" @@ -740,6 +755,26 @@ dependencies = [ "thiserror", ] +[[package]] +name = "icrc-ledger-types" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f7f6b54df25295dd0ce2722d583c15e2ee7eec9cef58c10b424feb54561b2" +dependencies = [ + "base32", + "candid", + "crc32fast", + "hex", + "itertools", + "num-bigint", + "num-traits", + "serde", + "serde_bytes", + "sha2", + "strum", + "time", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -774,6 +809,15 @@ dependencies = [ "regex", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.10" @@ -840,6 +884,7 @@ dependencies = [ "ic-cdk-macros", "ic-ledger-types", "ic-stable-structures", + "icrc-ledger-types", "regex", "serde", "time", @@ -909,6 +954,12 @@ version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + [[package]] name = "lock_api" version = "0.4.11" @@ -961,6 +1012,7 @@ dependencies = [ "ic-cdk", "ic-cdk-macros", "ic-ledger-types", + "icrc-ledger-types", "junobuild-shared", "serde", ] @@ -977,11 +1029,10 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.4" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ - "autocfg", "num-integer", "num-traits", "serde", @@ -1009,6 +1060,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1243,9 +1295,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.196" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" dependencies = [ "serde_derive", ] @@ -1271,9 +1323,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.196" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", @@ -1357,6 +1409,28 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.48", +] + [[package]] name = "syn" version = "1.0.109" @@ -1410,15 +1484,17 @@ dependencies = [ [[package]] name = "time" -version = "0.3.34" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde", "time-core", + "time-macros", ] [[package]] @@ -1427,6 +1503,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.0" diff --git a/Cargo.toml b/Cargo.toml index 5a9bed9f3..66210441f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ candid = "0.10.2" ic-cdk = "0.15.1" ic-cdk-macros = "0.15.0" ic-ledger-types = "0.12.0" +icrc-ledger-types = "0.1.6" ic-cdk-timers = "0.9.0" ic-stable-structures = "0.6.4" serde = "1.0.190" diff --git a/package-lock.json b/package-lock.json index de74aa481..795eefb01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@dfinity/cmc": "^3.1.0", "@dfinity/identity": "^1.4.0", "@dfinity/ledger-icp": "^2.4.0", + "@dfinity/ledger-icrc": "^2.4.0", "@dfinity/principal": "^1.4.0", "@dfinity/utils": "^2.4.0", "@junobuild/admin": "^0.0.55", @@ -30,7 +31,6 @@ "devDependencies": { "@dfinity/ic-management": "^5.1.0", "@dfinity/identity-secp256k1": "^1.4.0", - "@dfinity/ledger-icrc": "^2.4.0", "@esbuild-plugins/node-modules-polyfill": "^0.2.2", "@hadronous/pic": "^0.8.1", "@junobuild/cli-tools": "^0.0.14", @@ -863,7 +863,6 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/@dfinity/ledger-icrc/-/ledger-icrc-2.4.0.tgz", "integrity": "sha512-7NMFU3uoQ0QOarIzGEjmXkVEJZYSp9L4dj5ZR+TM5S0Dvd1TMmMb8GOW8TCN9WGDQUfuhd2Pe3l6dRykRq23gQ==", - "dev": true, "license": "Apache-2.0", "peerDependencies": { "@dfinity/agent": "^1.4.0", @@ -7843,7 +7842,6 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/@dfinity/ledger-icrc/-/ledger-icrc-2.4.0.tgz", "integrity": "sha512-7NMFU3uoQ0QOarIzGEjmXkVEJZYSp9L4dj5ZR+TM5S0Dvd1TMmMb8GOW8TCN9WGDQUfuhd2Pe3l6dRykRq23gQ==", - "dev": true, "requires": {} }, "@dfinity/principal": { diff --git a/package.json b/package.json index c38754e03..c18d6304e 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "devDependencies": { "@dfinity/ic-management": "^5.1.0", "@dfinity/identity-secp256k1": "^1.4.0", - "@dfinity/ledger-icrc": "^2.4.0", "@esbuild-plugins/node-modules-polyfill": "^0.2.2", "@hadronous/pic": "^0.8.1", "@junobuild/cli-tools": "^0.0.14", @@ -89,6 +88,7 @@ "@dfinity/cmc": "^3.1.0", "@dfinity/identity": "^1.4.0", "@dfinity/ledger-icp": "^2.4.0", + "@dfinity/ledger-icrc": "^2.4.0", "@dfinity/principal": "^1.4.0", "@dfinity/utils": "^2.4.0", "@junobuild/admin": "^0.0.55", diff --git a/src/declarations/mission_control/mission_control.did.d.ts b/src/declarations/mission_control/mission_control.did.d.ts index c2df47a9b..8b0e34827 100644 --- a/src/declarations/mission_control/mission_control.did.d.ts +++ b/src/declarations/mission_control/mission_control.did.d.ts @@ -2,6 +2,10 @@ import type { ActorMethod } from '@dfinity/agent'; import type { IDL } from '@dfinity/candid'; import type { Principal } from '@dfinity/principal'; +export interface Account { + owner: Principal; + subaccount: [] | [Uint8Array | number[]]; +} export type CanisterStatusType = { stopped: null } | { stopping: null } | { running: null }; export interface Controller { updated_at: bigint; @@ -25,7 +29,9 @@ export interface Orbiter { metadata: Array<[string, string]>; created_at: bigint; } -export type Result = { Ok: SegmentStatus } | { Err: string }; +export type Result = { Ok: bigint } | { Err: TransferError }; +export type Result_1 = { Ok: bigint } | { Err: TransferError_1 }; +export type Result_2 = { Ok: SegmentStatus } | { Err: string }; export interface Satellite { updated_at: bigint; metadata: Array<[string, string]>; @@ -53,9 +59,9 @@ export interface SegmentStatus { status_at: bigint; } export interface SegmentsStatuses { - orbiters: [] | [Array]; - satellites: [] | [Array]; - mission_control: Result; + orbiters: [] | [Array]; + satellites: [] | [Array]; + mission_control: Result_2; } export interface SetController { metadata: Array<[string, string]>; @@ -68,9 +74,47 @@ export interface StatusesArgs { satellites: Array<[Principal, CronJobStatusesConfig]>; cycles_threshold: [] | [bigint]; } +export interface Timestamp { + timestamp_nanos: bigint; +} export interface Tokens { e8s: bigint; } +export interface TransferArg { + to: Account; + fee: [] | [bigint]; + memo: [] | [Uint8Array | number[]]; + from_subaccount: [] | [Uint8Array | number[]]; + created_at_time: [] | [bigint]; + amount: bigint; +} +export interface TransferArgs { + to: Uint8Array | number[]; + fee: Tokens; + memo: bigint; + from_subaccount: [] | [Uint8Array | number[]]; + created_at_time: [] | [Timestamp]; + amount: Tokens; +} +export type TransferError = + | { + TxTooOld: { allowed_window_nanos: bigint }; + } + | { BadFee: { expected_fee: Tokens } } + | { TxDuplicate: { duplicate_of: bigint } } + | { TxCreatedInFuture: null } + | { InsufficientFunds: { balance: Tokens } }; +export type TransferError_1 = + | { + GenericError: { message: string; error_code: bigint }; + } + | { TemporarilyUnavailable: null } + | { BadBurn: { min_burn_amount: bigint } } + | { Duplicate: { duplicate_of: bigint } } + | { BadFee: { expected_fee: bigint } } + | { CreatedInFuture: { ledger_time: bigint } } + | { TooOld: null } + | { InsufficientFunds: { balance: bigint } }; export interface _SERVICE { add_mission_control_controllers: ActorMethod<[Array], undefined>; add_satellites_controllers: ActorMethod<[Array, Array], undefined>; @@ -83,11 +127,13 @@ export interface _SERVICE { del_satellites_controllers: ActorMethod<[Array, Array], undefined>; deposit_cycles: ActorMethod<[DepositCyclesArgs], undefined>; get_user: ActorMethod<[], Principal>; + icp_transfer: ActorMethod<[TransferArgs], Result>; + icrc_transfer: ActorMethod<[Principal, TransferArg], Result_1>; list_mission_control_controllers: ActorMethod<[], Array<[Principal, Controller]>>; - list_mission_control_statuses: ActorMethod<[], Array<[bigint, Result]>>; - list_orbiter_statuses: ActorMethod<[Principal], [] | [Array<[bigint, Result]>]>; + list_mission_control_statuses: ActorMethod<[], Array<[bigint, Result_2]>>; + list_orbiter_statuses: ActorMethod<[Principal], [] | [Array<[bigint, Result_2]>]>; list_orbiters: ActorMethod<[], Array<[Principal, Orbiter]>>; - list_satellite_statuses: ActorMethod<[Principal], [] | [Array<[bigint, Result]>]>; + list_satellite_statuses: ActorMethod<[Principal], [] | [Array<[bigint, Result_2]>]>; list_satellites: ActorMethod<[], Array<[Principal, Satellite]>>; remove_mission_control_controllers: ActorMethod<[Array], undefined>; remove_satellites_controllers: ActorMethod<[Array, Array], undefined>; diff --git a/src/declarations/mission_control/mission_control.factory.did.js b/src/declarations/mission_control/mission_control.factory.did.js index 1253f6d19..a6c166f4c 100644 --- a/src/declarations/mission_control/mission_control.factory.did.js +++ b/src/declarations/mission_control/mission_control.factory.did.js @@ -16,6 +16,50 @@ export const idlFactory = ({ IDL }) => { cycles: IDL.Nat, destination_id: IDL.Principal }); + const Tokens = IDL.Record({ e8s: IDL.Nat64 }); + const Timestamp = IDL.Record({ timestamp_nanos: IDL.Nat64 }); + const TransferArgs = IDL.Record({ + to: IDL.Vec(IDL.Nat8), + fee: Tokens, + memo: IDL.Nat64, + from_subaccount: IDL.Opt(IDL.Vec(IDL.Nat8)), + created_at_time: IDL.Opt(Timestamp), + amount: Tokens + }); + const TransferError = IDL.Variant({ + TxTooOld: IDL.Record({ allowed_window_nanos: IDL.Nat64 }), + BadFee: IDL.Record({ expected_fee: Tokens }), + TxDuplicate: IDL.Record({ duplicate_of: IDL.Nat64 }), + TxCreatedInFuture: IDL.Null, + InsufficientFunds: IDL.Record({ balance: Tokens }) + }); + const Result = IDL.Variant({ Ok: IDL.Nat64, Err: TransferError }); + const Account = IDL.Record({ + owner: IDL.Principal, + subaccount: IDL.Opt(IDL.Vec(IDL.Nat8)) + }); + const TransferArg = IDL.Record({ + to: Account, + fee: IDL.Opt(IDL.Nat), + memo: IDL.Opt(IDL.Vec(IDL.Nat8)), + from_subaccount: IDL.Opt(IDL.Vec(IDL.Nat8)), + created_at_time: IDL.Opt(IDL.Nat64), + amount: IDL.Nat + }); + const TransferError_1 = IDL.Variant({ + GenericError: IDL.Record({ + message: IDL.Text, + error_code: IDL.Nat + }), + TemporarilyUnavailable: IDL.Null, + BadBurn: IDL.Record({ min_burn_amount: IDL.Nat }), + Duplicate: IDL.Record({ duplicate_of: IDL.Nat }), + BadFee: IDL.Record({ expected_fee: IDL.Nat }), + CreatedInFuture: IDL.Record({ ledger_time: IDL.Nat64 }), + TooOld: IDL.Null, + InsufficientFunds: IDL.Record({ balance: IDL.Nat }) + }); + const Result_1 = IDL.Variant({ Ok: IDL.Nat, Err: TransferError_1 }); const ControllerScope = IDL.Variant({ Write: IDL.Null, Admin: IDL.Null @@ -52,7 +96,7 @@ export const idlFactory = ({ IDL }) => { metadata: IDL.Opt(IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text))), status_at: IDL.Nat64 }); - const Result = IDL.Variant({ Ok: SegmentStatus, Err: IDL.Text }); + const Result_2 = IDL.Variant({ Ok: SegmentStatus, Err: IDL.Text }); const SetController = IDL.Record({ metadata: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), scope: ControllerScope, @@ -69,11 +113,10 @@ export const idlFactory = ({ IDL }) => { cycles_threshold: IDL.Opt(IDL.Nat64) }); const SegmentsStatuses = IDL.Record({ - orbiters: IDL.Opt(IDL.Vec(Result)), - satellites: IDL.Opt(IDL.Vec(Result)), - mission_control: Result + orbiters: IDL.Opt(IDL.Vec(Result_2)), + satellites: IDL.Opt(IDL.Vec(Result_2)), + mission_control: Result_2 }); - const Tokens = IDL.Record({ e8s: IDL.Nat64 }); return IDL.Service({ add_mission_control_controllers: IDL.Func([IDL.Vec(IDL.Principal)], [], []), add_satellites_controllers: IDL.Func([IDL.Vec(IDL.Principal), IDL.Vec(IDL.Principal)], [], []), @@ -86,21 +129,27 @@ export const idlFactory = ({ IDL }) => { del_satellites_controllers: IDL.Func([IDL.Vec(IDL.Principal), IDL.Vec(IDL.Principal)], [], []), deposit_cycles: IDL.Func([DepositCyclesArgs], [], []), get_user: IDL.Func([], [IDL.Principal], ['query']), + icp_transfer: IDL.Func([TransferArgs], [Result], []), + icrc_transfer: IDL.Func([IDL.Principal, TransferArg], [Result_1], []), list_mission_control_controllers: IDL.Func( [], [IDL.Vec(IDL.Tuple(IDL.Principal, Controller))], ['query'] ), - list_mission_control_statuses: IDL.Func([], [IDL.Vec(IDL.Tuple(IDL.Nat64, Result))], ['query']), + list_mission_control_statuses: IDL.Func( + [], + [IDL.Vec(IDL.Tuple(IDL.Nat64, Result_2))], + ['query'] + ), list_orbiter_statuses: IDL.Func( [IDL.Principal], - [IDL.Opt(IDL.Vec(IDL.Tuple(IDL.Nat64, Result)))], + [IDL.Opt(IDL.Vec(IDL.Tuple(IDL.Nat64, Result_2)))], ['query'] ), list_orbiters: IDL.Func([], [IDL.Vec(IDL.Tuple(IDL.Principal, Orbiter))], ['query']), list_satellite_statuses: IDL.Func( [IDL.Principal], - [IDL.Opt(IDL.Vec(IDL.Tuple(IDL.Nat64, Result)))], + [IDL.Opt(IDL.Vec(IDL.Tuple(IDL.Nat64, Result_2)))], ['query'] ), list_satellites: IDL.Func([], [IDL.Vec(IDL.Tuple(IDL.Principal, Satellite))], ['query']), diff --git a/src/frontend/src/app.d.ts b/src/frontend/src/app.d.ts index 87a8d08f2..b0c1a374b 100644 --- a/src/frontend/src/app.d.ts +++ b/src/frontend/src/app.d.ts @@ -22,6 +22,7 @@ declare namespace svelteHTML { 'on:junoReloadVersions'?: (event: CustomEvent) => void; 'on:junoCloseActions'?: (event: CustomEvent) => void; 'on:junoRegistrationState'?: (event: CustomEvent) => void; + 'on:junoSyncBalance'?: (event: CustomEvent) => void; } } diff --git a/src/frontend/src/lib/api/ledger.api.ts b/src/frontend/src/lib/api/icp-index.api.ts similarity index 100% rename from src/frontend/src/lib/api/ledger.api.ts rename to src/frontend/src/lib/api/icp-index.api.ts diff --git a/src/frontend/src/lib/api/mission-control.api.ts b/src/frontend/src/lib/api/mission-control.api.ts index a2c798d80..19f005dba 100644 --- a/src/frontend/src/lib/api/mission-control.api.ts +++ b/src/frontend/src/lib/api/mission-control.api.ts @@ -2,7 +2,11 @@ import type { Controller, Orbiter, Result, - Satellite + Result_1, + Result_2, + Satellite, + TransferArg, + TransferArgs } from '$declarations/mission_control/mission_control.did'; import type { SetControllerParams } from '$lib/types/controllers'; import type { OptionIdentity } from '$lib/types/itentity'; @@ -361,7 +365,7 @@ export const listSatelliteStatuses = async ({ missionControlId: Principal; identity: OptionIdentity; satelliteId: Principal; -}): Promise<[] | [[bigint, Result][]]> => { +}): Promise<[] | [[bigint, Result_2][]]> => { const { list_satellite_statuses } = await getMissionControlActor({ missionControlId, identity }); return list_satellite_statuses(satelliteId); }; @@ -374,7 +378,7 @@ export const listOrbiterStatuses = async ({ missionControlId: Principal; identity: OptionIdentity; orbiterId: Principal; -}): Promise<[] | [[bigint, Result][]]> => { +}): Promise<[] | [[bigint, Result_2][]]> => { const { list_orbiter_statuses } = await getMissionControlActor({ missionControlId, identity }); return list_orbiter_statuses(orbiterId); }; @@ -385,10 +389,38 @@ export const listMissionControlStatuses = async ({ }: { missionControlId: Principal; identity: OptionIdentity; -}): Promise<[] | [[bigint, Result][]]> => { +}): Promise<[] | [[bigint, Result_2][]]> => { const { list_mission_control_statuses } = await getMissionControlActor({ missionControlId, identity }); return [await list_mission_control_statuses()]; }; + +export const icpTransfer = async ({ + missionControlId, + args, + identity +}: { + missionControlId: Principal; + args: TransferArgs; + identity: OptionIdentity; +}): Promise => { + const { icp_transfer } = await getMissionControlActor({ missionControlId, identity }); + return icp_transfer(args); +}; + +export const icrcTransfer = async ({ + ledgerId, + missionControlId, + args, + identity +}: { + ledgerId: Principal; + missionControlId: Principal; + args: TransferArg; + identity: OptionIdentity; +}): Promise => { + const { icrc_transfer } = await getMissionControlActor({ missionControlId, identity }); + return icrc_transfer(ledgerId, args); +}; diff --git a/src/frontend/src/lib/components/core/Wallet.svelte b/src/frontend/src/lib/components/core/Wallet.svelte index c42dd1a0a..0fe765509 100644 --- a/src/frontend/src/lib/components/core/Wallet.svelte +++ b/src/frontend/src/lib/components/core/Wallet.svelte @@ -6,6 +6,7 @@ import type { WalletWorker } from '$lib/services/worker.wallet.services'; import type { Principal } from '@dfinity/principal'; import { onDestroy, onMount } from 'svelte'; + import { emit } from '$lib/utils/events.utils'; export let missionControlId: Principal; export let balance: bigint | undefined = undefined; @@ -20,6 +21,11 @@ balance = data.wallet.balance; transactions = [...JSON.parse(data.wallet.newTransactions, jsonReviver), ...transactions]; + + emit({ + message: 'junoSyncBalance', + detail: balance + }); }; const initWorker = async () => { diff --git a/src/frontend/src/lib/components/mission-control/MissionControl.svelte b/src/frontend/src/lib/components/mission-control/MissionControl.svelte index 3ada98be1..8216aada7 100644 --- a/src/frontend/src/lib/components/mission-control/MissionControl.svelte +++ b/src/frontend/src/lib/components/mission-control/MissionControl.svelte @@ -20,11 +20,7 @@
{$i18n.mission_control.id} - +
diff --git a/src/frontend/src/lib/components/mission-control/MissionControlWallet.svelte b/src/frontend/src/lib/components/mission-control/MissionControlWallet.svelte index 5f3fbff45..c57fc8126 100644 --- a/src/frontend/src/lib/components/mission-control/MissionControlWallet.svelte +++ b/src/frontend/src/lib/components/mission-control/MissionControlWallet.svelte @@ -9,7 +9,7 @@ import Identifier from '$lib/components/ui/Identifier.svelte'; import QRCodeContainer from '$lib/components/ui/QRCodeContainer.svelte'; import { onMount } from 'svelte'; - import { getAccountIdentifier, getTransactions } from '$lib/api/ledger.api'; + import { getAccountIdentifier, getTransactions } from '$lib/api/icp-index.api'; import { getCredits } from '$lib/api/console.api'; import { toasts } from '$lib/stores/toasts.store'; import Transactions from '$lib/components/transactions/Transactions.svelte'; @@ -18,6 +18,9 @@ import { fade } from 'svelte/transition'; import type { TransactionWithId } from '@dfinity/ledger-icp'; import Wallet from '$lib/components/core/Wallet.svelte'; + import { emit } from '$lib/utils/events.utils'; + import { versionStore } from '$lib/stores/version.store'; + import { compare } from 'semver'; export let missionControlId: Principal; @@ -95,6 +98,28 @@ */ onMount(async () => await loadCredits()); + + /** + * Actions + */ + + const openModal = () => { + emit({ + message: 'junoModal', + detail: { + type: 'send_tokens', + detail: { + balance + } + } + }); + }; + + let send = false; + $: send = + nonNullish(balance) && + balance > 0n && + compare($versionStore?.missionControl?.current ?? '0.0.0', '0.0.12') > 0; {#if $authSignedInStore} @@ -104,6 +129,13 @@
+ + {$i18n.wallet.wallet_id} +

+ +

+
+ {$i18n.wallet.account_identifier}

@@ -139,6 +171,10 @@

+ {#if send} + + {/if} + {/if} + +{#if modal?.type === 'send_tokens' && nonNullish(modal.detail)} + +{/if} diff --git a/src/frontend/src/lib/components/modals/SendTokensModal.svelte b/src/frontend/src/lib/components/modals/SendTokensModal.svelte new file mode 100644 index 000000000..e500185aa --- /dev/null +++ b/src/frontend/src/lib/components/modals/SendTokensModal.svelte @@ -0,0 +1,87 @@ + + + (balance = syncBalance)} /> + +{#if nonNullish($missionControlStore)} + + {#if steps === 'ready'} + + +
+

{$i18n.wallet.icp_on_its_way}

+ +
+ {:else if steps === 'in_progress'} + +

{$i18n.wallet.sending_in_progress}

+
+ {:else if steps === 'review'} +
+ (steps = detail)} + /> +
+ {:else} + (steps = 'review')} + /> + {/if} +
+{/if} + + diff --git a/src/frontend/src/lib/components/tokens/SendTokensForm.svelte b/src/frontend/src/lib/components/tokens/SendTokensForm.svelte new file mode 100644 index 000000000..305ad9aaa --- /dev/null +++ b/src/frontend/src/lib/components/tokens/SendTokensForm.svelte @@ -0,0 +1,114 @@ + + +

{$i18n.wallet.send}

+ +

+ {@html i18nFormat($i18n.wallet.send_information, [ + { + placeholder: '{0}', + value: formatE8sICP(balance ?? 0n) + } + ])} +

+ +
+
+ + {$i18n.wallet.destination} + + +
+ +
+ + {$i18n.wallet.icp_amount} + + +
+ + +
+ + diff --git a/src/frontend/src/lib/components/tokens/SendTokensReview.svelte b/src/frontend/src/lib/components/tokens/SendTokensReview.svelte new file mode 100644 index 000000000..c7fa4cc10 --- /dev/null +++ b/src/frontend/src/lib/components/tokens/SendTokensReview.svelte @@ -0,0 +1,150 @@ + + +

{$i18n.wallet.send}

+ +

{$i18n.wallet.review_and_confirm}

+ +
+
+
+ {$i18n.wallet.tx_from} + +
+ + {$i18n.wallet.wallet_id} +

+ +

+
+ + + {$i18n.wallet.account_identifier} +

+ +

+
+ + + {$i18n.wallet.balance} +

+ {#if nonNullish(balance)}{formatE8sICP(balance)} ICP{/if} +

+
+
+
+ +
+ {$i18n.wallet.tx_to} + +
+ + {$i18n.wallet.destination} +

+ +

+
+
+
+ +
+ {$i18n.wallet.sending} + +
+ + {$i18n.wallet.tx_amount} +

+ {#if nonNullish(token)}{formatE8sICP(token.toE8s())} ICP{/if} +

+
+ + + {$i18n.wallet.fee} +

+ {formatE8sICP(IC_TRANSACTION_FEE_ICP)} ICP +

+
+
+
+
+ +
+ + +
+
+ + diff --git a/src/frontend/src/lib/components/ui/Identifier.svelte b/src/frontend/src/lib/components/ui/Identifier.svelte index c13c70f07..e7d8e3f21 100644 --- a/src/frontend/src/lib/components/ui/Identifier.svelte +++ b/src/frontend/src/lib/components/ui/Identifier.svelte @@ -11,7 +11,7 @@

- {shortIdentifier} + {shortIdentifier}

@@ -22,7 +22,9 @@ word-break: break-all; @include text.truncate; - margin: 0 0 var(--padding-0_5x); + &.small { + margin: 0 0 var(--padding-0_5x); + } } p { diff --git a/src/frontend/src/lib/components/ui/Input.svelte b/src/frontend/src/lib/components/ui/Input.svelte index d5a07e958..9ffdedb05 100644 --- a/src/frontend/src/lib/components/ui/Input.svelte +++ b/src/frontend/src/lib/components/ui/Input.svelte @@ -1,15 +1,22 @@ diff --git a/src/frontend/src/lib/constants/constants.ts b/src/frontend/src/lib/constants/constants.ts index 636b19d30..ddba449b9 100644 --- a/src/frontend/src/lib/constants/constants.ts +++ b/src/frontend/src/lib/constants/constants.ts @@ -35,6 +35,7 @@ export const INTERNET_IDENTITY_CANISTER_ID = 'rdmx6-jaaaa-aaaaa-aaadq-cai'; export const CMC_CANISTER_ID = 'rkp4c-7iaaa-aaaaa-aaaca-cai'; export const CONSOLE_CANISTER_ID = 'cokmz-oiaaa-aaaal-aby6q-cai'; export const OBSERVATORY_CANISTER_ID = 'klbfr-lqaaa-aaaak-qbwsa-cai'; +export const ICP_LEDGER_CANISTER_ID = 'ryjl3-tyaaa-aaaaa-aaaba-cai'; /** * Revoked principals that must not be used. diff --git a/src/frontend/src/lib/i18n/en.json b/src/frontend/src/lib/i18n/en.json index c848af19f..3e7d38608 100644 --- a/src/frontend/src/lib/i18n/en.json +++ b/src/frontend/src/lib/i18n/en.json @@ -43,7 +43,9 @@ "refresh": "Refresh", "reload": "Reload", "open_website": "Open Juno website and documentation", - "file": "File" + "file": "File", + "review": "Review", + "confirm": "Confirm" }, "canisters": { "insight": "Insight", @@ -201,7 +203,19 @@ "memo_received": "Received", "memo_sent": "Sent", "export_title": "Export to CSV", - "export_info": "This process exports the displayed transactions to a CSV file. Shall we proceed?" + "export_info": "This process exports the displayed transactions to a CSV file. Shall we proceed?", + "send": "Send", + "send_information": "Send tokens securely to any address. Your wallet currently holds {0} ICP.", + "destination": "Destination", + "destination_placeholder": "Enter destination address", + "icp_amount": "Amount (ICP)", + "amount_placeholder": "Enter an amount", + "wallet_id": "Wallet ID", + "sending": "Sending", + "sending_in_progress": "Sending...", + "fee": "Fee", + "review_and_confirm": "Review the details below and confirm to proceed with your transaction.", + "icp_on_its_way": "Your ICP is on its way!" }, "authentication": { "title": "Authentication", @@ -411,7 +425,12 @@ "no_file_selected_for_upload": "No file was selected for the upload.", "upload_error": "Unexpected error(s) while uploading the file.", "no_collection_for_upload": "Unexpected error. No collection is selected for the upload.", - "invalid_email": "Please enter a valid email address." + "invalid_email": "Please enter a valid email address.", + "invalid_destination": "Please enter a valid ICP or ICRC destination address.", + "empty_amount": "Please enter an amount for the transfer.", + "invalid_amount": "The amount should be greater than zero and less than your balance minus the fee.", + "empty_balance": "Your wallet does not have any available funds.", + "sending_error": "Unexpected error(s) while sending the tokens." }, "document": { "owner": "Owner", diff --git a/src/frontend/src/lib/services/balance.services.ts b/src/frontend/src/lib/services/balance.services.ts index a9a0e6ad7..b167e991e 100644 --- a/src/frontend/src/lib/services/balance.services.ts +++ b/src/frontend/src/lib/services/balance.services.ts @@ -1,5 +1,5 @@ import { getCredits } from '$lib/api/console.api'; -import { getAccountIdentifier, getBalance } from '$lib/api/ledger.api'; +import { getAccountIdentifier, getBalance } from '$lib/api/icp-index.api'; import { authStore } from '$lib/stores/auth.store'; import { i18n } from '$lib/stores/i18n.store'; import { toasts } from '$lib/stores/toasts.store'; diff --git a/src/frontend/src/lib/services/tokens.services.ts b/src/frontend/src/lib/services/tokens.services.ts new file mode 100644 index 000000000..06efbeb4a --- /dev/null +++ b/src/frontend/src/lib/services/tokens.services.ts @@ -0,0 +1,125 @@ +import type { TransferArg, TransferArgs } from '$declarations/mission_control/mission_control.did'; +import { icpTransfer, icrcTransfer } from '$lib/api/mission-control.api'; +import { ICP_LEDGER_CANISTER_ID, IC_TRANSACTION_FEE_ICP } from '$lib/constants/constants'; +import { i18n } from '$lib/stores/i18n.store'; +import { toasts } from '$lib/stores/toasts.store'; +import { nowInBigIntNanoSeconds } from '$lib/utils/date.utils'; +import { invalidIcpAddress } from '$lib/utils/icp-account.utils'; +import { invalidIcrcAddress } from '$lib/utils/icrc-account.utils'; +import type { Identity } from '@dfinity/agent'; +import { AccountIdentifier } from '@dfinity/ledger-icp'; +import { decodeIcrcAccount } from '@dfinity/ledger-icrc'; +import { Principal } from '@dfinity/principal'; +import { isNullish, toNullable, type TokenAmountV2 } from '@dfinity/utils'; +import { get } from 'svelte/store'; + +export const sendTokens = async ({ + destination, + token, + identity, + missionControlId +}: { + destination: string; + token: TokenAmountV2 | undefined; + identity: Identity | undefined | null; + missionControlId: Principal; +}): Promise<{ success: boolean }> => { + const notIcp = invalidIcpAddress(destination); + const notIcrc = invalidIcrcAddress(destination); + + if (notIcp && notIcrc) { + const labels = get(i18n); + + toasts.error({ + text: labels.errors.invalid_destination + }); + return { success: false }; + } + + if (isNullish(token)) { + const labels = get(i18n); + + toasts.error({ + text: labels.errors.empty_amount + }); + return { success: false }; + } + + try { + const fn = !invalidIcpAddress(destination) ? sendIcp : sendIcrc; + + await fn({ + destination, + token, + identity, + missionControlId + }); + + return { success: true }; + } catch (err: unknown) { + const labels = get(i18n); + + toasts.error({ + text: labels.errors.sending_error, + detail: err + }); + + return { success: false }; + } +}; + +export const sendIcrc = async ({ + destination, + token, + ...rest +}: { + destination: string; + token: TokenAmountV2; + identity: Identity | undefined | null; + missionControlId: Principal; +}): Promise => { + const { owner, subaccount } = decodeIcrcAccount(destination); + + const args: TransferArg = { + to: { + owner, + subaccount: toNullable(subaccount) + }, + amount: token.toE8s(), + fee: toNullable(IC_TRANSACTION_FEE_ICP), + memo: toNullable(), + from_subaccount: toNullable(), + created_at_time: toNullable(nowInBigIntNanoSeconds()) + }; + + await icrcTransfer({ + args, + ledgerId: Principal.fromText(ICP_LEDGER_CANISTER_ID), + ...rest + }); +}; + +export const sendIcp = async ({ + destination, + token, + ...rest +}: { + destination: string; + token: TokenAmountV2; + identity: Identity | undefined | null; + missionControlId: Principal; +}): Promise => { + const args: TransferArgs = { + to: AccountIdentifier.fromHex(destination).toUint8Array(), + amount: { e8s: token.toE8s() }, + fee: { e8s: IC_TRANSACTION_FEE_ICP }, + memo: 0n, + created_at_time: toNullable({ timestamp_nanos: nowInBigIntNanoSeconds() }), + from_subaccount: toNullable() + }; + + await icpTransfer({ + args, + ...rest + }); +}; diff --git a/src/frontend/src/lib/types/i18n.d.ts b/src/frontend/src/lib/types/i18n.d.ts index 7624f4f5b..b3618e2ce 100644 --- a/src/frontend/src/lib/types/i18n.d.ts +++ b/src/frontend/src/lib/types/i18n.d.ts @@ -47,6 +47,8 @@ interface I18nCore { reload: string; open_website: string; file: string; + review: string; + confirm: string; } interface I18nCanisters { @@ -210,6 +212,18 @@ interface I18nWallet { memo_sent: string; export_title: string; export_info: string; + send: string; + send_information: string; + destination: string; + destination_placeholder: string; + icp_amount: string; + amount_placeholder: string; + wallet_id: string; + sending: string; + sending_in_progress: string; + fee: string; + review_and_confirm: string; + icp_on_its_way: string; } interface I18nAuthentication { @@ -428,6 +442,11 @@ interface I18nErrors { upload_error: string; no_collection_for_upload: string; invalid_email: string; + invalid_destination: string; + empty_amount: string; + invalid_amount: string; + empty_balance: string; + sending_error: string; } interface I18nDocument { diff --git a/src/frontend/src/lib/types/modal.ts b/src/frontend/src/lib/types/modal.ts index da819fca8..dcdbea21e 100644 --- a/src/frontend/src/lib/types/modal.ts +++ b/src/frontend/src/lib/types/modal.ts @@ -70,6 +70,10 @@ export interface JunoModalEditCanisterSettingsDetail { settings: CanisterSettings; } +export interface JunoModalSendTokensDetail { + balance: bigint | undefined; +} + export type JunoModalDetail = | JunoModalTopUpSatelliteDetail | JunoModalTopUpMissionControlDetail @@ -77,7 +81,8 @@ export type JunoModalDetail = | JunoModalCustomDomainDetail | JunoModalCreateControllerDetail | JunoModalCyclesSatelliteDetail - | JunoModalDeleteSatelliteDetail; + | JunoModalDeleteSatelliteDetail + | JunoModalSendTokensDetail; export interface JunoModal { type: @@ -96,6 +101,7 @@ export interface JunoModal { | 'edit_canister_settings' | 'upgrade_satellite' | 'upgrade_mission_control' - | 'upgrade_orbiter'; + | 'upgrade_orbiter' + | 'send_tokens'; detail?: JunoModalDetail; } diff --git a/src/frontend/src/lib/utils/date.utils.ts b/src/frontend/src/lib/utils/date.utils.ts index e790259ac..f8a1b08de 100644 --- a/src/frontend/src/lib/utils/date.utils.ts +++ b/src/frontend/src/lib/utils/date.utils.ts @@ -1,3 +1,5 @@ +export const nowInBigIntNanoSeconds = (): bigint => BigInt(Date.now()) * BigInt(1e6); + export const formatToDate = (nanoseconds: bigint): string => { const options: Intl.DateTimeFormatOptions = { month: 'short', diff --git a/src/frontend/src/lib/utils/icp-account.utils.ts b/src/frontend/src/lib/utils/icp-account.utils.ts new file mode 100644 index 000000000..30fc8d1bc --- /dev/null +++ b/src/frontend/src/lib/utils/icp-account.utils.ts @@ -0,0 +1,20 @@ +import { checkAccountId } from '@dfinity/ledger-icp'; +import { isNullish } from '@dfinity/utils'; + +export const isIcpAccountIdentifier = (address: string | undefined): boolean => { + if (isNullish(address)) { + return false; + } + + try { + checkAccountId(address); + return true; + } catch (_: unknown) { + // We do not parse the error + } + + return false; +}; + +export const invalidIcpAddress = (address: string | undefined): boolean => + !isIcpAccountIdentifier(address); diff --git a/src/frontend/src/lib/utils/icrc-account.utils.ts b/src/frontend/src/lib/utils/icrc-account.utils.ts new file mode 100644 index 000000000..f9392c8d5 --- /dev/null +++ b/src/frontend/src/lib/utils/icrc-account.utils.ts @@ -0,0 +1,22 @@ +import { decodeIcrcAccount, type IcrcAccount } from '@dfinity/ledger-icrc'; +import type { Principal } from '@dfinity/principal'; +import { isNullish } from '@dfinity/utils'; + +export const getIcrcAccount = (principal: Principal): IcrcAccount => ({ owner: principal }); + +export const isIcrcAddress = (address: string | undefined): boolean => { + if (isNullish(address)) { + return false; + } + + try { + decodeIcrcAccount(address); + return true; + } catch (_: unknown) { + // We do not parse the error + } + + return false; +}; + +export const invalidIcrcAddress = (address: string | undefined): boolean => !isIcrcAddress(address); diff --git a/src/frontend/src/lib/utils/token.utils.ts b/src/frontend/src/lib/utils/token.utils.ts new file mode 100644 index 000000000..9136454e5 --- /dev/null +++ b/src/frontend/src/lib/utils/token.utils.ts @@ -0,0 +1,16 @@ +import { FromStringToTokenError, ICPToken, isNullish, TokenAmountV2 } from '@dfinity/utils'; + +export const amountToICPToken = (amount: string | undefined): TokenAmountV2 | undefined => { + // For convenience reason the function accept undefined + if (isNullish(amount)) { + return undefined; + } + + const token = TokenAmountV2.fromString({ token: ICPToken, amount }); + + if (Object.values(FromStringToTokenError).includes(token as string | FromStringToTokenError)) { + return undefined; + } + + return token; +}; diff --git a/src/frontend/src/lib/workers/wallet.worker.ts b/src/frontend/src/lib/workers/wallet.worker.ts index 54776c623..1aaa06c0f 100644 --- a/src/frontend/src/lib/workers/wallet.worker.ts +++ b/src/frontend/src/lib/workers/wallet.worker.ts @@ -1,4 +1,4 @@ -import { getTransactions } from '$lib/api/ledger.api'; +import { getTransactions } from '$lib/api/icp-index.api'; import { PAGINATION, SYNC_WALLET_TIMER_INTERVAL } from '$lib/constants/constants'; import type { PostMessage, PostMessageDataRequest } from '$lib/types/post-message'; import { loadIdentity } from '$lib/utils/worker.utils'; diff --git a/src/frontend/src/routes/+layout.ts b/src/frontend/src/routes/+layout.ts index 1e62f0b8a..8c718d6e4 100644 --- a/src/frontend/src/routes/+layout.ts +++ b/src/frontend/src/routes/+layout.ts @@ -1,3 +1,11 @@ export const prerender = true; export const ssr = false; export const trailingSlash = 'always'; + +// ⚠️ For production build the polyfill needs to be injected with Rollup (see vite.config.ts) because the page might be loaded before the _layout.js which will contains this polyfill. +// The / in buffer/ is mandatory here. +// More workaround: https://github.com/vitejs/vite/discussions/2785 +import { Buffer } from 'buffer/'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore Polyfill Buffer for development purpose +globalThis.Buffer = Buffer; diff --git a/src/libs/shared/Cargo.toml b/src/libs/shared/Cargo.toml index b58f23036..921c101bd 100644 --- a/src/libs/shared/Cargo.toml +++ b/src/libs/shared/Cargo.toml @@ -18,6 +18,7 @@ candid.workspace = true ic-cdk.workspace = true ic-cdk-macros.workspace = true ic-ledger-types.workspace = true +icrc-ledger-types.workspace = true ic-stable-structures.workspace = true serde.workspace = true futures = "0.3.28" diff --git a/src/libs/shared/src/ledger/icp.rs b/src/libs/shared/src/ledger/icp.rs index 7b406f94a..da7e53edf 100644 --- a/src/libs/shared/src/ledger/icp.rs +++ b/src/libs/shared/src/ledger/icp.rs @@ -47,15 +47,28 @@ pub async fn transfer_payment( amount: Tokens, fee: Tokens, ) -> CallResult { + let account_identifier: AccountIdentifier = principal_to_account_identifier(to, to_sub_account); + 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, }; + transfer_token(args).await +} + +/// Initiates a transfer of ICP tokens using the provided arguments and "old" ICP account identifier. +/// +/// # Arguments +/// * `args` - A `TransferArgs` struct containing the details of the ICP transfer. +/// +/// # Returns +/// A `CallResult` indicating either the success or failure of the ICP token transfer. +pub async fn transfer_token(args: TransferArgs) -> CallResult { let ledger = Principal::from_text(LEDGER).unwrap(); transfer(ledger, args).await diff --git a/src/libs/shared/src/ledger/icrc.rs b/src/libs/shared/src/ledger/icrc.rs new file mode 100644 index 000000000..5557ac841 --- /dev/null +++ b/src/libs/shared/src/ledger/icrc.rs @@ -0,0 +1,24 @@ +use crate::ledger::types::icrc::IcrcTransferResult; +use candid::Principal; +use ic_cdk::api::call::CallResult; +use ic_cdk::call; +use icrc_ledger_types::icrc1::transfer::TransferArg; + +/// Initiates a transfer of tokens on a specified ledger using the provided ICRC-1 arguments. +/// +/// This function performs a transfer using the `icrc1_transfer` method on the specified ledger +/// and returns the result of the transfer operation. +/// +/// # Arguments +/// * `ledger_id` - A `Principal` representing the ID of the ledger where the transfer will be executed. +/// * `args` - A `TransferArg` struct containing the details of the ICRC-1 transfer. +/// +/// # Returns +/// A `CallResult` indicating either the success or failure of the ICRC-1 token transfer. +pub async fn icrc_transfer_token( + ledger_id: Principal, + args: TransferArg, +) -> CallResult { + let (result,) = call(ledger_id, "icrc1_transfer", (args,)).await?; + Ok(result) +} diff --git a/src/libs/shared/src/ledger/mod.rs b/src/libs/shared/src/ledger/mod.rs index a11918d17..e3100fc68 100644 --- a/src/libs/shared/src/ledger/mod.rs +++ b/src/libs/shared/src/ledger/mod.rs @@ -1,3 +1,4 @@ pub mod icp; -mod types; +pub mod icrc; +pub mod types; mod utils; diff --git a/src/libs/shared/src/ledger/types.rs b/src/libs/shared/src/ledger/types.rs index 8c94e675f..e52417ea0 100644 --- a/src/libs/shared/src/ledger/types.rs +++ b/src/libs/shared/src/ledger/types.rs @@ -4,3 +4,9 @@ pub mod icp { pub type BlockIndexed = (BlockIndex, Block); pub type Blocks = Vec; } + +pub mod icrc { + use icrc_ledger_types::icrc1::transfer::{BlockIndex, TransferError}; + + pub type IcrcTransferResult = Result; +} diff --git a/src/mission_control/Cargo.toml b/src/mission_control/Cargo.toml index 3d84fa5b8..b2030ec76 100644 --- a/src/mission_control/Cargo.toml +++ b/src/mission_control/Cargo.toml @@ -12,6 +12,7 @@ candid.workspace = true ic-cdk.workspace = true ic-cdk-macros.workspace = true ic-ledger-types.workspace = true +icrc-ledger-types.workspace = true serde.workspace = true futures = "0.3.28" junobuild-shared = { path = "../libs/shared" } diff --git a/src/mission_control/mission_control.did b/src/mission_control/mission_control.did index 623843aee..89aaf3a12 100644 --- a/src/mission_control/mission_control.did +++ b/src/mission_control/mission_control.did @@ -1,3 +1,4 @@ +type Account = record { owner : principal; subaccount : opt blob }; type CanisterStatusType = variant { stopped; stopping; running }; type Controller = record { updated_at : nat64; @@ -18,7 +19,9 @@ type Orbiter = record { metadata : vec record { text; text }; created_at : nat64; }; -type Result = variant { Ok : SegmentStatus; Err : text }; +type Result = variant { Ok : nat64; Err : TransferError }; +type Result_1 = variant { Ok : nat; Err : TransferError_1 }; +type Result_2 = variant { Ok : SegmentStatus; Err : text }; type Satellite = record { updated_at : nat64; metadata : vec record { text; text }; @@ -46,9 +49,9 @@ type SegmentStatus = record { status_at : nat64; }; type SegmentsStatuses = record { - orbiters : opt vec Result; - satellites : opt vec Result; - mission_control : Result; + orbiters : opt vec Result_2; + satellites : opt vec Result_2; + mission_control : Result_2; }; type SetController = record { metadata : vec record { text; text }; @@ -61,7 +64,41 @@ type StatusesArgs = record { satellites : vec record { principal; CronJobStatusesConfig }; cycles_threshold : opt nat64; }; +type Timestamp = record { timestamp_nanos : nat64 }; type Tokens = record { e8s : nat64 }; +type TransferArg = record { + to : Account; + fee : opt nat; + memo : opt blob; + from_subaccount : opt blob; + created_at_time : opt nat64; + amount : nat; +}; +type TransferArgs = record { + to : blob; + fee : Tokens; + memo : nat64; + from_subaccount : opt blob; + created_at_time : opt Timestamp; + amount : Tokens; +}; +type TransferError = variant { + TxTooOld : record { allowed_window_nanos : nat64 }; + BadFee : record { expected_fee : Tokens }; + TxDuplicate : record { duplicate_of : nat64 }; + TxCreatedInFuture; + InsufficientFunds : record { balance : Tokens }; +}; +type TransferError_1 = variant { + GenericError : record { message : text; error_code : nat }; + TemporarilyUnavailable; + BadBurn : record { min_burn_amount : nat }; + Duplicate : record { duplicate_of : nat }; + BadFee : record { expected_fee : nat }; + CreatedInFuture : record { ledger_time : nat64 }; + TooOld; + InsufficientFunds : record { balance : nat }; +}; service : () -> { add_mission_control_controllers : (vec principal) -> (); add_satellites_controllers : (vec principal, vec principal) -> (); @@ -74,16 +111,18 @@ service : () -> { del_satellites_controllers : (vec principal, vec principal) -> (); deposit_cycles : (DepositCyclesArgs) -> (); get_user : () -> (principal) query; + icp_transfer : (TransferArgs) -> (Result); + icrc_transfer : (principal, TransferArg) -> (Result_1); list_mission_control_controllers : () -> ( vec record { principal; Controller }, ) query; - list_mission_control_statuses : () -> (vec record { nat64; Result }) query; + list_mission_control_statuses : () -> (vec record { nat64; Result_2 }) query; list_orbiter_statuses : (principal) -> ( - opt vec record { nat64; Result }, + opt vec record { nat64; Result_2 }, ) query; list_orbiters : () -> (vec record { principal; Orbiter }) query; list_satellite_statuses : (principal) -> ( - opt vec record { nat64; Result }, + opt vec record { nat64; Result_2 }, ) query; list_satellites : () -> (vec record { principal; Satellite }) query; remove_mission_control_controllers : (vec principal) -> (); diff --git a/src/mission_control/src/lib.rs b/src/mission_control/src/lib.rs index f04c15b9a..3cd20619f 100644 --- a/src/mission_control/src/lib.rs +++ b/src/mission_control/src/lib.rs @@ -43,8 +43,12 @@ use candid::Principal; use ic_cdk::api::call::{arg_data, ArgDecoderConfig}; use ic_cdk::{id, storage, trap}; use ic_cdk_macros::{export_candid, init, post_upgrade, pre_upgrade, query, update}; -use ic_ledger_types::Tokens; +use ic_ledger_types::{Tokens, TransferArgs, TransferResult}; +use icrc_ledger_types::icrc1::transfer::TransferArg; use junobuild_shared::ic::deposit_cycles as deposit_cycles_shared; +use junobuild_shared::ledger::icp::transfer_token; +use junobuild_shared::ledger::icrc::icrc_transfer_token; +use junobuild_shared::ledger::types::icrc::IcrcTransferResult; use junobuild_shared::types::interface::{ DepositCyclesArgs, MissionControlArgs, SetController, StatusesArgs, }; @@ -362,6 +366,24 @@ fn list_orbiter_statuses(orbiter_id: OrbiterId) -> Option { list_orbiter_statuses_store(&orbiter_id) } +/// +/// Wallet +/// + +#[update(guard = "caller_is_user_or_admin_controller")] +async fn icp_transfer(args: TransferArgs) -> TransferResult { + transfer_token(args) + .await + .map_err(|e| trap(&format!("Failed to call ledger: {:?}", e)))? +} + +#[update(guard = "caller_is_user_or_admin_controller")] +async fn icrc_transfer(ledger_id: Principal, args: TransferArg) -> IcrcTransferResult { + icrc_transfer_token(ledger_id, args) + .await + .map_err(|e| trap(&format!("Failed to call ICRC ledger: {:?}", e)))? +} + // Generate did files export_candid!(); diff --git a/src/tests/constants/ledger-tests.contants.ts b/src/tests/constants/ledger-tests.contants.ts new file mode 100644 index 000000000..dfacaf25e --- /dev/null +++ b/src/tests/constants/ledger-tests.contants.ts @@ -0,0 +1,3 @@ +import { Principal } from '@dfinity/principal'; + +export const LEDGER_ID = Principal.fromText('ryjl3-tyaaa-aaaaa-aaaba-cai'); diff --git a/src/tests/mission-control-wallet.spec.ts b/src/tests/mission-control-wallet.spec.ts new file mode 100644 index 000000000..f4c5759f4 --- /dev/null +++ b/src/tests/mission-control-wallet.spec.ts @@ -0,0 +1,204 @@ +import type { + _SERVICE as MissionControlActor, + TransferArg, + TransferArgs +} from '$declarations/mission_control/mission_control.did'; +import { idlFactory as idlFactorMissionControl } from '$declarations/mission_control/mission_control.factory.did'; +import { AnonymousIdentity } from '@dfinity/agent'; +import { IDL } from '@dfinity/candid'; +import { Ed25519KeyIdentity } from '@dfinity/identity'; +import { AccountIdentifier } from '@dfinity/ledger-icp'; +import type { _SERVICE as LedgerActor } from '@dfinity/ledger-icp/dist/candid/ledger'; +import { Principal } from '@dfinity/principal'; +import { PocketIc, type Actor } from '@hadronous/pic'; +import { afterAll, beforeAll, describe, expect, inject } from 'vitest'; +import { LEDGER_ID } from './constants/ledger-tests.contants'; +import { CONTROLLER_ERROR_MSG } from './constants/mission-control-tests.constants'; +import { setupLedger } from './utils/ledger-tests.utils'; +import { MISSION_CONTROL_WASM_PATH } from './utils/setup-tests.utils'; + +describe('Mission Control - Wallet', () => { + let pic: PocketIc; + let actor: Actor; + let missionControlId: Principal; + + const to = Ed25519KeyIdentity.generate(); + + const args: TransferArgs = { + to: AccountIdentifier.fromPrincipal({ principal: to.getPrincipal() }).toUint8Array(), + amount: { e8s: 100_000n }, + fee: { e8s: 10_000n }, + memo: 0n, + created_at_time: [], + from_subaccount: [] + }; + + const arg: TransferArg = { + to: { + owner: to.getPrincipal(), + subaccount: [] + }, + amount: 100_000n, + fee: [10_000n], + memo: [], + from_subaccount: [], + created_at_time: [] + }; + + const incorrectUser = Ed25519KeyIdentity.generate(); + const controller = Ed25519KeyIdentity.generate(); + + beforeAll(async () => { + pic = await PocketIc.create(inject('PIC_URL'), { nns: true }); + }); + + afterAll(async () => { + await pic?.tearDown(); + }); + + const initMissionControl = async (owner: Principal) => { + const userInitArgs = (): ArrayBuffer => + IDL.encode( + [ + IDL.Record({ + user: IDL.Principal + }) + ], + [{ user: owner }] + ); + + const { actor: c, canisterId } = await pic.setupCanister({ + idlFactory: idlFactorMissionControl, + wasm: MISSION_CONTROL_WASM_PATH, + arg: userInitArgs(), + sender: controller.getPrincipal() + }); + + actor = c; + missionControlId = canisterId; + }; + + describe('Guards', () => { + beforeAll(async () => { + await initMissionControl(incorrectUser.getPrincipal()); + }); + + const testIdentity = () => { + it('should throw errors on icp transfer', async () => { + const { icp_transfer } = actor; + + await expect(icp_transfer(args)).rejects.toThrow(CONTROLLER_ERROR_MSG); + }); + + it('should throw errors on icrc transfer', async () => { + const { icrc_transfer } = actor; + + await expect(icrc_transfer(LEDGER_ID, arg)).rejects.toThrow(CONTROLLER_ERROR_MSG); + }); + }; + + describe('anonymous', () => { + beforeAll(() => { + actor.setIdentity(new AnonymousIdentity()); + }); + + testIdentity(); + }); + + describe('unknown identity', () => { + beforeAll(() => { + actor.setIdentity(Ed25519KeyIdentity.generate()); + }); + + testIdentity(); + }); + }); + + describe('owner', () => { + let ledgerActor: Actor; + + beforeAll(async () => { + await initMissionControl(controller.getPrincipal()); + + actor.setIdentity(controller); + + const { actor: c } = await setupLedger({ pic, controller }); + ledgerActor = c; + }); + + describe('InsufficientFunds', () => { + it('should fail at icp transfer', async () => { + const { icp_transfer } = actor; + + const result = await icp_transfer(args); + + if ('Ok' in result) { + throw new Error('Unexpected result. Icp transfer should have failed.'); + } + + expect(result.Err).toEqual({ + InsufficientFunds: { + balance: { + e8s: 0n + } + } + }); + }); + + it('should fail at icrc transfer', async () => { + const { icrc_transfer } = actor; + + const result = await icrc_transfer(LEDGER_ID, arg); + + if ('Ok' in result) { + throw new Error('Unexpected result. Icrc transfer should have failed.'); + } + + expect(result.Err).toEqual({ + InsufficientFunds: { + balance: 0n + } + }); + }); + }); + + describe('Transfer success', () => { + beforeAll(async () => { + const { icrc1_transfer } = ledgerActor; + + await icrc1_transfer({ + amount: 5_500_010_000n, + to: { owner: missionControlId, subaccount: [] }, + fee: [], + memo: [], + from_subaccount: [], + created_at_time: [] + }); + }); + + it('should execute icp transfer', async () => { + const { icp_transfer } = actor; + + const result = await icp_transfer(args); + + if ('Err' in result) { + throw new Error('Unexpected result. Icrc transfer should have succeeded.'); + } + + expect(result.Ok).toEqual(2n); + }); + + it('should execute icrc transfer', async () => { + const { icrc_transfer } = actor; + + const result = await icrc_transfer(LEDGER_ID, arg); + + if ('Err' in result) { + throw new Error('Unexpected result. Icrc transfer should have succeeded.'); + } + + expect(result.Ok).toEqual(3n); + }); + }); + }); +}); diff --git a/src/tests/utils/ledger-tests.utils.ts b/src/tests/utils/ledger-tests.utils.ts new file mode 100644 index 000000000..9cd9d3e6d --- /dev/null +++ b/src/tests/utils/ledger-tests.utils.ts @@ -0,0 +1,77 @@ +import type { Identity } from '@dfinity/agent'; +import { IDL } from '@dfinity/candid'; +import { Ed25519KeyIdentity } from '@dfinity/identity'; +import { AccountIdentifier } from '@dfinity/ledger-icp'; +import type { _SERVICE as LedgerActor } from '@dfinity/ledger-icp/dist/candid/ledger'; +// @ts-expect-error init is not packaged / exposed +import { idlFactory, init } from '@dfinity/ledger-icp/dist/candid/ledger.idl.js'; +import type { CanisterFixture, PocketIc } from '@hadronous/pic'; +import { assertNonNullish } from '@junobuild/utils'; +import { LEDGER_ID } from '../constants/ledger-tests.contants'; +import { download } from './setup-tests.utils'; + +const IC_VERSION = 'b0ade55f7e8999e2842fe3f49df163ba224b71a2'; +const url = `https://download.dfinity.systems/ic/${IC_VERSION}/canisters/ledger-canister.wasm.gz`; + +export const setupLedger = async ({ + pic, + controller +}: { + pic: PocketIc; + controller: Identity; +}): Promise> => { + const destination = await download({ + wasm: 'ledger.wasm.gz', + url + }); + + const minter = Ed25519KeyIdentity.generate(); + + const minterAccountIdentifier = AccountIdentifier.fromPrincipal({ + principal: minter.getPrincipal() + }).toHex(); + + const ledgerAccountIdentifier = AccountIdentifier.fromPrincipal({ + principal: controller.getPrincipal() + }).toHex(); + + const initArgs = { + send_whitelist: [], + token_symbol: ['ICP'], + transfer_fee: [{ e8s: 10_000n }], + minting_account: minterAccountIdentifier, + maximum_number_of_accounts: [], + accounts_overflow_trim_quantity: [], + transaction_window: [], + max_message_size_bytes: [], + icrc1_minting_account: [], + archive_options: [], + initial_values: [[ledgerAccountIdentifier, { e8s: 100_000_000_000n }]], + token_name: ['Internet Computer'], + feature_flags: [] + }; + + // Type definitions generated by Candid are not clean enough. + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const arg = IDL.encode(init({ IDL }), [{ Init: initArgs }]); + + const subnet = pic.getNnsSubnet(); + + assertNonNullish(subnet); + + const { actor, ...rest } = await pic.setupCanister({ + idlFactory, + wasm: destination, + sender: controller.getPrincipal(), + arg, + targetCanisterId: LEDGER_ID, + targetSubnetId: subnet.id + }); + + actor.setIdentity(controller); + + return { + actor, + ...rest + }; +}; diff --git a/src/tests/utils/setup-tests.utils.ts b/src/tests/utils/setup-tests.utils.ts index f83495feb..197672c3f 100644 --- a/src/tests/utils/setup-tests.utils.ts +++ b/src/tests/utils/setup-tests.utils.ts @@ -101,7 +101,7 @@ const downloadGitHub = async ({ url: `https://github.com/junobuild/juno/releases/download/v${junoVersion}/${wasm}` }); -const download = async ({ wasm, url }: { wasm: string; url: string }): Promise => { +export const download = async ({ wasm, url }: { wasm: string; url: string }): Promise => { const destination = join(process.cwd(), wasm); if (existsSync(destination)) {