From 6dce3f7f2dbd124136a61a9b74e035e3c57040bc Mon Sep 17 00:00:00 2001 From: olaszakos Date: Tue, 26 Nov 2024 17:11:29 +0100 Subject: [PATCH] feat(station)!: multi chain support (#374) design doc: https://docs.google.com/document/d/1l8u2-m2lH-FeN6tLJv80pqUnHpWIy3ROkz7jxTz0HtM/edit?tab=t.0#heading=h.lic4oqo4af1m Jira ticket: https://dfinity.atlassian.net/browse/PEN-142?atlOrigin=eyJpIjoiNTExMDU0ODUzODFjNDRjNDhlMDEzNTI1OTdjOWRlMDAiLCJwIjoiaiJ9 --------- Co-authored-by: Kepler Vital Co-authored-by: mraszyk <31483726+mraszyk@users.noreply.github.com> --- .github/workflows/tests.yaml | 2 + Cargo.lock | 48 +- Cargo.toml | 3 +- apps/wallet/package.json | 4 +- .../src/components/ShortenedAddress.vue | 20 + apps/wallet/src/components/TextOverflow.vue | 22 +- .../components/accounts/AccountAssetsCell.vue | 21 + .../accounts/AccountSetupDialog.vue | 4 +- .../accounts/AddAccountAssetBtn.vue | 65 ++ .../accounts/AddAccountAssetDialog.spec.ts | 116 ++ .../accounts/AddAccountAssetDialog.vue | 137 +++ .../accounts/BatchTransfersActionBtn.vue | 39 +- .../accounts/RemoveAssetDialog.spec.ts | 99 ++ .../components/accounts/RemoveAssetDialog.vue | 122 ++ .../src/components/accounts/TransferBtn.vue | 4 +- .../accounts/TransferDialog.spec.ts | 49 +- .../components/accounts/TransferDialog.vue | 32 +- .../src/components/accounts/TransferForm.vue | 17 +- .../wizard/AccountConfigurationSettings.vue | 30 +- .../address-book/AddressBookDialog.vue | 14 +- .../address-book/AddressBookForm.vue | 3 +- .../src/components/assets/AssetDialog.spec.ts | 138 +++ .../src/components/assets/AssetDialog.vue | 178 +++ .../src/components/assets/AssetDialogBtn.vue | 66 ++ .../src/components/assets/AssetForm.spec.ts | 56 + .../src/components/assets/AssetForm.vue | 183 +++ ...InternetComputerNativeStandardForm.spec.ts | 43 + .../InternetComputerNativeStandardForm.vue | 76 ++ .../inputs/AssetAutocomplete.spec.ts | 49 + .../components/inputs/AssetAutocomplete.vue | 91 ++ .../src/components/inputs/MetadataField.vue | 64 +- .../inputs/StandardsAutocomplete.spec.ts | 36 + .../inputs/StandardsAutocomplete.vue | 74 ++ .../components/inputs/TokenAutocomplete.vue | 34 +- .../specifier/AssetSpecifier.vue | 91 ++ .../specifier/SpecifierSelector.vue | 15 + .../components/requests/RecentRequests.vue | 3 +- .../components/requests/RequestDetailView.vue | 6 + .../components/requests/RequestDialog.spec.ts | 17 +- .../components/requests/RequestListItem.vue | 8 + .../operations/AddAccountOperation.vue | 20 +- .../requests/operations/AddAssetOperation.vue | 52 + .../operations/EditAccountOperation.vue | 70 +- .../EditAddressBookEntryOperation.vue | 3 +- .../operations/EditAssetOperation.vue | 140 +++ .../operations/EditPermissionOperation.vue | 2 +- .../RemoveAddressBookEntryOperation.vue | 3 +- .../operations/RemoveAssetOperation.vue | 77 ++ .../requests/operations/TransferOperation.vue | 15 +- .../src/composables/account.composable.ts | 9 +- .../composables/autocomplete.composable.ts | 15 + .../src/composables/request.composable.ts | 7 + apps/wallet/src/configs/permissions.config.ts | 42 + .../src/configs/request-policies.config.ts | 3 + apps/wallet/src/configs/routes.config.ts | 5 +- .../src/generated/icp_ledger/icp_ledger.did | 451 ++++++++ .../generated/icp_ledger/icp_ledger.did.d.ts | 248 ++++ .../generated/icp_ledger/icp_ledger.did.js | 342 ++++++ .../src/generated/icp_ledger/index.d.ts | 50 + apps/wallet/src/generated/icp_ledger/index.js | 40 + .../icrc1_index/icrc1_index_canister.did | 143 +++ .../icrc1_index/icrc1_index_canister.did.d.ts | 108 ++ .../icrc1_index/icrc1_index_canister.did.js | 126 ++ .../src/generated/icrc1_index/index.d.ts | 50 + .../wallet/src/generated/icrc1_index/index.js | 40 + .../icrc1_ledger/icrc1_ledger_canister.did | 379 ++++++ .../icrc1_ledger_canister.did.d.ts | 245 ++++ .../icrc1_ledger/icrc1_ledger_canister.did.js | 342 ++++++ .../src/generated/icrc1_ledger/index.d.ts | 50 + .../src/generated/icrc1_ledger/index.js | 40 + apps/wallet/src/generated/station/station.did | 287 ++++- .../src/generated/station/station.did.d.ts | 133 ++- .../src/generated/station/station.did.js | 183 ++- apps/wallet/src/locales/en.locale.ts | 60 +- apps/wallet/src/locales/fr.locale.ts | 60 +- apps/wallet/src/locales/pt.locale.ts | 60 +- apps/wallet/src/mappers/permissions.mapper.ts | 4 + .../src/mappers/request-specifiers.mapper.ts | 12 + apps/wallet/src/mappers/requests.mapper.ts | 52 +- apps/wallet/src/pages/AccountAssetPage.vue | 515 +++++++++ apps/wallet/src/pages/AccountPage.vue | 277 ++--- apps/wallet/src/pages/AccountsPage.vue | 41 +- apps/wallet/src/pages/AddressBookPage.vue | 12 +- apps/wallet/src/pages/AssetsPage.vue | 178 +++ apps/wallet/src/pages/DashboardPage.vue | 219 ++++ apps/wallet/src/plugins/navigation.plugin.ts | 27 + apps/wallet/src/plugins/router.plugin.ts | 85 +- .../services/chains/ic-native-api.service.ts | 62 +- .../src/services/chains/icrc1-api.service.ts | 144 +++ apps/wallet/src/services/chains/index.ts | 48 +- apps/wallet/src/services/station.service.ts | 140 ++- apps/wallet/src/stores/station.store.ts | 10 +- apps/wallet/src/types/auth.types.ts | 2 + apps/wallet/src/types/chain.types.ts | 15 +- apps/wallet/src/types/permissions.types.ts | 1 + apps/wallet/src/types/requests.types.ts | 1 + apps/wallet/src/types/station.types.ts | 13 + apps/wallet/src/utils/asset.utils.spec.ts | 27 + apps/wallet/src/utils/asset.utils.ts | 72 ++ apps/wallet/src/utils/form.utils.ts | 38 + apps/wallet/src/utils/helper.utils.ts | 10 + apps/wallet/src/workers/accounts.worker.ts | 7 +- .../impl/src/controllers/station.rs | 2 +- .../impl/src/services/canister.rs | 2 + .../control-panel/impl/src/services/deploy.rs | 1 + core/station/api/spec.did | 287 ++++- core/station/api/src/account.rs | 41 +- core/station/api/src/address_book.rs | 3 + core/station/api/src/asset.rs | 94 ++ core/station/api/src/capabilities.rs | 35 +- core/station/api/src/lib.rs | 3 + core/station/api/src/request.rs | 37 +- core/station/api/src/request_policy.rs | 3 + core/station/api/src/resource.rs | 1 + core/station/api/src/system.rs | 17 +- core/station/api/src/transfer.rs | 5 +- core/station/api/src/user.rs | 2 + core/station/impl/Cargo.toml | 1 + core/station/impl/results.yml | 22 +- core/station/impl/src/controllers/asset.rs | 78 ++ .../impl/src/controllers/capabilities.rs | 39 +- core/station/impl/src/controllers/mod.rs | 3 + core/station/impl/src/controllers/system.rs | 16 +- core/station/impl/src/core/assets.rs | 16 - core/station/impl/src/core/init.rs | 36 +- core/station/impl/src/core/memory.rs | 3 +- core/station/impl/src/core/metrics.rs | 217 +++- core/station/impl/src/core/mod.rs | 5 +- core/station/impl/src/core/request.rs | 15 +- core/station/impl/src/core/standards.rs | 17 + core/station/impl/src/core/validation.rs | 84 +- core/station/impl/src/errors/account.rs | 17 + core/station/impl/src/errors/asset.rs | 102 ++ .../station/impl/src/errors/blockchain_api.rs | 42 +- core/station/impl/src/errors/factory.rs | 13 +- core/station/impl/src/errors/mod.rs | 3 + .../impl/src/factories/blockchains/core.rs | 38 +- .../blockchains/internet_computer.rs | 459 ++++++-- .../impl/src/factories/requests/add_asset.rs | 75 ++ .../impl/src/factories/requests/edit_asset.rs | 69 ++ .../impl/src/factories/requests/mod.rs | 30 + .../src/factories/requests/remove_asset.rs | 69 ++ .../impl/src/factories/requests/transfer.rs | 63 +- .../src/jobs/execute_created_transfers.rs | 29 +- core/station/impl/src/jobs/mod.rs | 14 +- core/station/impl/src/lib.rs | 3 +- core/station/impl/src/mappers/account.rs | 214 +++- core/station/impl/src/mappers/address_book.rs | 14 +- core/station/impl/src/mappers/asset.rs | 33 +- .../station/impl/src/mappers/authorization.rs | 31 +- core/station/impl/src/mappers/blockchain.rs | 6 +- core/station/impl/src/mappers/helper.rs | 8 + core/station/impl/src/mappers/metadata.rs | 10 +- .../impl/src/mappers/notification_type.rs | 10 +- .../impl/src/mappers/request_operation.rs | 204 +++- .../src/mappers/request_operation_type.rs | 18 + .../impl/src/mappers/request_policy.rs | 30 + core/station/impl/src/mappers/resource.rs | 2 + core/station/impl/src/migration.rs | 1015 +++++++++-------- core/station/impl/src/migration_tests/mod.rs | 280 +++++ .../snapshots/account_repository_v1.bin | Bin 0 -> 65536 bytes .../snapshots/account_repository_v2.bin | Bin 0 -> 65536 bytes .../snapshots/address_book_repository_v1.bin | Bin 0 -> 65536 bytes .../snapshots/address_book_repository_v2.bin | Bin 0 -> 65536 bytes .../snapshots/request_repository_v1.bin | Bin 0 -> 65536 bytes .../snapshots/request_repository_v2.bin | Bin 0 -> 65536 bytes .../snapshots/transfer_repository_v1.bin | Bin 0 -> 65536 bytes .../snapshots/transfer_repository_v2.bin | Bin 0 -> 65536 bytes core/station/impl/src/models/account.rs | 322 ++++-- core/station/impl/src/models/address_book.rs | 10 +- core/station/impl/src/models/asset.rs | 233 +++- core/station/impl/src/models/blockchain.rs | 23 +- .../impl/src/models/blockchain_standard.rs | 105 +- .../models/indexes/transfer_account_index.rs | 2 + .../impl/src/models/indexes/unique_index.rs | 29 +- core/station/impl/src/models/request.rs | 106 +- .../impl/src/models/request_operation.rs | 160 ++- .../models/request_operation_filter_type.rs | 6 + .../impl/src/models/request_operation_type.rs | 18 + .../impl/src/models/request_policy_rule.rs | 37 +- .../impl/src/models/request_specifier.rs | 60 +- core/station/impl/src/models/resource.rs | 58 +- core/station/impl/src/models/transfer.rs | 14 +- core/station/impl/src/repositories/account.rs | 39 +- .../impl/src/repositories/address_book.rs | 42 +- core/station/impl/src/repositories/asset.rs | 255 +++++ core/station/impl/src/repositories/mod.rs | 8 + .../impl/src/repositories/user_group.rs | 2 +- core/station/impl/src/services/account.rs | 570 +++++++-- .../station/impl/src/services/address_book.rs | 5 +- core/station/impl/src/services/asset.rs | 347 ++++++ .../impl/src/services/disaster_recovery.rs | 254 ++++- .../impl/src/services/external_canister.rs | 2 +- core/station/impl/src/services/mod.rs | 3 + core/station/impl/src/services/request.rs | 66 +- core/station/impl/src/services/system.rs | 241 +++- core/station/impl/src/services/transfer.rs | 68 +- core/upgrader/api/spec.did | 50 +- core/upgrader/api/src/lib.rs | 53 + .../impl/src/controllers/disaster_recovery.rs | 121 +- core/upgrader/impl/src/controllers/logs.rs | 34 + core/upgrader/impl/src/errors/mod.rs | 6 + core/upgrader/impl/src/lib.rs | 5 +- .../impl/src/model/disaster_recovery.rs | 180 +++ core/upgrader/impl/src/model/logging.rs | 26 +- .../impl/src/services/disaster_recovery.rs | 110 +- core/upgrader/impl/src/services/logger.rs | 139 ++- dfx.json | 44 +- docs/GLOSSARY.md | 14 +- libs/orbit-essentials/src/utils/lock.rs | 33 +- orbit | 68 ++ pnpm-lock.yaml | 87 +- scripts/benchmark-canister.sh | 6 +- scripts/run-integration-tests.sh | 1 + tests/integration/Cargo.toml | 1 + .../integration/assets/station-memory-v1.bin | Bin 0 -> 280334 bytes tests/integration/src/account_tests.rs | 203 ++++ tests/integration/src/address_book_tests.rs | 38 +- tests/integration/src/asset_tests.rs | 123 ++ tests/integration/src/cycles_monitor_tests.rs | 10 +- .../src/disaster_recovery_tests.rs | 208 +++- tests/integration/src/interfaces.rs | 132 ++- tests/integration/src/lib.rs | 2 + tests/integration/src/migration_tests.rs | 84 +- tests/integration/src/setup.rs | 1 + tests/integration/src/test_data.rs | 34 + tests/integration/src/test_data/account.rs | 36 +- .../integration/src/test_data/address_book.rs | 3 +- tests/integration/src/test_data/asset.rs | 123 ++ tests/integration/src/transfer_tests.rs | 347 +++++- tests/integration/src/utils.rs | 196 +++- tools/dfx-orbit/src/me.rs | 2 + tools/dfx-orbit/src/review/display.rs | 3 + 233 files changed, 15564 insertions(+), 2021 deletions(-) create mode 100644 apps/wallet/src/components/ShortenedAddress.vue create mode 100644 apps/wallet/src/components/accounts/AccountAssetsCell.vue create mode 100644 apps/wallet/src/components/accounts/AddAccountAssetBtn.vue create mode 100644 apps/wallet/src/components/accounts/AddAccountAssetDialog.spec.ts create mode 100644 apps/wallet/src/components/accounts/AddAccountAssetDialog.vue create mode 100644 apps/wallet/src/components/accounts/RemoveAssetDialog.spec.ts create mode 100644 apps/wallet/src/components/accounts/RemoveAssetDialog.vue create mode 100644 apps/wallet/src/components/assets/AssetDialog.spec.ts create mode 100644 apps/wallet/src/components/assets/AssetDialog.vue create mode 100644 apps/wallet/src/components/assets/AssetDialogBtn.vue create mode 100644 apps/wallet/src/components/assets/AssetForm.spec.ts create mode 100644 apps/wallet/src/components/assets/AssetForm.vue create mode 100644 apps/wallet/src/components/assets/standards/InternetComputerNativeStandardForm.spec.ts create mode 100644 apps/wallet/src/components/assets/standards/InternetComputerNativeStandardForm.vue create mode 100644 apps/wallet/src/components/inputs/AssetAutocomplete.spec.ts create mode 100644 apps/wallet/src/components/inputs/AssetAutocomplete.vue create mode 100644 apps/wallet/src/components/inputs/StandardsAutocomplete.spec.ts create mode 100644 apps/wallet/src/components/inputs/StandardsAutocomplete.vue create mode 100644 apps/wallet/src/components/request-policies/specifier/AssetSpecifier.vue create mode 100644 apps/wallet/src/components/requests/operations/AddAssetOperation.vue create mode 100644 apps/wallet/src/components/requests/operations/EditAssetOperation.vue create mode 100644 apps/wallet/src/components/requests/operations/RemoveAssetOperation.vue create mode 100644 apps/wallet/src/generated/icp_ledger/icp_ledger.did create mode 100644 apps/wallet/src/generated/icp_ledger/icp_ledger.did.d.ts create mode 100644 apps/wallet/src/generated/icp_ledger/icp_ledger.did.js create mode 100644 apps/wallet/src/generated/icp_ledger/index.d.ts create mode 100644 apps/wallet/src/generated/icp_ledger/index.js create mode 100644 apps/wallet/src/generated/icrc1_index/icrc1_index_canister.did create mode 100644 apps/wallet/src/generated/icrc1_index/icrc1_index_canister.did.d.ts create mode 100644 apps/wallet/src/generated/icrc1_index/icrc1_index_canister.did.js create mode 100644 apps/wallet/src/generated/icrc1_index/index.d.ts create mode 100644 apps/wallet/src/generated/icrc1_index/index.js create mode 100644 apps/wallet/src/generated/icrc1_ledger/icrc1_ledger_canister.did create mode 100644 apps/wallet/src/generated/icrc1_ledger/icrc1_ledger_canister.did.d.ts create mode 100644 apps/wallet/src/generated/icrc1_ledger/icrc1_ledger_canister.did.js create mode 100644 apps/wallet/src/generated/icrc1_ledger/index.d.ts create mode 100644 apps/wallet/src/generated/icrc1_ledger/index.js create mode 100644 apps/wallet/src/pages/AccountAssetPage.vue create mode 100644 apps/wallet/src/pages/AssetsPage.vue create mode 100644 apps/wallet/src/pages/DashboardPage.vue create mode 100644 apps/wallet/src/services/chains/icrc1-api.service.ts create mode 100644 apps/wallet/src/utils/asset.utils.spec.ts create mode 100644 apps/wallet/src/utils/asset.utils.ts create mode 100644 core/station/api/src/asset.rs create mode 100644 core/station/impl/src/controllers/asset.rs delete mode 100644 core/station/impl/src/core/assets.rs create mode 100644 core/station/impl/src/core/standards.rs create mode 100644 core/station/impl/src/errors/asset.rs create mode 100644 core/station/impl/src/factories/requests/add_asset.rs create mode 100644 core/station/impl/src/factories/requests/edit_asset.rs create mode 100644 core/station/impl/src/factories/requests/remove_asset.rs create mode 100644 core/station/impl/src/migration_tests/mod.rs create mode 100644 core/station/impl/src/migration_tests/snapshots/account_repository_v1.bin create mode 100644 core/station/impl/src/migration_tests/snapshots/account_repository_v2.bin create mode 100644 core/station/impl/src/migration_tests/snapshots/address_book_repository_v1.bin create mode 100644 core/station/impl/src/migration_tests/snapshots/address_book_repository_v2.bin create mode 100644 core/station/impl/src/migration_tests/snapshots/request_repository_v1.bin create mode 100644 core/station/impl/src/migration_tests/snapshots/request_repository_v2.bin create mode 100644 core/station/impl/src/migration_tests/snapshots/transfer_repository_v1.bin create mode 100644 core/station/impl/src/migration_tests/snapshots/transfer_repository_v2.bin create mode 100644 core/station/impl/src/repositories/asset.rs create mode 100644 core/station/impl/src/services/asset.rs create mode 100644 tests/integration/assets/station-memory-v1.bin create mode 100644 tests/integration/src/account_tests.rs create mode 100644 tests/integration/src/asset_tests.rs create mode 100644 tests/integration/src/test_data/asset.rs diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 35a3c0197..1fff29485 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -96,6 +96,8 @@ jobs: name: 'ic-icp-index-canister' - canister: 'cmc' name: 'cycles-minting-canister' + - canister: 'icrc1_ledger' + name: 'ic-icrc1-ledger' steps: - name: 'Checkout' uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index cf4b39db1..6e2f63b81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,6 +324,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[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" @@ -2423,9 +2429,9 @@ dependencies = [ [[package]] name = "ic-stable-structures" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03f3044466a69802de74e710dc0300b706a05696a0531c942ca856751a13b0db" +checksum = "fcaf89c1bc326c72498bcc0cd954f2edf718c018e7c586d2193d701d3c9af29a" dependencies = [ "ic_principal", ] @@ -2541,6 +2547,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 0.12.1", + "num-bigint 0.4.6", + "num-traits", + "serde", + "serde_bytes", + "sha2 0.10.8", + "strum", + "time", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -2597,6 +2623,7 @@ dependencies = [ "hex", "ic-certified-assets", "ic-ledger-types", + "icrc-ledger-types", "itertools 0.13.0", "lazy_static", "num-bigint 0.4.6", @@ -2686,6 +2713,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2828,6 +2864,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + [[package]] name = "libredox" version = "0.1.3" @@ -3152,6 +3194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4656,6 +4699,7 @@ dependencies = [ "ic-cdk-macros 0.16.0", "ic-ledger-types", "ic-stable-structures", + "icrc-ledger-types", "lazy_static", "num-bigint 0.4.6", "orbit-essentials", diff --git a/Cargo.toml b/Cargo.toml index 73dbcd949..12c521b1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,8 @@ ic-cdk = "0.16.0" ic-cdk-macros = "0.16.0" ic-cdk-timers = "0.9.0" ic-ledger-types = "0.12.0" -ic-stable-structures = "0.6.4" +ic-stable-structures = "0.6.6" +icrc-ledger-types = "0.1.6" ic-utils = "0.38" itertools = "0.13.0" lazy_static = "1.4.0" diff --git a/apps/wallet/package.json b/apps/wallet/package.json index 2e339f6a8..c5fabd067 100644 --- a/apps/wallet/package.json +++ b/apps/wallet/package.json @@ -27,9 +27,11 @@ "@dfinity/agent": "1.4.0", "@dfinity/auth-client": "1.4.0", "@dfinity/candid": "1.4.0", - "@dfinity/didc": "0.0.2", "@dfinity/identity": "1.4.0", "@dfinity/principal": "1.4.0", + "@dfinity/ledger-icrc": "2.3.3", + "@dfinity/utils": "2.3.1", + "@dfinity/didc": "0.0.2", "@mdi/font": "7.4.47", "@mdi/js": "7.4.47", "buffer": "6.0.3", diff --git a/apps/wallet/src/components/ShortenedAddress.vue b/apps/wallet/src/components/ShortenedAddress.vue new file mode 100644 index 000000000..02f39f3d6 --- /dev/null +++ b/apps/wallet/src/components/ShortenedAddress.vue @@ -0,0 +1,20 @@ + + + diff --git a/apps/wallet/src/components/TextOverflow.vue b/apps/wallet/src/components/TextOverflow.vue index 649a5d5af..b0551f2c2 100644 --- a/apps/wallet/src/components/TextOverflow.vue +++ b/apps/wallet/src/components/TextOverflow.vue @@ -13,7 +13,7 @@ const props = withDefaults( text: string; maxLength?: number; overflowText?: string; - overflowPosition?: 'start' | 'middle' | 'end'; + overflowPosition?: 'start' | 'middle' | 'end' | ((input: string) => string); }>(), { maxLength: 18, @@ -40,15 +40,19 @@ const truncatedText = computed(() => { }`; } - const overflowLengthStart = Math.ceil(props.overflowText.length / 2); - const overflowLengthEnd = Math.floor(props.overflowText.length / 2); - const start = Math.ceil((props.maxLength - 1) / 2) - overflowLengthStart; - const end = Math.floor((props.maxLength - 1) / 2) - overflowLengthEnd; + if (props.overflowPosition === 'middle') { + const overflowLengthStart = Math.ceil(props.overflowText.length / 2); + const overflowLengthEnd = Math.floor(props.overflowText.length / 2); + const start = Math.ceil((props.maxLength - 1) / 2) - overflowLengthStart; + const end = Math.floor((props.maxLength - 1) / 2) - overflowLengthEnd; - return `${props.text.slice(0, start)}${props.overflowText}${props.text.slice( - props.text.length - end, - props.text.length, - )}`; + return `${props.text.slice(0, start)}${props.overflowText}${props.text.slice( + props.text.length - end, + props.text.length, + )}`; + } + + return props.overflowPosition(props.text); }); const handleCopy = (event: ClipboardEvent): void => { diff --git a/apps/wallet/src/components/accounts/AccountAssetsCell.vue b/apps/wallet/src/components/accounts/AccountAssetsCell.vue new file mode 100644 index 000000000..da61b97c7 --- /dev/null +++ b/apps/wallet/src/components/accounts/AccountAssetsCell.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/wallet/src/components/accounts/AccountSetupDialog.vue b/apps/wallet/src/components/accounts/AccountSetupDialog.vue index bab9f03ce..4465adc5f 100644 --- a/apps/wallet/src/components/accounts/AccountSetupDialog.vue +++ b/apps/wallet/src/components/accounts/AccountSetupDialog.vue @@ -154,15 +154,15 @@ const saveChangesToExistingAccount = async (accountId: UUID): Promise = changes.configs_permission = [ assertAndReturn(wizard.value.permission.configuration, 'update_access'), ]; + changes.change_assets = []; return station.service.editAccount(changes as EditAccountOperationInput); }; const createNewAccount = async (): Promise => { const changes: Partial = {}; + changes.assets = assertAndReturn(wizard.value.configuration.assets, 'assets'); changes.name = assertAndReturn(wizard.value.configuration.name, 'name'); - changes.blockchain = assertAndReturn(wizard.value.configuration.blockchain, 'blockchain'); - changes.standard = assertAndReturn(wizard.value.configuration.standard, 'standard'); changes.configs_request_policy = wizard.value.request_policy.configurationRule ? [wizard.value.request_policy.configurationRule] : []; diff --git a/apps/wallet/src/components/accounts/AddAccountAssetBtn.vue b/apps/wallet/src/components/accounts/AddAccountAssetBtn.vue new file mode 100644 index 000000000..ef11f0a05 --- /dev/null +++ b/apps/wallet/src/components/accounts/AddAccountAssetBtn.vue @@ -0,0 +1,65 @@ + + diff --git a/apps/wallet/src/components/accounts/AddAccountAssetDialog.spec.ts b/apps/wallet/src/components/accounts/AddAccountAssetDialog.spec.ts new file mode 100644 index 000000000..684fe4c93 --- /dev/null +++ b/apps/wallet/src/components/accounts/AddAccountAssetDialog.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Account, Asset } from '~/generated/station/station.did'; +import { mount } from '~/test.utils'; +import AddAccountAssetDialog from './AddAccountAssetDialog.vue'; +import { BlockchainStandard } from '~/types/chain.types'; +import { useStationStore } from '~/stores/station.store'; +import TokenAutocomplete from '../inputs/TokenAutocomplete.vue'; +import { flushPromises } from '@vue/test-utils'; +import { StationService } from '~/services/station.service'; +import { services } from '~/plugins/services.plugin'; + +vi.mock('~/services/station.service', () => { + const mock: Partial = { + editAccount: vi.fn().mockImplementation(() => Promise.resolve({} as Account)), + }; + + return { + StationService: vi.fn(() => mock), + }; +}); + +const mockAssets: Asset[] = [ + { + id: '1', + blockchain: 'icp', + decimals: 8, + metadata: [], + name: 'Test', + symbol: 'TEST', + standards: [BlockchainStandard.Native], + }, + + { + id: '2', + blockchain: 'icp', + decimals: 8, + metadata: [], + name: 'Test2', + symbol: 'TEST2', + standards: [BlockchainStandard.ICRC1], + }, +]; + +const mockAccount: Account = { + id: '1', + assets: [ + { + asset_id: mockAssets[0].id, + balance: [], + }, + ], + addresses: [], + configs_request_policy: [], + metadata: [], + last_modification_timestamp: '2021-09-01T00:00:00Z', + name: 'Test', + transfer_request_policy: [], +}; + +describe('AddAccountAssetDialog', () => { + it('renders correctly', () => { + const wrapper = mount(AddAccountAssetDialog, { + props: { + account: mockAccount, + + open: true, + attach: true, + }, + }); + + expect(wrapper.exists()).toBe(true); + }); + + it('edits the account when submitted', async () => { + const wrapper = mount(AddAccountAssetDialog, { + props: { + account: { ...mockAccount }, + open: true, + attach: true, + }, + }); + + const station = useStationStore(); + station.configuration.details.supported_assets = mockAssets; + + const submitBtn = wrapper.find('button[data-test-id="add-asset-dialog-save-button"]'); + + const tokenField = wrapper.findComponent(TokenAutocomplete); + + tokenField.vm.$emit('update:modelValue', [mockAssets[1].id]); + + await wrapper.vm.$nextTick(); + await flushPromises(); + + await submitBtn.trigger('click'); + + await wrapper.vm.$nextTick(); + await flushPromises(); + + // check if editAccount was called with the correct asset + expect(services().station.editAccount).toHaveBeenCalledWith( + expect.objectContaining({ + change_assets: [ + { + Change: { + add_assets: [mockAssets[1].id], + remove_assets: [], + }, + }, + ], + }), + ); + + vi.clearAllMocks(); + }); +}); diff --git a/apps/wallet/src/components/accounts/AddAccountAssetDialog.vue b/apps/wallet/src/components/accounts/AddAccountAssetDialog.vue new file mode 100644 index 000000000..7959fc9d8 --- /dev/null +++ b/apps/wallet/src/components/accounts/AddAccountAssetDialog.vue @@ -0,0 +1,137 @@ + + diff --git a/apps/wallet/src/components/accounts/BatchTransfersActionBtn.vue b/apps/wallet/src/components/accounts/BatchTransfersActionBtn.vue index 0ff2cdbe2..35ea1f8a4 100644 --- a/apps/wallet/src/components/accounts/BatchTransfersActionBtn.vue +++ b/apps/wallet/src/components/accounts/BatchTransfersActionBtn.vue @@ -83,7 +83,7 @@ - {{ formatBalance(transfer.amount, account.decimals) }} + {{ formatBalance(transfer.amount, asset.decimals) }} @@ -80,6 +82,7 @@ const input = withDefaults( density?: 'comfortable' | 'compact'; readonly?: boolean; disabled?: boolean; + hideKeys?: string[]; }>(), { modelValue: () => [], @@ -87,6 +90,7 @@ const input = withDefaults( density: 'comfortable', readonly: false, disabled: false, + hideKeys: () => [], }, ); diff --git a/apps/wallet/src/components/inputs/StandardsAutocomplete.spec.ts b/apps/wallet/src/components/inputs/StandardsAutocomplete.spec.ts new file mode 100644 index 000000000..e4e8334f2 --- /dev/null +++ b/apps/wallet/src/components/inputs/StandardsAutocomplete.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { mount } from '~/test.utils'; +import StandardsAutocomplete from './StandardsAutocomplete.vue'; + +describe('StandardsAutocomplete', () => { + it('renders with selected ids', () => { + const wrapper = mount(StandardsAutocomplete, { + props: { + modelValue: ['1'], + blockchain: 'icp', + }, + }); + + expect(wrapper.exists()).toBe(true); + + const autocomplete = wrapper.findComponent({ name: 'VSelect' }); + expect(autocomplete.exists()).toBe(true); + + expect(autocomplete.props('modelValue')).toEqual(['1']); + }); + + it('renders with empty list of standards', async () => { + const wrapper = mount(StandardsAutocomplete, { + props: { + blockchain: 'icp', + }, + }); + const autocomplete = wrapper.findComponent({ name: 'VSelect' }); + + expect(autocomplete.exists()).toBe(true); + + await wrapper.vm.$nextTick(); + + expect(autocomplete.props('items')).toEqual([]); + }); +}); diff --git a/apps/wallet/src/components/inputs/StandardsAutocomplete.vue b/apps/wallet/src/components/inputs/StandardsAutocomplete.vue new file mode 100644 index 000000000..189d1423a --- /dev/null +++ b/apps/wallet/src/components/inputs/StandardsAutocomplete.vue @@ -0,0 +1,74 @@ + + + diff --git a/apps/wallet/src/components/inputs/TokenAutocomplete.vue b/apps/wallet/src/components/inputs/TokenAutocomplete.vue index 9f3d8c35b..d47fb97c5 100644 --- a/apps/wallet/src/components/inputs/TokenAutocomplete.vue +++ b/apps/wallet/src/components/inputs/TokenAutocomplete.vue @@ -3,26 +3,29 @@ v-model="model" :multiple="props.multiple.value" :label="props.label.value" - item-value="value" - item-title="text" + item-value="id" + :item-title="item => `${item.name} (${item.symbol})`" :items="items" :variant="props.variant.value" :density="props.density.value" :readonly="props.readonly.value" :disabled="props.disabled.value" :rules="props.rules.value" + :no-data-text="props.noDataText.value" + data-test-id="token-autocomplete" /> diff --git a/apps/wallet/src/components/request-policies/specifier/AssetSpecifier.vue b/apps/wallet/src/components/request-policies/specifier/AssetSpecifier.vue new file mode 100644 index 000000000..9961411eb --- /dev/null +++ b/apps/wallet/src/components/request-policies/specifier/AssetSpecifier.vue @@ -0,0 +1,91 @@ + + diff --git a/apps/wallet/src/components/request-policies/specifier/SpecifierSelector.vue b/apps/wallet/src/components/request-policies/specifier/SpecifierSelector.vue index 170716ef7..ce9fb54f6 100644 --- a/apps/wallet/src/components/request-policies/specifier/SpecifierSelector.vue +++ b/apps/wallet/src/components/request-policies/specifier/SpecifierSelector.vue @@ -37,6 +37,7 @@ import UserGroupSpecifier from './UserGroupSpecifier.vue'; import UserSpecifier from './UserSpecifier.vue'; import UnsupportedSpecifier from './UnsupportedSpecifier.vue'; import { VAutocomplete } from 'vuetify/components'; +import AssetSpecifier from './AssetSpecifier.vue'; const input = withDefaults( defineProps<{ @@ -70,6 +71,8 @@ const componentsMap: { } = { AddUser: null, AddUserGroup: null, + AddAsset: null, + AddAccount: null, AddRequestPolicy: null, AddAddressBookEntry: null, @@ -83,6 +86,9 @@ const componentsMap: { EditUser: UserSpecifier, EditAddressBookEntry: AddressBookEntrySpecifier, RemoveAddressBookEntry: AddressBookEntrySpecifier, + EditAsset: AssetSpecifier, + RemoveAsset: AssetSpecifier, + // below variants are not supported yet EditPermission: UnsupportedSpecifier, EditRequestPolicy: UnsupportedSpecifier, @@ -247,6 +253,15 @@ watch( case RequestSpecifierEnum.SetDisasterRecovery: model.value = { [specifier.value]: null }; break; + case RequestSpecifierEnum.AddAsset: + model.value = { [specifier.value]: null }; + break; + case RequestSpecifierEnum.EditAsset: + model.value = { [specifier.value]: { Any: null } }; + break; + case RequestSpecifierEnum.RemoveAsset: + model.value = { [specifier.value]: { Any: null } }; + break; default: unreachable(specifier.value); } diff --git a/apps/wallet/src/components/requests/RecentRequests.vue b/apps/wallet/src/components/requests/RecentRequests.vue index 552d8c2f4..232ed0128 100644 --- a/apps/wallet/src/components/requests/RecentRequests.vue +++ b/apps/wallet/src/components/requests/RecentRequests.vue @@ -69,7 +69,7 @@ import RequestList from './RequestList.vue'; const props = withDefaults( defineProps<{ - types: ListRequestsOperationType[]; + types?: ListRequestsOperationType[]; title?: string; limit?: number; sortBy?: ListRequestsArgs['sortBy']; @@ -82,6 +82,7 @@ const props = withDefaults( }>(), { title: undefined, + types: undefined, limit: 3, sortBy: () => ({ expirationDt: 'asc', diff --git a/apps/wallet/src/components/requests/RequestDetailView.vue b/apps/wallet/src/components/requests/RequestDetailView.vue index 7430c72fa..600a0c1ac 100644 --- a/apps/wallet/src/components/requests/RequestDetailView.vue +++ b/apps/wallet/src/components/requests/RequestDetailView.vue @@ -241,6 +241,7 @@ import RequestMetadata from './RequestMetadata.vue'; import RequestStatusChip from './RequestStatusChip.vue'; import AddAccountOperation from './operations/AddAccountOperation.vue'; import AddAddressBookEntryOperation from './operations/AddAddressBookEntryOperation.vue'; +import AddAssetOperation from './operations/AddAssetOperation.vue'; import AddRequestPolicyOperation from './operations/AddRequestPolicyOperation.vue'; import AddUserGroupOperation from './operations/AddUserGroupOperation.vue'; import AddUserOperation from './operations/AddUserOperation.vue'; @@ -258,6 +259,8 @@ import RemoveUserGroupOperation from './operations/RemoveUserGroupOperation.vue' import SystemUpgradeOperation from './operations/SystemUpgradeOperation.vue'; import TransferOperation from './operations/TransferOperation.vue'; import UnsupportedOperation from './operations/UnsupportedOperation.vue'; +import EditAssetOperation from './operations/EditAssetOperation.vue'; +import RemoveAssetOperation from './operations/RemoveAssetOperation.vue'; const i18n = useI18n(); @@ -294,6 +297,9 @@ const componentsMap: { SystemUpgrade: SystemUpgradeOperation, EditPermission: EditPermissionOperation, ManageSystemInfo: ManageSystemInfoOperation, + AddAsset: AddAssetOperation, + EditAsset: EditAssetOperation, + RemoveAsset: RemoveAssetOperation, CallExternalCanister: CallExternalCanisterOperation, ChangeExternalCanister: UnsupportedOperation, CreateExternalCanister: UnsupportedOperation, diff --git a/apps/wallet/src/components/requests/RequestDialog.spec.ts b/apps/wallet/src/components/requests/RequestDialog.spec.ts index fabd7f20c..2626b61e2 100644 --- a/apps/wallet/src/components/requests/RequestDialog.spec.ts +++ b/apps/wallet/src/components/requests/RequestDialog.spec.ts @@ -6,22 +6,34 @@ import { GetRequestResultData, RequestOperation, RequestApproval, + Asset, } from '~/generated/station/station.did'; import { services } from '~/plugins/services.plugin'; import { mount } from '~/test.utils'; import { ExtractOk } from '~/types/helper.types'; import RequestDialog from './RequestDialog.vue'; +const mockAsset: Asset = { + blockchain: 'icp', + decimals: 2, + id: '1', + metadata: [], + name: 'ICP', + symbol: 'ICP', + standards: ['icp_native', 'icrc1'], +}; + const transferOperation1 = { Transfer: { from_account: [ { - address: 'fromaddress1', + addresses: [{ address: 'fromaddress1' }], }, ], input: { to: 'toaddress1', }, + from_asset: mockAsset, }, } as RequestOperation; @@ -29,12 +41,13 @@ const transferOperation2 = { Transfer: { from_account: [ { - address: 'fromaddress2', + addresses: [{ address: 'fromaddress2' }], }, ], input: { to: 'toaddress2', }, + from_asset: mockAsset, }, } as RequestOperation; diff --git a/apps/wallet/src/components/requests/RequestListItem.vue b/apps/wallet/src/components/requests/RequestListItem.vue index fc31661e0..b14947c7f 100644 --- a/apps/wallet/src/components/requests/RequestListItem.vue +++ b/apps/wallet/src/components/requests/RequestListItem.vue @@ -46,6 +46,7 @@ import { KeysOfUnion } from '~/utils/helper.utils'; import RequestStatusChip from './RequestStatusChip.vue'; import ReviewRequestBtn from './ReviewRequestBtn.vue'; import AddAccountOperation from './operations/AddAccountOperation.vue'; +import AddAssetOperation from './operations/AddAssetOperation.vue'; import AddAddressBookEntryOperation from './operations/AddAddressBookEntryOperation.vue'; import AddRequestPolicyOperation from './operations/AddRequestPolicyOperation.vue'; import AddUserGroupOperation from './operations/AddUserGroupOperation.vue'; @@ -64,6 +65,8 @@ import RemoveUserGroupOperation from './operations/RemoveUserGroupOperation.vue' import SystemUpgradeOperation from './operations/SystemUpgradeOperation.vue'; import TransferOperation from './operations/TransferOperation.vue'; import UnsupportedOperation from './operations/UnsupportedOperation.vue'; +import EditAssetOperation from './operations/EditAssetOperation.vue'; +import RemoveAssetOperation from './operations/RemoveAssetOperation.vue'; const props = withDefaults( defineProps<{ @@ -103,6 +106,11 @@ const componentsMap: { EditPermission: EditPermissionOperation, ManageSystemInfo: ManageSystemInfoOperation, CallExternalCanister: CallExternalCanisterOperation, + AddAsset: AddAssetOperation, + EditAsset: EditAssetOperation, + RemoveAsset: RemoveAssetOperation, + + // below variants are not supported yet ChangeExternalCanister: UnsupportedOperation, CreateExternalCanister: UnsupportedOperation, ConfigureExternalCanister: UnsupportedOperation, diff --git a/apps/wallet/src/components/requests/operations/AddAccountOperation.vue b/apps/wallet/src/components/requests/operations/AddAccountOperation.vue index bf57e775f..0068af1f3 100644 --- a/apps/wallet/src/components/requests/operations/AddAccountOperation.vue +++ b/apps/wallet/src/components/requests/operations/AddAccountOperation.vue @@ -6,10 +6,10 @@ {{ accountSetup.configuration.name ?? '-' }} - - + + @@ -24,6 +24,7 @@ import AccountSetupWizard, { import { useDefaultAccountSetupWizardModel } from '~/composables/account.composable'; import { AddAccountOperation, Request } from '~/generated/station/station.did'; import RequestOperationListRow from '../RequestOperationListRow.vue'; +import { useStationStore } from '~/stores/station.store'; const props = withDefaults( defineProps<{ @@ -35,15 +36,22 @@ const props = withDefaults( mode: 'list', }, ); - +const station = useStationStore(); const isListMode = computed(() => props.mode === 'list'); const accountSetup: Ref = ref(useDefaultAccountSetupWizardModel()); +const assetsText = computed(() => + props.operation.input.assets + .map(id => station.configuration.details.supported_assets.find(asset => asset.id === id)) + .filter(a => !!a) + .map(asset => `${asset.name} (${asset.symbol})`) + .join(', '), +); + onBeforeMount(() => { const model: AccountSetupWizardModel = useDefaultAccountSetupWizardModel(); model.configuration.name = props.operation.input.name; - model.configuration.blockchain = props.operation.input.blockchain; - model.configuration.standard = props.operation.input.standard; + model.configuration.assets = props.operation.input.assets; model.request_policy.configurationRule = props.operation.input.configs_request_policy?.[0]; model.request_policy.transferRule = props.operation.input.transfer_request_policy?.[0]; model.permission.configuration = props.operation.input.configs_permission; diff --git a/apps/wallet/src/components/requests/operations/AddAssetOperation.vue b/apps/wallet/src/components/requests/operations/AddAssetOperation.vue new file mode 100644 index 000000000..70d914cd6 --- /dev/null +++ b/apps/wallet/src/components/requests/operations/AddAssetOperation.vue @@ -0,0 +1,52 @@ + + + diff --git a/apps/wallet/src/components/requests/operations/EditAccountOperation.vue b/apps/wallet/src/components/requests/operations/EditAccountOperation.vue index ccdf4c51f..9d07f71db 100644 --- a/apps/wallet/src/components/requests/operations/EditAccountOperation.vue +++ b/apps/wallet/src/components/requests/operations/EditAccountOperation.vue @@ -6,6 +6,22 @@ {{ props.operation.input.name[0] ?? '-' }} + + + + @@ -23,8 +39,10 @@ import { } from '~/composables/account.composable'; import logger from '~/core/logger.core'; import { EditAccountOperation, Request } from '~/generated/station/station.did'; -import { variantIs } from '~/utils/helper.utils'; +import { unreachable, variantIs } from '~/utils/helper.utils'; import RequestOperationListRow from '../RequestOperationListRow.vue'; +import { useI18n } from 'vue-i18n'; +import { useStationStore } from '~/stores/station.store'; const props = withDefaults( defineProps<{ @@ -37,10 +55,60 @@ const props = withDefaults( }, ); +const i18n = useI18n(); + const isListMode = computed(() => props.mode === 'list'); const model: Ref = ref(useDefaultAccountSetupWizardModel()); const loading = ref(false); +const editAssets = computed(() => { + const assets = { + addAssets: '', + replaceAssets: '', + removeAssets: '', + }; + if (props.operation.input.change_assets[0]) { + if (variantIs(props.operation.input.change_assets[0], 'Change')) { + if (props.operation.input.change_assets[0].Change.add_assets.length > 0) { + assets.addAssets = `${i18n.t('requests.types.editaccount.added_assets')}: ${assetIdsToString( + props.operation.input.change_assets[0].Change.add_assets, + )}`; + } + + if (props.operation.input.change_assets[0].Change.remove_assets.length > 0) { + assets.removeAssets = `${i18n.t('requests.types.editaccount.removed_assets')}: ${assetIdsToString( + props.operation.input.change_assets[0].Change.remove_assets, + )}`; + } + } else if (variantIs(props.operation.input.change_assets[0], 'ReplaceWith')) { + assets.replaceAssets = `${i18n.t('requests.types.editaccount.replaced_assets')}: ${assetIdsToString( + props.operation.input.change_assets[0].ReplaceWith.assets, + )}`; + } else { + unreachable(props.operation.input.change_assets[0]); + } + } + + return assets; +}); + +const station = useStationStore(); + +function assetIdsToString(ids: string[]): string { + return ids + .map(id => { + const maybeAsset = station.configuration.details.supported_assets.find( + asset => asset.id == id, + ); + if (maybeAsset) { + return `${maybeAsset.symbol} (${maybeAsset.name})`; + } else { + return id; + } + }) + .join(', '); +} + const fetchDetails = async () => { try { if (loading.value || isListMode.value) { diff --git a/apps/wallet/src/components/requests/operations/EditAddressBookEntryOperation.vue b/apps/wallet/src/components/requests/operations/EditAddressBookEntryOperation.vue index 8feda4535..79d1a5ed4 100644 --- a/apps/wallet/src/components/requests/operations/EditAddressBookEntryOperation.vue +++ b/apps/wallet/src/components/requests/operations/EditAddressBookEntryOperation.vue @@ -19,7 +19,7 @@ - + @@ -35,6 +35,7 @@ import { import { useStationStore } from '~/stores/station.store'; import { variantIs } from '~/utils/helper.utils'; import RequestOperationListRow from '../RequestOperationListRow.vue'; +import { VProgressCircular } from 'vuetify/components'; const props = withDefaults( defineProps<{ diff --git a/apps/wallet/src/components/requests/operations/EditAssetOperation.vue b/apps/wallet/src/components/requests/operations/EditAssetOperation.vue new file mode 100644 index 000000000..85b0b78c7 --- /dev/null +++ b/apps/wallet/src/components/requests/operations/EditAssetOperation.vue @@ -0,0 +1,140 @@ + + + diff --git a/apps/wallet/src/components/requests/operations/EditPermissionOperation.vue b/apps/wallet/src/components/requests/operations/EditPermissionOperation.vue index fca1a296c..277dfd0c0 100644 --- a/apps/wallet/src/components/requests/operations/EditPermissionOperation.vue +++ b/apps/wallet/src/components/requests/operations/EditPermissionOperation.vue @@ -11,7 +11,7 @@ - + diff --git a/apps/wallet/src/components/requests/operations/RemoveAddressBookEntryOperation.vue b/apps/wallet/src/components/requests/operations/RemoveAddressBookEntryOperation.vue index 562582380..676312280 100644 --- a/apps/wallet/src/components/requests/operations/RemoveAddressBookEntryOperation.vue +++ b/apps/wallet/src/components/requests/operations/RemoveAddressBookEntryOperation.vue @@ -7,7 +7,7 @@ - + @@ -22,6 +22,7 @@ import { } from '~/generated/station/station.did'; import { useStationStore } from '~/stores/station.store'; import RequestOperationListRow from '../RequestOperationListRow.vue'; +import { VProgressCircular } from 'vuetify/components'; const props = withDefaults( defineProps<{ diff --git a/apps/wallet/src/components/requests/operations/RemoveAssetOperation.vue b/apps/wallet/src/components/requests/operations/RemoveAssetOperation.vue new file mode 100644 index 000000000..e02a22a9c --- /dev/null +++ b/apps/wallet/src/components/requests/operations/RemoveAssetOperation.vue @@ -0,0 +1,77 @@ + + + diff --git a/apps/wallet/src/components/requests/operations/TransferOperation.vue b/apps/wallet/src/components/requests/operations/TransferOperation.vue index 08542c758..aa23fb3cb 100644 --- a/apps/wallet/src/components/requests/operations/TransferOperation.vue +++ b/apps/wallet/src/components/requests/operations/TransferOperation.vue @@ -18,7 +18,7 @@
- +
- {{ account ? formatBalance(formValue.amount, account.decimals) : '-' }} - {{ account ? account.symbol : '' }} + {{ account ? formatBalance(formValue.amount, asset.decimals) : '-' }} + {{ account ? asset.symbol : '' }}
@@ -46,7 +46,7 @@ :prepend-icon="mdiWallet" readonly /> - + @@ -60,6 +60,9 @@ import { Routes } from '~/configs/routes.config'; import TextOverflow from '~/components/TextOverflow.vue'; import { copyToClipboard } from '~/utils/app.utils'; import { formatBalance } from '~/utils/helper.utils'; +import ShortenedAddress from '~/components/ShortenedAddress.vue'; +import { AddressFormat } from '~/types/chain.types'; +import { detectAddressFormat } from '~/utils/asset.utils'; const props = withDefaults( defineProps<{ @@ -75,6 +78,8 @@ const props = withDefaults( const isListMode = computed(() => props.mode === 'list'); const formValue: Ref> = ref({}); const account = computed(() => props.operation.from_account?.[0]); +const asset = computed(() => props.operation.from_asset); +const format = ref(undefined); onBeforeMount(() => { const transfer: Partial = {}; @@ -89,6 +94,8 @@ onBeforeMount(() => { } transfer.metadata = props.operation.input.metadata; + format.value = detectAddressFormat(props.operation.from_asset.blockchain, transfer.to); + formValue.value = transfer; }); diff --git a/apps/wallet/src/composables/account.composable.ts b/apps/wallet/src/composables/account.composable.ts index 34b7cb944..2699de2e3 100644 --- a/apps/wallet/src/composables/account.composable.ts +++ b/apps/wallet/src/composables/account.composable.ts @@ -7,7 +7,6 @@ import logger from '~/core/logger.core'; import { UUID } from '~/generated/station/station.did'; import { useAppStore } from '~/stores/app.store'; import { useStationStore } from '~/stores/station.store'; -import { BlockchainStandard, BlockchainType, TokenSymbol } from '~/types/chain.types'; import { parseDate } from '~/utils/date.utils'; export type Filters = { @@ -84,9 +83,7 @@ export const useDefaultAccountSetupWizardModel = ({ } = {}): AccountSetupWizardModel => { return { configuration: { - blockchain: BlockchainType.InternetComputer, - standard: BlockchainStandard.Native, - symbol: TokenSymbol.ICP, + assets: [], }, permission: { read: { @@ -147,10 +144,8 @@ export const useLoadAccountSetupWizardModel = async ( configuration: { id: account.id, name: account.name, - blockchain: account.blockchain, lastModified: account.last_modification_timestamp, - standard: account.standard, - symbol: account.symbol, + assets: account.assets.map(accountAsset => accountAsset.asset_id), }, permission: { read, diff --git a/apps/wallet/src/composables/autocomplete.composable.ts b/apps/wallet/src/composables/autocomplete.composable.ts index ee3161c30..8c6a1e3d8 100644 --- a/apps/wallet/src/composables/autocomplete.composable.ts +++ b/apps/wallet/src/composables/autocomplete.composable.ts @@ -101,3 +101,18 @@ export const useAddressBookAutocomplete = () => { return autocomplete; }; + +export const useAssetAutocomplete = () => { + const station = useStationStore(); + + const autocomplete = useAutocomplete(async () => { + const results = await station.service.listAssets({ + limit: 100, + offset: 0, + }); + + return results.assets; + }); + + return autocomplete; +}; diff --git a/apps/wallet/src/composables/request.composable.ts b/apps/wallet/src/composables/request.composable.ts index ed4f0db9a..356ae8fc5 100644 --- a/apps/wallet/src/composables/request.composable.ts +++ b/apps/wallet/src/composables/request.composable.ts @@ -73,6 +73,13 @@ export const useAvailableDomains = ( }); } + if (hasRequiredPrivilege({ anyOf: [Privilege.ListAssets] })) { + domains.value.push({ + id: RequestDomains.Assets, + types: [{ AddAsset: null }, { EditAsset: null }, { RemoveAsset: null }], + }); + } + domains.value.push({ id: RequestDomains.System, types: [ diff --git a/apps/wallet/src/configs/permissions.config.ts b/apps/wallet/src/configs/permissions.config.ts index bb9e198fd..2cfbc5011 100644 --- a/apps/wallet/src/configs/permissions.config.ts +++ b/apps/wallet/src/configs/permissions.config.ts @@ -364,6 +364,48 @@ export const globalPermissions = (): AggregatedResoucePermissions[] => [ ); } + return false; + }, + }, + { + resourceType: ResourceTypeEnum.Asset, + resources: [ + { + action: ResourceActionEnum.List, + resource: { Asset: { List: null } }, + allow: defaultAllowLevels(), + canEdit: false, + }, + { + action: ResourceActionEnum.Create, + resource: { Asset: { Create: null } }, + allow: defaultAllowLevels(), + canEdit: false, + }, + { + action: ResourceActionEnum.Read, + resource: { Asset: { Read: { Any: null } } }, + allow: defaultAllowLevels(), + canEdit: false, + }, + { + action: ResourceActionEnum.Update, + resource: { Asset: { Update: { Any: null } } }, + allow: defaultAllowLevels(), + canEdit: false, + }, + { + action: ResourceActionEnum.Delete, + resource: { Asset: { Delete: { Any: null } } }, + allow: defaultAllowLevels(), + canEdit: false, + }, + ], + match(specifier: Resource, resource: Resource): boolean { + if (variantIs(specifier, 'Asset') && variantIs(resource, 'Asset')) { + return isResourceActionContained(specifier.Asset, resource.Asset); + } + return false; }, }, diff --git a/apps/wallet/src/configs/request-policies.config.ts b/apps/wallet/src/configs/request-policies.config.ts index 0400a4b07..c7bcb37cf 100644 --- a/apps/wallet/src/configs/request-policies.config.ts +++ b/apps/wallet/src/configs/request-policies.config.ts @@ -39,4 +39,7 @@ export const requestSpecifiersIncludedRules = (): Record< [RequestSpecifierEnum.CallExternalCanister]: [...defaultRequestPolicyRules], [RequestSpecifierEnum.FundExternalCanister]: [...defaultRequestPolicyRules], [RequestSpecifierEnum.SetDisasterRecovery]: [...defaultRequestPolicyRules], + [RequestSpecifierEnum.AddAsset]: [...defaultRequestPolicyRules], + [RequestSpecifierEnum.EditAsset]: [...defaultRequestPolicyRules], + [RequestSpecifierEnum.RemoveAsset]: [...defaultRequestPolicyRules], }); diff --git a/apps/wallet/src/configs/routes.config.ts b/apps/wallet/src/configs/routes.config.ts index 29b83872a..bf081c6fd 100644 --- a/apps/wallet/src/configs/routes.config.ts +++ b/apps/wallet/src/configs/routes.config.ts @@ -2,8 +2,10 @@ export enum Routes { Login = 'Login', Error = 'Error', NotFound = 'NotFound', + Dashboard = 'Dashboard', Accounts = 'Accounts', Account = 'Account', + AccountAsset = 'AccountAsset', MySettings = 'MySettings', UserGroups = 'UserGroups', SystemSettings = 'SystemSettings', @@ -14,6 +16,7 @@ export enum Routes { Initialization = 'Initialization', AddStation = 'AddStation', Permissions = 'Permissions', + Assets = 'Assets', ExternalCanisters = 'ExternalCanisters', ExternalCanister = 'ExternalCanister', // Request Pages @@ -31,4 +34,4 @@ export enum RouteStatusCode { } export const defaultLoginRoute = Routes.Login; -export const defaultHomeRoute = Routes.Accounts; +export const defaultHomeRoute = Routes.Dashboard; diff --git a/apps/wallet/src/generated/icp_ledger/icp_ledger.did b/apps/wallet/src/generated/icp_ledger/icp_ledger.did new file mode 100644 index 000000000..c11b9434f --- /dev/null +++ b/apps/wallet/src/generated/icp_ledger/icp_ledger.did @@ -0,0 +1,451 @@ +// This is the official Ledger interface that is guaranteed to be backward compatible. + +// Amount of tokens, measured in 10^-8 of a token. +type Tokens = record { + e8s : nat64; +}; + +// Number of nanoseconds from the UNIX epoch in UTC timezone. +type TimeStamp = record { + timestamp_nanos: nat64; +}; + +// AccountIdentifier is a 32-byte array. +// The first 4 bytes is big-endian encoding of a CRC32 checksum of the last 28 bytes. +type AccountIdentifier = blob; + +// Subaccount is an arbitrary 32-byte byte array. +// Ledger uses subaccounts to compute the source address, which enables one +// principal to control multiple ledger accounts. +type SubAccount = blob; + +// Sequence number of a block produced by the ledger. +type BlockIndex = nat64; + +type Transaction = record { + memo : Memo; + icrc1_memo: opt blob; + operation : opt Operation; + created_at_time : TimeStamp; +}; + +// An arbitrary number associated with a transaction. +// The caller can set it in a `transfer` call as a correlation identifier. +type Memo = nat64; + +// Arguments for the `transfer` call. +type TransferArgs = record { + // Transaction memo. + // See comments for the `Memo` type. + memo: Memo; + // The amount that the caller wants to transfer to the destination address. + amount: Tokens; + // The amount that the caller pays for the transaction. + // Must be 10000 e8s. + fee: Tokens; + // The subaccount from which the caller wants to transfer funds. + // If null, the ledger uses the default (all zeros) subaccount to compute the source address. + // See comments for the `SubAccount` type. + from_subaccount: opt SubAccount; + // The destination account. + // If the transfer is successful, the balance of this address increases by `amount`. + to: AccountIdentifier; + // The point in time when the caller created this request. + // If null, the ledger uses current IC time as the timestamp. + created_at_time: opt TimeStamp; +}; + +type TransferError = variant { + // The fee that the caller specified in the transfer request was not the one that ledger expects. + // The caller can change the transfer fee to the `expected_fee` and retry the request. + BadFee : record { expected_fee : Tokens; }; + // The account specified by the caller doesn't have enough funds. + InsufficientFunds : record { balance: Tokens; }; + // The request is too old. + // The ledger only accepts requests created within 24 hours window. + // This is a non-recoverable error. + TxTooOld : record { allowed_window_nanos: nat64 }; + // The caller specified `created_at_time` that is too far in future. + // The caller can retry the request later. + TxCreatedInFuture : null; + // The ledger has already executed the request. + // `duplicate_of` field is equal to the index of the block containing the original transaction. + TxDuplicate : record { duplicate_of: BlockIndex; } +}; + +type TransferResult = variant { + Ok : BlockIndex; + Err : TransferError; +}; + +// Arguments for the `account_balance` call. +type AccountBalanceArgs = record { + account: AccountIdentifier; +}; + +type TransferFeeArg = record {}; + +type TransferFee = record { + // The fee to pay to perform a transfer + transfer_fee: Tokens; +}; + +type GetBlocksArgs = record { + // The index of the first block to fetch. + start : BlockIndex; + // Max number of blocks to fetch. + length : nat64; +}; + +type Operation = variant { + Mint : record { + to : AccountIdentifier; + amount : Tokens; + }; + Burn : record { + from : AccountIdentifier; + spender : opt AccountIdentifier; + amount : Tokens; + }; + Transfer : record { + from : AccountIdentifier; + to : AccountIdentifier; + amount : Tokens; + fee : Tokens; + }; + Approve : record { + from : AccountIdentifier; + spender : AccountIdentifier; + // This field is deprecated and should not be used. + allowance_e8s : int; + allowance: Tokens; + fee : Tokens; + expires_at : opt TimeStamp; + }; + TransferFrom : record { + from : AccountIdentifier; + to : AccountIdentifier; + spender : AccountIdentifier; + amount : Tokens; + fee : Tokens; + }; +}; + + + +type Block = record { + parent_hash : opt blob; + transaction : Transaction; + timestamp : TimeStamp; +}; + +// A prefix of the block range specified in the [GetBlocksArgs] request. +type BlockRange = record { + // A prefix of the requested block range. + // The index of the first block is equal to [GetBlocksArgs.from]. + // + // Note that the number of blocks might be less than the requested + // [GetBlocksArgs.len] for various reasons, for example: + // + // 1. The query might have hit the replica with an outdated state + // that doesn't have the full block range yet. + // 2. The requested range is too large to fit into a single reply. + // + // NOTE: the list of blocks can be empty if: + // 1. [GetBlocksArgs.len] was zero. + // 2. [GetBlocksArgs.from] was larger than the last block known to the canister. + blocks : vec Block; +}; + +// An error indicating that the arguments passed to [QueryArchiveFn] were invalid. +type QueryArchiveError = variant { + // [GetBlocksArgs.from] argument was smaller than the first block + // served by the canister that received the request. + BadFirstBlockIndex : record { + requested_index : BlockIndex; + first_valid_index : BlockIndex; + }; + + // Reserved for future use. + Other : record { + error_code : nat64; + error_message : text; + }; +}; + +type QueryArchiveResult = variant { + // Successfully fetched zero or more blocks. + Ok : BlockRange; + // The [GetBlocksArgs] request was invalid. + Err : QueryArchiveError; +}; + +// A function that is used for fetching archived ledger blocks. +type QueryArchiveFn = func (GetBlocksArgs) -> (QueryArchiveResult) query; + +// The result of a "query_blocks" call. +// +// The structure of the result is somewhat complicated because the main ledger canister might +// not have all the blocks that the caller requested: One or more "archive" canisters might +// store some of the requested blocks. +// +// Note: as of Q4 2021 when this interface is authored, the IC doesn't support making nested +// query calls within a query call. +type QueryBlocksResponse = record { + // The total number of blocks in the chain. + // If the chain length is positive, the index of the last block is `chain_len - 1`. + chain_length : nat64; + + // System certificate for the hash of the latest block in the chain. + // Only present if `query_blocks` is called in a non-replicated query context. + certificate : opt blob; + + // List of blocks that were available in the ledger when it processed the call. + // + // The blocks form a contiguous range, with the first block having index + // [first_block_index] (see below), and the last block having index + // [first_block_index] + len(blocks) - 1. + // + // The block range can be an arbitrary sub-range of the originally requested range. + blocks : vec Block; + + // The index of the first block in "blocks". + // If the blocks vector is empty, the exact value of this field is not specified. + first_block_index : BlockIndex; + + // Encoding of instructions for fetching archived blocks whose indices fall into the + // requested range. + // + // For each entry `e` in [archived_blocks], `[e.from, e.from + len)` is a sub-range + // of the originally requested block range. + archived_blocks : vec ArchivedBlocksRange; +}; + +type ArchivedBlocksRange = record { + // The index of the first archived block that can be fetched using the callback. + start : BlockIndex; + + // The number of blocks that can be fetch using the callback. + length : nat64; + + // The function that should be called to fetch the archived blocks. + // The range of the blocks accessible using this function is given by [from] + // and [len] fields above. + callback : QueryArchiveFn; +}; + +type ArchivedEncodedBlocksRange = record { + callback : func (GetBlocksArgs) -> ( + variant { Ok : vec blob; Err : QueryArchiveError }, + ) query; + start : nat64; + length : nat64; +}; + +type QueryEncodedBlocksResponse = record { + certificate : opt blob; + blocks : vec blob; + chain_length : nat64; + first_block_index : nat64; + archived_blocks : vec ArchivedEncodedBlocksRange; +}; + +type Archive = record { + canister_id: principal; +}; + +type Archives = record { + archives: vec Archive; +}; + +type Duration = record { + secs: nat64; + nanos: nat32; +}; + +type ArchiveOptions = record { + trigger_threshold : nat64; + num_blocks_to_archive : nat64; + node_max_memory_size_bytes: opt nat64; + max_message_size_bytes: opt nat64; + controller_id: principal; + cycles_for_archive_creation: opt nat64; +}; + +// Account identifier encoded as a 64-byte ASCII hex string. +type TextAccountIdentifier = text; + +// Arguments for the `send_dfx` call. +type SendArgs = record { + memo: Memo; + amount: Tokens; + fee: Tokens; + from_subaccount: opt SubAccount; + to: TextAccountIdentifier; + created_at_time: opt TimeStamp; +}; + +type AccountBalanceArgsDfx = record { + account: TextAccountIdentifier; +}; + +type FeatureFlags = record { + icrc2 : bool; +}; + +type InitArgs = record { + minting_account: TextAccountIdentifier; + icrc1_minting_account: opt Account; + initial_values: vec record {TextAccountIdentifier; Tokens}; + max_message_size_bytes: opt nat64; + transaction_window: opt Duration; + archive_options: opt ArchiveOptions; + send_whitelist: vec principal; + transfer_fee: opt Tokens; + token_symbol: opt text; + token_name: opt text; + feature_flags : opt FeatureFlags; + maximum_number_of_accounts : opt nat64; + accounts_overflow_trim_quantity: opt nat64; +}; + +type Icrc1BlockIndex = nat; +// Number of nanoseconds since the UNIX epoch in UTC timezone. +type Icrc1Timestamp = nat64; +type Icrc1Tokens = nat; + +type Account = record { + owner : principal; + subaccount : opt SubAccount; +}; + +type TransferArg = record { + from_subaccount : opt SubAccount; + to : Account; + amount : Icrc1Tokens; + fee : opt Icrc1Tokens; + memo : opt blob; + created_at_time: opt Icrc1Timestamp; +}; + +type Icrc1TransferError = variant { + BadFee : record { expected_fee : Icrc1Tokens }; + BadBurn : record { min_burn_amount : Icrc1Tokens }; + InsufficientFunds : record { balance : Icrc1Tokens }; + TooOld; + CreatedInFuture : record { ledger_time : nat64 }; + TemporarilyUnavailable; + Duplicate : record { duplicate_of : Icrc1BlockIndex }; + GenericError : record { error_code : nat; message : text }; +}; + +type Icrc1TransferResult = variant { + Ok : Icrc1BlockIndex; + Err : Icrc1TransferError; +}; + +// The value returned from the [icrc1_metadata] endpoint. +type Value = variant { + Nat : nat; + Int : int; + Text : text; + Blob : blob; +}; + +type UpgradeArgs = record { + maximum_number_of_accounts : opt nat64; + icrc1_minting_account : opt Account; + feature_flags : opt FeatureFlags; +}; + +type LedgerCanisterPayload = variant { + Init: InitArgs; + Upgrade: opt UpgradeArgs; +}; + +type ApproveArgs = record { + from_subaccount : opt SubAccount; + spender : Account; + amount : Icrc1Tokens; + expected_allowance : opt Icrc1Tokens; + expires_at : opt TimeStamp; + fee : opt Icrc1Tokens; + memo : opt blob; + created_at_time: opt TimeStamp; +}; + +type ApproveError = variant { + BadFee : record { expected_fee : Icrc1Tokens }; + InsufficientFunds : record { balance : Icrc1Tokens }; + AllowanceChanged : record { current_allowance : Icrc1Tokens }; + Expired : record { ledger_time : nat64 }; + TooOld; + CreatedInFuture : record { ledger_time : nat64 }; + Duplicate : record { duplicate_of : Icrc1BlockIndex }; + TemporarilyUnavailable; + GenericError : record { error_code : nat; message : text }; +}; + +type ApproveResult = variant { + Ok : Icrc1BlockIndex; + Err : ApproveError; +}; + +type AllowanceArgs = record { + account : Account; + spender : Account; +}; + +type Allowance = record { + allowance : Icrc1Tokens; + expires_at : opt TimeStamp; +}; + +service: (LedgerCanisterPayload) -> { + // Transfers tokens from a subaccount of the caller to the destination address. + // The source address is computed from the principal of the caller and the specified subaccount. + // When successful, returns the index of the block containing the transaction. + transfer : (TransferArgs) -> (TransferResult); + + // Returns the amount of Tokens on the specified account. + account_balance : (AccountBalanceArgs) -> (Tokens) query; + + // Returns the current transfer_fee. + transfer_fee : (TransferFeeArg) -> (TransferFee) query; + + // Queries blocks in the specified range. + query_blocks : (GetBlocksArgs) -> (QueryBlocksResponse) query; + + // Queries encoded blocks in the specified range + query_encoded_blocks : (GetBlocksArgs) -> (QueryEncodedBlocksResponse) query; + + // Returns token symbol. + symbol : () -> (record { symbol: text }) query; + + // Returns token name. + name : () -> (record { name: text }) query; + + // Returns token decimals. + decimals : () -> (record { decimals: nat32 }) query; + + // Returns the existing archive canisters information. + archives : () -> (Archives) query; + + send_dfx : (SendArgs) -> (BlockIndex); + account_balance_dfx : (AccountBalanceArgsDfx) -> (Tokens) query; + + // The following methods implement the ICRC-1 Token Standard. + // https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-1 + icrc1_name : () -> (text) query; + icrc1_symbol : () -> (text) query; + icrc1_decimals : () -> (nat8) query; + icrc1_metadata : () -> (vec record { text; Value }) query; + icrc1_total_supply : () -> (Icrc1Tokens) query; + icrc1_fee : () -> (Icrc1Tokens) query; + icrc1_minting_account : () -> (opt Account) query; + icrc1_balance_of : (Account) -> (Icrc1Tokens) query; + icrc1_transfer : (TransferArg) -> (Icrc1TransferResult); + icrc1_supported_standards : () -> (vec record { name : text; url : text }) query; + icrc2_approve : (ApproveArgs) -> (ApproveResult); + icrc2_allowance : (AllowanceArgs) -> (Allowance) query; +} diff --git a/apps/wallet/src/generated/icp_ledger/icp_ledger.did.d.ts b/apps/wallet/src/generated/icp_ledger/icp_ledger.did.d.ts new file mode 100644 index 000000000..420381746 --- /dev/null +++ b/apps/wallet/src/generated/icp_ledger/icp_ledger.did.d.ts @@ -0,0 +1,248 @@ +import type { Principal } from '@dfinity/principal'; +import type { ActorMethod } from '@dfinity/agent'; +import type { IDL } from '@dfinity/candid'; + +export interface Account { + 'owner' : Principal, + 'subaccount' : [] | [SubAccount], +} +export interface AccountBalanceArgs { 'account' : AccountIdentifier } +export interface AccountBalanceArgsDfx { 'account' : TextAccountIdentifier } +export type AccountIdentifier = Uint8Array | number[]; +export interface Allowance { + 'allowance' : Icrc1Tokens, + 'expires_at' : [] | [TimeStamp], +} +export interface AllowanceArgs { 'account' : Account, 'spender' : Account } +export interface ApproveArgs { + 'fee' : [] | [Icrc1Tokens], + 'memo' : [] | [Uint8Array | number[]], + 'from_subaccount' : [] | [SubAccount], + 'created_at_time' : [] | [TimeStamp], + 'amount' : Icrc1Tokens, + 'expected_allowance' : [] | [Icrc1Tokens], + 'expires_at' : [] | [TimeStamp], + 'spender' : Account, +} +export type ApproveError = { + 'GenericError' : { 'message' : string, 'error_code' : bigint } + } | + { 'TemporarilyUnavailable' : null } | + { 'Duplicate' : { 'duplicate_of' : Icrc1BlockIndex } } | + { 'BadFee' : { 'expected_fee' : Icrc1Tokens } } | + { 'AllowanceChanged' : { 'current_allowance' : Icrc1Tokens } } | + { 'CreatedInFuture' : { 'ledger_time' : bigint } } | + { 'TooOld' : null } | + { 'Expired' : { 'ledger_time' : bigint } } | + { 'InsufficientFunds' : { 'balance' : Icrc1Tokens } }; +export type ApproveResult = { 'Ok' : Icrc1BlockIndex } | + { 'Err' : ApproveError }; +export interface Archive { 'canister_id' : Principal } +export interface ArchiveOptions { + 'num_blocks_to_archive' : bigint, + 'trigger_threshold' : bigint, + 'max_message_size_bytes' : [] | [bigint], + 'cycles_for_archive_creation' : [] | [bigint], + 'node_max_memory_size_bytes' : [] | [bigint], + 'controller_id' : Principal, +} +export interface ArchivedBlocksRange { + 'callback' : QueryArchiveFn, + 'start' : BlockIndex, + 'length' : bigint, +} +export interface ArchivedEncodedBlocksRange { + 'callback' : [Principal, string], + 'start' : bigint, + 'length' : bigint, +} +export interface Archives { 'archives' : Array } +export interface Block { + 'transaction' : Transaction, + 'timestamp' : TimeStamp, + 'parent_hash' : [] | [Uint8Array | number[]], +} +export type BlockIndex = bigint; +export interface BlockRange { 'blocks' : Array } +export interface Duration { 'secs' : bigint, 'nanos' : number } +export interface FeatureFlags { 'icrc2' : boolean } +export interface GetBlocksArgs { 'start' : BlockIndex, 'length' : bigint } +export type Icrc1BlockIndex = bigint; +export type Icrc1Timestamp = bigint; +export type Icrc1Tokens = bigint; +export type Icrc1TransferError = { + 'GenericError' : { 'message' : string, 'error_code' : bigint } + } | + { 'TemporarilyUnavailable' : null } | + { 'BadBurn' : { 'min_burn_amount' : Icrc1Tokens } } | + { 'Duplicate' : { 'duplicate_of' : Icrc1BlockIndex } } | + { 'BadFee' : { 'expected_fee' : Icrc1Tokens } } | + { 'CreatedInFuture' : { 'ledger_time' : bigint } } | + { 'TooOld' : null } | + { 'InsufficientFunds' : { 'balance' : Icrc1Tokens } }; +export type Icrc1TransferResult = { 'Ok' : Icrc1BlockIndex } | + { 'Err' : Icrc1TransferError }; +export interface InitArgs { + 'send_whitelist' : Array, + 'token_symbol' : [] | [string], + 'transfer_fee' : [] | [Tokens], + 'minting_account' : TextAccountIdentifier, + 'maximum_number_of_accounts' : [] | [bigint], + 'accounts_overflow_trim_quantity' : [] | [bigint], + 'transaction_window' : [] | [Duration], + 'max_message_size_bytes' : [] | [bigint], + 'icrc1_minting_account' : [] | [Account], + 'archive_options' : [] | [ArchiveOptions], + 'initial_values' : Array<[TextAccountIdentifier, Tokens]>, + 'token_name' : [] | [string], + 'feature_flags' : [] | [FeatureFlags], +} +export type LedgerCanisterPayload = { 'Upgrade' : [] | [UpgradeArgs] } | + { 'Init' : InitArgs }; +export type Memo = bigint; +export type Operation = { + 'Approve' : { + 'fee' : Tokens, + 'from' : AccountIdentifier, + 'allowance_e8s' : bigint, + 'allowance' : Tokens, + 'expires_at' : [] | [TimeStamp], + 'spender' : AccountIdentifier, + } + } | + { + 'Burn' : { + 'from' : AccountIdentifier, + 'amount' : Tokens, + 'spender' : [] | [AccountIdentifier], + } + } | + { 'Mint' : { 'to' : AccountIdentifier, 'amount' : Tokens } } | + { + 'Transfer' : { + 'to' : AccountIdentifier, + 'fee' : Tokens, + 'from' : AccountIdentifier, + 'amount' : Tokens, + } + } | + { + 'TransferFrom' : { + 'to' : AccountIdentifier, + 'fee' : Tokens, + 'from' : AccountIdentifier, + 'amount' : Tokens, + 'spender' : AccountIdentifier, + } + }; +export type QueryArchiveError = { + 'BadFirstBlockIndex' : { + 'requested_index' : BlockIndex, + 'first_valid_index' : BlockIndex, + } + } | + { 'Other' : { 'error_message' : string, 'error_code' : bigint } }; +export type QueryArchiveFn = ActorMethod<[GetBlocksArgs], QueryArchiveResult>; +export type QueryArchiveResult = { 'Ok' : BlockRange } | + { 'Err' : QueryArchiveError }; +export interface QueryBlocksResponse { + 'certificate' : [] | [Uint8Array | number[]], + 'blocks' : Array, + 'chain_length' : bigint, + 'first_block_index' : BlockIndex, + 'archived_blocks' : Array, +} +export interface QueryEncodedBlocksResponse { + 'certificate' : [] | [Uint8Array | number[]], + 'blocks' : Array, + 'chain_length' : bigint, + 'first_block_index' : bigint, + 'archived_blocks' : Array, +} +export interface SendArgs { + 'to' : TextAccountIdentifier, + 'fee' : Tokens, + 'memo' : Memo, + 'from_subaccount' : [] | [SubAccount], + 'created_at_time' : [] | [TimeStamp], + 'amount' : Tokens, +} +export type SubAccount = Uint8Array | number[]; +export type TextAccountIdentifier = string; +export interface TimeStamp { 'timestamp_nanos' : bigint } +export interface Tokens { 'e8s' : bigint } +export interface Transaction { + 'memo' : Memo, + 'icrc1_memo' : [] | [Uint8Array | number[]], + 'operation' : [] | [Operation], + 'created_at_time' : TimeStamp, +} +export interface TransferArg { + 'to' : Account, + 'fee' : [] | [Icrc1Tokens], + 'memo' : [] | [Uint8Array | number[]], + 'from_subaccount' : [] | [SubAccount], + 'created_at_time' : [] | [Icrc1Timestamp], + 'amount' : Icrc1Tokens, +} +export interface TransferArgs { + 'to' : AccountIdentifier, + 'fee' : Tokens, + 'memo' : Memo, + 'from_subaccount' : [] | [SubAccount], + 'created_at_time' : [] | [TimeStamp], + 'amount' : Tokens, +} +export type TransferError = { + 'TxTooOld' : { 'allowed_window_nanos' : bigint } + } | + { 'BadFee' : { 'expected_fee' : Tokens } } | + { 'TxDuplicate' : { 'duplicate_of' : BlockIndex } } | + { 'TxCreatedInFuture' : null } | + { 'InsufficientFunds' : { 'balance' : Tokens } }; +export interface TransferFee { 'transfer_fee' : Tokens } +export type TransferFeeArg = {}; +export type TransferResult = { 'Ok' : BlockIndex } | + { 'Err' : TransferError }; +export interface UpgradeArgs { + 'maximum_number_of_accounts' : [] | [bigint], + 'icrc1_minting_account' : [] | [Account], + 'feature_flags' : [] | [FeatureFlags], +} +export type Value = { 'Int' : bigint } | + { 'Nat' : bigint } | + { 'Blob' : Uint8Array | number[] } | + { 'Text' : string }; +export interface _SERVICE { + 'account_balance' : ActorMethod<[AccountBalanceArgs], Tokens>, + 'account_balance_dfx' : ActorMethod<[AccountBalanceArgsDfx], Tokens>, + 'archives' : ActorMethod<[], Archives>, + 'decimals' : ActorMethod<[], { 'decimals' : number }>, + 'icrc1_balance_of' : ActorMethod<[Account], Icrc1Tokens>, + 'icrc1_decimals' : ActorMethod<[], number>, + 'icrc1_fee' : ActorMethod<[], Icrc1Tokens>, + 'icrc1_metadata' : ActorMethod<[], Array<[string, Value]>>, + 'icrc1_minting_account' : ActorMethod<[], [] | [Account]>, + 'icrc1_name' : ActorMethod<[], string>, + 'icrc1_supported_standards' : ActorMethod< + [], + Array<{ 'url' : string, 'name' : string }> + >, + 'icrc1_symbol' : ActorMethod<[], string>, + 'icrc1_total_supply' : ActorMethod<[], Icrc1Tokens>, + 'icrc1_transfer' : ActorMethod<[TransferArg], Icrc1TransferResult>, + 'icrc2_allowance' : ActorMethod<[AllowanceArgs], Allowance>, + 'icrc2_approve' : ActorMethod<[ApproveArgs], ApproveResult>, + 'name' : ActorMethod<[], { 'name' : string }>, + 'query_blocks' : ActorMethod<[GetBlocksArgs], QueryBlocksResponse>, + 'query_encoded_blocks' : ActorMethod< + [GetBlocksArgs], + QueryEncodedBlocksResponse + >, + 'send_dfx' : ActorMethod<[SendArgs], BlockIndex>, + 'symbol' : ActorMethod<[], { 'symbol' : string }>, + 'transfer' : ActorMethod<[TransferArgs], TransferResult>, + 'transfer_fee' : ActorMethod<[TransferFeeArg], TransferFee>, +} +export declare const idlFactory: IDL.InterfaceFactory; +export declare const init: (args: { IDL: typeof IDL }) => IDL.Type[]; diff --git a/apps/wallet/src/generated/icp_ledger/icp_ledger.did.js b/apps/wallet/src/generated/icp_ledger/icp_ledger.did.js new file mode 100644 index 000000000..1dc00ee62 --- /dev/null +++ b/apps/wallet/src/generated/icp_ledger/icp_ledger.did.js @@ -0,0 +1,342 @@ +export const idlFactory = ({ IDL }) => { + const SubAccount = IDL.Vec(IDL.Nat8); + const Account = IDL.Record({ + 'owner' : IDL.Principal, + 'subaccount' : IDL.Opt(SubAccount), + }); + const FeatureFlags = IDL.Record({ 'icrc2' : IDL.Bool }); + const UpgradeArgs = IDL.Record({ + 'maximum_number_of_accounts' : IDL.Opt(IDL.Nat64), + 'icrc1_minting_account' : IDL.Opt(Account), + 'feature_flags' : IDL.Opt(FeatureFlags), + }); + const Tokens = IDL.Record({ 'e8s' : IDL.Nat64 }); + const TextAccountIdentifier = IDL.Text; + const Duration = IDL.Record({ 'secs' : IDL.Nat64, 'nanos' : IDL.Nat32 }); + const ArchiveOptions = IDL.Record({ + 'num_blocks_to_archive' : IDL.Nat64, + 'trigger_threshold' : IDL.Nat64, + 'max_message_size_bytes' : IDL.Opt(IDL.Nat64), + 'cycles_for_archive_creation' : IDL.Opt(IDL.Nat64), + 'node_max_memory_size_bytes' : IDL.Opt(IDL.Nat64), + 'controller_id' : IDL.Principal, + }); + const InitArgs = IDL.Record({ + 'send_whitelist' : IDL.Vec(IDL.Principal), + 'token_symbol' : IDL.Opt(IDL.Text), + 'transfer_fee' : IDL.Opt(Tokens), + 'minting_account' : TextAccountIdentifier, + 'maximum_number_of_accounts' : IDL.Opt(IDL.Nat64), + 'accounts_overflow_trim_quantity' : IDL.Opt(IDL.Nat64), + 'transaction_window' : IDL.Opt(Duration), + 'max_message_size_bytes' : IDL.Opt(IDL.Nat64), + 'icrc1_minting_account' : IDL.Opt(Account), + 'archive_options' : IDL.Opt(ArchiveOptions), + 'initial_values' : IDL.Vec(IDL.Tuple(TextAccountIdentifier, Tokens)), + 'token_name' : IDL.Opt(IDL.Text), + 'feature_flags' : IDL.Opt(FeatureFlags), + }); + const LedgerCanisterPayload = IDL.Variant({ + 'Upgrade' : IDL.Opt(UpgradeArgs), + 'Init' : InitArgs, + }); + const AccountIdentifier = IDL.Vec(IDL.Nat8); + const AccountBalanceArgs = IDL.Record({ 'account' : AccountIdentifier }); + const AccountBalanceArgsDfx = IDL.Record({ + 'account' : TextAccountIdentifier, + }); + const Archive = IDL.Record({ 'canister_id' : IDL.Principal }); + const Archives = IDL.Record({ 'archives' : IDL.Vec(Archive) }); + const Icrc1Tokens = IDL.Nat; + const Value = IDL.Variant({ + 'Int' : IDL.Int, + 'Nat' : IDL.Nat, + 'Blob' : IDL.Vec(IDL.Nat8), + 'Text' : IDL.Text, + }); + const Icrc1Timestamp = IDL.Nat64; + const TransferArg = IDL.Record({ + 'to' : Account, + 'fee' : IDL.Opt(Icrc1Tokens), + 'memo' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'from_subaccount' : IDL.Opt(SubAccount), + 'created_at_time' : IDL.Opt(Icrc1Timestamp), + 'amount' : Icrc1Tokens, + }); + const Icrc1BlockIndex = IDL.Nat; + const Icrc1TransferError = IDL.Variant({ + 'GenericError' : IDL.Record({ + 'message' : IDL.Text, + 'error_code' : IDL.Nat, + }), + 'TemporarilyUnavailable' : IDL.Null, + 'BadBurn' : IDL.Record({ 'min_burn_amount' : Icrc1Tokens }), + 'Duplicate' : IDL.Record({ 'duplicate_of' : Icrc1BlockIndex }), + 'BadFee' : IDL.Record({ 'expected_fee' : Icrc1Tokens }), + 'CreatedInFuture' : IDL.Record({ 'ledger_time' : IDL.Nat64 }), + 'TooOld' : IDL.Null, + 'InsufficientFunds' : IDL.Record({ 'balance' : Icrc1Tokens }), + }); + const Icrc1TransferResult = IDL.Variant({ + 'Ok' : Icrc1BlockIndex, + 'Err' : Icrc1TransferError, + }); + const AllowanceArgs = IDL.Record({ + 'account' : Account, + 'spender' : Account, + }); + const TimeStamp = IDL.Record({ 'timestamp_nanos' : IDL.Nat64 }); + const Allowance = IDL.Record({ + 'allowance' : Icrc1Tokens, + 'expires_at' : IDL.Opt(TimeStamp), + }); + const ApproveArgs = IDL.Record({ + 'fee' : IDL.Opt(Icrc1Tokens), + 'memo' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'from_subaccount' : IDL.Opt(SubAccount), + 'created_at_time' : IDL.Opt(TimeStamp), + 'amount' : Icrc1Tokens, + 'expected_allowance' : IDL.Opt(Icrc1Tokens), + 'expires_at' : IDL.Opt(TimeStamp), + 'spender' : Account, + }); + const ApproveError = IDL.Variant({ + 'GenericError' : IDL.Record({ + 'message' : IDL.Text, + 'error_code' : IDL.Nat, + }), + 'TemporarilyUnavailable' : IDL.Null, + 'Duplicate' : IDL.Record({ 'duplicate_of' : Icrc1BlockIndex }), + 'BadFee' : IDL.Record({ 'expected_fee' : Icrc1Tokens }), + 'AllowanceChanged' : IDL.Record({ 'current_allowance' : Icrc1Tokens }), + 'CreatedInFuture' : IDL.Record({ 'ledger_time' : IDL.Nat64 }), + 'TooOld' : IDL.Null, + 'Expired' : IDL.Record({ 'ledger_time' : IDL.Nat64 }), + 'InsufficientFunds' : IDL.Record({ 'balance' : Icrc1Tokens }), + }); + const ApproveResult = IDL.Variant({ + 'Ok' : Icrc1BlockIndex, + 'Err' : ApproveError, + }); + const BlockIndex = IDL.Nat64; + const GetBlocksArgs = IDL.Record({ + 'start' : BlockIndex, + 'length' : IDL.Nat64, + }); + const Memo = IDL.Nat64; + const Operation = IDL.Variant({ + 'Approve' : IDL.Record({ + 'fee' : Tokens, + 'from' : AccountIdentifier, + 'allowance_e8s' : IDL.Int, + 'allowance' : Tokens, + 'expires_at' : IDL.Opt(TimeStamp), + 'spender' : AccountIdentifier, + }), + 'Burn' : IDL.Record({ + 'from' : AccountIdentifier, + 'amount' : Tokens, + 'spender' : IDL.Opt(AccountIdentifier), + }), + 'Mint' : IDL.Record({ 'to' : AccountIdentifier, 'amount' : Tokens }), + 'Transfer' : IDL.Record({ + 'to' : AccountIdentifier, + 'fee' : Tokens, + 'from' : AccountIdentifier, + 'amount' : Tokens, + }), + 'TransferFrom' : IDL.Record({ + 'to' : AccountIdentifier, + 'fee' : Tokens, + 'from' : AccountIdentifier, + 'amount' : Tokens, + 'spender' : AccountIdentifier, + }), + }); + const Transaction = IDL.Record({ + 'memo' : Memo, + 'icrc1_memo' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'operation' : IDL.Opt(Operation), + 'created_at_time' : TimeStamp, + }); + const Block = IDL.Record({ + 'transaction' : Transaction, + 'timestamp' : TimeStamp, + 'parent_hash' : IDL.Opt(IDL.Vec(IDL.Nat8)), + }); + const BlockRange = IDL.Record({ 'blocks' : IDL.Vec(Block) }); + const QueryArchiveError = IDL.Variant({ + 'BadFirstBlockIndex' : IDL.Record({ + 'requested_index' : BlockIndex, + 'first_valid_index' : BlockIndex, + }), + 'Other' : IDL.Record({ + 'error_message' : IDL.Text, + 'error_code' : IDL.Nat64, + }), + }); + const QueryArchiveResult = IDL.Variant({ + 'Ok' : BlockRange, + 'Err' : QueryArchiveError, + }); + const QueryArchiveFn = IDL.Func( + [GetBlocksArgs], + [QueryArchiveResult], + ['query'], + ); + const ArchivedBlocksRange = IDL.Record({ + 'callback' : QueryArchiveFn, + 'start' : BlockIndex, + 'length' : IDL.Nat64, + }); + const QueryBlocksResponse = IDL.Record({ + 'certificate' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'blocks' : IDL.Vec(Block), + 'chain_length' : IDL.Nat64, + 'first_block_index' : BlockIndex, + 'archived_blocks' : IDL.Vec(ArchivedBlocksRange), + }); + const ArchivedEncodedBlocksRange = IDL.Record({ + 'callback' : IDL.Func( + [GetBlocksArgs], + [ + IDL.Variant({ + 'Ok' : IDL.Vec(IDL.Vec(IDL.Nat8)), + 'Err' : QueryArchiveError, + }), + ], + ['query'], + ), + 'start' : IDL.Nat64, + 'length' : IDL.Nat64, + }); + const QueryEncodedBlocksResponse = IDL.Record({ + 'certificate' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'blocks' : IDL.Vec(IDL.Vec(IDL.Nat8)), + 'chain_length' : IDL.Nat64, + 'first_block_index' : IDL.Nat64, + 'archived_blocks' : IDL.Vec(ArchivedEncodedBlocksRange), + }); + const SendArgs = IDL.Record({ + 'to' : TextAccountIdentifier, + 'fee' : Tokens, + 'memo' : Memo, + 'from_subaccount' : IDL.Opt(SubAccount), + 'created_at_time' : IDL.Opt(TimeStamp), + 'amount' : Tokens, + }); + const TransferArgs = IDL.Record({ + 'to' : AccountIdentifier, + 'fee' : Tokens, + 'memo' : Memo, + 'from_subaccount' : IDL.Opt(SubAccount), + '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' : BlockIndex }), + 'TxCreatedInFuture' : IDL.Null, + 'InsufficientFunds' : IDL.Record({ 'balance' : Tokens }), + }); + const TransferResult = IDL.Variant({ + 'Ok' : BlockIndex, + 'Err' : TransferError, + }); + const TransferFeeArg = IDL.Record({}); + const TransferFee = IDL.Record({ 'transfer_fee' : Tokens }); + return IDL.Service({ + 'account_balance' : IDL.Func([AccountBalanceArgs], [Tokens], ['query']), + 'account_balance_dfx' : IDL.Func( + [AccountBalanceArgsDfx], + [Tokens], + ['query'], + ), + 'archives' : IDL.Func([], [Archives], ['query']), + 'decimals' : IDL.Func( + [], + [IDL.Record({ 'decimals' : IDL.Nat32 })], + ['query'], + ), + 'icrc1_balance_of' : IDL.Func([Account], [Icrc1Tokens], ['query']), + 'icrc1_decimals' : IDL.Func([], [IDL.Nat8], ['query']), + 'icrc1_fee' : IDL.Func([], [Icrc1Tokens], ['query']), + 'icrc1_metadata' : IDL.Func( + [], + [IDL.Vec(IDL.Tuple(IDL.Text, Value))], + ['query'], + ), + 'icrc1_minting_account' : IDL.Func([], [IDL.Opt(Account)], ['query']), + 'icrc1_name' : IDL.Func([], [IDL.Text], ['query']), + 'icrc1_supported_standards' : IDL.Func( + [], + [IDL.Vec(IDL.Record({ 'url' : IDL.Text, 'name' : IDL.Text }))], + ['query'], + ), + 'icrc1_symbol' : IDL.Func([], [IDL.Text], ['query']), + 'icrc1_total_supply' : IDL.Func([], [Icrc1Tokens], ['query']), + 'icrc1_transfer' : IDL.Func([TransferArg], [Icrc1TransferResult], []), + 'icrc2_allowance' : IDL.Func([AllowanceArgs], [Allowance], ['query']), + 'icrc2_approve' : IDL.Func([ApproveArgs], [ApproveResult], []), + 'name' : IDL.Func([], [IDL.Record({ 'name' : IDL.Text })], ['query']), + 'query_blocks' : IDL.Func( + [GetBlocksArgs], + [QueryBlocksResponse], + ['query'], + ), + 'query_encoded_blocks' : IDL.Func( + [GetBlocksArgs], + [QueryEncodedBlocksResponse], + ['query'], + ), + 'send_dfx' : IDL.Func([SendArgs], [BlockIndex], []), + 'symbol' : IDL.Func([], [IDL.Record({ 'symbol' : IDL.Text })], ['query']), + 'transfer' : IDL.Func([TransferArgs], [TransferResult], []), + 'transfer_fee' : IDL.Func([TransferFeeArg], [TransferFee], ['query']), + }); +}; +export const init = ({ IDL }) => { + const SubAccount = IDL.Vec(IDL.Nat8); + const Account = IDL.Record({ + 'owner' : IDL.Principal, + 'subaccount' : IDL.Opt(SubAccount), + }); + const FeatureFlags = IDL.Record({ 'icrc2' : IDL.Bool }); + const UpgradeArgs = IDL.Record({ + 'maximum_number_of_accounts' : IDL.Opt(IDL.Nat64), + 'icrc1_minting_account' : IDL.Opt(Account), + 'feature_flags' : IDL.Opt(FeatureFlags), + }); + const Tokens = IDL.Record({ 'e8s' : IDL.Nat64 }); + const TextAccountIdentifier = IDL.Text; + const Duration = IDL.Record({ 'secs' : IDL.Nat64, 'nanos' : IDL.Nat32 }); + const ArchiveOptions = IDL.Record({ + 'num_blocks_to_archive' : IDL.Nat64, + 'trigger_threshold' : IDL.Nat64, + 'max_message_size_bytes' : IDL.Opt(IDL.Nat64), + 'cycles_for_archive_creation' : IDL.Opt(IDL.Nat64), + 'node_max_memory_size_bytes' : IDL.Opt(IDL.Nat64), + 'controller_id' : IDL.Principal, + }); + const InitArgs = IDL.Record({ + 'send_whitelist' : IDL.Vec(IDL.Principal), + 'token_symbol' : IDL.Opt(IDL.Text), + 'transfer_fee' : IDL.Opt(Tokens), + 'minting_account' : TextAccountIdentifier, + 'maximum_number_of_accounts' : IDL.Opt(IDL.Nat64), + 'accounts_overflow_trim_quantity' : IDL.Opt(IDL.Nat64), + 'transaction_window' : IDL.Opt(Duration), + 'max_message_size_bytes' : IDL.Opt(IDL.Nat64), + 'icrc1_minting_account' : IDL.Opt(Account), + 'archive_options' : IDL.Opt(ArchiveOptions), + 'initial_values' : IDL.Vec(IDL.Tuple(TextAccountIdentifier, Tokens)), + 'token_name' : IDL.Opt(IDL.Text), + 'feature_flags' : IDL.Opt(FeatureFlags), + }); + const LedgerCanisterPayload = IDL.Variant({ + 'Upgrade' : IDL.Opt(UpgradeArgs), + 'Init' : InitArgs, + }); + return [LedgerCanisterPayload]; +}; diff --git a/apps/wallet/src/generated/icp_ledger/index.d.ts b/apps/wallet/src/generated/icp_ledger/index.d.ts new file mode 100644 index 000000000..513ca9e74 --- /dev/null +++ b/apps/wallet/src/generated/icp_ledger/index.d.ts @@ -0,0 +1,50 @@ +import type { + ActorSubclass, + HttpAgentOptions, + ActorConfig, + Agent, +} from "@dfinity/agent"; +import type { Principal } from "@dfinity/principal"; +import type { IDL } from "@dfinity/candid"; + +import { _SERVICE } from './icp_ledger.did'; + +export declare const idlFactory: IDL.InterfaceFactory; +export declare const canisterId: string; + +export declare interface CreateActorOptions { + /** + * @see {@link Agent} + */ + agent?: Agent; + /** + * @see {@link HttpAgentOptions} + */ + agentOptions?: HttpAgentOptions; + /** + * @see {@link ActorConfig} + */ + actorOptions?: ActorConfig; +} + +/** + * Intializes an {@link ActorSubclass}, configured with the provided SERVICE interface of a canister. + * @constructs {@link ActorSubClass} + * @param {string | Principal} canisterId - ID of the canister the {@link Actor} will talk to + * @param {CreateActorOptions} options - see {@link CreateActorOptions} + * @param {CreateActorOptions["agent"]} options.agent - a pre-configured agent you'd like to use. Supercedes agentOptions + * @param {CreateActorOptions["agentOptions"]} options.agentOptions - options to set up a new agent + * @see {@link HttpAgentOptions} + * @param {CreateActorOptions["actorOptions"]} options.actorOptions - options for the Actor + * @see {@link ActorConfig} + */ +export declare const createActor: ( + canisterId: string | Principal, + options?: CreateActorOptions +) => ActorSubclass<_SERVICE>; + +/** + * Intialized Actor using default settings, ready to talk to a canister using its candid interface + * @constructs {@link ActorSubClass} + */ +export declare const icp_ledger: ActorSubclass<_SERVICE>; diff --git a/apps/wallet/src/generated/icp_ledger/index.js b/apps/wallet/src/generated/icp_ledger/index.js new file mode 100644 index 000000000..e0f474543 --- /dev/null +++ b/apps/wallet/src/generated/icp_ledger/index.js @@ -0,0 +1,40 @@ +import { Actor, HttpAgent } from "@dfinity/agent"; + +// Imports and re-exports candid interface +import { idlFactory } from "./icp_ledger.did.js"; +export { idlFactory } from "./icp_ledger.did.js"; + +/* CANISTER_ID is replaced by webpack based on node environment + * Note: canister environment variable will be standardized as + * process.env.CANISTER_ID_ + * beginning in dfx 0.15.0 + */ +export const canisterId = + process.env.CANISTER_ID_ICP_LEDGER; + +export const createActor = (canisterId, options = {}) => { + const agent = options.agent || new HttpAgent({ ...options.agentOptions }); + + if (options.agent && options.agentOptions) { + console.warn( + "Detected both agent and agentOptions passed to createActor. Ignoring agentOptions and proceeding with the provided agent." + ); + } + + // Fetch root key for certificate validation during development + if (process.env.DFX_NETWORK !== "ic") { + agent.fetchRootKey().catch((err) => { + console.warn( + "Unable to fetch root key. Check to ensure that your local replica is running" + ); + console.error(err); + }); + } + + // Creates an actor with using the candid interface and the HttpAgent + return Actor.createActor(idlFactory, { + agent, + canisterId, + ...options.actorOptions, + }); +}; diff --git a/apps/wallet/src/generated/icrc1_index/icrc1_index_canister.did b/apps/wallet/src/generated/icrc1_index/icrc1_index_canister.did new file mode 100644 index 000000000..5cc7429a6 --- /dev/null +++ b/apps/wallet/src/generated/icrc1_index/icrc1_index_canister.did @@ -0,0 +1,143 @@ +type Tokens = nat; + +type InitArg = record { + ledger_id: principal; +}; + +type UpgradeArg = record { + ledger_id: opt principal; +}; + +type IndexArg = variant { + Init: InitArg; + Upgrade: UpgradeArg; +}; + +type GetBlocksRequest = record { + start : nat; + length : nat; +}; + +type Value = variant { + Blob : blob; + Text : text; + Nat : nat; + Nat64: nat64; + Int : int; + Array : vec Value; + Map : Map; +}; + +type Map = vec record { text; Value }; + +type Block = Value; + +type GetBlocksResponse = record { + chain_length: nat64; + blocks: vec Block; +}; + +type BlockIndex = nat; + +type SubAccount = blob; + +type Account = record { owner : principal; subaccount : opt SubAccount }; + +type Transaction = record { + burn : opt Burn; + kind : text; + mint : opt Mint; + approve : opt Approve; + timestamp : nat64; + transfer : opt Transfer; +}; + +type Approve = record { + fee : opt nat; + from : Account; + memo : opt vec nat8; + created_at_time : opt nat64; + amount : nat; + expected_allowance : opt nat; + expires_at : opt nat64; + spender : Account; +}; + +type Burn = record { + from : Account; + memo : opt vec nat8; + created_at_time : opt nat64; + amount : nat; + spender : opt Account; +}; + +type Mint = record { + to : Account; + memo : opt vec nat8; + created_at_time : opt nat64; + amount : nat; +}; + +type Transfer = record { + to : Account; + fee : opt nat; + from : Account; + memo : opt vec nat8; + created_at_time : opt nat64; + amount : nat; + spender : opt Account; +}; + +type GetAccountTransactionsArgs = record { + account : Account; + // The txid of the last transaction seen by the client. + // If None then the results will start from the most recent + // txid. + start : opt BlockIndex; + // Maximum number of transactions to fetch. + max_results : nat; +}; + +type TransactionWithId = record { + id : BlockIndex; + transaction : Transaction; +}; + +type GetTransactions = record { + balance : Tokens; + transactions : vec TransactionWithId; + // The txid of the oldest transaction the account has + oldest_tx_id : opt BlockIndex; +}; + +type GetTransactionsErr = record { + message : text; +}; + +type GetTransactionsResult = variant { + Ok : GetTransactions; + Err : GetTransactionsErr; +}; + +type ListSubaccountsArgs = record { + owner: principal; + start: opt SubAccount; +}; + +type Status = record { + num_blocks_synced : BlockIndex; +}; + +type FeeCollectorRanges = record { + ranges : vec record { Account; vec record { BlockIndex; BlockIndex } }; +} + +service : (index_arg: opt IndexArg) -> { + get_account_transactions : (GetAccountTransactionsArgs) -> (GetTransactionsResult) query; + get_blocks : (GetBlocksRequest) -> (GetBlocksResponse) query; + get_fee_collectors_ranges : () -> (FeeCollectorRanges) query; + icrc1_balance_of : (Account) -> (Tokens) query; + ledger_id : () -> (principal) query; + list_subaccounts : (ListSubaccountsArgs) -> (vec SubAccount) query; + status : () -> (Status) query; +} diff --git a/apps/wallet/src/generated/icrc1_index/icrc1_index_canister.did.d.ts b/apps/wallet/src/generated/icrc1_index/icrc1_index_canister.did.d.ts new file mode 100644 index 000000000..9a93a9a71 --- /dev/null +++ b/apps/wallet/src/generated/icrc1_index/icrc1_index_canister.did.d.ts @@ -0,0 +1,108 @@ +import type { Principal } from '@dfinity/principal'; +import type { ActorMethod } from '@dfinity/agent'; +import type { IDL } from '@dfinity/candid'; + +export interface Account { + 'owner' : Principal, + 'subaccount' : [] | [SubAccount], +} +export interface Approve { + 'fee' : [] | [bigint], + 'from' : Account, + 'memo' : [] | [Uint8Array | number[]], + 'created_at_time' : [] | [bigint], + 'amount' : bigint, + 'expected_allowance' : [] | [bigint], + 'expires_at' : [] | [bigint], + 'spender' : Account, +} +export type Block = Value; +export type BlockIndex = bigint; +export interface Burn { + 'from' : Account, + 'memo' : [] | [Uint8Array | number[]], + 'created_at_time' : [] | [bigint], + 'amount' : bigint, + 'spender' : [] | [Account], +} +export interface FeeCollectorRanges { + 'ranges' : Array<[Account, Array<[BlockIndex, BlockIndex]>]>, +} +export interface GetAccountTransactionsArgs { + 'max_results' : bigint, + 'start' : [] | [BlockIndex], + 'account' : Account, +} +export interface GetBlocksRequest { 'start' : bigint, 'length' : bigint } +export interface GetBlocksResponse { + 'blocks' : Array, + 'chain_length' : bigint, +} +export interface GetTransactions { + 'balance' : Tokens, + 'transactions' : Array, + 'oldest_tx_id' : [] | [BlockIndex], +} +export interface GetTransactionsErr { 'message' : string } +export type GetTransactionsResult = { 'Ok' : GetTransactions } | + { 'Err' : GetTransactionsErr }; +export type IndexArg = { 'Upgrade' : UpgradeArg } | + { 'Init' : InitArg }; +export interface InitArg { 'ledger_id' : Principal } +export interface ListSubaccountsArgs { + 'owner' : Principal, + 'start' : [] | [SubAccount], +} +export type Map = Array<[string, Value]>; +export interface Mint { + 'to' : Account, + 'memo' : [] | [Uint8Array | number[]], + 'created_at_time' : [] | [bigint], + 'amount' : bigint, +} +export interface Status { 'num_blocks_synced' : BlockIndex } +export type SubAccount = Uint8Array | number[]; +export type Tokens = bigint; +export interface Transaction { + 'burn' : [] | [Burn], + 'kind' : string, + 'mint' : [] | [Mint], + 'approve' : [] | [Approve], + 'timestamp' : bigint, + 'transfer' : [] | [Transfer], +} +export interface TransactionWithId { + 'id' : BlockIndex, + 'transaction' : Transaction, +} +export interface Transfer { + 'to' : Account, + 'fee' : [] | [bigint], + 'from' : Account, + 'memo' : [] | [Uint8Array | number[]], + 'created_at_time' : [] | [bigint], + 'amount' : bigint, + 'spender' : [] | [Account], +} +export interface UpgradeArg { 'ledger_id' : [] | [Principal] } +export type Value = { 'Int' : bigint } | + { 'Map' : Map } | + { 'Nat' : bigint } | + { 'Nat64' : bigint } | + { 'Blob' : Uint8Array | number[] } | + { 'Text' : string } | + { 'Array' : Array }; +export interface _SERVICE { + 'get_account_transactions' : ActorMethod< + [GetAccountTransactionsArgs], + GetTransactionsResult + >, + 'get_blocks' : ActorMethod<[GetBlocksRequest], GetBlocksResponse>, + 'get_fee_collectors_ranges' : ActorMethod<[], FeeCollectorRanges>, + 'icrc1_balance_of' : ActorMethod<[Account], Tokens>, + 'ledger_id' : ActorMethod<[], Principal>, + 'list_subaccounts' : ActorMethod<[ListSubaccountsArgs], Array>, + 'status' : ActorMethod<[], Status>, +} +export declare const idlFactory: IDL.InterfaceFactory; +export declare const init: (args: { IDL: typeof IDL }) => IDL.Type[]; diff --git a/apps/wallet/src/generated/icrc1_index/icrc1_index_canister.did.js b/apps/wallet/src/generated/icrc1_index/icrc1_index_canister.did.js new file mode 100644 index 000000000..0374e7632 --- /dev/null +++ b/apps/wallet/src/generated/icrc1_index/icrc1_index_canister.did.js @@ -0,0 +1,126 @@ +export const idlFactory = ({ IDL }) => { + const Value = IDL.Rec(); + const UpgradeArg = IDL.Record({ 'ledger_id' : IDL.Opt(IDL.Principal) }); + const InitArg = IDL.Record({ 'ledger_id' : IDL.Principal }); + const IndexArg = IDL.Variant({ 'Upgrade' : UpgradeArg, 'Init' : InitArg }); + const BlockIndex = IDL.Nat; + const SubAccount = IDL.Vec(IDL.Nat8); + const Account = IDL.Record({ + 'owner' : IDL.Principal, + 'subaccount' : IDL.Opt(SubAccount), + }); + const GetAccountTransactionsArgs = IDL.Record({ + 'max_results' : IDL.Nat, + 'start' : IDL.Opt(BlockIndex), + 'account' : Account, + }); + const Tokens = IDL.Nat; + const Burn = IDL.Record({ + 'from' : Account, + 'memo' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'created_at_time' : IDL.Opt(IDL.Nat64), + 'amount' : IDL.Nat, + 'spender' : IDL.Opt(Account), + }); + const Mint = IDL.Record({ + 'to' : Account, + 'memo' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'created_at_time' : IDL.Opt(IDL.Nat64), + 'amount' : IDL.Nat, + }); + const Approve = IDL.Record({ + 'fee' : IDL.Opt(IDL.Nat), + 'from' : Account, + 'memo' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'created_at_time' : IDL.Opt(IDL.Nat64), + 'amount' : IDL.Nat, + 'expected_allowance' : IDL.Opt(IDL.Nat), + 'expires_at' : IDL.Opt(IDL.Nat64), + 'spender' : Account, + }); + const Transfer = IDL.Record({ + 'to' : Account, + 'fee' : IDL.Opt(IDL.Nat), + 'from' : Account, + 'memo' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'created_at_time' : IDL.Opt(IDL.Nat64), + 'amount' : IDL.Nat, + 'spender' : IDL.Opt(Account), + }); + const Transaction = IDL.Record({ + 'burn' : IDL.Opt(Burn), + 'kind' : IDL.Text, + 'mint' : IDL.Opt(Mint), + 'approve' : IDL.Opt(Approve), + 'timestamp' : IDL.Nat64, + 'transfer' : IDL.Opt(Transfer), + }); + const TransactionWithId = IDL.Record({ + 'id' : BlockIndex, + 'transaction' : Transaction, + }); + const GetTransactions = IDL.Record({ + 'balance' : Tokens, + 'transactions' : IDL.Vec(TransactionWithId), + 'oldest_tx_id' : IDL.Opt(BlockIndex), + }); + const GetTransactionsErr = IDL.Record({ 'message' : IDL.Text }); + const GetTransactionsResult = IDL.Variant({ + 'Ok' : GetTransactions, + 'Err' : GetTransactionsErr, + }); + const GetBlocksRequest = IDL.Record({ + 'start' : IDL.Nat, + 'length' : IDL.Nat, + }); + const Map = IDL.Vec(IDL.Tuple(IDL.Text, Value)); + Value.fill( + IDL.Variant({ + 'Int' : IDL.Int, + 'Map' : Map, + 'Nat' : IDL.Nat, + 'Nat64' : IDL.Nat64, + 'Blob' : IDL.Vec(IDL.Nat8), + 'Text' : IDL.Text, + 'Array' : IDL.Vec(Value), + }) + ); + const Block = Value; + const GetBlocksResponse = IDL.Record({ + 'blocks' : IDL.Vec(Block), + 'chain_length' : IDL.Nat64, + }); + const FeeCollectorRanges = IDL.Record({ + 'ranges' : IDL.Vec( + IDL.Tuple(Account, IDL.Vec(IDL.Tuple(BlockIndex, BlockIndex))) + ), + }); + const ListSubaccountsArgs = IDL.Record({ + 'owner' : IDL.Principal, + 'start' : IDL.Opt(SubAccount), + }); + const Status = IDL.Record({ 'num_blocks_synced' : BlockIndex }); + return IDL.Service({ + 'get_account_transactions' : IDL.Func( + [GetAccountTransactionsArgs], + [GetTransactionsResult], + ['query'], + ), + 'get_blocks' : IDL.Func([GetBlocksRequest], [GetBlocksResponse], ['query']), + 'get_fee_collectors_ranges' : IDL.Func([], [FeeCollectorRanges], ['query']), + 'icrc1_balance_of' : IDL.Func([Account], [Tokens], ['query']), + 'ledger_id' : IDL.Func([], [IDL.Principal], ['query']), + 'list_subaccounts' : IDL.Func( + [ListSubaccountsArgs], + [IDL.Vec(SubAccount)], + ['query'], + ), + 'status' : IDL.Func([], [Status], ['query']), + }); +}; +export const init = ({ IDL }) => { + const UpgradeArg = IDL.Record({ 'ledger_id' : IDL.Opt(IDL.Principal) }); + const InitArg = IDL.Record({ 'ledger_id' : IDL.Principal }); + const IndexArg = IDL.Variant({ 'Upgrade' : UpgradeArg, 'Init' : InitArg }); + return [IDL.Opt(IndexArg)]; +}; diff --git a/apps/wallet/src/generated/icrc1_index/index.d.ts b/apps/wallet/src/generated/icrc1_index/index.d.ts new file mode 100644 index 000000000..b5d550620 --- /dev/null +++ b/apps/wallet/src/generated/icrc1_index/index.d.ts @@ -0,0 +1,50 @@ +import type { + ActorSubclass, + HttpAgentOptions, + ActorConfig, + Agent, +} from "@dfinity/agent"; +import type { Principal } from "@dfinity/principal"; +import type { IDL } from "@dfinity/candid"; + +import { _SERVICE } from './icrc1_index_canister.did'; + +export declare const idlFactory: IDL.InterfaceFactory; +export declare const canisterId: string; + +export declare interface CreateActorOptions { + /** + * @see {@link Agent} + */ + agent?: Agent; + /** + * @see {@link HttpAgentOptions} + */ + agentOptions?: HttpAgentOptions; + /** + * @see {@link ActorConfig} + */ + actorOptions?: ActorConfig; +} + +/** + * Intializes an {@link ActorSubclass}, configured with the provided SERVICE interface of a canister. + * @constructs {@link ActorSubClass} + * @param {string | Principal} canisterId - ID of the canister the {@link Actor} will talk to + * @param {CreateActorOptions} options - see {@link CreateActorOptions} + * @param {CreateActorOptions["agent"]} options.agent - a pre-configured agent you'd like to use. Supercedes agentOptions + * @param {CreateActorOptions["agentOptions"]} options.agentOptions - options to set up a new agent + * @see {@link HttpAgentOptions} + * @param {CreateActorOptions["actorOptions"]} options.actorOptions - options for the Actor + * @see {@link ActorConfig} + */ +export declare const createActor: ( + canisterId: string | Principal, + options?: CreateActorOptions +) => ActorSubclass<_SERVICE>; + +/** + * Intialized Actor using default settings, ready to talk to a canister using its candid interface + * @constructs {@link ActorSubClass} + */ +export declare const icrc1_index_canister: ActorSubclass<_SERVICE>; diff --git a/apps/wallet/src/generated/icrc1_index/index.js b/apps/wallet/src/generated/icrc1_index/index.js new file mode 100644 index 000000000..084e136d6 --- /dev/null +++ b/apps/wallet/src/generated/icrc1_index/index.js @@ -0,0 +1,40 @@ +import { Actor, HttpAgent } from "@dfinity/agent"; + +// Imports and re-exports candid interface +import { idlFactory } from "./icrc1_index_canister.did.js"; +export { idlFactory } from "./icrc1_index_canister.did.js"; + +/* CANISTER_ID is replaced by webpack based on node environment + * Note: canister environment variable will be standardized as + * process.env.CANISTER_ID_ + * beginning in dfx 0.15.0 + */ +export const canisterId = + process.env.CANISTER_ID_ICRC1_INDEX_CANISTER; + +export const createActor = (canisterId, options = {}) => { + const agent = options.agent || new HttpAgent({ ...options.agentOptions }); + + if (options.agent && options.agentOptions) { + console.warn( + "Detected both agent and agentOptions passed to createActor. Ignoring agentOptions and proceeding with the provided agent." + ); + } + + // Fetch root key for certificate validation during development + if (process.env.DFX_NETWORK !== "ic") { + agent.fetchRootKey().catch((err) => { + console.warn( + "Unable to fetch root key. Check to ensure that your local replica is running" + ); + console.error(err); + }); + } + + // Creates an actor with using the candid interface and the HttpAgent + return Actor.createActor(idlFactory, { + agent, + canisterId, + ...options.actorOptions, + }); +}; diff --git a/apps/wallet/src/generated/icrc1_ledger/icrc1_ledger_canister.did b/apps/wallet/src/generated/icrc1_ledger/icrc1_ledger_canister.did new file mode 100644 index 000000000..1efe7f6fb --- /dev/null +++ b/apps/wallet/src/generated/icrc1_ledger/icrc1_ledger_canister.did @@ -0,0 +1,379 @@ +type BlockIndex = nat; +type Subaccount = blob; +// Number of nanoseconds since the UNIX epoch in UTC timezone. +type Timestamp = nat64; +// Number of nanoseconds between two [Timestamp]s. +type Duration = nat64; +type Tokens = nat; +type TxIndex = nat; +type Allowance = record { allowance : nat; expires_at : opt nat64 }; +type AllowanceArgs = record { account : Account; spender : Account }; +type Approve = record { + fee : opt nat; + from : Account; + memo : opt vec nat8; + created_at_time : opt nat64; + amount : nat; + expected_allowance : opt nat; + expires_at : opt nat64; + spender : Account; +}; +type ApproveArgs = record { + fee : opt nat; + memo : opt vec nat8; + from_subaccount : opt vec nat8; + created_at_time : opt nat64; + amount : nat; + expected_allowance : opt nat; + expires_at : opt nat64; + spender : Account; +}; +type ApproveError = variant { + GenericError : record { message : text; error_code : nat }; + TemporarilyUnavailable; + Duplicate : record { duplicate_of : nat }; + BadFee : record { expected_fee : nat }; + AllowanceChanged : record { current_allowance : nat }; + CreatedInFuture : record { ledger_time : nat64 }; + TooOld; + Expired : record { ledger_time : nat64 }; + InsufficientFunds : record { balance : nat }; +}; +type ApproveResult = variant { Ok : nat; Err : ApproveError }; + +type HttpRequest = record { + url : text; + method : text; + body : vec nat8; + headers : vec record { text; text }; +}; +type HttpResponse = record { + body : vec nat8; + headers : vec record { text; text }; + status_code : nat16; +}; + +type Account = record { + owner : principal; + subaccount : opt Subaccount; +}; + +type TransferArg = record { + from_subaccount : opt Subaccount; + to : Account; + amount : Tokens; + fee : opt Tokens; + memo : opt blob; + created_at_time: opt Timestamp; +}; + +type TransferError = variant { + BadFee : record { expected_fee : Tokens }; + BadBurn : record { min_burn_amount : Tokens }; + InsufficientFunds : record { balance : Tokens }; + TooOld; + CreatedInFuture : record { ledger_time : nat64 }; + TemporarilyUnavailable; + Duplicate : record { duplicate_of : BlockIndex }; + GenericError : record { error_code : nat; message : text }; +}; + +type TransferResult = variant { + Ok : BlockIndex; + Err : TransferError; +}; + +// The value returned from the [icrc1_metadata] endpoint. +type MetadataValue = variant { + Nat : nat; + Int : int; + Text : text; + Blob : blob; +}; + +type FeatureFlags = record { + icrc2 : bool; +}; + +// The initialization parameters of the Ledger +type InitArgs = record { + minting_account : Account; + fee_collector_account : opt Account; + transfer_fee : nat; + decimals : opt nat8; + max_memo_length : opt nat16; + token_symbol : text; + token_name : text; + metadata : vec record { text; MetadataValue }; + initial_balances : vec record { Account; nat }; + feature_flags : opt FeatureFlags; + maximum_number_of_accounts : opt nat64; + accounts_overflow_trim_quantity : opt nat64; + archive_options : record { + num_blocks_to_archive : nat64; + max_transactions_per_response : opt nat64; + trigger_threshold : nat64; + max_message_size_bytes : opt nat64; + cycles_for_archive_creation : opt nat64; + node_max_memory_size_bytes : opt nat64; + controller_id : principal; + }; +}; + +type ChangeFeeCollector = variant { + Unset; SetTo: Account; +}; + +type UpgradeArgs = record { + metadata : opt vec record { text; MetadataValue }; + token_symbol : opt text; + token_name : opt text; + transfer_fee : opt nat; + change_fee_collector : opt ChangeFeeCollector; + max_memo_length : opt nat16; + feature_flags : opt FeatureFlags; + maximum_number_of_accounts: opt nat64; + accounts_overflow_trim_quantity: opt nat64; +}; + +type LedgerArg = variant { + Init: InitArgs; + Upgrade: opt UpgradeArgs; +}; + +type GetTransactionsRequest = record { + // The index of the first tx to fetch. + start : TxIndex; + // The number of transactions to fetch. + length : nat; +}; + +type GetTransactionsResponse = record { + // The total number of transactions in the log. + log_length : nat; + + // List of transaction that were available in the ledger when it processed the call. + // + // The transactions form a contiguous range, with the first transaction having index + // [first_index] (see below), and the last transaction having index + // [first_index] + len(transactions) - 1. + // + // The transaction range can be an arbitrary sub-range of the originally requested range. + transactions : vec Transaction; + + // The index of the first transaction in [transactions]. + // If the transaction vector is empty, the exact value of this field is not specified. + first_index : TxIndex; + + // Encoding of instructions for fetching archived transactions whose indices fall into the + // requested range. + // + // For each entry `e` in [archived_transactions], `[e.from, e.from + len)` is a sub-range + // of the originally requested transaction range. + archived_transactions : vec record { + // The index of the first archived transaction you can fetch using the [callback]. + start : TxIndex; + + // The number of transactions you can fetch using the callback. + length : nat; + + // The function you should call to fetch the archived transactions. + // The range of the transaction accessible using this function is given by [from] + // and [len] fields above. + callback : QueryArchiveFn; + }; +}; + + +// A prefix of the transaction range specified in the [GetTransactionsRequest] request. +type TransactionRange = record { + // A prefix of the requested transaction range. + // The index of the first transaction is equal to [GetTransactionsRequest.from]. + // + // Note that the number of transactions might be less than the requested + // [GetTransactionsRequest.length] for various reasons, for example: + // + // 1. The query might have hit the replica with an outdated state + // that doesn't have the whole range yet. + // 2. The requested range is too large to fit into a single reply. + // + // NOTE: the list of transactions can be empty if: + // + // 1. [GetTransactionsRequest.length] was zero. + // 2. [GetTransactionsRequest.from] was larger than the last transaction known to + // the canister. + transactions : vec Transaction; +}; + +// A function for fetching archived transaction. +type QueryArchiveFn = func (GetTransactionsRequest) -> (TransactionRange) query; + +type Transaction = record { + burn : opt Burn; + kind : text; + mint : opt Mint; + approve : opt Approve; + timestamp : nat64; + transfer : opt Transfer; +}; + +type Burn = record { + from : Account; + memo : opt vec nat8; + created_at_time : opt nat64; + amount : nat; + spender : opt Account; +}; + +type Mint = record { + to : Account; + memo : opt vec nat8; + created_at_time : opt nat64; + amount : nat; +}; + +type Transfer = record { + to : Account; + fee : opt nat; + from : Account; + memo : opt vec nat8; + created_at_time : opt nat64; + amount : nat; + spender : opt Account; +}; + +type Value = variant { + Blob : blob; + Text : text; + Nat : nat; + Nat64: nat64; + Int : int; + Array : vec Value; + Map : Map; +}; + +type Map = vec record { text; Value }; + +type Block = Value; + +type GetBlocksArgs = record { + // The index of the first block to fetch. + start : BlockIndex; + // Max number of blocks to fetch. + length : nat; +}; + +// A prefix of the block range specified in the [GetBlocksArgs] request. +type BlockRange = record { + // A prefix of the requested block range. + // The index of the first block is equal to [GetBlocksArgs.start]. + // + // Note that the number of blocks might be less than the requested + // [GetBlocksArgs.length] for various reasons, for example: + // + // 1. The query might have hit the replica with an outdated state + // that doesn't have the whole range yet. + // 2. The requested range is too large to fit into a single reply. + // + // NOTE: the list of blocks can be empty if: + // + // 1. [GetBlocksArgs.length] was zero. + // 2. [GetBlocksArgs.start] was larger than the last block known to + // the canister. + blocks : vec Block; +}; + +// A function for fetching archived blocks. +type QueryBlockArchiveFn = func (GetBlocksArgs) -> (BlockRange) query; + +// The result of a "get_blocks" call. +type GetBlocksResponse = record { + // The index of the first block in "blocks". + // If the blocks vector is empty, the exact value of this field is not specified. + first_index : BlockIndex; + + // The total number of blocks in the chain. + // If the chain length is positive, the index of the last block is `chain_len - 1`. + chain_length : nat64; + + // System certificate for the hash of the latest block in the chain. + // Only present if `get_blocks` is called in a non-replicated query context. + certificate : opt blob; + + // List of blocks that were available in the ledger when it processed the call. + // + // The blocks form a contiguous range, with the first block having index + // [first_block_index] (see below), and the last block having index + // [first_block_index] + len(blocks) - 1. + // + // The block range can be an arbitrary sub-range of the originally requested range. + blocks : vec Block; + + // Encoding of instructions for fetching archived blocks. + archived_blocks : vec record { + // The index of the first archived block. + start : BlockIndex; + + // The number of blocks that can be fetched. + length : nat; + + // Callback to fetch the archived blocks. + callback : QueryBlockArchiveFn; + }; +}; + +// Certificate for the block at `block_index`. +type DataCertificate = record { + certificate : opt blob; + hash_tree : blob; +}; + +type StandardRecord = record { url : text; name : text }; + +type TransferFromArgs = record { + spender_subaccount : opt Subaccount; + from : Account; + to : Account; + amount : Tokens; + fee : opt Tokens; + memo : opt blob; + created_at_time: opt Timestamp; +}; + +type TransferFromResult = variant { + Ok : BlockIndex; + Err : TransferFromError; +}; + +type TransferFromError = variant { + BadFee : record { expected_fee : Tokens }; + BadBurn : record { min_burn_amount : Tokens }; + InsufficientFunds : record { balance : Tokens }; + InsufficientAllowance : record { allowance : Tokens }; + TooOld; + CreatedInFuture : record { ledger_time : nat64 }; + Duplicate : record { duplicate_of : BlockIndex }; + TemporarilyUnavailable; + GenericError : record { error_code : nat; message : text }; +}; + +service : (ledger_arg : LedgerArg) -> { + get_transactions : (GetTransactionsRequest) -> (GetTransactionsResponse) query; + get_blocks : (GetBlocksArgs) -> (GetBlocksResponse) query; + get_data_certificate : () -> (DataCertificate) query; + + icrc1_name : () -> (text) query; + icrc1_symbol : () -> (text) query; + icrc1_decimals : () -> (nat8) query; + icrc1_metadata : () -> (vec record { text; MetadataValue }) query; + icrc1_total_supply : () -> (Tokens) query; + icrc1_fee : () -> (Tokens) query; + icrc1_minting_account : () -> (opt Account) query; + icrc1_balance_of : (Account) -> (Tokens) query; + icrc1_transfer : (TransferArg) -> (TransferResult); + icrc1_supported_standards : () -> (vec StandardRecord) query; + + icrc2_approve : (ApproveArgs) -> (ApproveResult); + icrc2_allowance : (AllowanceArgs) -> (Allowance) query; + icrc2_transfer_from : (TransferFromArgs) -> (TransferFromResult); +} diff --git a/apps/wallet/src/generated/icrc1_ledger/icrc1_ledger_canister.did.d.ts b/apps/wallet/src/generated/icrc1_ledger/icrc1_ledger_canister.did.d.ts new file mode 100644 index 000000000..5a6ef4b78 --- /dev/null +++ b/apps/wallet/src/generated/icrc1_ledger/icrc1_ledger_canister.did.d.ts @@ -0,0 +1,245 @@ +import type { Principal } from '@dfinity/principal'; +import type { ActorMethod } from '@dfinity/agent'; +import type { IDL } from '@dfinity/candid'; + +export interface Account { + 'owner' : Principal, + 'subaccount' : [] | [Subaccount], +} +export interface Allowance { + 'allowance' : bigint, + 'expires_at' : [] | [bigint], +} +export interface AllowanceArgs { 'account' : Account, 'spender' : Account } +export interface Approve { + 'fee' : [] | [bigint], + 'from' : Account, + 'memo' : [] | [Uint8Array | number[]], + 'created_at_time' : [] | [bigint], + 'amount' : bigint, + 'expected_allowance' : [] | [bigint], + 'expires_at' : [] | [bigint], + 'spender' : Account, +} +export interface ApproveArgs { + 'fee' : [] | [bigint], + 'memo' : [] | [Uint8Array | number[]], + 'from_subaccount' : [] | [Uint8Array | number[]], + 'created_at_time' : [] | [bigint], + 'amount' : bigint, + 'expected_allowance' : [] | [bigint], + 'expires_at' : [] | [bigint], + 'spender' : Account, +} +export type ApproveError = { + 'GenericError' : { 'message' : string, 'error_code' : bigint } + } | + { 'TemporarilyUnavailable' : null } | + { 'Duplicate' : { 'duplicate_of' : bigint } } | + { 'BadFee' : { 'expected_fee' : bigint } } | + { 'AllowanceChanged' : { 'current_allowance' : bigint } } | + { 'CreatedInFuture' : { 'ledger_time' : bigint } } | + { 'TooOld' : null } | + { 'Expired' : { 'ledger_time' : bigint } } | + { 'InsufficientFunds' : { 'balance' : bigint } }; +export type ApproveResult = { 'Ok' : bigint } | + { 'Err' : ApproveError }; +export type Block = Value; +export type BlockIndex = bigint; +export interface BlockRange { 'blocks' : Array } +export interface Burn { + 'from' : Account, + 'memo' : [] | [Uint8Array | number[]], + 'created_at_time' : [] | [bigint], + 'amount' : bigint, + 'spender' : [] | [Account], +} +export type ChangeFeeCollector = { 'SetTo' : Account } | + { 'Unset' : null }; +export interface DataCertificate { + 'certificate' : [] | [Uint8Array | number[]], + 'hash_tree' : Uint8Array | number[], +} +export type Duration = bigint; +export interface FeatureFlags { 'icrc2' : boolean } +export interface GetBlocksArgs { 'start' : BlockIndex, 'length' : bigint } +export interface GetBlocksResponse { + 'certificate' : [] | [Uint8Array | number[]], + 'first_index' : BlockIndex, + 'blocks' : Array, + 'chain_length' : bigint, + 'archived_blocks' : Array< + { + 'callback' : QueryBlockArchiveFn, + 'start' : BlockIndex, + 'length' : bigint, + } + >, +} +export interface GetTransactionsRequest { 'start' : TxIndex, 'length' : bigint } +export interface GetTransactionsResponse { + 'first_index' : TxIndex, + 'log_length' : bigint, + 'transactions' : Array, + 'archived_transactions' : Array< + { 'callback' : QueryArchiveFn, 'start' : TxIndex, 'length' : bigint } + >, +} +export interface HttpRequest { + 'url' : string, + 'method' : string, + 'body' : Uint8Array | number[], + 'headers' : Array<[string, string]>, +} +export interface HttpResponse { + 'body' : Uint8Array | number[], + 'headers' : Array<[string, string]>, + 'status_code' : number, +} +export interface InitArgs { + 'decimals' : [] | [number], + 'token_symbol' : string, + 'transfer_fee' : bigint, + 'metadata' : Array<[string, MetadataValue]>, + 'minting_account' : Account, + 'initial_balances' : Array<[Account, bigint]>, + 'maximum_number_of_accounts' : [] | [bigint], + 'accounts_overflow_trim_quantity' : [] | [bigint], + 'fee_collector_account' : [] | [Account], + 'archive_options' : { + 'num_blocks_to_archive' : bigint, + 'max_transactions_per_response' : [] | [bigint], + 'trigger_threshold' : bigint, + 'max_message_size_bytes' : [] | [bigint], + 'cycles_for_archive_creation' : [] | [bigint], + 'node_max_memory_size_bytes' : [] | [bigint], + 'controller_id' : Principal, + }, + 'max_memo_length' : [] | [number], + 'token_name' : string, + 'feature_flags' : [] | [FeatureFlags], +} +export type LedgerArg = { 'Upgrade' : [] | [UpgradeArgs] } | + { 'Init' : InitArgs }; +export type Map = Array<[string, Value]>; +export type MetadataValue = { 'Int' : bigint } | + { 'Nat' : bigint } | + { 'Blob' : Uint8Array | number[] } | + { 'Text' : string }; +export interface Mint { + 'to' : Account, + 'memo' : [] | [Uint8Array | number[]], + 'created_at_time' : [] | [bigint], + 'amount' : bigint, +} +export type QueryArchiveFn = ActorMethod< + [GetTransactionsRequest], + TransactionRange +>; +export type QueryBlockArchiveFn = ActorMethod<[GetBlocksArgs], BlockRange>; +export interface StandardRecord { 'url' : string, 'name' : string } +export type Subaccount = Uint8Array | number[]; +export type Timestamp = bigint; +export type Tokens = bigint; +export interface Transaction { + 'burn' : [] | [Burn], + 'kind' : string, + 'mint' : [] | [Mint], + 'approve' : [] | [Approve], + 'timestamp' : bigint, + 'transfer' : [] | [Transfer], +} +export interface TransactionRange { 'transactions' : Array } +export interface Transfer { + 'to' : Account, + 'fee' : [] | [bigint], + 'from' : Account, + 'memo' : [] | [Uint8Array | number[]], + 'created_at_time' : [] | [bigint], + 'amount' : bigint, + 'spender' : [] | [Account], +} +export interface TransferArg { + 'to' : Account, + 'fee' : [] | [Tokens], + 'memo' : [] | [Uint8Array | number[]], + 'from_subaccount' : [] | [Subaccount], + 'created_at_time' : [] | [Timestamp], + 'amount' : Tokens, +} +export type TransferError = { + 'GenericError' : { 'message' : string, 'error_code' : bigint } + } | + { 'TemporarilyUnavailable' : null } | + { 'BadBurn' : { 'min_burn_amount' : Tokens } } | + { 'Duplicate' : { 'duplicate_of' : BlockIndex } } | + { 'BadFee' : { 'expected_fee' : Tokens } } | + { 'CreatedInFuture' : { 'ledger_time' : bigint } } | + { 'TooOld' : null } | + { 'InsufficientFunds' : { 'balance' : Tokens } }; +export interface TransferFromArgs { + 'to' : Account, + 'fee' : [] | [Tokens], + 'spender_subaccount' : [] | [Subaccount], + 'from' : Account, + 'memo' : [] | [Uint8Array | number[]], + 'created_at_time' : [] | [Timestamp], + 'amount' : Tokens, +} +export type TransferFromError = { + 'GenericError' : { 'message' : string, 'error_code' : bigint } + } | + { 'TemporarilyUnavailable' : null } | + { 'InsufficientAllowance' : { 'allowance' : Tokens } } | + { 'BadBurn' : { 'min_burn_amount' : Tokens } } | + { 'Duplicate' : { 'duplicate_of' : BlockIndex } } | + { 'BadFee' : { 'expected_fee' : Tokens } } | + { 'CreatedInFuture' : { 'ledger_time' : bigint } } | + { 'TooOld' : null } | + { 'InsufficientFunds' : { 'balance' : Tokens } }; +export type TransferFromResult = { 'Ok' : BlockIndex } | + { 'Err' : TransferFromError }; +export type TransferResult = { 'Ok' : BlockIndex } | + { 'Err' : TransferError }; +export type TxIndex = bigint; +export interface UpgradeArgs { + 'token_symbol' : [] | [string], + 'transfer_fee' : [] | [bigint], + 'metadata' : [] | [Array<[string, MetadataValue]>], + 'maximum_number_of_accounts' : [] | [bigint], + 'accounts_overflow_trim_quantity' : [] | [bigint], + 'change_fee_collector' : [] | [ChangeFeeCollector], + 'max_memo_length' : [] | [number], + 'token_name' : [] | [string], + 'feature_flags' : [] | [FeatureFlags], +} +export type Value = { 'Int' : bigint } | + { 'Map' : Map } | + { 'Nat' : bigint } | + { 'Nat64' : bigint } | + { 'Blob' : Uint8Array | number[] } | + { 'Text' : string } | + { 'Array' : Array }; +export interface _SERVICE { + 'get_blocks' : ActorMethod<[GetBlocksArgs], GetBlocksResponse>, + 'get_data_certificate' : ActorMethod<[], DataCertificate>, + 'get_transactions' : ActorMethod< + [GetTransactionsRequest], + GetTransactionsResponse + >, + 'icrc1_balance_of' : ActorMethod<[Account], Tokens>, + 'icrc1_decimals' : ActorMethod<[], number>, + 'icrc1_fee' : ActorMethod<[], Tokens>, + 'icrc1_metadata' : ActorMethod<[], Array<[string, MetadataValue]>>, + 'icrc1_minting_account' : ActorMethod<[], [] | [Account]>, + 'icrc1_name' : ActorMethod<[], string>, + 'icrc1_supported_standards' : ActorMethod<[], Array>, + 'icrc1_symbol' : ActorMethod<[], string>, + 'icrc1_total_supply' : ActorMethod<[], Tokens>, + 'icrc1_transfer' : ActorMethod<[TransferArg], TransferResult>, + 'icrc2_allowance' : ActorMethod<[AllowanceArgs], Allowance>, + 'icrc2_approve' : ActorMethod<[ApproveArgs], ApproveResult>, + 'icrc2_transfer_from' : ActorMethod<[TransferFromArgs], TransferFromResult>, +} +export declare const idlFactory: IDL.InterfaceFactory; +export declare const init: (args: { IDL: typeof IDL }) => IDL.Type[]; diff --git a/apps/wallet/src/generated/icrc1_ledger/icrc1_ledger_canister.did.js b/apps/wallet/src/generated/icrc1_ledger/icrc1_ledger_canister.did.js new file mode 100644 index 000000000..d5c422e15 --- /dev/null +++ b/apps/wallet/src/generated/icrc1_ledger/icrc1_ledger_canister.did.js @@ -0,0 +1,342 @@ +export const idlFactory = ({ IDL }) => { + const Value = IDL.Rec(); + const MetadataValue = IDL.Variant({ + 'Int' : IDL.Int, + 'Nat' : IDL.Nat, + 'Blob' : IDL.Vec(IDL.Nat8), + 'Text' : IDL.Text, + }); + const Subaccount = IDL.Vec(IDL.Nat8); + const Account = IDL.Record({ + 'owner' : IDL.Principal, + 'subaccount' : IDL.Opt(Subaccount), + }); + const ChangeFeeCollector = IDL.Variant({ + 'SetTo' : Account, + 'Unset' : IDL.Null, + }); + const FeatureFlags = IDL.Record({ 'icrc2' : IDL.Bool }); + const UpgradeArgs = IDL.Record({ + 'token_symbol' : IDL.Opt(IDL.Text), + 'transfer_fee' : IDL.Opt(IDL.Nat), + 'metadata' : IDL.Opt(IDL.Vec(IDL.Tuple(IDL.Text, MetadataValue))), + 'maximum_number_of_accounts' : IDL.Opt(IDL.Nat64), + 'accounts_overflow_trim_quantity' : IDL.Opt(IDL.Nat64), + 'change_fee_collector' : IDL.Opt(ChangeFeeCollector), + 'max_memo_length' : IDL.Opt(IDL.Nat16), + 'token_name' : IDL.Opt(IDL.Text), + 'feature_flags' : IDL.Opt(FeatureFlags), + }); + const InitArgs = IDL.Record({ + 'decimals' : IDL.Opt(IDL.Nat8), + 'token_symbol' : IDL.Text, + 'transfer_fee' : IDL.Nat, + 'metadata' : IDL.Vec(IDL.Tuple(IDL.Text, MetadataValue)), + 'minting_account' : Account, + 'initial_balances' : IDL.Vec(IDL.Tuple(Account, IDL.Nat)), + 'maximum_number_of_accounts' : IDL.Opt(IDL.Nat64), + 'accounts_overflow_trim_quantity' : IDL.Opt(IDL.Nat64), + 'fee_collector_account' : IDL.Opt(Account), + 'archive_options' : IDL.Record({ + 'num_blocks_to_archive' : IDL.Nat64, + 'max_transactions_per_response' : IDL.Opt(IDL.Nat64), + 'trigger_threshold' : IDL.Nat64, + 'max_message_size_bytes' : IDL.Opt(IDL.Nat64), + 'cycles_for_archive_creation' : IDL.Opt(IDL.Nat64), + 'node_max_memory_size_bytes' : IDL.Opt(IDL.Nat64), + 'controller_id' : IDL.Principal, + }), + 'max_memo_length' : IDL.Opt(IDL.Nat16), + 'token_name' : IDL.Text, + 'feature_flags' : IDL.Opt(FeatureFlags), + }); + const LedgerArg = IDL.Variant({ + 'Upgrade' : IDL.Opt(UpgradeArgs), + 'Init' : InitArgs, + }); + const BlockIndex = IDL.Nat; + const GetBlocksArgs = IDL.Record({ + 'start' : BlockIndex, + 'length' : IDL.Nat, + }); + const Map = IDL.Vec(IDL.Tuple(IDL.Text, Value)); + Value.fill( + IDL.Variant({ + 'Int' : IDL.Int, + 'Map' : Map, + 'Nat' : IDL.Nat, + 'Nat64' : IDL.Nat64, + 'Blob' : IDL.Vec(IDL.Nat8), + 'Text' : IDL.Text, + 'Array' : IDL.Vec(Value), + }) + ); + const Block = Value; + const BlockRange = IDL.Record({ 'blocks' : IDL.Vec(Block) }); + const QueryBlockArchiveFn = IDL.Func( + [GetBlocksArgs], + [BlockRange], + ['query'], + ); + const GetBlocksResponse = IDL.Record({ + 'certificate' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'first_index' : BlockIndex, + 'blocks' : IDL.Vec(Block), + 'chain_length' : IDL.Nat64, + 'archived_blocks' : IDL.Vec( + IDL.Record({ + 'callback' : QueryBlockArchiveFn, + 'start' : BlockIndex, + 'length' : IDL.Nat, + }) + ), + }); + const DataCertificate = IDL.Record({ + 'certificate' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'hash_tree' : IDL.Vec(IDL.Nat8), + }); + const TxIndex = IDL.Nat; + const GetTransactionsRequest = IDL.Record({ + 'start' : TxIndex, + 'length' : IDL.Nat, + }); + const Burn = IDL.Record({ + 'from' : Account, + 'memo' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'created_at_time' : IDL.Opt(IDL.Nat64), + 'amount' : IDL.Nat, + 'spender' : IDL.Opt(Account), + }); + const Mint = IDL.Record({ + 'to' : Account, + 'memo' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'created_at_time' : IDL.Opt(IDL.Nat64), + 'amount' : IDL.Nat, + }); + const Approve = IDL.Record({ + 'fee' : IDL.Opt(IDL.Nat), + 'from' : Account, + 'memo' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'created_at_time' : IDL.Opt(IDL.Nat64), + 'amount' : IDL.Nat, + 'expected_allowance' : IDL.Opt(IDL.Nat), + 'expires_at' : IDL.Opt(IDL.Nat64), + 'spender' : Account, + }); + const Transfer = IDL.Record({ + 'to' : Account, + 'fee' : IDL.Opt(IDL.Nat), + 'from' : Account, + 'memo' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'created_at_time' : IDL.Opt(IDL.Nat64), + 'amount' : IDL.Nat, + 'spender' : IDL.Opt(Account), + }); + const Transaction = IDL.Record({ + 'burn' : IDL.Opt(Burn), + 'kind' : IDL.Text, + 'mint' : IDL.Opt(Mint), + 'approve' : IDL.Opt(Approve), + 'timestamp' : IDL.Nat64, + 'transfer' : IDL.Opt(Transfer), + }); + const TransactionRange = IDL.Record({ + 'transactions' : IDL.Vec(Transaction), + }); + const QueryArchiveFn = IDL.Func( + [GetTransactionsRequest], + [TransactionRange], + ['query'], + ); + const GetTransactionsResponse = IDL.Record({ + 'first_index' : TxIndex, + 'log_length' : IDL.Nat, + 'transactions' : IDL.Vec(Transaction), + 'archived_transactions' : IDL.Vec( + IDL.Record({ + 'callback' : QueryArchiveFn, + 'start' : TxIndex, + 'length' : IDL.Nat, + }) + ), + }); + const Tokens = IDL.Nat; + const StandardRecord = IDL.Record({ 'url' : IDL.Text, 'name' : IDL.Text }); + const Timestamp = IDL.Nat64; + const TransferArg = IDL.Record({ + 'to' : Account, + 'fee' : IDL.Opt(Tokens), + 'memo' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'from_subaccount' : IDL.Opt(Subaccount), + 'created_at_time' : IDL.Opt(Timestamp), + 'amount' : Tokens, + }); + const TransferError = IDL.Variant({ + 'GenericError' : IDL.Record({ + 'message' : IDL.Text, + 'error_code' : IDL.Nat, + }), + 'TemporarilyUnavailable' : IDL.Null, + 'BadBurn' : IDL.Record({ 'min_burn_amount' : Tokens }), + 'Duplicate' : IDL.Record({ 'duplicate_of' : BlockIndex }), + 'BadFee' : IDL.Record({ 'expected_fee' : Tokens }), + 'CreatedInFuture' : IDL.Record({ 'ledger_time' : IDL.Nat64 }), + 'TooOld' : IDL.Null, + 'InsufficientFunds' : IDL.Record({ 'balance' : Tokens }), + }); + const TransferResult = IDL.Variant({ + 'Ok' : BlockIndex, + 'Err' : TransferError, + }); + const AllowanceArgs = IDL.Record({ + 'account' : Account, + 'spender' : Account, + }); + const Allowance = IDL.Record({ + 'allowance' : IDL.Nat, + 'expires_at' : IDL.Opt(IDL.Nat64), + }); + const ApproveArgs = IDL.Record({ + '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, + 'expected_allowance' : IDL.Opt(IDL.Nat), + 'expires_at' : IDL.Opt(IDL.Nat64), + 'spender' : Account, + }); + const ApproveError = IDL.Variant({ + 'GenericError' : IDL.Record({ + 'message' : IDL.Text, + 'error_code' : IDL.Nat, + }), + 'TemporarilyUnavailable' : IDL.Null, + 'Duplicate' : IDL.Record({ 'duplicate_of' : IDL.Nat }), + 'BadFee' : IDL.Record({ 'expected_fee' : IDL.Nat }), + 'AllowanceChanged' : IDL.Record({ 'current_allowance' : IDL.Nat }), + 'CreatedInFuture' : IDL.Record({ 'ledger_time' : IDL.Nat64 }), + 'TooOld' : IDL.Null, + 'Expired' : IDL.Record({ 'ledger_time' : IDL.Nat64 }), + 'InsufficientFunds' : IDL.Record({ 'balance' : IDL.Nat }), + }); + const ApproveResult = IDL.Variant({ 'Ok' : IDL.Nat, 'Err' : ApproveError }); + const TransferFromArgs = IDL.Record({ + 'to' : Account, + 'fee' : IDL.Opt(Tokens), + 'spender_subaccount' : IDL.Opt(Subaccount), + 'from' : Account, + 'memo' : IDL.Opt(IDL.Vec(IDL.Nat8)), + 'created_at_time' : IDL.Opt(Timestamp), + 'amount' : Tokens, + }); + const TransferFromError = IDL.Variant({ + 'GenericError' : IDL.Record({ + 'message' : IDL.Text, + 'error_code' : IDL.Nat, + }), + 'TemporarilyUnavailable' : IDL.Null, + 'InsufficientAllowance' : IDL.Record({ 'allowance' : Tokens }), + 'BadBurn' : IDL.Record({ 'min_burn_amount' : Tokens }), + 'Duplicate' : IDL.Record({ 'duplicate_of' : BlockIndex }), + 'BadFee' : IDL.Record({ 'expected_fee' : Tokens }), + 'CreatedInFuture' : IDL.Record({ 'ledger_time' : IDL.Nat64 }), + 'TooOld' : IDL.Null, + 'InsufficientFunds' : IDL.Record({ 'balance' : Tokens }), + }); + const TransferFromResult = IDL.Variant({ + 'Ok' : BlockIndex, + 'Err' : TransferFromError, + }); + return IDL.Service({ + 'get_blocks' : IDL.Func([GetBlocksArgs], [GetBlocksResponse], ['query']), + 'get_data_certificate' : IDL.Func([], [DataCertificate], ['query']), + 'get_transactions' : IDL.Func( + [GetTransactionsRequest], + [GetTransactionsResponse], + ['query'], + ), + 'icrc1_balance_of' : IDL.Func([Account], [Tokens], ['query']), + 'icrc1_decimals' : IDL.Func([], [IDL.Nat8], ['query']), + 'icrc1_fee' : IDL.Func([], [Tokens], ['query']), + 'icrc1_metadata' : IDL.Func( + [], + [IDL.Vec(IDL.Tuple(IDL.Text, MetadataValue))], + ['query'], + ), + 'icrc1_minting_account' : IDL.Func([], [IDL.Opt(Account)], ['query']), + 'icrc1_name' : IDL.Func([], [IDL.Text], ['query']), + 'icrc1_supported_standards' : IDL.Func( + [], + [IDL.Vec(StandardRecord)], + ['query'], + ), + 'icrc1_symbol' : IDL.Func([], [IDL.Text], ['query']), + 'icrc1_total_supply' : IDL.Func([], [Tokens], ['query']), + 'icrc1_transfer' : IDL.Func([TransferArg], [TransferResult], []), + 'icrc2_allowance' : IDL.Func([AllowanceArgs], [Allowance], ['query']), + 'icrc2_approve' : IDL.Func([ApproveArgs], [ApproveResult], []), + 'icrc2_transfer_from' : IDL.Func( + [TransferFromArgs], + [TransferFromResult], + [], + ), + }); +}; +export const init = ({ IDL }) => { + const MetadataValue = IDL.Variant({ + 'Int' : IDL.Int, + 'Nat' : IDL.Nat, + 'Blob' : IDL.Vec(IDL.Nat8), + 'Text' : IDL.Text, + }); + const Subaccount = IDL.Vec(IDL.Nat8); + const Account = IDL.Record({ + 'owner' : IDL.Principal, + 'subaccount' : IDL.Opt(Subaccount), + }); + const ChangeFeeCollector = IDL.Variant({ + 'SetTo' : Account, + 'Unset' : IDL.Null, + }); + const FeatureFlags = IDL.Record({ 'icrc2' : IDL.Bool }); + const UpgradeArgs = IDL.Record({ + 'token_symbol' : IDL.Opt(IDL.Text), + 'transfer_fee' : IDL.Opt(IDL.Nat), + 'metadata' : IDL.Opt(IDL.Vec(IDL.Tuple(IDL.Text, MetadataValue))), + 'maximum_number_of_accounts' : IDL.Opt(IDL.Nat64), + 'accounts_overflow_trim_quantity' : IDL.Opt(IDL.Nat64), + 'change_fee_collector' : IDL.Opt(ChangeFeeCollector), + 'max_memo_length' : IDL.Opt(IDL.Nat16), + 'token_name' : IDL.Opt(IDL.Text), + 'feature_flags' : IDL.Opt(FeatureFlags), + }); + const InitArgs = IDL.Record({ + 'decimals' : IDL.Opt(IDL.Nat8), + 'token_symbol' : IDL.Text, + 'transfer_fee' : IDL.Nat, + 'metadata' : IDL.Vec(IDL.Tuple(IDL.Text, MetadataValue)), + 'minting_account' : Account, + 'initial_balances' : IDL.Vec(IDL.Tuple(Account, IDL.Nat)), + 'maximum_number_of_accounts' : IDL.Opt(IDL.Nat64), + 'accounts_overflow_trim_quantity' : IDL.Opt(IDL.Nat64), + 'fee_collector_account' : IDL.Opt(Account), + 'archive_options' : IDL.Record({ + 'num_blocks_to_archive' : IDL.Nat64, + 'max_transactions_per_response' : IDL.Opt(IDL.Nat64), + 'trigger_threshold' : IDL.Nat64, + 'max_message_size_bytes' : IDL.Opt(IDL.Nat64), + 'cycles_for_archive_creation' : IDL.Opt(IDL.Nat64), + 'node_max_memory_size_bytes' : IDL.Opt(IDL.Nat64), + 'controller_id' : IDL.Principal, + }), + 'max_memo_length' : IDL.Opt(IDL.Nat16), + 'token_name' : IDL.Text, + 'feature_flags' : IDL.Opt(FeatureFlags), + }); + const LedgerArg = IDL.Variant({ + 'Upgrade' : IDL.Opt(UpgradeArgs), + 'Init' : InitArgs, + }); + return [LedgerArg]; +}; diff --git a/apps/wallet/src/generated/icrc1_ledger/index.d.ts b/apps/wallet/src/generated/icrc1_ledger/index.d.ts new file mode 100644 index 000000000..bff346fcc --- /dev/null +++ b/apps/wallet/src/generated/icrc1_ledger/index.d.ts @@ -0,0 +1,50 @@ +import type { + ActorSubclass, + HttpAgentOptions, + ActorConfig, + Agent, +} from "@dfinity/agent"; +import type { Principal } from "@dfinity/principal"; +import type { IDL } from "@dfinity/candid"; + +import { _SERVICE } from './icrc1_ledger_canister.did'; + +export declare const idlFactory: IDL.InterfaceFactory; +export declare const canisterId: string; + +export declare interface CreateActorOptions { + /** + * @see {@link Agent} + */ + agent?: Agent; + /** + * @see {@link HttpAgentOptions} + */ + agentOptions?: HttpAgentOptions; + /** + * @see {@link ActorConfig} + */ + actorOptions?: ActorConfig; +} + +/** + * Intializes an {@link ActorSubclass}, configured with the provided SERVICE interface of a canister. + * @constructs {@link ActorSubClass} + * @param {string | Principal} canisterId - ID of the canister the {@link Actor} will talk to + * @param {CreateActorOptions} options - see {@link CreateActorOptions} + * @param {CreateActorOptions["agent"]} options.agent - a pre-configured agent you'd like to use. Supercedes agentOptions + * @param {CreateActorOptions["agentOptions"]} options.agentOptions - options to set up a new agent + * @see {@link HttpAgentOptions} + * @param {CreateActorOptions["actorOptions"]} options.actorOptions - options for the Actor + * @see {@link ActorConfig} + */ +export declare const createActor: ( + canisterId: string | Principal, + options?: CreateActorOptions +) => ActorSubclass<_SERVICE>; + +/** + * Intialized Actor using default settings, ready to talk to a canister using its candid interface + * @constructs {@link ActorSubClass} + */ +export declare const icrc1_ledger_canister: ActorSubclass<_SERVICE>; diff --git a/apps/wallet/src/generated/icrc1_ledger/index.js b/apps/wallet/src/generated/icrc1_ledger/index.js new file mode 100644 index 000000000..a5df8d522 --- /dev/null +++ b/apps/wallet/src/generated/icrc1_ledger/index.js @@ -0,0 +1,40 @@ +import { Actor, HttpAgent } from "@dfinity/agent"; + +// Imports and re-exports candid interface +import { idlFactory } from "./icrc1_ledger_canister.did.js"; +export { idlFactory } from "./icrc1_ledger_canister.did.js"; + +/* CANISTER_ID is replaced by webpack based on node environment + * Note: canister environment variable will be standardized as + * process.env.CANISTER_ID_ + * beginning in dfx 0.15.0 + */ +export const canisterId = + process.env.CANISTER_ID_ICRC1_LEDGER_CANISTER; + +export const createActor = (canisterId, options = {}) => { + const agent = options.agent || new HttpAgent({ ...options.agentOptions }); + + if (options.agent && options.agentOptions) { + console.warn( + "Detected both agent and agentOptions passed to createActor. Ignoring agentOptions and proceeding with the provided agent." + ); + } + + // Fetch root key for certificate validation during development + if (process.env.DFX_NETWORK !== "ic") { + agent.fetchRootKey().catch((err) => { + console.warn( + "Unable to fetch root key. Check to ensure that your local replica is running" + ); + console.error(err); + }); + } + + // Creates an actor with using the candid interface and the HttpAgent + return Actor.createActor(idlFactory, { + agent, + canisterId, + ...options.actorOptions, + }); +}; diff --git a/apps/wallet/src/generated/station/station.did b/apps/wallet/src/generated/station/station.did index a2fbdbd4f..ad4c9c2d4 100644 --- a/apps/wallet/src/generated/station/station.did +++ b/apps/wallet/src/generated/station/station.did @@ -59,6 +59,9 @@ type RequestSpecifier = variant { EditUserGroup : ResourceIds; RemoveUserGroup : ResourceIds; ManageSystemInfo; + AddAsset; + EditAsset : ResourceIds; + RemoveAsset : ResourceIds; }; // A record type that can be used to represent a percentage of users that are required to approve a rule. @@ -322,6 +325,10 @@ type RequestApproval = record { type TransferOperationInput = record { // The account id to use for the transaction. from_account_id : UUID; + // The asset id to transfer. + from_asset_id : UUID; + // The standard to use for the transfer. + with_standard : text; // The amount to transfer. amount : nat; // The destination address of the transaction (e.g. "1BvBMSE..."). @@ -342,6 +349,8 @@ type TransferOperationInput = record { type TransferOperation = record { // The account to use for the transaction. from_account : opt Account; + // The asset to use for the transaction. + from_asset : Asset; // The network to use for the transaction. network : Network; // The input to the request to transfer funds. @@ -352,12 +361,27 @@ type TransferOperation = record { fee : opt nat; }; +// Mutate the list of assets. +type ChangeAssets = variant { + // Replace all current assets with the specified list. + ReplaceWith : record { + assets : vec UUID; + }; + // Change the list of assets by adding and removing assets. + Change : record { + add_assets : vec UUID; + remove_assets : vec UUID; + }; +}; + // Input type for editing an account through a request. type EditAccountOperationInput = record { // The account id that will be edited. account_id : UUID; // A friendly name for the account (e.g. "My Account"). name : opt text; + // Mutate the list of assets. + change_assets : opt ChangeAssets; // Who can read the account information. read_permission : opt Allow; // Who can request configuration changes to the account. @@ -379,10 +403,8 @@ type EditAccountOperation = record { type AddAccountOperationInput = record { // A friendly name for the account (e.g. "My Account"). name : text; - // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) - blockchain : text; - // The asset standard for this account (e.g. `native`, `erc20`, etc.). - standard : text; + // The assets to add to the account. + assets : vec UUID; // Metadata associated with the account (e.g. `{"contract": "0x1234", "symbol": "ANY"}`). metadata : vec AccountMetadata; // Who can read the account information. @@ -417,6 +439,8 @@ type AddAddressBookEntryOperationInput = record { address_owner : text; // The actual address. address : text; + // The format of the address, eg. icp_account_identifier + address_format : text; // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) blockchain : text; // Metadata associated with the address book entry (e.g. `{"kyc": "true"}`). @@ -922,6 +946,12 @@ type RequestOperation = variant { RemoveRequestPolicy : RemoveRequestPolicyOperation; // An operation for managing system info. ManageSystemInfo : ManageSystemInfoOperation; + // An operation for adding a new asset. + AddAsset : AddAssetOperation; + // An operation for editing an existing asset. + EditAsset : EditAssetOperation; + // An operation for removing an existing asset. + RemoveAsset : RemoveAssetOperation; }; type RequestOperationInput = variant { @@ -971,6 +1001,12 @@ type RequestOperationInput = variant { RemoveRequestPolicy : RemoveRequestPolicyOperationInput; // An operation for managing system info. ManageSystemInfo : ManageSystemInfoOperationInput; + // An operation for adding a new asset. + AddAsset : AddAssetOperationInput; + // An operation for editing an existing asset. + EditAsset : EditAssetOperationInput; + // An operation for removing an existing asset. + RemoveAsset : RemoveAssetOperationInput; }; type RequestOperationType = variant { @@ -1020,6 +1056,12 @@ type RequestOperationType = variant { RemoveRequestPolicy; // And operation for managing system info. ManageSystemInfo; + // An operation for adding a new asset. + AddAsset; + // An operation for editing an existing asset. + EditAsset; + // An operation for removing an existing asset. + RemoveAsset; }; // The schedule for executing a transaction of a given transfer. @@ -1170,6 +1212,12 @@ type ListRequestsOperationType = variant { ManageSystemInfo; // An operation for setting disaster recovery config. SetDisasterRecovery; + // An operation for adding an asset. + AddAsset; + // An operation for editing an asset. + EditAsset; + // An operation for removing an asset. + RemoveAsset; }; // The direction to use for sorting. @@ -1549,21 +1597,15 @@ type AccountCallerPrivileges = record { type Account = record { // The internal account id. id : UUID; - // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) - blockchain : text; - // The asset symbol, e.g. "ICP" or "BTC". - symbol : AssetSymbol; - // The asset standard that is supported (e.g. `erc20`, etc.), canonically represented as a lowercase string - // with spaces replaced with underscores. - standard : text; - // The address of the account (e.g. "0x1234"). - address : text; - // The number of decimals used by the asset (e.g. `8` for `BTC`, `18` for `ETH`, etc.). - decimals : nat32; + + // The list of assets supported by this account. + assets : vec AccountAsset; + + // The list of addresses associated with the account. + addresses : vec AccountAddress; + // A friendly name for the account. name : text; - // Account balance when available. - balance : opt AccountBalanceInfo; // Metadata associated with the account (e.g. `{"contract": "0x1234", "symbol": "ANY"}`). metadata : vec AccountMetadata; // The transfer approval policy for the account. @@ -1578,6 +1620,25 @@ type Account = record { last_modification_timestamp : TimestampRFC3339; }; +// The seed used to derive the addresses of the account. +type AccountSeed = blob; + +// Record type to describe an address of an account. +type AccountAddress = record { + // The address. + address : text; + // The format of the address, eg. icp_account_identifier. + format : text; +}; + +// Record type to describe an asset of an account. +type AccountAsset = record { + // The asset id. + asset_id : UUID; + // The balance of the asset. + balance : opt AccountBalance; +}; + // Input type for getting a account. type GetAccountInput = record { // The account id to retrieve. @@ -1600,12 +1661,19 @@ type GetAccountResult = variant { type AccountBalance = record { // The account id. account_id : UUID; + // The asset id. + asset_id : UUID; // The balance of the account. balance : nat; // The number of decimals used by the asset (e.g. `8` for `BTC`, `18` for `ETH`, etc.). decimals : nat32; // The time at which the balance was last updated. last_update_timestamp : TimestampRFC3339; + // The state of balance query: + // - `fresh`: The balance was recently updated and is considered fresh. + // - `stale`: The balance may be out of date. + // - `stale_refreshing`: The balance may be out of date but it is being refreshed in the background. + query_state : text; }; // Input type for getting a account balance. @@ -1619,7 +1687,7 @@ type FetchAccountBalancesResult = variant { // The result data for a successful execution. Ok : record { // The account balance that was retrieved. - balances : vec AccountBalance; + balances : vec opt AccountBalance; }; // The error that occurred (e.g. the user does not have the necessary permissions). Err : Error; @@ -1652,6 +1720,8 @@ type AddressBookEntry = record { address_owner : text; // The actual address. address : text; + // The address format (e.g. "icp_account_identifier"). + address_format : text; // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) blockchain : text; // Metadata associated with the address book entry (e.g. `{"kyc": "true"}`). @@ -1691,6 +1761,8 @@ type ListAddressBookEntriesInput = record { blockchain : opt text; // The labels to search for, if provided only address book entries with the given labels will be returned. labels : opt vec text; + // The address formats to search for. + address_formats : opt vec text; // The pagination parameters. paginate : opt PaginationInput; }; @@ -1723,19 +1795,41 @@ type AssetMetadata = record { // A record type that can be used to represent an asset in the station. type Asset = record { + // The internal asset id. + id : UUID; // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) blockchain : text; // The asset standard that is supported (e.g. `erc20`, etc.), canonically represented as a lowercase string // with spaces replaced with underscores. - standard : text; + standards : vec text; // The asset symbol, e.g. "ICP" or "BTC". symbol : AssetSymbol; // The asset name (e.g. `Internet Computer`, `Bitcoin`, `Ethereum`, etc.) name : text; - // The asset metadata (e.g. `{"logo": "https://example.com/logo.png"}`), - // also, in the case of non-native assets, it can contain other required - // information (e.g. `{"address": "0x1234"}`). + // The asset metadata (e.g. `{"logo": "https://example.com/logo.png"}`). metadata : vec AssetMetadata; + // The number of decimals used by the asset (e.g. `8` for `BTC`, `18` for `ETH`, etc.). + decimals : nat32; +}; + +// Describes a standard suported by a blockchain. +type StandardData = record { + // The standard name. + standard : text; + // Required metadata fields for the standard (e.g. `["ledger_canister_id"]`). + required_metadata_fields : vec text; + // Supported operations for the standard (e.g. `["transfer", "list_transfers", "balance"]`). + supported_operations : vec text; + // Supported address formats of the standard. + supported_address_formats : vec text; +}; + +// Describes a blockchain and its standards supported by the station. +type SupportedBlockchain = record { + // The blockchain name. + blockchain : text; + // The supported standards for the blockchain. + supported_standards : vec StandardData; }; // A record type that is used to show the current capabilities of the station. @@ -1746,6 +1840,8 @@ type Capabilities = record { version : text; // The list of supported assets. supported_assets : vec Asset; + // The list of supported blockchains and standards. + supported_blockchains : vec SupportedBlockchain; }; // Result type for getting the current config. @@ -2014,6 +2110,7 @@ type Resource = variant { System : SystemResourceAction; User : UserResourceAction; UserGroup : ResourceAction; + Asset : ResourceAction; }; // A record type that can be used to represent the caller privileges for a given permission. @@ -2174,6 +2271,122 @@ type ListRequestPoliciesResult = variant { Err : Error; }; +type AddAssetOperation = record { + // The result of adding an asset. + asset : opt Asset; + // The input to the request to add an asset. + input : AddAssetOperationInput; +}; + +// The input type for adding an asset. +type AddAssetOperationInput = record { + // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) + blockchain : text; + // The asset standard that is supported (e.g. `erc20`, etc.), canonically represented as a lowercase string + // with spaces replaced with underscores. + standards : vec text; + // The asset symbol, e.g. "ICP" or "BTC". + symbol : AssetSymbol; + // The asset name (e.g. `Internet Computer`, `Bitcoin`, `Ethereum`, etc.) + name : text; + // The asset metadata (e.g. `{"logo": "https://example.com/logo.png"}`). + metadata : vec AssetMetadata; + // The number of decimals used by the asset (e.g. `8` for `BTC`, `18` for `ETH`, etc.). + decimals : nat32; +}; + +type EditAssetOperation = record { + // The input to the request to edit an asset. + input : EditAssetOperationInput; +}; + +// The input type for editing an asset. +type EditAssetOperationInput = record { + // The asset id to edit. + asset_id : UUID; + // The name of the asset. + name : opt text; + // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) + blockchain : opt text; + // The asset standard that is supported (e.g. `erc20`, etc.), canonically represented as a lowercase string + // with spaces replaced with underscores. + standards : opt vec text; + // The asset symbol, e.g. "ICP" or "BTC". + symbol : opt AssetSymbol; + // The metadata to change. + change_metadata : opt ChangeMetadata; +}; + +// Type for instructions to update the address book entry's metadata. +type ChangeMetadata = variant { + // Replace all existing metadata by the specified metadata. + ReplaceAllBy : vec AssetMetadata; + // Override values of existing metadata with the specified keys + // and add new metadata if no metadata can be found with the specified keys. + OverrideSpecifiedBy : vec AssetMetadata; + // Remove metadata with the specified keys. + RemoveKeys : vec text; +}; + +type RemoveAssetOperation = record { + // The input to the request to remove an asset. + input : RemoveAssetOperationInput; +}; + +// The input type for removing an asset. +type RemoveAssetOperationInput = record { + // The asset id to remove. + asset_id : UUID; +}; + +// The input type for listing assets. +type ListAssetsInput = record { + // The pagination parameters. + paginate : opt PaginationInput; +}; + +// The result type for listing assets. +type ListAssetsResult = variant { + // The result data for a successful execution. + Ok : record { + // The list of assets. + assets : vec Asset; + // The offset to use for the next page. + next_offset : opt nat64; + // The total number of assets. + total : nat64; + // The caller privileges for the assets. + privileges : vec AssetCallerPrivileges; + }; + // The error that occurred (e.g. the user does not have the necessary permissions). + Err : Error; +}; + +// The input type for getting an asset. +type GetAssetInput = record { + // The asset id to retrieve. + asset_id : UUID; +}; + +// The result type for getting an asset. +type GetAssetResult = variant { + // The result data for a successful execution. + Ok : record { + // The asset that was retrieved. + asset : Asset; + // The caller privileges for the asset. + privileges : AssetCallerPrivileges; + }; + // The error that occurred (e.g. the user does not have the necessary permissions). + Err : Error; +}; + +type AssetCallerPrivileges = record { + id : UUID; + can_edit : bool; + can_delete : bool; +}; + // The top level privileges that the user has when making calls to the canister. type UserPrivilege = variant { Capabilities; @@ -2195,6 +2408,8 @@ type UserPrivilege = variant { CreateExternalCanister; ListExternalCanisters; CallAnyExternalCanister; + ListAssets; + AddAsset; }; type MeResult = variant { @@ -2230,13 +2445,31 @@ type InitAccountInput = record { // A friendly name for the account (e.g. "My Account"). name : text; // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) - blockchain : text; + seed : AccountSeed; // The asset standard for this account (e.g. `native`, `erc20`, etc.). - standard : text; + assets : vec UUID; // Metadata associated with the account (e.g. `{"contract": "0x1234", "symbol": "ANY"}`). metadata : vec AccountMetadata; }; +// The initial assets to create when initializing the canister for the first time, e.g., after disaster recovery. +type InitAssetInput = record { + // The UUID of the asset, if not provided a new UUID will be generated. + id : UUID; + // The name of the asset. + name : text; + // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) + blockchain : text; + // The standards this asset supports. + standards : vec text; + // The asset symbol, e.g. "ICP" or "BTC". + symbol : text; + // The number of decimals used to format the asset balance. + decimals : nat32; + // Metadata associated with the asset. + metadata : vec AssetMetadata; +}; + // The init configuration for the canister. // // Only used when installing the canister for the first time. @@ -2253,6 +2486,8 @@ type SystemInit = record { fallback_controller : opt principal; // Optional initial accounts to create. accounts : opt vec InitAccountInput; + // Optional initial assets to create. + assets : opt vec InitAssetInput; }; // The upgrade configuration for the canister. @@ -2766,4 +3001,8 @@ service : (opt SystemInstall) -> { http_request : (HttpRequest) -> (HttpResponse) query; // Internal endpoint used by the upgrader canister to notify the station about a failed station upgrade request. notify_failed_station_upgrade : (NotifyFailedStationUpgradeInput) -> (NotifyFailedStationUpgradeResult); + // Get an asset by id. + get_asset : (input : GetAssetInput) -> (GetAssetResult) query; + // List all assets that the caller has access to. + list_assets : (input : ListAssetsInput) -> (ListAssetsResult) query; }; diff --git a/apps/wallet/src/generated/station/station.did.d.ts b/apps/wallet/src/generated/station/station.did.d.ts index 4655876e1..6d5d1b6f9 100644 --- a/apps/wallet/src/generated/station/station.did.d.ts +++ b/apps/wallet/src/generated/station/station.did.d.ts @@ -5,22 +5,25 @@ import type { IDL } from '@dfinity/candid'; export interface Account { 'id' : UUID, 'configs_request_policy' : [] | [RequestPolicyRule], - 'decimals' : number, - 'balance' : [] | [AccountBalanceInfo], 'metadata' : Array, 'name' : string, - 'blockchain' : string, - 'address' : string, + 'assets' : Array, + 'addresses' : Array, 'transfer_request_policy' : [] | [RequestPolicyRule], 'last_modification_timestamp' : TimestampRFC3339, - 'standard' : string, - 'symbol' : AssetSymbol, +} +export interface AccountAddress { 'address' : string, 'format' : string } +export interface AccountAsset { + 'balance' : [] | [AccountBalance], + 'asset_id' : UUID, } export interface AccountBalance { 'account_id' : UUID, 'decimals' : number, 'balance' : bigint, 'last_update_timestamp' : TimestampRFC3339, + 'query_state' : string, + 'asset_id' : UUID, } export interface AccountBalanceInfo { 'decimals' : number, @@ -38,6 +41,7 @@ export type AccountResourceAction = { 'List' : null } | { 'Create' : null } | { 'Transfer' : ResourceId } | { 'Update' : ResourceId }; +export type AccountSeed = Uint8Array | number[]; export interface AddAccountOperation { 'account' : [] | [Account], 'input' : AddAccountOperationInput, @@ -48,10 +52,9 @@ export interface AddAccountOperationInput { 'configs_permission' : Allow, 'metadata' : Array, 'name' : string, - 'blockchain' : string, + 'assets' : Array, 'transfer_request_policy' : [] | [RequestPolicyRule], 'transfer_permission' : Allow, - 'standard' : string, } export interface AddAddressBookEntryOperation { 'address_book_entry' : [] | [AddressBookEntry], @@ -62,8 +65,21 @@ export interface AddAddressBookEntryOperationInput { 'labels' : Array, 'blockchain' : string, 'address' : string, + 'address_format' : string, 'address_owner' : string, } +export interface AddAssetOperation { + 'asset' : [] | [Asset], + 'input' : AddAssetOperationInput, +} +export interface AddAssetOperationInput { + 'decimals' : number, + 'standards' : Array, + 'metadata' : Array, + 'name' : string, + 'blockchain' : string, + 'symbol' : AssetSymbol, +} export interface AddRequestPolicyOperation { 'input' : AddRequestPolicyOperationInput, 'policy_id' : [] | [UUID], @@ -94,6 +110,7 @@ export interface AddressBookEntry { 'blockchain' : string, 'address' : string, 'last_modification_timestamp' : string, + 'address_format' : string, 'address_owner' : string, } export interface AddressBookEntryCallerPrivileges { @@ -109,12 +126,19 @@ export interface Allow { 'users' : Array, } export interface Asset { + 'id' : UUID, + 'decimals' : number, + 'standards' : Array, 'metadata' : Array, 'name' : string, 'blockchain' : string, - 'standard' : string, 'symbol' : AssetSymbol, } +export interface AssetCallerPrivileges { + 'id' : UUID, + 'can_delete' : boolean, + 'can_edit' : boolean, +} export interface AssetMetadata { 'key' : string, 'value' : string } export type AssetSymbol = string; export type AuthScope = { 'Authenticated' : null } | @@ -185,6 +209,7 @@ export interface Capabilities { 'name' : string, 'version' : string, 'supported_assets' : Array, + 'supported_blockchains' : Array, } export type CapabilitiesResult = { 'Ok' : { 'capabilities' : Capabilities } } | { 'Err' : Error }; @@ -193,6 +218,8 @@ export type ChangeAddressBookMetadata = { } | { 'RemoveKeys' : Array } | { 'ReplaceAllBy' : Array }; +export type ChangeAssets = { 'ReplaceWith' : { 'assets' : Array } } | + { 'Change' : { 'add_assets' : Array, 'remove_assets' : Array } }; export type ChangeExternalCanisterMetadata = { 'OverrideSpecifiedBy' : Array } | @@ -211,6 +238,9 @@ export interface ChangeExternalCanisterOperationInput { 'canister_id' : Principal, 'module' : Uint8Array | number[], } +export type ChangeMetadata = { 'OverrideSpecifiedBy' : Array } | + { 'RemoveKeys' : Array } | + { 'ReplaceAllBy' : Array }; export type ConfigureExternalCanisterOperation = ConfigureExternalCanisterOperationInput; export interface ConfigureExternalCanisterOperationInput { 'kind' : ConfigureExternalCanisterOperationKind, @@ -311,6 +341,7 @@ export interface EditAccountOperationInput { 'read_permission' : [] | [Allow], 'configs_permission' : [] | [Allow], 'name' : [] | [string], + 'change_assets' : [] | [ChangeAssets], 'transfer_request_policy' : [] | [RequestPolicyRuleInput], 'transfer_permission' : [] | [Allow], } @@ -323,6 +354,15 @@ export interface EditAddressBookEntryOperationInput { 'address_book_entry_id' : UUID, 'address_owner' : [] | [string], } +export interface EditAssetOperation { 'input' : EditAssetOperationInput } +export interface EditAssetOperationInput { + 'standards' : [] | [Array], + 'name' : [] | [string], + 'blockchain' : [] | [string], + 'change_metadata' : [] | [ChangeMetadata], + 'asset_id' : UUID, + 'symbol' : [] | [AssetSymbol], +} export interface EditPermissionOperation { 'input' : EditPermissionOperationInput, } @@ -525,7 +565,7 @@ export type ExternalCanisterState = { 'Active' : null } | { 'Archived' : null }; export interface FetchAccountBalancesInput { 'account_ids' : Array } export type FetchAccountBalancesResult = { - 'Ok' : { 'balances' : Array } + 'Ok' : { 'balances' : Array<[] | [AccountBalance]> } } | { 'Err' : Error }; export type FundExternalCanisterOperation = FundExternalCanisterOperationInput; @@ -550,6 +590,11 @@ export type GetAddressBookEntryResult = { } } | { 'Err' : Error }; +export interface GetAssetInput { 'asset_id' : UUID } +export type GetAssetResult = { + 'Ok' : { 'privileges' : AssetCallerPrivileges, 'asset' : Asset } + } | + { 'Err' : Error }; export interface GetExternalCanisterFiltersInput { 'with_labels' : [] | [boolean], 'with_name' : [] | [{ 'prefix' : [] | [string] }], @@ -638,8 +683,17 @@ export interface InitAccountInput { 'id' : [] | [UUID], 'metadata' : Array, 'name' : string, + 'assets' : Array, + 'seed' : AccountSeed, +} +export interface InitAssetInput { + 'id' : UUID, + 'decimals' : number, + 'standards' : Array, + 'metadata' : Array, + 'name' : string, 'blockchain' : string, - 'standard' : string, + 'symbol' : string, } export interface ListAccountTransfersInput { 'account_id' : UUID, @@ -666,6 +720,7 @@ export type ListAccountsResult = { { 'Err' : Error }; export interface ListAddressBookEntriesInput { 'ids' : [] | [Array], + 'address_formats' : [] | [Array], 'labels' : [] | [Array], 'blockchain' : [] | [string], 'addresses' : [] | [Array], @@ -680,6 +735,16 @@ export type ListAddressBookEntriesResult = { } } | { 'Err' : Error }; +export interface ListAssetsInput { 'paginate' : [] | [PaginationInput] } +export type ListAssetsResult = { + 'Ok' : { + 'total' : bigint, + 'privileges' : Array, + 'assets' : Array, + 'next_offset' : [] | [bigint], + } + } | + { 'Err' : Error }; export interface ListExternalCanistersInput { 'sort_by' : [] | [ListExternalCanistersSortInput], 'states' : [] | [Array], @@ -746,15 +811,18 @@ export interface ListRequestsInput { 'only_approvable' : boolean, 'created_from_dt' : [] | [TimestampRFC3339], } -export type ListRequestsOperationType = { 'AddUserGroup' : null } | +export type ListRequestsOperationType = { 'RemoveAsset' : null } | + { 'AddUserGroup' : null } | { 'EditPermission' : null } | { 'ConfigureExternalCanister' : [] | [Principal] } | { 'ChangeExternalCanister' : [] | [Principal] } | { 'AddUser' : null } | + { 'EditAsset' : null } | { 'EditUserGroup' : null } | { 'SetDisasterRecovery' : null } | { 'EditRequestPolicy' : null } | { 'RemoveRequestPolicy' : null } | + { 'AddAsset' : null } | { 'SystemUpgrade' : null } | { 'RemoveAddressBookEntry' : null } | { 'CreateExternalCanister' : null } | @@ -894,6 +962,8 @@ export interface RemoveAddressBookEntryOperation { export interface RemoveAddressBookEntryOperationInput { 'address_book_entry_id' : UUID, } +export interface RemoveAssetOperation { 'input' : RemoveAssetOperationInput } +export interface RemoveAssetOperationInput { 'asset_id' : UUID } export interface RemoveRequestPolicyOperation { 'input' : RemoveRequestPolicyOperationInput, } @@ -940,15 +1010,18 @@ export interface RequestEvaluationResult { } export type RequestExecutionSchedule = { 'Immediate' : null } | { 'Scheduled' : { 'execution_time' : TimestampRFC3339 } }; -export type RequestOperation = { 'AddUserGroup' : AddUserGroupOperation } | +export type RequestOperation = { 'RemoveAsset' : RemoveAssetOperation } | + { 'AddUserGroup' : AddUserGroupOperation } | { 'EditPermission' : EditPermissionOperation } | { 'ConfigureExternalCanister' : ConfigureExternalCanisterOperation } | { 'ChangeExternalCanister' : ChangeExternalCanisterOperation } | { 'AddUser' : AddUserOperation } | + { 'EditAsset' : EditAssetOperation } | { 'EditUserGroup' : EditUserGroupOperation } | { 'SetDisasterRecovery' : SetDisasterRecoveryOperation } | { 'EditRequestPolicy' : EditRequestPolicyOperation } | { 'RemoveRequestPolicy' : RemoveRequestPolicyOperation } | + { 'AddAsset' : AddAssetOperation } | { 'SystemUpgrade' : SystemUpgradeOperation } | { 'RemoveAddressBookEntry' : RemoveAddressBookEntryOperation } | { 'CreateExternalCanister' : CreateExternalCanisterOperation } | @@ -964,16 +1037,19 @@ export type RequestOperation = { 'AddUserGroup' : AddUserGroupOperation } | { 'CallExternalCanister' : CallExternalCanisterOperation } | { 'AddAccount' : AddAccountOperation }; export type RequestOperationInput = { - 'AddUserGroup' : AddUserGroupOperationInput + 'RemoveAsset' : RemoveAssetOperationInput } | + { 'AddUserGroup' : AddUserGroupOperationInput } | { 'EditPermission' : EditPermissionOperationInput } | { 'ConfigureExternalCanister' : ConfigureExternalCanisterOperationInput } | { 'ChangeExternalCanister' : ChangeExternalCanisterOperationInput } | { 'AddUser' : AddUserOperationInput } | + { 'EditAsset' : EditAssetOperationInput } | { 'EditUserGroup' : EditUserGroupOperationInput } | { 'SetDisasterRecovery' : SetDisasterRecoveryOperationInput } | { 'EditRequestPolicy' : EditRequestPolicyOperationInput } | { 'RemoveRequestPolicy' : RemoveRequestPolicyOperationInput } | + { 'AddAsset' : AddAssetOperationInput } | { 'SystemUpgrade' : SystemUpgradeOperationInput } | { 'RemoveAddressBookEntry' : RemoveAddressBookEntryOperationInput } | { 'CreateExternalCanister' : CreateExternalCanisterOperationInput } | @@ -988,15 +1064,18 @@ export type RequestOperationInput = { { 'RemoveUserGroup' : RemoveUserGroupOperationInput } | { 'CallExternalCanister' : CallExternalCanisterOperationInput } | { 'AddAccount' : AddAccountOperationInput }; -export type RequestOperationType = { 'AddUserGroup' : null } | +export type RequestOperationType = { 'RemoveAsset' : null } | + { 'AddUserGroup' : null } | { 'EditPermission' : null } | { 'ConfigureExternalCanister' : null } | { 'ChangeExternalCanister' : null } | { 'AddUser' : null } | + { 'EditAsset' : null } | { 'EditUserGroup' : null } | { 'SetDisasterRecovery' : null } | { 'EditRequestPolicy' : null } | { 'RemoveRequestPolicy' : null } | + { 'AddAsset' : null } | { 'SystemUpgrade' : null } | { 'RemoveAddressBookEntry' : null } | { 'CreateExternalCanister' : null } | @@ -1037,14 +1116,17 @@ export interface RequestPolicyRuleResult { } export type RequestResourceAction = { 'List' : null } | { 'Read' : ResourceId }; -export type RequestSpecifier = { 'AddUserGroup' : null } | +export type RequestSpecifier = { 'RemoveAsset' : ResourceIds } | + { 'AddUserGroup' : null } | { 'EditPermission' : ResourceSpecifier } | { 'ChangeExternalCanister' : ExternalCanisterId } | { 'AddUser' : null } | + { 'EditAsset' : ResourceIds } | { 'EditUserGroup' : ResourceIds } | { 'SetDisasterRecovery' : null } | { 'EditRequestPolicy' : ResourceIds } | { 'RemoveRequestPolicy' : ResourceIds } | + { 'AddAsset' : null } | { 'SystemUpgrade' : null } | { 'RemoveAddressBookEntry' : ResourceIds } | { 'CreateExternalCanister' : null } | @@ -1082,6 +1164,7 @@ export type Resource = { 'Request' : RequestResourceAction } | { 'ExternalCanister' : ExternalCanisterResourceAction } | { 'Account' : AccountResourceAction } | { 'AddressBook' : ResourceAction } | + { 'Asset' : ResourceAction } | { 'UserGroup' : ResourceAction } | { 'Permission' : PermissionResourceAction } | { 'RequestPolicy' : ResourceAction }; @@ -1105,6 +1188,12 @@ export interface SetDisasterRecoveryOperationInput { export type Sha256Hash = string; export type SortByDirection = { 'Asc' : null } | { 'Desc' : null }; +export interface StandardData { + 'supported_operations' : Array, + 'supported_address_formats' : Array, + 'required_metadata_fields' : Array, + 'standard' : string, +} export interface SubmitRequestApprovalInput { 'request_id' : UUID, 'decision' : RequestApprovalStatus, @@ -1121,6 +1210,10 @@ export type SubmitRequestApprovalResult = { export interface SubnetFilter { 'subnet_type' : [] | [string] } export type SubnetSelection = { 'Filter' : SubnetFilter } | { 'Subnet' : { 'subnet' : Principal } }; +export interface SupportedBlockchain { + 'blockchain' : string, + 'supported_standards' : Array, +} export interface SystemInfo { 'disaster_recovery' : [] | [DisasterRecovery], 'name' : string, @@ -1135,6 +1228,7 @@ export type SystemInfoResult = { 'Ok' : { 'system' : SystemInfo } } | { 'Err' : Error }; export interface SystemInit { 'name' : string, + 'assets' : [] | [Array], 'fallback_controller' : [] | [Principal], 'upgrader' : SystemUpgraderInput, 'accounts' : [] | [Array], @@ -1186,6 +1280,7 @@ export interface TransferListItem { export interface TransferMetadata { 'key' : string, 'value' : string } export interface TransferOperation { 'fee' : [] | [bigint], + 'from_asset' : Asset, 'network' : Network, 'transfer_id' : [] | [UUID], 'from_account' : [] | [Account], @@ -1194,10 +1289,12 @@ export interface TransferOperation { export interface TransferOperationInput { 'to' : string, 'fee' : [] | [bigint], + 'with_standard' : string, 'from_account_id' : UUID, 'metadata' : Array, 'network' : [] | [Network], 'amount' : bigint, + 'from_asset_id' : UUID, } export type TransferStatus = { 'Failed' : { 'reason' : string } } | { 'Processing' : { 'started_at' : TimestampRFC3339 } } | @@ -1235,8 +1332,10 @@ export type UserPrivilege = { 'AddUserGroup' : null } | { 'ListUserGroups' : null } | { 'AddUser' : null } | { 'ListUsers' : null } | + { 'AddAsset' : null } | { 'SystemUpgrade' : null } | { 'CreateExternalCanister' : null } | + { 'ListAssets' : null } | { 'ManageSystemInfo' : null } | { 'AddAddressBookEntry' : null } | { 'ListAccounts' : null } | @@ -1278,6 +1377,7 @@ export interface _SERVICE { [GetAddressBookEntryInput], GetAddressBookEntryResult >, + 'get_asset' : ActorMethod<[GetAssetInput], GetAssetResult>, 'get_external_canister' : ActorMethod< [GetExternalCanisterInput], GetExternalCanisterResult @@ -1310,6 +1410,7 @@ export interface _SERVICE { [ListAddressBookEntriesInput], ListAddressBookEntriesResult >, + 'list_assets' : ActorMethod<[ListAssetsInput], ListAssetsResult>, 'list_external_canisters' : ActorMethod< [ListExternalCanistersInput], ListExternalCanistersResult diff --git a/apps/wallet/src/generated/station/station.did.js b/apps/wallet/src/generated/station/station.did.js index 11a797576..be19c7b1f 100644 --- a/apps/wallet/src/generated/station/station.did.js +++ b/apps/wallet/src/generated/station/station.did.js @@ -2,18 +2,29 @@ export const idlFactory = ({ IDL }) => { const RequestPolicyRule = IDL.Rec(); const RequestPolicyRuleResult = IDL.Rec(); const SystemUpgrade = IDL.Record({ 'name' : IDL.Opt(IDL.Text) }); + const UUID = IDL.Text; + const AssetMetadata = IDL.Record({ 'key' : IDL.Text, 'value' : IDL.Text }); + const InitAssetInput = IDL.Record({ + 'id' : UUID, + 'decimals' : IDL.Nat32, + 'standards' : IDL.Vec(IDL.Text), + 'metadata' : IDL.Vec(AssetMetadata), + 'name' : IDL.Text, + 'blockchain' : IDL.Text, + 'symbol' : IDL.Text, + }); const SystemUpgraderInput = IDL.Variant({ 'Id' : IDL.Principal, 'WasmModule' : IDL.Vec(IDL.Nat8), }); - const UUID = IDL.Text; const AccountMetadata = IDL.Record({ 'key' : IDL.Text, 'value' : IDL.Text }); + const AccountSeed = IDL.Vec(IDL.Nat8); const InitAccountInput = IDL.Record({ 'id' : IDL.Opt(UUID), 'metadata' : IDL.Vec(AccountMetadata), 'name' : IDL.Text, - 'blockchain' : IDL.Text, - 'standard' : IDL.Text, + 'assets' : IDL.Vec(UUID), + 'seed' : AccountSeed, }); const AdminInitInput = IDL.Record({ 'name' : IDL.Text, @@ -21,6 +32,7 @@ export const idlFactory = ({ IDL }) => { }); const SystemInit = IDL.Record({ 'name' : IDL.Text, + 'assets' : IDL.Opt(IDL.Vec(InitAssetInput)), 'fallback_controller' : IDL.Opt(IDL.Principal), 'upgrader' : SystemUpgraderInput, 'accounts' : IDL.Opt(IDL.Vec(InitAccountInput)), @@ -50,6 +62,10 @@ export const idlFactory = ({ IDL }) => { 'Immediate' : IDL.Null, 'Scheduled' : IDL.Record({ 'execution_time' : TimestampRFC3339 }), }); + const RemoveAssetOperationInput = IDL.Record({ 'asset_id' : UUID }); + const RemoveAssetOperation = IDL.Record({ + 'input' : RemoveAssetOperationInput, + }); const UserGroup = IDL.Record({ 'id' : UUID, 'name' : IDL.Text }); const AddUserGroupOperationInput = IDL.Record({ 'name' : IDL.Text }); const AddUserGroupOperation = IDL.Record({ @@ -131,6 +147,7 @@ export const idlFactory = ({ IDL }) => { 'ExternalCanister' : ExternalCanisterResourceAction, 'Account' : AccountResourceAction, 'AddressBook' : ResourceAction, + 'Asset' : ResourceAction, 'UserGroup' : ResourceAction, 'Permission' : PermissionResourceAction, 'RequestPolicy' : ResourceAction, @@ -333,6 +350,21 @@ export const idlFactory = ({ IDL }) => { 'user' : IDL.Opt(User), 'input' : AddUserOperationInput, }); + const ChangeMetadata = IDL.Variant({ + 'OverrideSpecifiedBy' : IDL.Vec(AssetMetadata), + 'RemoveKeys' : IDL.Vec(IDL.Text), + 'ReplaceAllBy' : IDL.Vec(AssetMetadata), + }); + const AssetSymbol = IDL.Text; + const EditAssetOperationInput = IDL.Record({ + 'standards' : IDL.Opt(IDL.Vec(IDL.Text)), + 'name' : IDL.Opt(IDL.Text), + 'blockchain' : IDL.Opt(IDL.Text), + 'change_metadata' : IDL.Opt(ChangeMetadata), + 'asset_id' : UUID, + 'symbol' : IDL.Opt(AssetSymbol), + }); + const EditAssetOperation = IDL.Record({ 'input' : EditAssetOperationInput }); const EditUserGroupOperationInput = IDL.Record({ 'name' : IDL.Text, 'user_group_id' : UUID, @@ -347,20 +379,23 @@ export const idlFactory = ({ IDL }) => { const SetDisasterRecoveryOperation = IDL.Record({ 'committee' : IDL.Opt(DisasterRecoveryCommittee), }); + const ResourceIds = IDL.Variant({ 'Any' : IDL.Null, 'Ids' : IDL.Vec(UUID) }); const ResourceSpecifier = IDL.Variant({ 'Any' : IDL.Null, 'Resource' : Resource, }); - const ResourceIds = IDL.Variant({ 'Any' : IDL.Null, 'Ids' : IDL.Vec(UUID) }); const RequestSpecifier = IDL.Variant({ + 'RemoveAsset' : ResourceIds, 'AddUserGroup' : IDL.Null, 'EditPermission' : ResourceSpecifier, 'ChangeExternalCanister' : ExternalCanisterId, 'AddUser' : IDL.Null, + 'EditAsset' : ResourceIds, 'EditUserGroup' : ResourceIds, 'SetDisasterRecovery' : IDL.Null, 'EditRequestPolicy' : ResourceIds, 'RemoveRequestPolicy' : ResourceIds, + 'AddAsset' : IDL.Null, 'SystemUpgrade' : IDL.Null, 'RemoveAddressBookEntry' : ResourceIds, 'CreateExternalCanister' : IDL.Null, @@ -388,6 +423,27 @@ export const idlFactory = ({ IDL }) => { const RemoveRequestPolicyOperation = IDL.Record({ 'input' : RemoveRequestPolicyOperationInput, }); + const Asset = IDL.Record({ + 'id' : UUID, + 'decimals' : IDL.Nat32, + 'standards' : IDL.Vec(IDL.Text), + 'metadata' : IDL.Vec(AssetMetadata), + 'name' : IDL.Text, + 'blockchain' : IDL.Text, + 'symbol' : AssetSymbol, + }); + const AddAssetOperationInput = IDL.Record({ + 'decimals' : IDL.Nat32, + 'standards' : IDL.Vec(IDL.Text), + 'metadata' : IDL.Vec(AssetMetadata), + 'name' : IDL.Text, + 'blockchain' : IDL.Text, + 'symbol' : AssetSymbol, + }); + const AddAssetOperation = IDL.Record({ + 'asset' : IDL.Opt(Asset), + 'input' : AddAssetOperationInput, + }); const SystemUpgradeTarget = IDL.Variant({ 'UpgradeUpgrader' : IDL.Null, 'UpgradeStation' : IDL.Null, @@ -489,37 +545,46 @@ export const idlFactory = ({ IDL }) => { }); const NetworkId = IDL.Text; const Network = IDL.Record({ 'id' : NetworkId, 'name' : IDL.Text }); - const AccountBalanceInfo = IDL.Record({ + const AccountBalance = IDL.Record({ + 'account_id' : UUID, 'decimals' : IDL.Nat32, 'balance' : IDL.Nat, 'last_update_timestamp' : TimestampRFC3339, + 'query_state' : IDL.Text, + 'asset_id' : UUID, + }); + const AccountAsset = IDL.Record({ + 'balance' : IDL.Opt(AccountBalance), + 'asset_id' : UUID, + }); + const AccountAddress = IDL.Record({ + 'address' : IDL.Text, + 'format' : IDL.Text, }); - const AssetSymbol = IDL.Text; const Account = IDL.Record({ 'id' : UUID, 'configs_request_policy' : IDL.Opt(RequestPolicyRule), - 'decimals' : IDL.Nat32, - 'balance' : IDL.Opt(AccountBalanceInfo), 'metadata' : IDL.Vec(AccountMetadata), 'name' : IDL.Text, - 'blockchain' : IDL.Text, - 'address' : IDL.Text, + 'assets' : IDL.Vec(AccountAsset), + 'addresses' : IDL.Vec(AccountAddress), 'transfer_request_policy' : IDL.Opt(RequestPolicyRule), 'last_modification_timestamp' : TimestampRFC3339, - 'standard' : IDL.Text, - 'symbol' : AssetSymbol, }); const TransferMetadata = IDL.Record({ 'key' : IDL.Text, 'value' : IDL.Text }); const TransferOperationInput = IDL.Record({ 'to' : IDL.Text, 'fee' : IDL.Opt(IDL.Nat), + 'with_standard' : IDL.Text, 'from_account_id' : UUID, 'metadata' : IDL.Vec(TransferMetadata), 'network' : IDL.Opt(Network), 'amount' : IDL.Nat, + 'from_asset_id' : UUID, }); const TransferOperation = IDL.Record({ 'fee' : IDL.Opt(IDL.Nat), + 'from_asset' : Asset, 'network' : Network, 'transfer_id' : IDL.Opt(UUID), 'from_account' : IDL.Opt(Account), @@ -529,12 +594,20 @@ export const idlFactory = ({ IDL }) => { 'Set' : RequestPolicyRule, 'Remove' : IDL.Null, }); + const ChangeAssets = IDL.Variant({ + 'ReplaceWith' : IDL.Record({ 'assets' : IDL.Vec(UUID) }), + 'Change' : IDL.Record({ + 'add_assets' : IDL.Vec(UUID), + 'remove_assets' : IDL.Vec(UUID), + }), + }); const EditAccountOperationInput = IDL.Record({ 'account_id' : UUID, 'configs_request_policy' : IDL.Opt(RequestPolicyRuleInput), 'read_permission' : IDL.Opt(Allow), 'configs_permission' : IDL.Opt(Allow), 'name' : IDL.Opt(IDL.Text), + 'change_assets' : IDL.Opt(ChangeAssets), 'transfer_request_policy' : IDL.Opt(RequestPolicyRuleInput), 'transfer_permission' : IDL.Opt(Allow), }); @@ -548,6 +621,7 @@ export const idlFactory = ({ IDL }) => { 'blockchain' : IDL.Text, 'address' : IDL.Text, 'last_modification_timestamp' : IDL.Text, + 'address_format' : IDL.Text, 'address_owner' : IDL.Text, }); const AddAddressBookEntryOperationInput = IDL.Record({ @@ -555,6 +629,7 @@ export const idlFactory = ({ IDL }) => { 'labels' : IDL.Vec(IDL.Text), 'blockchain' : IDL.Text, 'address' : IDL.Text, + 'address_format' : IDL.Text, 'address_owner' : IDL.Text, }); const AddAddressBookEntryOperation = IDL.Record({ @@ -588,25 +663,27 @@ export const idlFactory = ({ IDL }) => { 'configs_permission' : Allow, 'metadata' : IDL.Vec(AccountMetadata), 'name' : IDL.Text, - 'blockchain' : IDL.Text, + 'assets' : IDL.Vec(UUID), 'transfer_request_policy' : IDL.Opt(RequestPolicyRule), 'transfer_permission' : Allow, - 'standard' : IDL.Text, }); const AddAccountOperation = IDL.Record({ 'account' : IDL.Opt(Account), 'input' : AddAccountOperationInput, }); const RequestOperation = IDL.Variant({ + 'RemoveAsset' : RemoveAssetOperation, 'AddUserGroup' : AddUserGroupOperation, 'EditPermission' : EditPermissionOperation, 'ConfigureExternalCanister' : ConfigureExternalCanisterOperation, 'ChangeExternalCanister' : ChangeExternalCanisterOperation, 'AddUser' : AddUserOperation, + 'EditAsset' : EditAssetOperation, 'EditUserGroup' : EditUserGroupOperation, 'SetDisasterRecovery' : SetDisasterRecoveryOperation, 'EditRequestPolicy' : EditRequestPolicyOperation, 'RemoveRequestPolicy' : RemoveRequestPolicyOperation, + 'AddAsset' : AddAssetOperation, 'SystemUpgrade' : SystemUpgradeOperation, 'RemoveAddressBookEntry' : RemoveAddressBookEntryOperation, 'CreateExternalCanister' : CreateExternalCanisterOperation, @@ -686,18 +763,21 @@ export const idlFactory = ({ IDL }) => { 'Ok' : CanisterStatusResponse, 'Err' : Error, }); - const AssetMetadata = IDL.Record({ 'key' : IDL.Text, 'value' : IDL.Text }); - const Asset = IDL.Record({ - 'metadata' : IDL.Vec(AssetMetadata), - 'name' : IDL.Text, - 'blockchain' : IDL.Text, + const StandardData = IDL.Record({ + 'supported_operations' : IDL.Vec(IDL.Text), + 'supported_address_formats' : IDL.Vec(IDL.Text), + 'required_metadata_fields' : IDL.Vec(IDL.Text), 'standard' : IDL.Text, - 'symbol' : AssetSymbol, + }); + const SupportedBlockchain = IDL.Record({ + 'blockchain' : IDL.Text, + 'supported_standards' : IDL.Vec(StandardData), }); const Capabilities = IDL.Record({ 'name' : IDL.Text, 'version' : IDL.Text, 'supported_assets' : IDL.Vec(Asset), + 'supported_blockchains' : IDL.Vec(SupportedBlockchain), }); const CapabilitiesResult = IDL.Variant({ 'Ok' : IDL.Record({ 'capabilities' : Capabilities }), @@ -731,15 +811,18 @@ export const idlFactory = ({ IDL }) => { 'execution_method_cycles' : IDL.Opt(IDL.Nat64), }); const RequestOperationInput = IDL.Variant({ + 'RemoveAsset' : RemoveAssetOperationInput, 'AddUserGroup' : AddUserGroupOperationInput, 'EditPermission' : EditPermissionOperationInput, 'ConfigureExternalCanister' : ConfigureExternalCanisterOperationInput, 'ChangeExternalCanister' : ChangeExternalCanisterOperationInput, 'AddUser' : AddUserOperationInput, + 'EditAsset' : EditAssetOperationInput, 'EditUserGroup' : EditUserGroupOperationInput, 'SetDisasterRecovery' : SetDisasterRecoveryOperationInput, 'EditRequestPolicy' : EditRequestPolicyOperationInput, 'RemoveRequestPolicy' : RemoveRequestPolicyOperationInput, + 'AddAsset' : AddAssetOperationInput, 'SystemUpgrade' : SystemUpgradeOperationInput, 'RemoveAddressBookEntry' : RemoveAddressBookEntryOperationInput, 'CreateExternalCanister' : CreateExternalCanisterOperationInput, @@ -825,14 +908,8 @@ export const idlFactory = ({ IDL }) => { const FetchAccountBalancesInput = IDL.Record({ 'account_ids' : IDL.Vec(UUID), }); - const AccountBalance = IDL.Record({ - 'account_id' : UUID, - 'decimals' : IDL.Nat32, - 'balance' : IDL.Nat, - 'last_update_timestamp' : TimestampRFC3339, - }); const FetchAccountBalancesResult = IDL.Variant({ - 'Ok' : IDL.Record({ 'balances' : IDL.Vec(AccountBalance) }), + 'Ok' : IDL.Record({ 'balances' : IDL.Vec(IDL.Opt(AccountBalance)) }), 'Err' : Error, }); const GetAccountInput = IDL.Record({ 'account_id' : UUID }); @@ -863,6 +940,19 @@ export const idlFactory = ({ IDL }) => { }), 'Err' : Error, }); + const GetAssetInput = IDL.Record({ 'asset_id' : UUID }); + const AssetCallerPrivileges = IDL.Record({ + 'id' : UUID, + 'can_delete' : IDL.Bool, + 'can_edit' : IDL.Bool, + }); + const GetAssetResult = IDL.Variant({ + 'Ok' : IDL.Record({ + 'privileges' : AssetCallerPrivileges, + 'asset' : Asset, + }), + 'Err' : Error, + }); const GetExternalCanisterInput = IDL.Record({ 'canister_id' : IDL.Principal, }); @@ -927,15 +1017,18 @@ export const idlFactory = ({ IDL }) => { 'Err' : Error, }); const ListRequestsOperationType = IDL.Variant({ + 'RemoveAsset' : IDL.Null, 'AddUserGroup' : IDL.Null, 'EditPermission' : IDL.Null, 'ConfigureExternalCanister' : IDL.Opt(IDL.Principal), 'ChangeExternalCanister' : IDL.Opt(IDL.Principal), 'AddUser' : IDL.Null, + 'EditAsset' : IDL.Null, 'EditUserGroup' : IDL.Null, 'SetDisasterRecovery' : IDL.Null, 'EditRequestPolicy' : IDL.Null, 'RemoveRequestPolicy' : IDL.Null, + 'AddAsset' : IDL.Null, 'SystemUpgrade' : IDL.Null, 'RemoveAddressBookEntry' : IDL.Null, 'CreateExternalCanister' : IDL.Null, @@ -1110,6 +1203,7 @@ export const idlFactory = ({ IDL }) => { }); const ListAddressBookEntriesInput = IDL.Record({ 'ids' : IDL.Opt(IDL.Vec(UUID)), + 'address_formats' : IDL.Opt(IDL.Vec(IDL.Text)), 'labels' : IDL.Opt(IDL.Vec(IDL.Text)), 'blockchain' : IDL.Opt(IDL.Text), 'addresses' : IDL.Opt(IDL.Vec(IDL.Text)), @@ -1124,6 +1218,16 @@ export const idlFactory = ({ IDL }) => { }), 'Err' : Error, }); + const ListAssetsInput = IDL.Record({ 'paginate' : IDL.Opt(PaginationInput) }); + const ListAssetsResult = IDL.Variant({ + 'Ok' : IDL.Record({ + 'total' : IDL.Nat64, + 'privileges' : IDL.Vec(AssetCallerPrivileges), + 'assets' : IDL.Vec(Asset), + 'next_offset' : IDL.Opt(IDL.Nat64), + }), + 'Err' : Error, + }); const SortByDirection = IDL.Variant({ 'Asc' : IDL.Null, 'Desc' : IDL.Null }); const ListExternalCanistersSortInput = IDL.Variant({ 'Name' : SortByDirection, @@ -1159,15 +1263,18 @@ export const idlFactory = ({ IDL }) => { 'notification_type' : IDL.Opt(NotificationTypeInput), }); const RequestOperationType = IDL.Variant({ + 'RemoveAsset' : IDL.Null, 'AddUserGroup' : IDL.Null, 'EditPermission' : IDL.Null, 'ConfigureExternalCanister' : IDL.Null, 'ChangeExternalCanister' : IDL.Null, 'AddUser' : IDL.Null, + 'EditAsset' : IDL.Null, 'EditUserGroup' : IDL.Null, 'SetDisasterRecovery' : IDL.Null, 'EditRequestPolicy' : IDL.Null, 'RemoveRequestPolicy' : IDL.Null, + 'AddAsset' : IDL.Null, 'SystemUpgrade' : IDL.Null, 'RemoveAddressBookEntry' : IDL.Null, 'CreateExternalCanister' : IDL.Null, @@ -1327,8 +1434,10 @@ export const idlFactory = ({ IDL }) => { 'ListUserGroups' : IDL.Null, 'AddUser' : IDL.Null, 'ListUsers' : IDL.Null, + 'AddAsset' : IDL.Null, 'SystemUpgrade' : IDL.Null, 'CreateExternalCanister' : IDL.Null, + 'ListAssets' : IDL.Null, 'ManageSystemInfo' : IDL.Null, 'AddAddressBookEntry' : IDL.Null, 'ListAccounts' : IDL.Null, @@ -1416,6 +1525,7 @@ export const idlFactory = ({ IDL }) => { [GetAddressBookEntryResult], ['query'], ), + 'get_asset' : IDL.Func([GetAssetInput], [GetAssetResult], ['query']), 'get_external_canister' : IDL.Func( [GetExternalCanisterInput], [GetExternalCanisterResult], @@ -1470,6 +1580,7 @@ export const idlFactory = ({ IDL }) => { [ListAddressBookEntriesResult], ['query'], ), + 'list_assets' : IDL.Func([ListAssetsInput], [ListAssetsResult], ['query']), 'list_external_canisters' : IDL.Func( [ListExternalCanistersInput], [ListExternalCanistersResult], @@ -1522,18 +1633,29 @@ export const idlFactory = ({ IDL }) => { }; export const init = ({ IDL }) => { const SystemUpgrade = IDL.Record({ 'name' : IDL.Opt(IDL.Text) }); + const UUID = IDL.Text; + const AssetMetadata = IDL.Record({ 'key' : IDL.Text, 'value' : IDL.Text }); + const InitAssetInput = IDL.Record({ + 'id' : UUID, + 'decimals' : IDL.Nat32, + 'standards' : IDL.Vec(IDL.Text), + 'metadata' : IDL.Vec(AssetMetadata), + 'name' : IDL.Text, + 'blockchain' : IDL.Text, + 'symbol' : IDL.Text, + }); const SystemUpgraderInput = IDL.Variant({ 'Id' : IDL.Principal, 'WasmModule' : IDL.Vec(IDL.Nat8), }); - const UUID = IDL.Text; const AccountMetadata = IDL.Record({ 'key' : IDL.Text, 'value' : IDL.Text }); + const AccountSeed = IDL.Vec(IDL.Nat8); const InitAccountInput = IDL.Record({ 'id' : IDL.Opt(UUID), 'metadata' : IDL.Vec(AccountMetadata), 'name' : IDL.Text, - 'blockchain' : IDL.Text, - 'standard' : IDL.Text, + 'assets' : IDL.Vec(UUID), + 'seed' : AccountSeed, }); const AdminInitInput = IDL.Record({ 'name' : IDL.Text, @@ -1541,6 +1663,7 @@ export const init = ({ IDL }) => { }); const SystemInit = IDL.Record({ 'name' : IDL.Text, + 'assets' : IDL.Opt(IDL.Vec(InitAssetInput)), 'fallback_controller' : IDL.Opt(IDL.Principal), 'upgrader' : SystemUpgraderInput, 'accounts' : IDL.Opt(IDL.Vec(InitAccountInput)), diff --git a/apps/wallet/src/locales/en.locale.ts b/apps/wallet/src/locales/en.locale.ts index 694db6e60..297cd51c1 100644 --- a/apps/wallet/src/locales/en.locale.ts +++ b/apps/wallet/src/locales/en.locale.ts @@ -95,6 +95,7 @@ export default { verify_instructions: 'To verify the update, open the terminal and follow the instructions bellow:', }, + asset: 'Asset', no_data: 'No data available.', no_matching_results: 'No matching results found for `{search}`.', add_new_label: 'Add new label: {label}', @@ -110,7 +111,12 @@ export default { icp: { name: 'Internet Computer', standards: { - native: 'Native', + icp_native: 'ICP (Native)', + icrc1: 'ICRC-1', + }, + formats: { + icp_account_identifier: 'ICP Native', + icrc1_account: 'ICRC-1', }, }, eth: { @@ -169,6 +175,7 @@ export default { system: 'System', transfers: 'Transfers', users: 'Users', + assets: 'Assets', external_canisters: 'Canisters', }, headers: { @@ -265,6 +272,9 @@ export default { editaccount: { title: 'Edit account', request_title: 'Edit account request', + added_assets: 'Added', + removed_assets: 'Removed', + replaced_assets: 'Replaced', }, editaddressbookentry: { title: 'Edit address book entry', @@ -282,6 +292,18 @@ export default { title: 'Manage system info', request_title: 'Manage system info request', }, + addasset: { + title: 'Add asset', + request_title: 'Add asset request', + }, + editasset: { + title: 'Edit asset', + request_title: 'Edit asset request', + }, + removeasset: { + title: 'Remove asset', + request_title: 'Remove asset request', + }, createexternalcanister: { title: 'Create canister', request_title: 'Create canister request', @@ -696,6 +718,7 @@ export default { principal: 'Principal', status: 'Status', transfer: 'Transfer', + transfer_asset: 'Transfer {asset}', invalid: 'Invalid', control_panel: 'Control panel', confirmed: 'Confirmed', @@ -710,6 +733,9 @@ export default { version: 'Version', continue: 'Continue', cycle_obtain_strategy: 'Wallet top-up method', + symbol: 'Symbol', + standards: 'Standards', + assets: 'Assets', }, forms: { create: 'Create', @@ -732,11 +758,15 @@ export default { numberRange: 'This field must be between {min} and {max}.', invalidDecimalPlaces: 'This field must have a maximum of {decimals} decimal places.', isHex: 'This field must be a valid hexadecimal value.', + validAddress: 'This field must be a valid address.', + validSymbol: 'Symbol must be 1-32 alphanumeric characters.', }, }, navigation: { home: 'Home', + dashboard: 'Dashboard', accounts: 'Accounts', + account: 'Account', address_book: 'Address Book', users: 'Users', settings: 'Settings', @@ -750,9 +780,14 @@ export default { transfer_requests: 'Transfer Requests', permissions: 'Permissions', request_policies: 'Request Policies', + assets: 'Assets', external_canisters: 'Canisters', }, pages: { + dashboard: { + title: 'Dashboard', + available_assets: 'Available Assets', + }, accounts: { title: 'Accounts', btn_new_transfer: 'New transfer', @@ -773,6 +808,14 @@ export default { csv_ignored_transfers_hint: 'Transfers with errors will be ignored.', csv_transfer_failed: 'Failed to process transfers, please try again.', csv_download_invalid: 'Download invalid', + add_asset: 'Add asset', + remove_asset: 'Remove asset', + no_assets_to_add: 'No assets available to add.', + remove_asset_confirm: + 'Are you sure you want to remove this asset? Removing the asset does not affect the account balance. Re-adding the asset will restore access to the balance.', + transfers_not_supported: 'Transfers are not supported for this asset.', + add_index_canister_to_see_transactions: + 'Consider adding the index canister to the asset to see transactions.', }, address_book: { title: 'Address Book', @@ -871,6 +914,17 @@ export default { create_label: 'Add Policy', dialog_title: 'Policy', }, + assets: { + title: 'Assets', + btn_new_entry: 'New asset', + no_results_found: 'No assets found.', + error_fetching_assets: 'Error fetching assets, please try again.', + forms: { + ledger_canister_id: 'Ledger Canister ID', + index_canister_id: 'Index Canister ID', + decimals: 'Decimals', + }, + }, not_found: { title: 'Whoops, 404', subtitle: 'The page you were looking for does not exist.', @@ -910,6 +964,7 @@ export default { select_resource: 'Resource Type', resources: { account: 'Account', + asset: 'Asset', user: 'User', usergroup: 'User Group', permission: 'Access Policy', @@ -988,6 +1043,9 @@ export default { setdisasterrecovery: 'Configure disaster recovery', callexternalcanister: 'Call canister', createexternalcanister: 'Create canister', + addasset: 'Add asset', + editasset: 'Edit asset', + removeasset: 'Remove asset', }, }, cycle_obtain_strategies: { diff --git a/apps/wallet/src/locales/fr.locale.ts b/apps/wallet/src/locales/fr.locale.ts index 4dc027894..7a66c2709 100644 --- a/apps/wallet/src/locales/fr.locale.ts +++ b/apps/wallet/src/locales/fr.locale.ts @@ -97,6 +97,7 @@ export default { verify_instructions: 'Pour vérifier la mise à jour, ouvrez le terminal et suivez les instructions ci-dessous:', }, + asset: 'Actif', no_data: 'Pas de données disponibles.', no_matching_results: 'Pas de résultats correspondants trouvés pour `{search}`.', add_new_label: 'Ajouter une nouvelle étiquette: {label}', @@ -120,7 +121,12 @@ export default { icp: { name: 'Internet Computer', standards: { - native: 'Native', + icp_native: 'ICP (Native)', + icrc1: 'ICRC-1', + }, + formats: { + icp_account_identifier: 'ICP Native', + icrc1_account: 'ICRC-1', }, }, eth: { @@ -179,6 +185,7 @@ export default { system: 'Système', transfers: 'Transferts', users: 'Usagers', + assets: 'Actifs', external_canisters: 'Canisters', }, headers: { @@ -275,6 +282,9 @@ export default { editaccount: { title: 'Modifier de modifier un compte', request_title: 'Demande de modifier un compte', + added_assets: 'Ajouté', + removed_assets: 'Supprimé', + replaced_assets: 'Remplacé', }, editaddressbookentry: { title: "Modifier une entrée de carnet d'adresses", @@ -292,6 +302,18 @@ export default { title: 'Gérer les informations système', request_title: 'Demande de gérer les informations système', }, + addasset: { + title: 'Ajouter un actif', + request_title: 'Demande d ajouter un actif', + }, + editasset: { + title: 'Modifier un actif', + request_title: 'Demande de modifier un actif', + }, + removeasset: { + title: 'Supprimer un actif', + request_title: 'Demande de supprimer un actif', + }, createexternalcanister: { title: 'Créer un canister', request_title: 'Demande de création de canister', @@ -704,6 +726,7 @@ export default { principal: 'Principal', status: 'Statut', transfer: 'Transfert', + transfer_asset: 'Transfert {asset}', invalid: 'Invalide', control_panel: 'Paneau de Contrôle', confirmed: 'Confirmé', @@ -718,6 +741,9 @@ export default { version: 'Version', continue: 'Continuer', cycle_obtain_strategy: 'Méthode de recharge du portefeuille', + symbol: 'Symbole', + standards: 'Standards', + assets: 'Actifs', }, forms: { create: 'Créer', @@ -740,11 +766,15 @@ export default { numberRange: 'Le champ doit contenir une valeur valide entre {min} et {max}.', invalidDecimalPlaces: 'Ce champ doit contenir un maximum de {decimals} décimales.', isHex: 'Ce champ doit contenir une valeur hexadécimale valide.', + validAddress: 'Ce champ doit contenir une adresse valide.', + validSymbol: 'Le symbole doit contenir entre 1 et 32 charactères alphanumériques.', }, }, navigation: { home: 'Acceuil', + dashboard: 'Tableau de Bord', accounts: 'Comptes', + account: 'Compte', address_book: "Carnet d'Adresses", users: 'Usagers', settings: 'Settings', @@ -758,9 +788,14 @@ export default { transfer_requests: 'Demandes de Transfert', permissions: "Polices d'Accés", request_policies: "Polices d'Aprobation", + assets: 'Actifs', external_canisters: 'Canisters', }, pages: { + dashboard: { + title: 'Tableau de Bord', + available_assets: 'Actifs Disponibles', + }, accounts: { title: 'Comptes', btn_new_transfer: 'Nouveau Transfert', @@ -783,6 +818,14 @@ export default { csv_ignored_transfers_hint: 'Transfers with errors will be ignored.', csv_transfer_failed: 'Échec de process transfers, veuillez essayer de nouveau.', csv_download_invalid: 'Téléchargement invalide', + add_asset: 'Ajouter un actif', + remove_asset: 'Supprimer un actif', + no_assets_to_add: 'Pas d actifs disponibles à ajouter.', + remove_asset_confirm: + 'Êtes-vous sûr de vouloir supprimer cet actif? Supprimer l actif n affecte pas le solde du compte. Réajouter l actif restaurera l accès au solde.', + transfers_not_supported: 'Les transferts ne sont pas supportés pour cet actif.', + add_index_canister_to_see_transactions: + 'Considérez d ajouter le canister d index à l actif pour voir les transactions.', }, address_book: { title: "Carnet d'Adresses", @@ -884,6 +927,17 @@ export default { create_label: 'Ajouter un police', dialog_title: 'Police', }, + assets: { + title: 'Actifs', + btn_new_asset: 'Nouvel Actif', + no_results_found: 'Pas d actif trouvé.', + error_fetching_assets: 'Erreur lors du chargement des actifs, veuillez essayer de nouveau.', + forms: { + ledger_canister_id: 'ID du Canister Ledger', + index_canister_id: 'ID du Canister Index', + decimals: 'Décimales', + }, + }, not_found: { title: 'Oulala, 404', subtitle: "La page que vous cherchez n'existe pas.", @@ -923,6 +977,7 @@ export default { select_resource: 'Type de Resource', resources: { account: 'Compte', + asset: 'Actif', user: 'Usager', usergroup: "Groupe d'Usagers", permission: "Police d'Accés", @@ -1001,6 +1056,9 @@ export default { setdisasterrecovery: 'Définir la récupération après sinistre', callexternalcanister: 'Appeler un canister', createexternalcanister: 'Créer un canister', + addasset: 'Ajouter un actif', + editasset: 'Modifier un actif', + removeasset: 'Éffacer un actif', }, }, cycle_obtain_strategies: { diff --git a/apps/wallet/src/locales/pt.locale.ts b/apps/wallet/src/locales/pt.locale.ts index 65fd5ce69..5275309ef 100644 --- a/apps/wallet/src/locales/pt.locale.ts +++ b/apps/wallet/src/locales/pt.locale.ts @@ -96,6 +96,7 @@ export default { verify_instructions: 'Para verificar a atualização, abra o terminal e siga as instruções abaixo:', }, + assets: 'Ativos', no_data: 'Nenhum dado disponível.', no_matching_results: 'Nenhum resultado encontrado para `{search}`.', add_new_label: 'Adicionar novo rótulo: {label}', @@ -125,7 +126,12 @@ export default { icp: { name: 'Internet Computer', standards: { - native: 'Nativo', + icp_native: 'ICP (Nativo)', + icrc1: 'ICRC-1', + }, + formats: { + icp_account_identifier: 'ICP Nativo', + icrc1_account: 'ICRC-1', }, }, eth: { @@ -178,6 +184,7 @@ export default { system: 'Sistema', transfers: 'Transferências', users: 'Usuários', + assets: 'Ativos', external_canisters: 'Canisters', }, download: { @@ -274,6 +281,9 @@ export default { editaccount: { title: 'Editar conta', request_title: 'Pedido de alteração de conta', + added_assets: 'Adicionado', + removed_assets: 'Removido', + replaced_assets: 'Substituído', }, editaddressbookentry: { title: 'Editar endereço', @@ -291,6 +301,18 @@ export default { title: 'Gerir informações do sistema', request_title: 'Pedido de alteração de informações do sistema', }, + addasset: { + title: 'Adicionar ativo', + request_title: 'Pedido de adição de ativo', + }, + editasset: { + title: 'Editar ativo', + request_title: 'Pedido de alteração de ativo', + }, + removeasset: { + title: 'Remover ativo', + request_title: 'Pedido de remoção de ativo', + }, createexternalcanister: { title: 'Criar canister', request_title: 'Pedido de criação de canister', @@ -686,6 +708,7 @@ export default { settings: 'Configuraçōes', close: 'Fechar', transfer: 'Transferência', + transfer_asset: 'Transferir {asset}', general: 'Geral', update: 'Atualizar', time: 'Horário', @@ -713,6 +736,9 @@ export default { version: 'Versão', continue: 'Continuar', cycle_obtain_strategy: 'Método de recarga da carteira', + symbol: 'Símbolo', + standards: 'Padrões', + assets: 'Ativos', }, forms: { create: 'Criar', @@ -735,11 +761,15 @@ export default { numberRange: 'Este campo deve estar entre {min} e {max}.', invalidDecimalPlaces: 'Este campo deve ter no máximo {decimals} casas decimais.', isHex: 'Este campo deve conter um valor hexadecimal válido.', + validAddress: 'Este campo deve conter um endereço válido.', + validSymbol: 'O símbolo deve ter de 1 a 32 caracteres alfanuméricos.', }, }, navigation: { home: 'Início', + dashboard: 'Dashboard', accounts: 'Contas', + account: 'Conta', address_book: 'Endereços', users: 'Usuários', settings: 'Configuraçōes', @@ -753,9 +783,14 @@ export default { transfer_requests: 'Pedidos de transferência', permissions: 'Permissões', request_policies: 'Regras de aprovação', + assets: 'Ativos', external_canisters: 'Canisters', }, pages: { + dashboard: { + title: 'Dashboard', + available_assets: 'Ativos disponíveis', + }, accounts: { title: 'Contas', btn_new_transfer: 'Nova transferência', @@ -777,6 +812,14 @@ export default { csv_ignored_transfers_hint: 'Transferências com erros serão ignoradas.', csv_transfer_failed: 'Error ao processar transferências, por favor, tente novamente.', csv_download_invalid: 'Baixar erros', + add_asset: 'Adicionar ativo', + remove_asset: 'Remover ativo', + no_assets_to_add: 'Nenhum ativo disponível para adicionar.', + remove_asset_confirm: + 'Tem a certeza que deseja remover este ativo? Remover o ativo não afeta o saldo da conta. Re-adicionar o ativo restaurará o acesso ao saldo.', + transfers_not_supported: 'As transferências não são suportadas para este ativo.', + add_index_canister_to_see_transactions: + 'Considere adicionar o canister de índice ao ativo para ver as transações.', }, address_book: { title: 'Livro de endereços', @@ -879,6 +922,17 @@ export default { create_label: 'Criar Regra', dialog_title: 'Regra', }, + assets: { + title: 'Ativos', + btn_new_entry: 'Novo ativo', + no_results_found: 'Nenhum ativo encontrado.', + error_fetching_assets: 'Erro ao carregar os ativos, por favor, tente novamente.', + forms: { + ledger_canister_id: 'ID do canister de contabilidade', + index_canister_id: 'ID do canister de índice', + decimals: 'Decimais', + }, + }, not_found: { title: 'Ups, 404', subtitle: 'A página que está a tentar aceder não existe.', @@ -918,6 +972,7 @@ export default { select_resource: 'Selecione o tipo de recurso', resources: { account: 'Conta', + asset: 'Ativo', user: 'Usuário', usergroup: 'Grupo de usuários', permission: 'Regra de acesso', @@ -995,6 +1050,9 @@ export default { fundexternalcanister: 'Financiar canister', setdisasterrecovery: 'Recuperação de sistema', callexternalcanister: 'Interagir com canister', + addasset: 'Adicionar ativo', + editasset: 'Editar ativo', + removeasset: 'Remover ativo', }, }, cycle_obtain_strategies: { diff --git a/apps/wallet/src/mappers/permissions.mapper.ts b/apps/wallet/src/mappers/permissions.mapper.ts index 179b53104..bde3bbb88 100644 --- a/apps/wallet/src/mappers/permissions.mapper.ts +++ b/apps/wallet/src/mappers/permissions.mapper.ts @@ -43,6 +43,10 @@ export const fromResourceToResourceEnum = (resource: Resource): ResourceTypeEnum return ResourceTypeEnum.Notification; } + if (variantIs(resource, 'Asset')) { + return ResourceTypeEnum.Asset; + } + return unreachable(resource); }; diff --git a/apps/wallet/src/mappers/request-specifiers.mapper.ts b/apps/wallet/src/mappers/request-specifiers.mapper.ts index 2cb33965f..189b629bf 100644 --- a/apps/wallet/src/mappers/request-specifiers.mapper.ts +++ b/apps/wallet/src/mappers/request-specifiers.mapper.ts @@ -91,6 +91,18 @@ export const mapRequestSpecifierToEnum = (specifier: RequestSpecifier): RequestS return RequestSpecifierEnum.SetDisasterRecovery; } + if (variantIs(specifier, 'AddAsset')) { + return RequestSpecifierEnum.AddAsset; + } + + if (variantIs(specifier, 'EditAsset')) { + return RequestSpecifierEnum.EditAsset; + } + + if (variantIs(specifier, 'RemoveAsset')) { + return RequestSpecifierEnum.RemoveAsset; + } + return unreachable(specifier); }; diff --git a/apps/wallet/src/mappers/requests.mapper.ts b/apps/wallet/src/mappers/requests.mapper.ts index cd7c4f9d0..1bba7ffcd 100644 --- a/apps/wallet/src/mappers/requests.mapper.ts +++ b/apps/wallet/src/mappers/requests.mapper.ts @@ -13,6 +13,7 @@ import { RequestWithDetails, } from '~/types/requests.types'; import { RequestOperationEnum, RequestStatusEnum } from '~/types/station.types'; +import { detectAddressFormat } from '~/utils/asset.utils'; import { formatBalance, stringify, unreachable, variantIs } from '~/utils/helper.utils'; export const mapRequestsOperationTypeToGroup = ( @@ -79,6 +80,14 @@ export const mapRequestsOperationTypeToGroup = ( return ListRequestsOperationTypeGroup.ExternalCanister; } + if ( + variantIs(operationType, 'AddAsset') || + variantIs(operationType, 'EditAsset') || + variantIs(operationType, 'RemoveAsset') + ) { + return ListRequestsOperationTypeGroup.Asset; + } + return unreachable(operationType); }; @@ -241,6 +250,15 @@ export const mapRequestOperationToTypeEnum = ( if (variantIs(operation, 'SetDisasterRecovery')) { return RequestOperationEnum.SetDisasterRecovery; } + if (variantIs(operation, 'AddAsset')) { + return RequestOperationEnum.AddAsset; + } + if (variantIs(operation, 'EditAsset')) { + return RequestOperationEnum.EditAsset; + } + if (variantIs(operation, 'RemoveAsset')) { + return RequestOperationEnum.RemoveAsset; + } return unreachable(operation); }; @@ -310,6 +328,12 @@ export const mapRequestOperationToListRequestsOperationType = ( return { FundExternalCanister: [] }; } else if (variantIs(requestOperation, 'SetDisasterRecovery')) { return { SetDisasterRecovery: null }; + } else if (variantIs(requestOperation, 'AddAsset')) { + return { AddAsset: null }; + } else if (variantIs(requestOperation, 'EditAsset')) { + return { EditAsset: null }; + } else if (variantIs(requestOperation, 'RemoveAsset')) { + return { RemoveAsset: null }; } else { return unreachable(requestOperation); } @@ -421,10 +445,9 @@ const mapRequestToAccountCsvRow = (request: Request): CsvRow => { return { account_id: request.operation.AddAccount.account?.[0]?.id ?? '', account_name: request.operation.AddAccount.input.name, - blockchain: request.operation.AddAccount.input.blockchain, details: stringify({ metadata: request.operation.AddAccount.input.metadata, - standard: request.operation.AddAccount.input.standard, + assets: request.operation.AddAccount.input.assets, configs_request_policy: request.operation.AddAccount.input.configs_request_policy, transfer_request_policy: request.operation.AddAccount.input.transfer_request_policy, }), @@ -480,18 +503,31 @@ const mapRequestToTransferCsvRow = (request: Request): CsvRow => { if (variantIs(request.operation, 'Transfer') && request.operation.Transfer.from_account?.[0]) { const account = request.operation.Transfer.from_account[0]; + const asset = request.operation.Transfer.from_asset; + + // to determine the `from address` we find a matching address to the format of the `to address` + const maybeToAddressFormat = detectAddressFormat( + asset.blockchain, + request.operation.Transfer.input.to, + ); + + const fallbackAddress = account.addresses[0]?.address ?? '-'; + + const fromAddress = maybeToAddressFormat + ? (account.addresses.find(accountAddress => accountAddress.format === maybeToAddressFormat) + ?.address ?? fallbackAddress) + : fallbackAddress; + return { from_account: account.name, - from_account_address: account.address, + from_account_address: fromAddress, + from_asset: `${asset.name} (${asset.blockchain} / ${asset.name})`, to: request.operation.Transfer.input.to, amount: - formatBalance(request.operation.Transfer.input.amount, account.decimals) + - ' ' + - account.symbol, + formatBalance(request.operation.Transfer.input.amount, asset.decimals) + ' ' + asset.symbol, fee: request.operation.Transfer.fee[0] - ? formatBalance(request.operation.Transfer.fee[0], account.decimals) + ' ' + account.symbol + ? formatBalance(request.operation.Transfer.fee[0], asset.decimals) + ' ' + asset.symbol : '', - // comment: request.summary[0] ?? '', comment: request.summary[0] ?? '', }; } diff --git a/apps/wallet/src/pages/AccountAssetPage.vue b/apps/wallet/src/pages/AccountAssetPage.vue new file mode 100644 index 000000000..b03cc78c2 --- /dev/null +++ b/apps/wallet/src/pages/AccountAssetPage.vue @@ -0,0 +1,515 @@ + + + diff --git a/apps/wallet/src/pages/AccountPage.vue b/apps/wallet/src/pages/AccountPage.vue index f5a3a2a7f..07530ac85 100644 --- a/apps/wallet/src/pages/AccountPage.vue +++ b/apps/wallet/src/pages/AccountPage.vue @@ -43,25 +43,11 @@ {{ $t('terms.settings') }} - - @@ -72,7 +58,7 @@ class="mb-4" :see-all-link="{ name: Routes.Requests, - query: { group_by: RequestDomains.Transfers }, + query: { group_by: RequestDomains.Accounts }, }" :types="[{ Transfer: [account.id] }]" hide-not-found @@ -84,71 +70,68 @@ class="d-flex flex-column-reverse flex-md-row ga-4 px-0 align-md-start pt-0" >
- - - - - - {{ $t('terms.time') }} - - {{ $t('app.destination_source') }} - - - {{ $t('app.amount_token', { token: account.symbol }) }} - - - - - - {{ $t('app.no_transfers') }} - - - - {{ - `${transfer.created_at?.toLocaleDateString()} ${transfer.created_at?.toLocaleTimeString()}` - }} - - -
- - -
- - - {{ formatBalance(transfer.amount, account.decimals) }} - - - - - - -
-
+ + + + + + + + + +
diff --git a/apps/wallet/src/pages/DashboardPage.vue b/apps/wallet/src/pages/DashboardPage.vue new file mode 100644 index 000000000..015aa4fb4 --- /dev/null +++ b/apps/wallet/src/pages/DashboardPage.vue @@ -0,0 +1,219 @@ + + + diff --git a/apps/wallet/src/plugins/navigation.plugin.ts b/apps/wallet/src/plugins/navigation.plugin.ts index 2781f9e04..77149231a 100644 --- a/apps/wallet/src/plugins/navigation.plugin.ts +++ b/apps/wallet/src/plugins/navigation.plugin.ts @@ -4,6 +4,7 @@ import { mdiDatabase, mdiFormatListText, mdiPlusBox, + mdiViewDashboard, mdiWalletBifold, } from '@mdi/js'; import { App, Ref, computed, ref, watch } from 'vue'; @@ -38,6 +39,19 @@ const sections = (): NavigationSections => ({ }, icon: mdiPlusBox, }, + { + name: 'dashboard', + localeKey: 'navigation.dashboard', + action: { + type: NavigationActionType.To, + handle: route => (route.params.locale ? `/${route.params.locale}/dashboard` : '/dashboard'), + }, + auth: { + type: NavigastionAuthType.Route, + route: Routes.Dashboard, + }, + icon: mdiViewDashboard, + }, { name: 'accounts', localeKey: 'navigation.accounts', @@ -177,6 +191,19 @@ const sections = (): NavigationSections => ({ route: Routes.Requests, }, }, + { + name: 'assets', + localeKey: 'navigation.assets', + action: { + type: NavigationActionType.To, + handle: route => + route.params.locale ? `/${route.params.locale}/settings/assets` : '/settings/assets', + }, + auth: { + type: NavigastionAuthType.Route, + route: Routes.Assets, + }, + }, ], }, ], diff --git a/apps/wallet/src/plugins/router.plugin.ts b/apps/wallet/src/plugins/router.plugin.ts index 03b65b087..02679d596 100644 --- a/apps/wallet/src/plugins/router.plugin.ts +++ b/apps/wallet/src/plugins/router.plugin.ts @@ -18,6 +18,7 @@ import { hasRequiredPrivilege, hasRequiredSession } from '~/utils/auth.utils'; import { i18n, i18nRouteGuard } from './i18n.plugin'; import { initStateGuard } from './pinia.plugin'; import { services } from './services.plugin'; +import DashboardPage from '~/pages/DashboardPage.vue'; export const redirectToKey = 'redirectTo'; @@ -53,6 +54,18 @@ const router = createRouter({ }, }, }, + { + path: 'dashboard', + name: Routes.Dashboard, + component: DashboardPage, + meta: { + auth: { + check: { + session: RequiredSessionState.ConnectedToStation, + }, + }, + }, + }, { path: 'accounts', component: RouterView, @@ -87,16 +100,7 @@ const router = createRouter({ }, { path: ':id', - name: Routes.Account, - component: () => import('~/pages/AccountPage.vue'), - props: () => { - return { - breadcrumbs: [ - { title: i18n.global.t('navigation.home'), to: { name: defaultHomeRoute } }, - { title: i18n.global.t('navigation.accounts'), to: { name: Routes.Accounts } }, - ], - }; - }, + component: RouterView, meta: { auth: { check: { @@ -105,6 +109,44 @@ const router = createRouter({ }, }, }, + children: [ + { + path: '', + name: Routes.Account, + component: () => import('~/pages/AccountPage.vue'), + props: () => { + return { + breadcrumbs: [ + { title: i18n.global.t('navigation.home'), to: { name: defaultHomeRoute } }, + { + title: i18n.global.t('navigation.accounts'), + to: { name: Routes.Accounts }, + }, + ], + }; + }, + }, + { + path: ':assetId', + name: Routes.AccountAsset, + component: () => import('~/pages/AccountAssetPage.vue'), + props: params => { + return { + breadcrumbs: [ + { title: i18n.global.t('navigation.home'), to: { name: defaultHomeRoute } }, + { + title: i18n.global.t('navigation.accounts'), + to: { name: Routes.Accounts }, + }, + { + title: i18n.global.t('navigation.account'), + to: { name: Routes.Account, params: { id: params.params.id } }, + }, + ], + }; + }, + }, + ], }, ], }, @@ -395,6 +437,29 @@ const router = createRouter({ }, }, }, + { + path: 'assets', + name: Routes.Assets, + component: () => import('~/pages/AssetsPage.vue'), + props: () => { + return { + title: i18n.global.t('pages.assets.title'), + breadcrumbs: [ + { title: i18n.global.t('navigation.home'), to: { name: defaultHomeRoute } }, + { title: i18n.global.t('navigation.settings') }, + { title: i18n.global.t('navigation.assets') }, + ], + }; + }, + meta: { + auth: { + check: { + session: RequiredSessionState.ConnectedToStation, + privileges: [Privilege.ListAssets], + }, + }, + }, + }, ], }, { diff --git a/apps/wallet/src/services/chains/ic-native-api.service.ts b/apps/wallet/src/services/chains/ic-native-api.service.ts index e79d3daa0..c08924b95 100644 --- a/apps/wallet/src/services/chains/ic-native-api.service.ts +++ b/apps/wallet/src/services/chains/ic-native-api.service.ts @@ -1,43 +1,68 @@ import { Actor, ActorSubclass, HttpAgent } from '@dfinity/agent'; -import { appInitConfig } from '~/configs/init.config'; import { icAgent } from '~/core/ic-agent.core'; -import { idlFactory } from '~/generated/icp_index'; -import { _SERVICE } from '~/generated/icp_index/icp_index.did'; -import { Account } from '~/generated/station/station.did'; -import { AccountIncomingTransfer, ChainApi, FetchTransfersInput } from '~/types/chain.types'; +import { idlFactory as IcpIndexIdlFactory } from '~/generated/icp_index'; +import { idlFactory as IcpLedgerIdlFactory } from '~/generated/icp_ledger'; +import { _SERVICE as IcpIndexService } from '~/generated/icp_index/icp_index.did'; +import { _SERVICE as IcpLedgerService } from '~/generated/icp_ledger/icp_ledger.did'; +import { + AccountIncomingTransfer, + ChainApi, + ChainApiCapability, + FetchTransfersInput, +} from '~/types/chain.types'; import { nanoToJsDate } from '~/utils/date.utils'; -import { isValidSha256 } from '~/utils/helper.utils'; +import { hexStringToUint8Array, isValidSha256 } from '~/utils/helper.utils'; export class ICNativeApi implements ChainApi { - private actor: ActorSubclass<_SERVICE>; + private indexActor: ActorSubclass | null = null; + private ledgerActor: ActorSubclass; static PAGE_SIZE = BigInt(100); constructor( - private readonly account: Account, + private readonly address: string, + private readonly ledgerCanisterId: string, + private readonly indexCanisterId: string | undefined, agent: HttpAgent = icAgent.get(), ) { - this.actor = Actor.createActor<_SERVICE>(idlFactory, { + if (this.indexCanisterId) { + this.indexActor = Actor.createActor(IcpIndexIdlFactory, { + agent, + canisterId: this.indexCanisterId, + }); + } + + this.ledgerActor = Actor.createActor(IcpLedgerIdlFactory, { agent, - canisterId: appInitConfig.canisters.icpIndex, + canisterId: this.ledgerCanisterId, }); } - isValidAddress(address: string): boolean { + static isValidAddress(address: string): boolean { return isValidSha256(address); } + isValidAddress(address: string): boolean { + return ICNativeApi.isValidAddress(address); + } + async fetchBalance(): Promise { - const balance = await this.actor.get_account_identifier_balance(this.account.address); + const balance = await this.ledgerActor.account_balance({ + account: hexStringToUint8Array(this.address), + }); - return balance; + return balance.e8s; } async fetchTransfers( input: FetchTransfersInput, startBlockId?: bigint, ): Promise { - const result = await this.actor.get_account_identifier_transactions({ - account_identifier: this.account.address, + if (!this.indexActor) { + throw new Error('Cannot fetch balance without index canister id.'); + } + + const result = await this.indexActor.get_account_identifier_transactions({ + account_identifier: this.address, start: startBlockId ? [startBlockId] : [], max_results: ICNativeApi.PAGE_SIZE, }); @@ -95,4 +120,11 @@ export class ICNativeApi implements ChainApi { return transfers; } + + getCapabilities(): ChainApiCapability[] { + return [ + ChainApiCapability.Balance, // balance always available due to ledger canister id mandatory + ...(this.indexActor ? [ChainApiCapability.Transfers] : []), + ]; + } } diff --git a/apps/wallet/src/services/chains/icrc1-api.service.ts b/apps/wallet/src/services/chains/icrc1-api.service.ts new file mode 100644 index 000000000..59cca23e6 --- /dev/null +++ b/apps/wallet/src/services/chains/icrc1-api.service.ts @@ -0,0 +1,144 @@ +import { Actor, ActorSubclass, HttpAgent } from '@dfinity/agent'; +import { icAgent } from '~/core/ic-agent.core'; +import { + AccountIncomingTransfer, + ChainApi, + ChainApiCapability, + FetchTransfersInput, +} from '~/types/chain.types'; +import { nanoToJsDate } from '~/utils/date.utils'; +import { decodeIcrcAccount, encodeIcrcAccount } from '@dfinity/ledger-icrc'; +import { Account } from '~/generated/icp_index/icp_index.did'; +import { idlFactory as Icrc1IndexIdlFactory } from '~/generated/icrc1_index'; +import { idlFactory as Icrc1LedgerIdlFactory } from '~/generated/icrc1_ledger'; +import { _SERVICE as Icrc1IndexService } from '~/generated/icrc1_index/icrc1_index_canister.did'; +import { _SERVICE as Icrc1LedgerService } from '~/generated/icrc1_ledger/icrc1_ledger_canister.did'; + +export class ICRC1Api implements ChainApi { + private indexActor: ActorSubclass | null = null; + private ledgerActor: ActorSubclass; + static PAGE_SIZE = BigInt(100); + + private account: Account; + + constructor( + address: string, + private readonly ledgerCanisterId: string, + private readonly indexCanisterId: string | undefined, + agent: HttpAgent = icAgent.get(), + ) { + const icrc1Account = decodeIcrcAccount(address); + + this.account = { + owner: icrc1Account.owner, + subaccount: icrc1Account.subaccount ? [icrc1Account.subaccount] : [], + }; + + if (this.indexCanisterId) { + this.indexActor = Actor.createActor(Icrc1IndexIdlFactory, { + agent, + canisterId: this.indexCanisterId, + }); + } + + this.ledgerActor = Actor.createActor(Icrc1LedgerIdlFactory, { + agent, + canisterId: this.ledgerCanisterId, + }); + } + + static isValidAddress(address: string): boolean { + try { + decodeIcrcAccount(address); + return true; + } catch { + return false; + } + } + isValidAddress(address: string): boolean { + return ICRC1Api.isValidAddress(address); + } + + async fetchBalance(): Promise { + return await this.ledgerActor.icrc1_balance_of(this.account); + } + + async fetchTransfers( + input: FetchTransfersInput, + startBlockId?: bigint, + ): Promise { + if (!this.indexActor) { + throw new Error('Cannot fetch balance without index canister id.'); + } + + const result = await this.indexActor.get_account_transactions({ + account: this.account, + max_results: ICRC1Api.PAGE_SIZE, + start: startBlockId ? [startBlockId] : [], + }); + + if ('Err' in result) { + throw result.Err; + } + + const response = result.Ok; + let transfers: AccountIncomingTransfer[] = []; + let nextTxId: null | bigint = null; + if (response.transactions.length) { + const lastTx = response.transactions[response.transactions.length - 1]; + nextTxId = lastTx.id; + } + response.transactions.forEach(tx => { + if (tx.transaction.transfer[0]) { + const transferInfo = tx.transaction.transfer[0]; + + transfers.push({ + from: encodeIcrcAccount({ + owner: transferInfo.from.owner, + subaccount: transferInfo.from.subaccount[0], + }), + to: encodeIcrcAccount({ + owner: transferInfo.to.owner, + subaccount: transferInfo.to.subaccount[0], + }), + amount: transferInfo.amount, + fee: transferInfo.fee[0] ?? 0n, + created_at: nanoToJsDate(tx.transaction.timestamp), + }); + } + }); + + if ( + transfers.length && + transfers[transfers.length - 1]?.created_at && + nextTxId !== null && + nextTxId !== response.oldest_tx_id?.[0] + ) { + const lastTransfer = transfers[transfers.length - 1]; + const lastTransferTime = lastTransfer.created_at!.getTime(); + const shouldFetchMore = + (input.fromDt && lastTransferTime > input.fromDt!.getTime()) || (!input.fromDt && nextTxId); + + if (shouldFetchMore) { + const moreTransfers = await this.fetchTransfers(input, nextTxId); + transfers.push(...moreTransfers); + } + } + + transfers = transfers.filter(t => { + const isInFromDt = !input.fromDt ? true : t.created_at && t.created_at >= input.fromDt; + const isInToDt = !input.toDt ? true : t.created_at && t.created_at <= input.toDt; + + return isInFromDt && isInToDt; + }); + + return transfers; + } + + getCapabilities(): ChainApiCapability[] { + return [ + ChainApiCapability.Balance, // balance always available due to ledger canister id mandatory + ...(this.indexActor ? [ChainApiCapability.Transfers] : []), + ]; + } +} diff --git a/apps/wallet/src/services/chains/index.ts b/apps/wallet/src/services/chains/index.ts index 4a022f024..f524f8c54 100644 --- a/apps/wallet/src/services/chains/index.ts +++ b/apps/wallet/src/services/chains/index.ts @@ -1,16 +1,48 @@ -import { Account } from '~/generated/station/station.did'; -import { BlockchainStandard, BlockchainType, ChainApi } from '~/types/chain.types'; +import { AccountAddress, Asset } from '~/generated/station/station.did'; +import { AddressFormat, BlockchainStandard, BlockchainType, ChainApi } from '~/types/chain.types'; +import { getAssetMetadata } from '~/utils/asset.utils'; import { ICNativeApi } from './ic-native-api.service'; +import { ICRC1Api } from './icrc1-api.service'; export class ChainApiFactory { - static create(account: Account): ChainApi { - const chainAndStandard = `${account.blockchain}-${account.standard}`; + static create(asset: Asset, addresses: AccountAddress[]): ChainApi { + switch (asset.blockchain) { + case BlockchainType.InternetComputer: { + const maybeIcpNativeAddress = addresses.find(a => a.format === AddressFormat.ICPNative); + const maybeIcrc1Address = addresses.find(a => a.format === AddressFormat.ICRC1); + const maybeLedgerCanisterId = getAssetMetadata(asset, 'ledger_canister_id'); + const maybeIndexCanisterId = getAssetMetadata(asset, 'index_canister_id'); - switch (chainAndStandard) { - case `${BlockchainType.InternetComputer}-${BlockchainStandard.Native}`: - return new ICNativeApi(account); + if ( + asset.standards.includes(BlockchainStandard.Native) && + maybeIcpNativeAddress && + maybeLedgerCanisterId + ) { + return new ICNativeApi( + maybeIcpNativeAddress.address, + maybeLedgerCanisterId, + maybeIndexCanisterId, + ); + } + + if ( + asset.standards.includes(BlockchainStandard.ICRC1) && + maybeIcrc1Address && + maybeLedgerCanisterId + ) { + return new ICRC1Api( + maybeIcrc1Address.address, + maybeLedgerCanisterId, + maybeIndexCanisterId, + ); + } + + throw new Error(`Blockchain not supported: ${asset.blockchain}`); + } + case BlockchainType.Bitcoin: + case BlockchainType.Ethereum: default: - throw new Error(`Blockchain not supported ${chainAndStandard}`); + throw new Error(`Blockchain not supported: ${asset.blockchain}`); } } } diff --git a/apps/wallet/src/services/station.service.ts b/apps/wallet/src/services/station.service.ts index 3a6e66aff..d497586d7 100644 --- a/apps/wallet/src/services/station.service.ts +++ b/apps/wallet/src/services/station.service.ts @@ -2,9 +2,11 @@ import { Actor, ActorSubclass, HttpAgent } from '@dfinity/agent'; import { Principal } from '@dfinity/principal'; import { idlFactory } from '~/generated/station'; import { - AccountBalance, + Account, + AccountCallerPrivileges, AddAccountOperationInput, AddAddressBookEntryOperationInput, + AddAssetOperationInput, AddRequestPolicyOperationInput, AddUserGroupOperationInput, AddUserOperationInput, @@ -20,16 +22,20 @@ import { DisasterRecoveryCommittee, EditAccountOperationInput, EditAddressBookEntryOperationInput, + EditAssetOperationInput, EditPermissionOperationInput, EditRequestPolicyOperationInput, EditUserGroupOperationInput, EditUserOperationInput, FetchAccountBalancesInput, + FetchAccountBalancesResult, FundExternalCanisterOperationInput, GetAccountInput, GetAccountResult, GetAddressBookEntryInput, GetAddressBookEntryResult, + GetAssetInput, + GetAssetResult, GetExternalCanisterFiltersResult, GetExternalCanisterResult, GetNextApprovableRequestResult, @@ -46,6 +52,7 @@ import { ListAccountTransfersInput, ListAccountsResult, ListAddressBookEntriesResult, + ListAssetsResult, ListExternalCanistersResult, ListNotificationsInput, ListPermissionsInput, @@ -59,6 +66,7 @@ import { MarkNotificationsReadInput, Notification, PaginationInput, + RemoveAssetOperationInput, RemoveUserGroupOperationInput, Request, SubmitRequestApprovalInput, @@ -78,6 +86,7 @@ import { GetNextApprovableRequestArgs, ListAccountsArgs, ListAddressBookEntriesArgs, + ListAssetsArgs, ListExternalCanistersArgs, ListRequestsArgs, } from '~/types/station.types'; @@ -548,8 +557,50 @@ export class StationService { return result.Ok; } + async listAllAccounts(verifiedCall = false): Promise<{ + accounts: Account[]; + privileges: AccountCallerPrivileges[]; + }> { + const actor = verifiedCall ? this.verified_actor : this.actor; + + const accounts: Account[] = []; + const privileges: AccountCallerPrivileges[] = []; + let nextOffset: [bigint] | [] = []; + + do { + const result = await actor.list_accounts({ + paginate: [ + { + limit: [100], + offset: nextOffset, + }, + ], + search_term: [], + }); + + if (variantIs(result, 'Err')) { + throw result.Err; + } + + accounts.push(...result.Ok.accounts); + privileges.push(...result.Ok.privileges); + + nextOffset = result.Ok.next_offset as [bigint] | []; // have to force cast here because of typescript inference + } while (nextOffset.length > 0); + + return { accounts, privileges }; + } + async listAddressBook( - { limit, offset, blockchain, labels, ids, addresses }: ListAddressBookEntriesArgs = {}, + { + limit, + offset, + blockchain, + labels, + ids, + addresses, + address_formats, + }: ListAddressBookEntriesArgs = {}, verifiedCall = false, ): Promise> { const actor = verifiedCall ? this.verified_actor : this.actor; @@ -564,6 +615,7 @@ export class StationService { labels: labels ? [labels] : [], addresses: addresses ? [addresses] : [], ids: ids ? [ids] : [], + address_formats: address_formats ? [address_formats] : [], }); if (variantIs(result, 'Err')) { @@ -573,6 +625,17 @@ export class StationService { return result.Ok; } + async getAsset(input: GetAssetInput, verifiedCall = false): Promise> { + const actor = verifiedCall ? this.verified_actor : this.actor; + const result = await actor.get_asset(input); + + if (variantIs(result, 'Err')) { + throw result.Err; + } + + return result.Ok; + } + async fundExternalCanister(input: FundExternalCanisterOperationInput): Promise { const result = await this.actor.create_request({ execution_plan: [{ Immediate: null }], @@ -677,6 +740,27 @@ export class StationService { return result.Ok; } + async listAssets( + { limit, offset }: ListAssetsArgs = {}, + verifiedCall = false, + ): Promise> { + const actor = verifiedCall ? this.verified_actor : this.actor; + const result = await actor.list_assets({ + paginate: [ + { + limit: limit !== undefined ? [limit] : [], + offset: offset !== undefined ? [BigInt(offset)] : [], + }, + ], + }); + + if (variantIs(result, 'Err')) { + throw result.Err; + } + + return result.Ok; + } + async getExternalCanisterByCanisterId( canisterId: Principal, verifiedCall = false, @@ -718,6 +802,22 @@ export class StationService { return result.Ok; } + async addAsset(input: AddAssetOperationInput): Promise { + const result = await this.actor.create_request({ + execution_plan: [{ Immediate: null }], + title: [], + summary: [], + operation: { AddAsset: input }, + expiration_dt: [], + }); + + if (variantIs(result, 'Err')) { + throw result.Err; + } + + return result.Ok.request; + } + async fetchExternalCanisterFilters( args: { with_labels?: boolean; @@ -757,6 +857,38 @@ export class StationService { return result.Ok.request; } + async editAsset(input: EditAssetOperationInput): Promise { + const result = await this.actor.create_request({ + execution_plan: [{ Immediate: null }], + title: [], + summary: [], + operation: { EditAsset: input }, + expiration_dt: [], + }); + + if (variantIs(result, 'Err')) { + throw result.Err; + } + + return result.Ok.request; + } + + async removeAsset(input: RemoveAssetOperationInput): Promise { + const result = await this.actor.create_request({ + execution_plan: [{ Immediate: null }], + title: [], + summary: [], + operation: { RemoveAsset: input }, + expiration_dt: [], + }); + + if (variantIs(result, 'Err')) { + throw result.Err; + } + + return result.Ok.request; + } + async getAccount( input: GetAccountInput, verifiedCall = false, @@ -824,7 +956,9 @@ export class StationService { return variantIs(result, 'Healthy'); } - async fetchAccountBalances(input: FetchAccountBalancesInput): Promise { + async fetchAccountBalances( + input: FetchAccountBalancesInput, + ): Promise['balances']> { const result = await this.actor.fetch_account_balances(input); if (variantIs(result, 'Err')) { diff --git a/apps/wallet/src/stores/station.store.ts b/apps/wallet/src/stores/station.store.ts index d33785318..a3e30cb7c 100644 --- a/apps/wallet/src/stores/station.store.ts +++ b/apps/wallet/src/stores/station.store.ts @@ -21,7 +21,7 @@ import { services } from '~/plugins/services.plugin'; import { StationService } from '~/services/station.service'; import { useAppStore } from '~/stores/app.store'; import { Privilege } from '~/types/auth.types'; -import { BlockchainStandard, BlockchainType } from '~/types/chain.types'; +import { BlockchainType } from '~/types/chain.types'; import { LoadableItem } from '~/types/helper.types'; import { computedStationName, isApiError, popRedirectToLocation } from '~/utils/app.utils'; import { hasRequiredPrivilege } from '~/utils/auth.utils'; @@ -73,6 +73,10 @@ export const createUserInitialAccount = async ( userId: UUID, station = useStationStore(), ): Promise => { + const maybeIcpId = station.configuration.details.supported_assets.find( + asset => asset.blockchain == BlockchainType.InternetComputer && asset.symbol == 'ICP', + )?.id; + await station.service.createRequest({ title: [], summary: [], @@ -81,8 +85,7 @@ export const createUserInitialAccount = async ( operation: { AddAccount: { name: i18n.global.t('app.initial_account_name'), - blockchain: BlockchainType.InternetComputer, - standard: BlockchainStandard.Native, + assets: maybeIcpId ? [maybeIcpId] : [], metadata: [], read_permission: { auth_scope: { Restricted: null }, user_groups: [], users: [userId] }, transfer_permission: { @@ -124,6 +127,7 @@ const initialStoreState = (): StationStoreState => { name: '', version: '', supported_assets: [], + supported_blockchains: [], }, cycleObtainStrategy: { Disabled: null }, }, diff --git a/apps/wallet/src/types/auth.types.ts b/apps/wallet/src/types/auth.types.ts index e18c717c6..4f277d99f 100644 --- a/apps/wallet/src/types/auth.types.ts +++ b/apps/wallet/src/types/auth.types.ts @@ -15,6 +15,8 @@ export enum Privilege { ListRequests = 'ListRequests', SystemUpgrade = 'SystemUpgrade', ManageSystemInfo = 'ManageSystemInfo', + ListAssets = 'ListAssets', + AddAsset = 'AddAsset', ListExternalCanisters = 'ListExternalCanisters', CreateExternalCanister = 'CreateExternalCanister', CallAnyExternalCanister = 'CallAnyExternalCanister', diff --git a/apps/wallet/src/types/chain.types.ts b/apps/wallet/src/types/chain.types.ts index 4aa6ba37c..b071b0a71 100644 --- a/apps/wallet/src/types/chain.types.ts +++ b/apps/wallet/src/types/chain.types.ts @@ -5,7 +5,13 @@ export enum BlockchainType { } export enum BlockchainStandard { - Native = 'native', + Native = 'icp_native', + ICRC1 = 'icrc1', +} + +export enum AddressFormat { + ICPNative = 'icp_account_identifier', + ICRC1 = 'icrc1_account', } export enum TokenSymbol { @@ -30,10 +36,17 @@ export interface FetchTransfersResponse { transfers: AccountIncomingTransfer[]; } +export enum ChainApiCapability { + Balance, + Transfers, +} + export interface ChainApi { fetchBalance(): Promise; fetchTransfers(input: FetchTransfersInput): Promise; isValidAddress(address: string): boolean; + + getCapabilities(): ChainApiCapability[]; } diff --git a/apps/wallet/src/types/permissions.types.ts b/apps/wallet/src/types/permissions.types.ts index 80ba85a60..a1c58ac27 100644 --- a/apps/wallet/src/types/permissions.types.ts +++ b/apps/wallet/src/types/permissions.types.ts @@ -13,6 +13,7 @@ export enum ResourceTypeEnum { ExternalCanister = 'ExternalCanister', SetDisasterRecovery = 'SetDisasterRecovery', Notification = 'Notification', + Asset = 'Asset', } export enum ResourceActionEnum { diff --git a/apps/wallet/src/types/requests.types.ts b/apps/wallet/src/types/requests.types.ts index 3143cf482..e37f5c080 100644 --- a/apps/wallet/src/types/requests.types.ts +++ b/apps/wallet/src/types/requests.types.ts @@ -11,6 +11,7 @@ export enum ListRequestsOperationTypeGroup { SystemUpgrade = 'system_upgrade', SystemInfo = 'system_info', ExternalCanister = 'external_canister', + Asset = 'asset', } export enum RequestApprovalStatusEnum { diff --git a/apps/wallet/src/types/station.types.ts b/apps/wallet/src/types/station.types.ts index 0bd8edc06..95b45ab98 100644 --- a/apps/wallet/src/types/station.types.ts +++ b/apps/wallet/src/types/station.types.ts @@ -80,6 +80,7 @@ export enum RequestDomains { Users = 'users', ExternalCanisters = 'external_canisters', System = 'system', + Assets = 'assets', } export interface ListAccountsArgs { @@ -116,6 +117,9 @@ export enum RequestSpecifierEnum { CallExternalCanister = 'CallExternalCanister', FundExternalCanister = 'FundExternalCanister', SetDisasterRecovery = 'SetDisasterRecovery', + AddAsset = 'AddAsset', + EditAsset = 'EditAsset', + RemoveAsset = 'RemoveAsset', } export enum RequestPolicyRuleEnum { @@ -142,6 +146,12 @@ export interface ListAddressBookEntriesArgs { blockchain?: string; labels?: []; ids?: UUID[]; + address_formats?: string[]; +} + +export interface ListAssetsArgs { + limit?: number; + offset?: number; } export interface ListExternalCanistersArgs { @@ -186,4 +196,7 @@ export enum RequestOperationEnum { ConfigureExternalCanister = 'ConfigureExternalCanister', FundExternalCanister = 'FundExternalCanister', SetDisasterRecovery = 'SetDisasterRecovery', + AddAsset = 'AddAsset', + EditAsset = 'EditAsset', + RemoveAsset = 'RemoveAsset', } diff --git a/apps/wallet/src/utils/asset.utils.spec.ts b/apps/wallet/src/utils/asset.utils.spec.ts new file mode 100644 index 000000000..e4d021e37 --- /dev/null +++ b/apps/wallet/src/utils/asset.utils.spec.ts @@ -0,0 +1,27 @@ +import { shortenIcrc1Address } from './asset.utils'; +import { describe, expect, it } from 'vitest'; + +describe('shortenIcrc1Address', () => { + it('returns the principal if the subaccount is not present', () => { + expect(shortenIcrc1Address('rwlgt-iiaaa-aaaaa-aaaaa-cai')).toBe('rwlgt-iiaaa-aaaaa-aaaaa-cai'); + expect( + shortenIcrc1Address('wmzac-nabae-aqcai-baeaq-caiba-eaqca-ibaea-qcaib-aeaqc-aibae-aqc'), + ).toBe('wmzac-nabae-...-aibae-aqc'); + }); + + it('returns some of the principal and some of the subaccount if the subaccount is present', () => { + expect( + shortenIcrc1Address( + 'wmzac-nabae-aqcai-baeaq-caiba-eaqca-ibaea-qcaib-aeaqc-aibae-aqc-haltvua.102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', + ), + ).toBe('wmzac-...102030405060708090a0...1e1f'); + + expect( + shortenIcrc1Address( + 'rwlgt-iiaaa-aaaaa-aaaaa-cai-pyz4egi.102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', + ), + ).toBe('rwlgt-...102030405060708090a0...1e1f'); + + expect(shortenIcrc1Address('rwlgt-iiaaa-aaaaa-aaaaa-cai-ltrlami.10203')).toBe('rwlgt-...10203'); + }); +}); diff --git a/apps/wallet/src/utils/asset.utils.ts b/apps/wallet/src/utils/asset.utils.ts new file mode 100644 index 000000000..8f6f7f956 --- /dev/null +++ b/apps/wallet/src/utils/asset.utils.ts @@ -0,0 +1,72 @@ +import { decodeIcrcAccount } from '@dfinity/ledger-icrc'; +import { Asset, StandardData, SupportedBlockchain } from '~/generated/station/station.did'; +import { ICNativeApi } from '~/services/chains/ic-native-api.service'; +import { ICRC1Api } from '~/services/chains/icrc1-api.service'; +import { AddressFormat, BlockchainType } from '~/types/chain.types'; + +export function getAssetMetadata(asset: Asset, key: string): string | undefined { + return asset.metadata.find(m => m.key === key)?.value; +} + +export function detectAddressFormat(blockchain: string, address: string): string | undefined { + switch (blockchain) { + case BlockchainType.InternetComputer: + if (ICNativeApi.isValidAddress(address)) { + return AddressFormat.ICPNative; + } else if (ICRC1Api.isValidAddress(address)) { + return AddressFormat.ICRC1; + } else { + return; + } + case BlockchainType.Bitcoin: + case BlockchainType.Ethereum: + return; + default: + throw new Error(`Blockchain not supported ${blockchain}`); + } +} + +export function detectAddressStandard( + asset: Asset, + address: string, + supportedBlockchains: SupportedBlockchain[], +): StandardData | undefined { + const maybeFormat = detectAddressFormat(asset.blockchain, address); + if (!maybeFormat) { + return; + } + + const supportedStandards = supportedBlockchains + .find(b => b.blockchain === asset.blockchain) + ?.supported_standards.filter(supportedStandard => + asset.standards.includes(supportedStandard.standard), + ); + + return supportedStandards?.find(s => s.supported_address_formats.includes(maybeFormat)); +} + +export function shortenIcrc1Address(address: string): string { + const account = decodeIcrcAccount(address); + const principal = account.owner.toText(); + + if (!account.subaccount || account.subaccount.every(b => b === 0)) { + // show just the principal, if there is no subaccount + if (principal.length <= 32) { + // the principal is short enough to show the whole thing + return principal; + } + + // shorten the principal + return principal.slice(0, 12) + '...' + principal.slice(-10); + } else { + const subaccount = address.split('.')[1]; + + if (subaccount.length <= 27) { + // the subaccount is short enough to show the whole thing + return `${address.slice(0, 6)}...${subaccount}`; + } + + // shorted the subaccount + return `${address.slice(0, 6)}...${subaccount.slice(0, 20)}...${address.slice(-4)}`; + } +} diff --git a/apps/wallet/src/utils/form.utils.ts b/apps/wallet/src/utils/form.utils.ts index 4a254289e..f58d23d07 100644 --- a/apps/wallet/src/utils/form.utils.ts +++ b/apps/wallet/src/utils/form.utils.ts @@ -1,6 +1,7 @@ import { Principal } from '@dfinity/principal'; import isUUID from 'validator/es/lib/isUUID'; import { i18n } from '~/plugins/i18n.plugin'; +import { detectAddressFormat } from './asset.utils'; export const requiredRule = (value: unknown): string | boolean => { if (value === null || value === undefined || value === '') { @@ -109,6 +110,20 @@ export const maxLengthRule = (max: number, field: string) => { }; }; +export const validSymbolRule = (value: unknown): string | boolean => { + const hasValue = !!value; + if (!hasValue) { + // this rule only applies if there is a value + return true; + } + + if (typeof value !== 'string') { + throw new Error('validSymbolRule only applies to strings'); + } + + return /^[a-zA-Z0-9]{1,32}$/.test(value) ? true : i18n.global.t('forms.rules.validSymbol'); +}; + export const uniqueRule = ( existing: unknown[], errorMessage: string = i18n.global.t('forms.rules.duplicate'), @@ -241,3 +256,26 @@ export const validEmail = (value: unknown): string | boolean => { return true; }; + +export const validAddress = + (blockchain: string) => + (value: unknown): string | boolean => { + const hasValue = !!value; + if (!hasValue) { + // this rule only applies if there is a value + return true; + } + + if (typeof value !== 'string') { + return i18n.global.t('forms.rules.validAddress'); + } + + try { + if (detectAddressFormat(blockchain, value) !== undefined) { + return true; + } + return i18n.global.t('forms.rules.validAddress'); + } catch { + return i18n.global.t('forms.rules.validAddress'); + } + }; diff --git a/apps/wallet/src/utils/helper.utils.ts b/apps/wallet/src/utils/helper.utils.ts index 0581a60e7..d3d64b457 100644 --- a/apps/wallet/src/utils/helper.utils.ts +++ b/apps/wallet/src/utils/helper.utils.ts @@ -542,6 +542,16 @@ export const transformData = ( return normalizedInput; }; +export function hexStringToUint8Array(input: string) { + const result = new Uint8Array(input.length / 2); + + for (let i = 0; i < input.length; i += 2) { + result[i / 2] = parseInt(input.slice(i, i + 2), 16); + } + + return result; +} + /** * Deep clones the input data using structured cloning, if Proxy objects are found they are * transformed to plain objects. diff --git a/apps/wallet/src/workers/accounts.worker.ts b/apps/wallet/src/workers/accounts.worker.ts index 111216e24..4025e6321 100644 --- a/apps/wallet/src/workers/accounts.worker.ts +++ b/apps/wallet/src/workers/accounts.worker.ts @@ -1,8 +1,9 @@ import { Principal } from '@dfinity/principal'; import { icAgent } from '~/core/ic-agent.core'; import { logger } from '~/core/logger.core'; -import { AccountBalance, UUID } from '~/generated/station/station.did'; +import { AccountBalance, FetchAccountBalancesResult, UUID } from '~/generated/station/station.did'; import { StationService } from '~/services/station.service'; +import { ExtractOk } from '~/types/helper.types'; import { arrayBatchMaker, timer, unreachable } from '~/utils/helper.utils'; const DEFAULT_INTERVAL_MS = 10000; @@ -42,7 +43,7 @@ export interface AccountsWorkerErrorResponse { } export interface AccountBalancesWorkerResponse { - balances: AccountBalance[]; + balances: Array<[] | [AccountBalance]>; } export type AccountsWorkerResponseMessage = @@ -130,7 +131,7 @@ class AccountsWorkerImpl { this.stationService.fetchAccountBalances({ account_ids: accountIds }).catch(err => { logger.error('Failed to update the balance for the given account ids', { err }); - return [] as AccountBalance[]; + return [] as ExtractOk['balances']; }), ); diff --git a/core/control-panel/impl/src/controllers/station.rs b/core/control-panel/impl/src/controllers/station.rs index 1029e3003..a8918b0b2 100644 --- a/core/control-panel/impl/src/controllers/station.rs +++ b/core/control-panel/impl/src/controllers/station.rs @@ -130,7 +130,7 @@ impl StationController { async fn deploy_station(&self, input: DeployStationInput) -> ApiResult { let ctx = CallContext::get(); let _lock = STATE - .with(|state| CallerGuard::new(state.clone(), ctx.caller())) + .with(|state| CallerGuard::new(state.clone(), ctx.caller(), None)) .ok_or(UserError::ConcurrentStationDeployment)?; let deployed_station_id = self.deploy_service.deploy_station(input, &ctx).await?; diff --git a/core/control-panel/impl/src/services/canister.rs b/core/control-panel/impl/src/services/canister.rs index efb5589b2..705a927eb 100644 --- a/core/control-panel/impl/src/services/canister.rs +++ b/core/control-panel/impl/src/services/canister.rs @@ -51,6 +51,7 @@ impl CanisterService { self.assert_controller(&CallContext::get(), "upload_canister_modules".to_string())?; let mut config = canister_config().unwrap_or_default(); + if let Some(upgrader_wasm_module) = input.upgrader_wasm_module { config.upgrader_wasm_module = upgrader_wasm_module; } @@ -60,6 +61,7 @@ impl CanisterService { if let Some(station_wasm_module_extra_chunks) = input.station_wasm_module_extra_chunks { config.station_wasm_module_extra_chunks = station_wasm_module_extra_chunks; } + write_canister_config(config); Ok(()) diff --git a/core/control-panel/impl/src/services/deploy.rs b/core/control-panel/impl/src/services/deploy.rs index ff1a9b6a5..4f54f8090 100644 --- a/core/control-panel/impl/src/services/deploy.rs +++ b/core/control-panel/impl/src/services/deploy.rs @@ -103,6 +103,7 @@ impl DeployService { quorum: Some(1), fallback_controller: Some(NNS_ROOT_CANISTER_ID), accounts: None, + assets: None, })) .map_err(|err| DeployError::Failed { reason: err.to_string(), diff --git a/core/station/api/spec.did b/core/station/api/spec.did index a2fbdbd4f..ad4c9c2d4 100644 --- a/core/station/api/spec.did +++ b/core/station/api/spec.did @@ -59,6 +59,9 @@ type RequestSpecifier = variant { EditUserGroup : ResourceIds; RemoveUserGroup : ResourceIds; ManageSystemInfo; + AddAsset; + EditAsset : ResourceIds; + RemoveAsset : ResourceIds; }; // A record type that can be used to represent a percentage of users that are required to approve a rule. @@ -322,6 +325,10 @@ type RequestApproval = record { type TransferOperationInput = record { // The account id to use for the transaction. from_account_id : UUID; + // The asset id to transfer. + from_asset_id : UUID; + // The standard to use for the transfer. + with_standard : text; // The amount to transfer. amount : nat; // The destination address of the transaction (e.g. "1BvBMSE..."). @@ -342,6 +349,8 @@ type TransferOperationInput = record { type TransferOperation = record { // The account to use for the transaction. from_account : opt Account; + // The asset to use for the transaction. + from_asset : Asset; // The network to use for the transaction. network : Network; // The input to the request to transfer funds. @@ -352,12 +361,27 @@ type TransferOperation = record { fee : opt nat; }; +// Mutate the list of assets. +type ChangeAssets = variant { + // Replace all current assets with the specified list. + ReplaceWith : record { + assets : vec UUID; + }; + // Change the list of assets by adding and removing assets. + Change : record { + add_assets : vec UUID; + remove_assets : vec UUID; + }; +}; + // Input type for editing an account through a request. type EditAccountOperationInput = record { // The account id that will be edited. account_id : UUID; // A friendly name for the account (e.g. "My Account"). name : opt text; + // Mutate the list of assets. + change_assets : opt ChangeAssets; // Who can read the account information. read_permission : opt Allow; // Who can request configuration changes to the account. @@ -379,10 +403,8 @@ type EditAccountOperation = record { type AddAccountOperationInput = record { // A friendly name for the account (e.g. "My Account"). name : text; - // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) - blockchain : text; - // The asset standard for this account (e.g. `native`, `erc20`, etc.). - standard : text; + // The assets to add to the account. + assets : vec UUID; // Metadata associated with the account (e.g. `{"contract": "0x1234", "symbol": "ANY"}`). metadata : vec AccountMetadata; // Who can read the account information. @@ -417,6 +439,8 @@ type AddAddressBookEntryOperationInput = record { address_owner : text; // The actual address. address : text; + // The format of the address, eg. icp_account_identifier + address_format : text; // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) blockchain : text; // Metadata associated with the address book entry (e.g. `{"kyc": "true"}`). @@ -922,6 +946,12 @@ type RequestOperation = variant { RemoveRequestPolicy : RemoveRequestPolicyOperation; // An operation for managing system info. ManageSystemInfo : ManageSystemInfoOperation; + // An operation for adding a new asset. + AddAsset : AddAssetOperation; + // An operation for editing an existing asset. + EditAsset : EditAssetOperation; + // An operation for removing an existing asset. + RemoveAsset : RemoveAssetOperation; }; type RequestOperationInput = variant { @@ -971,6 +1001,12 @@ type RequestOperationInput = variant { RemoveRequestPolicy : RemoveRequestPolicyOperationInput; // An operation for managing system info. ManageSystemInfo : ManageSystemInfoOperationInput; + // An operation for adding a new asset. + AddAsset : AddAssetOperationInput; + // An operation for editing an existing asset. + EditAsset : EditAssetOperationInput; + // An operation for removing an existing asset. + RemoveAsset : RemoveAssetOperationInput; }; type RequestOperationType = variant { @@ -1020,6 +1056,12 @@ type RequestOperationType = variant { RemoveRequestPolicy; // And operation for managing system info. ManageSystemInfo; + // An operation for adding a new asset. + AddAsset; + // An operation for editing an existing asset. + EditAsset; + // An operation for removing an existing asset. + RemoveAsset; }; // The schedule for executing a transaction of a given transfer. @@ -1170,6 +1212,12 @@ type ListRequestsOperationType = variant { ManageSystemInfo; // An operation for setting disaster recovery config. SetDisasterRecovery; + // An operation for adding an asset. + AddAsset; + // An operation for editing an asset. + EditAsset; + // An operation for removing an asset. + RemoveAsset; }; // The direction to use for sorting. @@ -1549,21 +1597,15 @@ type AccountCallerPrivileges = record { type Account = record { // The internal account id. id : UUID; - // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) - blockchain : text; - // The asset symbol, e.g. "ICP" or "BTC". - symbol : AssetSymbol; - // The asset standard that is supported (e.g. `erc20`, etc.), canonically represented as a lowercase string - // with spaces replaced with underscores. - standard : text; - // The address of the account (e.g. "0x1234"). - address : text; - // The number of decimals used by the asset (e.g. `8` for `BTC`, `18` for `ETH`, etc.). - decimals : nat32; + + // The list of assets supported by this account. + assets : vec AccountAsset; + + // The list of addresses associated with the account. + addresses : vec AccountAddress; + // A friendly name for the account. name : text; - // Account balance when available. - balance : opt AccountBalanceInfo; // Metadata associated with the account (e.g. `{"contract": "0x1234", "symbol": "ANY"}`). metadata : vec AccountMetadata; // The transfer approval policy for the account. @@ -1578,6 +1620,25 @@ type Account = record { last_modification_timestamp : TimestampRFC3339; }; +// The seed used to derive the addresses of the account. +type AccountSeed = blob; + +// Record type to describe an address of an account. +type AccountAddress = record { + // The address. + address : text; + // The format of the address, eg. icp_account_identifier. + format : text; +}; + +// Record type to describe an asset of an account. +type AccountAsset = record { + // The asset id. + asset_id : UUID; + // The balance of the asset. + balance : opt AccountBalance; +}; + // Input type for getting a account. type GetAccountInput = record { // The account id to retrieve. @@ -1600,12 +1661,19 @@ type GetAccountResult = variant { type AccountBalance = record { // The account id. account_id : UUID; + // The asset id. + asset_id : UUID; // The balance of the account. balance : nat; // The number of decimals used by the asset (e.g. `8` for `BTC`, `18` for `ETH`, etc.). decimals : nat32; // The time at which the balance was last updated. last_update_timestamp : TimestampRFC3339; + // The state of balance query: + // - `fresh`: The balance was recently updated and is considered fresh. + // - `stale`: The balance may be out of date. + // - `stale_refreshing`: The balance may be out of date but it is being refreshed in the background. + query_state : text; }; // Input type for getting a account balance. @@ -1619,7 +1687,7 @@ type FetchAccountBalancesResult = variant { // The result data for a successful execution. Ok : record { // The account balance that was retrieved. - balances : vec AccountBalance; + balances : vec opt AccountBalance; }; // The error that occurred (e.g. the user does not have the necessary permissions). Err : Error; @@ -1652,6 +1720,8 @@ type AddressBookEntry = record { address_owner : text; // The actual address. address : text; + // The address format (e.g. "icp_account_identifier"). + address_format : text; // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) blockchain : text; // Metadata associated with the address book entry (e.g. `{"kyc": "true"}`). @@ -1691,6 +1761,8 @@ type ListAddressBookEntriesInput = record { blockchain : opt text; // The labels to search for, if provided only address book entries with the given labels will be returned. labels : opt vec text; + // The address formats to search for. + address_formats : opt vec text; // The pagination parameters. paginate : opt PaginationInput; }; @@ -1723,19 +1795,41 @@ type AssetMetadata = record { // A record type that can be used to represent an asset in the station. type Asset = record { + // The internal asset id. + id : UUID; // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) blockchain : text; // The asset standard that is supported (e.g. `erc20`, etc.), canonically represented as a lowercase string // with spaces replaced with underscores. - standard : text; + standards : vec text; // The asset symbol, e.g. "ICP" or "BTC". symbol : AssetSymbol; // The asset name (e.g. `Internet Computer`, `Bitcoin`, `Ethereum`, etc.) name : text; - // The asset metadata (e.g. `{"logo": "https://example.com/logo.png"}`), - // also, in the case of non-native assets, it can contain other required - // information (e.g. `{"address": "0x1234"}`). + // The asset metadata (e.g. `{"logo": "https://example.com/logo.png"}`). metadata : vec AssetMetadata; + // The number of decimals used by the asset (e.g. `8` for `BTC`, `18` for `ETH`, etc.). + decimals : nat32; +}; + +// Describes a standard suported by a blockchain. +type StandardData = record { + // The standard name. + standard : text; + // Required metadata fields for the standard (e.g. `["ledger_canister_id"]`). + required_metadata_fields : vec text; + // Supported operations for the standard (e.g. `["transfer", "list_transfers", "balance"]`). + supported_operations : vec text; + // Supported address formats of the standard. + supported_address_formats : vec text; +}; + +// Describes a blockchain and its standards supported by the station. +type SupportedBlockchain = record { + // The blockchain name. + blockchain : text; + // The supported standards for the blockchain. + supported_standards : vec StandardData; }; // A record type that is used to show the current capabilities of the station. @@ -1746,6 +1840,8 @@ type Capabilities = record { version : text; // The list of supported assets. supported_assets : vec Asset; + // The list of supported blockchains and standards. + supported_blockchains : vec SupportedBlockchain; }; // Result type for getting the current config. @@ -2014,6 +2110,7 @@ type Resource = variant { System : SystemResourceAction; User : UserResourceAction; UserGroup : ResourceAction; + Asset : ResourceAction; }; // A record type that can be used to represent the caller privileges for a given permission. @@ -2174,6 +2271,122 @@ type ListRequestPoliciesResult = variant { Err : Error; }; +type AddAssetOperation = record { + // The result of adding an asset. + asset : opt Asset; + // The input to the request to add an asset. + input : AddAssetOperationInput; +}; + +// The input type for adding an asset. +type AddAssetOperationInput = record { + // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) + blockchain : text; + // The asset standard that is supported (e.g. `erc20`, etc.), canonically represented as a lowercase string + // with spaces replaced with underscores. + standards : vec text; + // The asset symbol, e.g. "ICP" or "BTC". + symbol : AssetSymbol; + // The asset name (e.g. `Internet Computer`, `Bitcoin`, `Ethereum`, etc.) + name : text; + // The asset metadata (e.g. `{"logo": "https://example.com/logo.png"}`). + metadata : vec AssetMetadata; + // The number of decimals used by the asset (e.g. `8` for `BTC`, `18` for `ETH`, etc.). + decimals : nat32; +}; + +type EditAssetOperation = record { + // The input to the request to edit an asset. + input : EditAssetOperationInput; +}; + +// The input type for editing an asset. +type EditAssetOperationInput = record { + // The asset id to edit. + asset_id : UUID; + // The name of the asset. + name : opt text; + // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) + blockchain : opt text; + // The asset standard that is supported (e.g. `erc20`, etc.), canonically represented as a lowercase string + // with spaces replaced with underscores. + standards : opt vec text; + // The asset symbol, e.g. "ICP" or "BTC". + symbol : opt AssetSymbol; + // The metadata to change. + change_metadata : opt ChangeMetadata; +}; + +// Type for instructions to update the address book entry's metadata. +type ChangeMetadata = variant { + // Replace all existing metadata by the specified metadata. + ReplaceAllBy : vec AssetMetadata; + // Override values of existing metadata with the specified keys + // and add new metadata if no metadata can be found with the specified keys. + OverrideSpecifiedBy : vec AssetMetadata; + // Remove metadata with the specified keys. + RemoveKeys : vec text; +}; + +type RemoveAssetOperation = record { + // The input to the request to remove an asset. + input : RemoveAssetOperationInput; +}; + +// The input type for removing an asset. +type RemoveAssetOperationInput = record { + // The asset id to remove. + asset_id : UUID; +}; + +// The input type for listing assets. +type ListAssetsInput = record { + // The pagination parameters. + paginate : opt PaginationInput; +}; + +// The result type for listing assets. +type ListAssetsResult = variant { + // The result data for a successful execution. + Ok : record { + // The list of assets. + assets : vec Asset; + // The offset to use for the next page. + next_offset : opt nat64; + // The total number of assets. + total : nat64; + // The caller privileges for the assets. + privileges : vec AssetCallerPrivileges; + }; + // The error that occurred (e.g. the user does not have the necessary permissions). + Err : Error; +}; + +// The input type for getting an asset. +type GetAssetInput = record { + // The asset id to retrieve. + asset_id : UUID; +}; + +// The result type for getting an asset. +type GetAssetResult = variant { + // The result data for a successful execution. + Ok : record { + // The asset that was retrieved. + asset : Asset; + // The caller privileges for the asset. + privileges : AssetCallerPrivileges; + }; + // The error that occurred (e.g. the user does not have the necessary permissions). + Err : Error; +}; + +type AssetCallerPrivileges = record { + id : UUID; + can_edit : bool; + can_delete : bool; +}; + // The top level privileges that the user has when making calls to the canister. type UserPrivilege = variant { Capabilities; @@ -2195,6 +2408,8 @@ type UserPrivilege = variant { CreateExternalCanister; ListExternalCanisters; CallAnyExternalCanister; + ListAssets; + AddAsset; }; type MeResult = variant { @@ -2230,13 +2445,31 @@ type InitAccountInput = record { // A friendly name for the account (e.g. "My Account"). name : text; // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) - blockchain : text; + seed : AccountSeed; // The asset standard for this account (e.g. `native`, `erc20`, etc.). - standard : text; + assets : vec UUID; // Metadata associated with the account (e.g. `{"contract": "0x1234", "symbol": "ANY"}`). metadata : vec AccountMetadata; }; +// The initial assets to create when initializing the canister for the first time, e.g., after disaster recovery. +type InitAssetInput = record { + // The UUID of the asset, if not provided a new UUID will be generated. + id : UUID; + // The name of the asset. + name : text; + // The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) + blockchain : text; + // The standards this asset supports. + standards : vec text; + // The asset symbol, e.g. "ICP" or "BTC". + symbol : text; + // The number of decimals used to format the asset balance. + decimals : nat32; + // Metadata associated with the asset. + metadata : vec AssetMetadata; +}; + // The init configuration for the canister. // // Only used when installing the canister for the first time. @@ -2253,6 +2486,8 @@ type SystemInit = record { fallback_controller : opt principal; // Optional initial accounts to create. accounts : opt vec InitAccountInput; + // Optional initial assets to create. + assets : opt vec InitAssetInput; }; // The upgrade configuration for the canister. @@ -2766,4 +3001,8 @@ service : (opt SystemInstall) -> { http_request : (HttpRequest) -> (HttpResponse) query; // Internal endpoint used by the upgrader canister to notify the station about a failed station upgrade request. notify_failed_station_upgrade : (NotifyFailedStationUpgradeInput) -> (NotifyFailedStationUpgradeResult); + // Get an asset by id. + get_asset : (input : GetAssetInput) -> (GetAssetResult) query; + // List all assets that the caller has access to. + list_assets : (input : ListAssetsInput) -> (ListAssetsResult) query; }; diff --git a/core/station/api/src/account.rs b/core/station/api/src/account.rs index b5a4e67d7..d55c318ce 100644 --- a/core/station/api/src/account.rs +++ b/core/station/api/src/account.rs @@ -14,22 +14,44 @@ pub struct AccountCallerPrivilegesDTO { pub struct AccountDTO { pub id: UuidDTO, pub name: String, - pub address: String, - pub blockchain: String, - pub standard: String, - pub symbol: String, - pub decimals: u32, - pub balance: Option, + pub assets: Vec, + pub addresses: Vec, pub metadata: Vec, pub transfer_request_policy: Option, pub configs_request_policy: Option, pub last_modification_timestamp: String, } +pub type AccountSeedDTO = [u8; 16]; + +#[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] +pub struct AccountAssetDTO { + pub asset_id: UuidDTO, + pub balance: Option, +} + +#[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] +pub struct AccountAddressDTO { + pub address: String, + pub format: String, +} + +#[derive(CandidType, serde::Serialize, Deserialize, Clone, Debug)] +pub enum ChangeAssets { + ReplaceWith { + assets: Vec, + }, + Change { + add_assets: Vec, + remove_assets: Vec, + }, +} + #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] pub struct EditAccountOperationInput { pub account_id: UuidDTO, pub name: Option, + pub change_assets: Option, pub read_permission: Option, pub configs_permission: Option, pub transfer_permission: Option, @@ -45,8 +67,7 @@ pub struct EditAccountOperationDTO { #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] pub struct AddAccountOperationInput { pub name: String, - pub blockchain: String, - pub standard: String, + pub assets: Vec, pub metadata: Vec, pub read_permission: AllowDTO, pub configs_permission: AllowDTO, @@ -80,9 +101,11 @@ pub struct FetchAccountBalancesInput { #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] pub struct AccountBalanceDTO { pub account_id: String, + pub asset_id: String, pub balance: candid::Nat, pub decimals: u32, pub last_update_timestamp: String, + pub query_state: String, } #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] @@ -94,7 +117,7 @@ pub struct AccountBalanceInfoDTO { #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] pub struct FetchAccountBalancesResponse { - pub balances: Vec, + pub balances: Vec>, } #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] diff --git a/core/station/api/src/address_book.rs b/core/station/api/src/address_book.rs index 0438463f1..caa4dcbfe 100644 --- a/core/station/api/src/address_book.rs +++ b/core/station/api/src/address_book.rs @@ -6,6 +6,7 @@ pub struct AddressBookEntryDTO { pub id: UuidDTO, pub address_owner: String, pub address: String, + pub address_format: String, pub blockchain: String, pub labels: Vec, pub metadata: Vec, @@ -29,6 +30,7 @@ pub struct AddAddressBookEntryOperationDTO { pub struct AddAddressBookEntryOperationInput { pub address_owner: String, pub address: String, + pub address_format: String, pub blockchain: String, pub metadata: Vec, pub labels: Vec, @@ -75,6 +77,7 @@ pub struct ListAddressBookEntriesInputDTO { pub blockchain: Option, pub labels: Option>, pub paginate: Option, + pub address_formats: Option>, } #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] diff --git a/core/station/api/src/asset.rs b/core/station/api/src/asset.rs new file mode 100644 index 000000000..b5b649ea0 --- /dev/null +++ b/core/station/api/src/asset.rs @@ -0,0 +1,94 @@ +use candid::CandidType; +use serde::Deserialize; + +use crate::{ChangeMetadataDTO, MetadataDTO, PaginationInput, UuidDTO}; + +#[derive(CandidType, serde::Serialize, Deserialize, Clone, Debug)] +pub struct AssetDTO { + /// The asset identifier, which is a UUID. + pub id: UuidDTO, + /// The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) + pub blockchain: String, + /// The asset symbol (e.g. `ICP`, `BTC`, `ETH`, etc.) + pub symbol: String, + /// The number of decimal places that the asset supports (e.g. `8` for `BTC`, `18` for `ETH`, etc.) + pub decimals: u32, + // The asset standard that is supported (e.g. `erc20`, etc.), canonically + // represented as a lowercase string with spaces replaced with underscores. + pub standards: Vec, + /// The asset name (e.g. `Internet Computer`, `Bitcoin`, `Ethereum`, etc.) + pub name: String, + /// The asset metadata (e.g. `{"logo": "https://example.com/logo.png"}`). + pub metadata: Vec, +} + +#[derive(CandidType, serde::Serialize, Deserialize, Clone, Debug)] +pub struct AddAssetOperationDTO { + pub asset: Option, + pub input: AddAssetOperationInput, +} + +#[derive(CandidType, serde::Serialize, Deserialize, Clone, Debug)] +pub struct AddAssetOperationInput { + pub name: String, + pub blockchain: String, + pub standards: Vec, + pub symbol: String, + pub decimals: u32, + pub metadata: Vec, +} +#[derive(CandidType, serde::Serialize, Deserialize, Clone, Debug)] +pub struct EditAssetOperationDTO { + pub input: EditAssetOperationInput, +} + +#[derive(CandidType, serde::Serialize, Deserialize, Clone, Debug)] +pub struct EditAssetOperationInput { + pub asset_id: UuidDTO, + pub name: Option, + pub blockchain: Option, + pub standards: Option>, + pub symbol: Option, + pub change_metadata: Option, +} + +#[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] +pub struct RemoveAssetOperationDTO { + pub input: RemoveAssetOperationInput, +} + +#[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] +pub struct RemoveAssetOperationInput { + pub asset_id: UuidDTO, +} + +#[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] +pub struct ListAssetsInput { + pub paginate: Option, +} + +#[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] +pub struct ListAssetsResponse { + pub assets: Vec, + pub next_offset: Option, + pub total: u64, + pub privileges: Vec, +} + +#[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] +pub struct AssetCallerPrivilegesDTO { + pub id: UuidDTO, + pub can_edit: bool, + pub can_delete: bool, +} + +#[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] +pub struct GetAssetInput { + pub asset_id: UuidDTO, +} + +#[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] +pub struct GetAssetResponse { + pub asset: AssetDTO, + pub privileges: AssetCallerPrivilegesDTO, +} diff --git a/core/station/api/src/capabilities.rs b/core/station/api/src/capabilities.rs index a3e966f78..59b6c1cce 100644 --- a/core/station/api/src/capabilities.rs +++ b/core/station/api/src/capabilities.rs @@ -1,23 +1,6 @@ -use crate::MetadataDTO; +use crate::AssetDTO; use candid::{CandidType, Deserialize}; -#[derive(CandidType, serde::Serialize, Deserialize, Clone, Debug)] -pub struct AssetDTO { - /// The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) - pub blockchain: String, - /// The asset symbol (e.g. `ICP`, `BTC`, `ETH`, etc.) - pub symbol: String, - // The asset standard that is supported (e.g. `erc20`, etc.), canonically - // represented as a lowercase string with spaces replaced with underscores. - pub standard: String, - /// The asset name (e.g. `Internet Computer`, `Bitcoin`, `Ethereum`, etc.) - pub name: String, - /// The asset metadata (e.g. `{"logo": "https://example.com/logo.png"}`), - /// also, in the case of non-native assets, it can contain other required - /// information (e.g. `{"address": "0x1234"}`). - pub metadata: Vec, -} - /// The capabilities of the canister. #[derive(CandidType, serde::Serialize, Deserialize, Clone, Debug)] pub struct CapabilitiesDTO { @@ -27,9 +10,25 @@ pub struct CapabilitiesDTO { pub version: String, /// The list of assets that are supported by the canister (e.g. `ICP`, `BTC`, `ETH`, etc.) pub supported_assets: Vec, + /// The list of blockchains and standards that are supported by the canister (e.g. `ethereum`, `bitcoin`, `icp`, etc.) + pub supported_blockchains: Vec, } #[derive(CandidType, serde::Serialize, Deserialize, Clone, Debug)] pub struct CapabilitiesResponse { pub capabilities: CapabilitiesDTO, } + +#[derive(CandidType, serde::Serialize, Deserialize, Clone, Debug)] +pub struct SupportedBlockchainDTO { + pub blockchain: String, + pub supported_standards: Vec, +} + +#[derive(CandidType, serde::Serialize, Deserialize, Clone, Debug)] +pub struct StandardDataDTO { + pub standard: String, + pub required_metadata_fields: Vec, + pub supported_operations: Vec, + pub supported_address_formats: Vec, +} diff --git a/core/station/api/src/lib.rs b/core/station/api/src/lib.rs index 6b1b4e144..4f47b8ad1 100644 --- a/core/station/api/src/lib.rs +++ b/core/station/api/src/lib.rs @@ -48,3 +48,6 @@ pub use resource::*; mod disaster_recovery; pub use disaster_recovery::*; + +mod asset; +pub use asset::*; diff --git a/core/station/api/src/request.rs b/core/station/api/src/request.rs index cfe1a8e97..6a2e8ac39 100644 --- a/core/station/api/src/request.rs +++ b/core/station/api/src/request.rs @@ -3,18 +3,19 @@ use super::{ }; use crate::{ AddAccountOperationDTO, AddAccountOperationInput, AddAddressBookEntryOperationDTO, - AddAddressBookEntryOperationInput, AddUserGroupOperationDTO, AddUserGroupOperationInput, - AddUserOperationDTO, AddUserOperationInput, CallExternalCanisterOperationDTO, - CallExternalCanisterOperationInput, ChangeExternalCanisterOperationDTO, - ChangeExternalCanisterOperationInput, ConfigureExternalCanisterOperationDTO, - ConfigureExternalCanisterOperationInput, CreateExternalCanisterOperationDTO, - CreateExternalCanisterOperationInput, DisplayUserDTO, EditAccountOperationDTO, - EditAddressBookEntryOperationDTO, EditAddressBookEntryOperationInput, - EditPermissionOperationDTO, EditPermissionOperationInput, EditUserGroupOperationDTO, - EditUserGroupOperationInput, EditUserOperationDTO, EditUserOperationInput, - FundExternalCanisterOperationDTO, FundExternalCanisterOperationInput, - ManageSystemInfoOperationDTO, ManageSystemInfoOperationInput, PaginationInput, - RemoveAddressBookEntryOperationDTO, RemoveAddressBookEntryOperationInput, + AddAddressBookEntryOperationInput, AddAssetOperationDTO, AddAssetOperationInput, + AddUserGroupOperationDTO, AddUserGroupOperationInput, AddUserOperationDTO, + AddUserOperationInput, CallExternalCanisterOperationDTO, CallExternalCanisterOperationInput, + ChangeExternalCanisterOperationDTO, ChangeExternalCanisterOperationInput, + ConfigureExternalCanisterOperationDTO, ConfigureExternalCanisterOperationInput, + CreateExternalCanisterOperationDTO, CreateExternalCanisterOperationInput, DisplayUserDTO, + EditAccountOperationDTO, EditAddressBookEntryOperationDTO, EditAddressBookEntryOperationInput, + EditAssetOperationDTO, EditAssetOperationInput, EditPermissionOperationDTO, + EditPermissionOperationInput, EditUserGroupOperationDTO, EditUserGroupOperationInput, + EditUserOperationDTO, EditUserOperationInput, FundExternalCanisterOperationDTO, + FundExternalCanisterOperationInput, ManageSystemInfoOperationDTO, + ManageSystemInfoOperationInput, PaginationInput, RemoveAddressBookEntryOperationDTO, + RemoveAddressBookEntryOperationInput, RemoveAssetOperationDTO, RemoveAssetOperationInput, RemoveUserGroupOperationDTO, RemoveUserGroupOperationInput, RequestEvaluationResultDTO, RequestPolicyRuleDTO, RequestSpecifierDTO, SetDisasterRecoveryOperationDTO, SetDisasterRecoveryOperationInput, SortDirection, SystemUpgradeOperationDTO, @@ -83,6 +84,9 @@ pub enum RequestOperationDTO { EditRequestPolicy(Box), RemoveRequestPolicy(Box), ManageSystemInfo(Box), + AddAsset(Box), + EditAsset(Box), + RemoveAsset(Box), } #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] @@ -110,6 +114,9 @@ pub enum RequestOperationInput { EditRequestPolicy(EditRequestPolicyOperationInput), RemoveRequestPolicy(RemoveRequestPolicyOperationInput), ManageSystemInfo(ManageSystemInfoOperationInput), + AddAsset(AddAssetOperationInput), + EditAsset(EditAssetOperationInput), + RemoveAsset(RemoveAssetOperationInput), } #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] @@ -137,6 +144,9 @@ pub enum RequestOperationTypeDTO { RemoveRequestPolicy, ManageSystemInfo, ConfigureExternalCanister, + AddAsset, + EditAsset, + RemoveAsset, } #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] @@ -164,6 +174,9 @@ pub enum ListRequestsOperationTypeDTO { ManageSystemInfo, SetDisasterRecovery, ConfigureExternalCanister(Option), + AddAsset, + EditAsset, + RemoveAsset, } #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] diff --git a/core/station/api/src/request_policy.rs b/core/station/api/src/request_policy.rs index 98001c18f..f48a80b4e 100644 --- a/core/station/api/src/request_policy.rs +++ b/core/station/api/src/request_policy.rs @@ -28,6 +28,9 @@ pub enum RequestSpecifierDTO { EditUserGroup(ResourceIdsDTO), RemoveUserGroup(ResourceIdsDTO), ManageSystemInfo, + AddAsset, + EditAsset(ResourceIdsDTO), + RemoveAsset(ResourceIdsDTO), } #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] diff --git a/core/station/api/src/resource.rs b/core/station/api/src/resource.rs index f13be8774..eb15823a6 100644 --- a/core/station/api/src/resource.rs +++ b/core/station/api/src/resource.rs @@ -13,6 +13,7 @@ pub enum ResourceDTO { System(SystemResourceActionDTO), User(UserResourceActionDTO), UserGroup(ResourceActionDTO), + Asset(ResourceActionDTO), } #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] diff --git a/core/station/api/src/system.rs b/core/station/api/src/system.rs index e1d8636f3..9e50d0b16 100644 --- a/core/station/api/src/system.rs +++ b/core/station/api/src/system.rs @@ -1,5 +1,5 @@ use super::TimestampRfc3339; -use crate::{DisasterRecoveryCommitteeDTO, MetadataDTO, Sha256HashDTO, UuidDTO}; +use crate::{AccountSeedDTO, DisasterRecoveryCommitteeDTO, MetadataDTO, Sha256HashDTO, UuidDTO}; use candid::{CandidType, Deserialize, Principal}; use orbit_essentials::types::WasmModuleExtraChunks; @@ -68,9 +68,20 @@ pub enum SystemUpgraderInput { pub struct InitAccountInput { pub id: Option, pub name: String, + pub seed: AccountSeedDTO, + pub assets: Vec, + pub metadata: Vec, +} + +#[derive(CandidType, serde::Serialize, Deserialize, Clone, Debug)] +pub struct InitAssetInput { + pub id: UuidDTO, + pub name: String, pub blockchain: String, - pub standard: String, + pub standards: Vec, pub metadata: Vec, + pub symbol: String, + pub decimals: u32, } #[derive(CandidType, serde::Serialize, Deserialize, Clone, Debug)] @@ -87,6 +98,8 @@ pub struct SystemInit { pub fallback_controller: Option, /// Optionally set the initial accounts. pub accounts: Option>, + /// Optionally set the initial accounts. + pub assets: Option>, } #[derive(CandidType, serde::Serialize, Deserialize, Clone, Debug)] diff --git a/core/station/api/src/transfer.rs b/core/station/api/src/transfer.rs index f2f471b78..8389e6235 100644 --- a/core/station/api/src/transfer.rs +++ b/core/station/api/src/transfer.rs @@ -1,5 +1,5 @@ use super::{AccountDTO, TimestampRfc3339}; -use crate::{MetadataDTO, UuidDTO}; +use crate::{AssetDTO, MetadataDTO, UuidDTO}; use candid::{CandidType, Deserialize}; pub type NetworkIdDTO = String; @@ -13,6 +13,8 @@ pub struct NetworkDTO { #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] pub struct TransferOperationInput { pub from_account_id: UuidDTO, + pub from_asset_id: UuidDTO, + pub with_standard: String, pub to: String, pub amount: candid::Nat, pub fee: Option, @@ -23,6 +25,7 @@ pub struct TransferOperationInput { #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] pub struct TransferOperationDTO { pub from_account: Option, + pub from_asset: AssetDTO, pub network: NetworkDTO, pub input: TransferOperationInput, pub transfer_id: Option, diff --git a/core/station/api/src/user.rs b/core/station/api/src/user.rs index adff70832..8e1b30585 100644 --- a/core/station/api/src/user.rs +++ b/core/station/api/src/user.rs @@ -114,6 +114,8 @@ pub enum UserPrivilege { CreateExternalCanister, ListExternalCanisters, CallAnyExternalCanister, + ListAssets, + AddAsset, } #[derive(CandidType, serde::Serialize, Deserialize, Debug, Clone)] diff --git a/core/station/impl/Cargo.toml b/core/station/impl/Cargo.toml index e84b4555b..6beb9032f 100644 --- a/core/station/impl/Cargo.toml +++ b/core/station/impl/Cargo.toml @@ -31,6 +31,7 @@ ic-cdk = { workspace = true } ic-cdk-macros = { workspace = true } ic-ledger-types = { workspace = true } ic-stable-structures = { workspace = true } +icrc-ledger-types = { workspace = true } lazy_static = { workspace = true } num-bigint = { workspace = true } serde = { workspace = true, features = ['derive'] } diff --git a/core/station/impl/results.yml b/core/station/impl/results.yml index a6c7ee77d..108226765 100644 --- a/core/station/impl/results.yml +++ b/core/station/impl/results.yml @@ -1,7 +1,7 @@ benches: batch_insert_100_requests: total: - instructions: 224453231 + instructions: 229054294 heap_increase: 0 stable_memory_increase: 96 scopes: {} @@ -13,44 +13,44 @@ benches: scopes: {} find_500_external_canister_policies_from_50k_dataset: total: - instructions: 30909471 + instructions: 28629145 heap_increase: 0 stable_memory_increase: 0 scopes: {} heap_size_of_indexed_request_fields_cache_is_lt_300mib: total: - instructions: 131557138 - heap_increase: 87 + instructions: 195191574 + heap_increase: 85 stable_memory_increase: 0 scopes: {} list_1k_requests: total: - instructions: 98802710 - heap_increase: 7 + instructions: 142128177 + heap_increase: 14 stable_memory_increase: 0 scopes: {} list_external_canisters_with_all_statuses: total: - instructions: 211825288 + instructions: 213226507 heap_increase: 0 stable_memory_increase: 0 scopes: {} repository_find_1k_requests_from_10k_dataset_default_filters: total: - instructions: 94586868 + instructions: 92053986 heap_increase: 17 stable_memory_increase: 0 scopes: {} service_filter_5k_requests_from_100k_dataset: total: - instructions: 686346031 + instructions: 680738365 heap_increase: 106 stable_memory_increase: 16 scopes: {} service_find_all_requests_from_2k_dataset: total: - instructions: 277217390 + instructions: 275726235 heap_increase: 44 stable_memory_increase: 16 scopes: {} -version: 0.1.4 +version: 0.1.8 diff --git a/core/station/impl/src/controllers/asset.rs b/core/station/impl/src/controllers/asset.rs new file mode 100644 index 000000000..1e103cfdc --- /dev/null +++ b/core/station/impl/src/controllers/asset.rs @@ -0,0 +1,78 @@ +use crate::{ + core::middlewares::{authorize, call_context}, + mappers::HelperMapper, + models::resource::{Resource, ResourceAction}, + services::AssetService, +}; +use ic_cdk_macros::query; +use lazy_static::lazy_static; +use orbit_essentials::api::ApiResult; +use orbit_essentials::with_middleware; +use station_api::{ + AssetCallerPrivilegesDTO, GetAssetInput, GetAssetResponse, ListAssetsInput, ListAssetsResponse, +}; + +#[query(name = "get_asset")] +async fn get_asset(input: GetAssetInput) -> ApiResult { + CONTROLLER.get_asset(input).await +} + +#[query(name = "list_assets")] +async fn list_assets(input: ListAssetsInput) -> ApiResult { + CONTROLLER.list_assets(input).await +} + +lazy_static! { + static ref CONTROLLER: AssetController = AssetController::new(AssetService::default()); +} + +#[derive(Debug)] +pub struct AssetController { + asset_service: AssetService, +} + +impl AssetController { + pub fn new(asset_service: AssetService) -> Self { + Self { asset_service } + } + + #[with_middleware(guard = authorize(&call_context(), &[Resource::from(&input)]))] + async fn get_asset(&self, input: GetAssetInput) -> ApiResult { + let ctx = call_context(); + let asset = self + .asset_service + .get(HelperMapper::to_uuid(input.asset_id)?.as_bytes())?; + let privileges = self + .asset_service + .get_caller_privileges_for_asset(&asset.id, &ctx) + .await?; + + Ok(GetAssetResponse { + asset: asset.into(), + privileges: privileges.into(), + }) + } + + #[with_middleware(guard = authorize(&call_context(), &[Resource::Asset(ResourceAction::List)]))] + async fn list_assets(&self, input: ListAssetsInput) -> ApiResult { + let ctx = call_context(); + let result = self.asset_service.list(input, Some(&ctx))?; + let mut privileges = Vec::new(); + + for asset in &result.items { + let asset_privileges = self + .asset_service + .get_caller_privileges_for_asset(&asset.id, &ctx) + .await?; + + privileges.push(AssetCallerPrivilegesDTO::from(asset_privileges)); + } + + Ok(ListAssetsResponse { + assets: result.items.into_iter().map(Into::into).collect(), + next_offset: result.next_offset, + total: result.total, + privileges, + }) + } +} diff --git a/core/station/impl/src/controllers/capabilities.rs b/core/station/impl/src/controllers/capabilities.rs index a3cf75999..3189bbb29 100644 --- a/core/station/impl/src/controllers/capabilities.rs +++ b/core/station/impl/src/controllers/capabilities.rs @@ -1,16 +1,17 @@ use crate::{ core::{ middlewares::{authorize, call_context}, - read_system_info, ASSETS, + read_system_info, SUPPORTED_BLOCKCHAINS, }, models::resource::{Resource, SystemResourceAction}, + repositories::ASSET_REPOSITORY, SYSTEM_VERSION, }; use ic_cdk_macros::query; use lazy_static::lazy_static; -use orbit_essentials::api::ApiResult; use orbit_essentials::with_middleware; -use station_api::{CapabilitiesDTO, CapabilitiesResponse}; +use orbit_essentials::{api::ApiResult, repository::Repository}; +use station_api::{CapabilitiesDTO, CapabilitiesResponse, StandardDataDTO, SupportedBlockchainDTO}; #[query(name = "capabilities")] async fn capabilities() -> ApiResult { @@ -32,14 +33,42 @@ impl CapabilitiesController { #[with_middleware(guard = authorize(&call_context(), &[Resource::System(SystemResourceAction::Capabilities)]))] async fn capabilities(&self) -> ApiResult { - let assets = ASSETS.with(|asset| asset.borrow().clone()); let system = read_system_info(); Ok(CapabilitiesResponse { capabilities: CapabilitiesDTO { name: system.get_name().to_string(), version: SYSTEM_VERSION.to_string(), - supported_assets: assets.into_iter().map(|asset| asset.into()).collect(), + supported_assets: ASSET_REPOSITORY + .list() + .into_iter() + .map(|asset| asset.into()) + .collect(), + supported_blockchains: SUPPORTED_BLOCKCHAINS + .iter() + .map(|suported_blockchain| SupportedBlockchainDTO { + blockchain: suported_blockchain.blockchain.to_string(), + supported_standards: suported_blockchain + .supported_standards + .iter() + .map(|standard| StandardDataDTO { + required_metadata_fields: standard.get_required_metadata(), + standard: standard.to_string(), + supported_operations: standard + .get_supported_operations() + .iter() + .map(|operation| operation.to_string()) + .collect(), + supported_address_formats: standard + .get_info() + .address_formats + .iter() + .map(|format| format.to_string()) + .collect(), + }) + .collect(), + }) + .collect(), }, }) } diff --git a/core/station/impl/src/controllers/mod.rs b/core/station/impl/src/controllers/mod.rs index 420facaa0..cb70a4d3e 100644 --- a/core/station/impl/src/controllers/mod.rs +++ b/core/station/impl/src/controllers/mod.rs @@ -42,6 +42,9 @@ pub use user_group::*; mod http; pub use http::*; +mod asset; +pub use asset::*; + #[cfg(test)] mod tests { use orbit_essentials::api::*; diff --git a/core/station/impl/src/controllers/system.rs b/core/station/impl/src/controllers/system.rs index d66cfa7bf..54385f1aa 100644 --- a/core/station/impl/src/controllers/system.rs +++ b/core/station/impl/src/controllers/system.rs @@ -6,7 +6,7 @@ use crate::{ errors::AuthorizationError, migration, models::resource::{Resource, SystemResourceAction}, - services::{SystemService, SYSTEM_SERVICE}, + services::{SystemService, INITIALIZING, SYSTEM_SERVICE}, SYSTEM_VERSION, }; use ic_cdk_macros::{post_upgrade, query, update}; @@ -27,6 +27,10 @@ fn set_certified_data_for_skip_certification() { #[cfg(any(not(feature = "canbench"), test))] #[ic_cdk_macros::init] async fn initialize(input: Option) { + INITIALIZING.with_borrow_mut(|initializing| { + *initializing = true; + }); + set_certified_data_for_skip_certification(); match input { Some(SystemInstall::Init(input)) => CONTROLLER.initialize(input).await, @@ -57,6 +61,10 @@ pub async fn mock_init() { #[post_upgrade] async fn post_upgrade(input: Option) { + INITIALIZING.with_borrow_mut(|initializing| { + *initializing = true; + }); + // Runs the migrations for the canister to ensure the stable memory schema is up-to-date // // WARNING: This needs to be done before any other access to stable memory is done, this is because @@ -168,9 +176,11 @@ mod tests { #[tokio::test] async fn apply_migration_should_migrate_stable_memory_version() { + let base_stable_memory_version = STABLE_MEMORY_VERSION - 1; + let mut system_info = SystemInfo::new(Principal::management_canister(), Vec::new()); - system_info.set_stable_memory_version(0); + system_info.set_stable_memory_version(base_stable_memory_version); write_system_info(system_info); @@ -191,7 +201,7 @@ mod tests { REQUEST_REPOSITORY.insert(request.to_key(), request.clone()); - system_info.set_stable_memory_version(0); + system_info.set_stable_memory_version(base_stable_memory_version); system_info.set_change_canister_request(request.id); write_system_info(system_info); diff --git a/core/station/impl/src/core/assets.rs b/core/station/impl/src/core/assets.rs deleted file mode 100644 index 5d5f9ce30..000000000 --- a/core/station/impl/src/core/assets.rs +++ /dev/null @@ -1,16 +0,0 @@ -use crate::models::{Asset, Blockchain, BlockchainStandard, Metadata}; -use std::{cell::RefCell, collections::HashSet}; - -thread_local! { - /// The list of assets that are supported by the canister (e.g. `ICP`, `BTC`, `ETH`, etc.) - pub static ASSETS: RefCell> = - RefCell::new(vec![ - Asset { - blockchain: Blockchain::InternetComputer, - standard: BlockchainStandard::Native, - symbol: "ICP".to_string(), - name: "Internet Computer".to_string(), - metadata: Metadata::default(), - }, - ].into_iter().collect()); -} diff --git a/core/station/impl/src/core/init.rs b/core/station/impl/src/core/init.rs index d99127199..8fa894b29 100644 --- a/core/station/impl/src/core/init.rs +++ b/core/station/impl/src/core/init.rs @@ -164,7 +164,28 @@ lazy_static! { ( Allow::user_groups(vec![*ADMIN_GROUP_ID]), Resource::ExternalCanister(ExternalCanisterResourceAction::Fund(ExternalCanisterId::Any)), - ) + ), + // assets + ( + Allow::user_groups(vec![*ADMIN_GROUP_ID]), + Resource::Asset(ResourceAction::Create), + ), + ( + Allow::authenticated(), + Resource::Asset(ResourceAction::List), + ), + ( + Allow::authenticated(), + Resource::Asset(ResourceAction::Read(ResourceId::Any)), + ), + ( + Allow::user_groups(vec![*ADMIN_GROUP_ID]), + Resource::Asset(ResourceAction::Update(ResourceId::Any)), + ), + ( + Allow::user_groups(vec![*ADMIN_GROUP_ID]), + Resource::Asset(ResourceAction::Delete(ResourceId::Any)), + ), ]; } @@ -252,5 +273,18 @@ pub fn default_policies(admin_quorum: u16) -> Vec<(RequestSpecifier, RequestPoli RequestSpecifier::FundExternalCanister(ExternalCanisterId::Any), RequestPolicyRule::Quorum(UserSpecifier::Group(vec![*ADMIN_GROUP_ID]), admin_quorum), ), + // create, edit, and remove assets + ( + RequestSpecifier::AddAsset, + RequestPolicyRule::Quorum(UserSpecifier::Group(vec![*ADMIN_GROUP_ID]), admin_quorum), + ), + ( + RequestSpecifier::EditAsset(ResourceIds::Any), + RequestPolicyRule::Quorum(UserSpecifier::Group(vec![*ADMIN_GROUP_ID]), admin_quorum), + ), + ( + RequestSpecifier::RemoveAsset(ResourceIds::Any), + RequestPolicyRule::Quorum(UserSpecifier::Group(vec![*ADMIN_GROUP_ID]), admin_quorum), + ), ] } diff --git a/core/station/impl/src/core/memory.rs b/core/station/impl/src/core/memory.rs index 5c9bd06d8..8ff01933c 100644 --- a/core/station/impl/src/core/memory.rs +++ b/core/station/impl/src/core/memory.rs @@ -17,6 +17,7 @@ pub const TRANSFER_MEMORY_ID: MemoryId = MemoryId::new(4); pub const UNIQUE_INDEX_MEMORY_ID: MemoryId = MemoryId::new(5); // new pub const TRANSFER_ACCOUNT_INDEX_MEMORY_ID: MemoryId = MemoryId::new(6); pub const REQUEST_MEMORY_ID: MemoryId = MemoryId::new(7); +pub const ASSET_MEMORY_ID: MemoryId = MemoryId::new(8); pub const NOTIFICATION_MEMORY_ID: MemoryId = MemoryId::new(11); pub const NOTIFICATION_USER_INDEX_MEMORY_ID: MemoryId = MemoryId::new(12); pub const TRANSFER_STATUS_INDEX_MEMORY_ID: MemoryId = MemoryId::new(13); @@ -37,7 +38,7 @@ thread_local! { // The memory manager is used for simulating multiple memories. Given a `MemoryId` it can // return a memory that can be used by stable structures. - static MEMORY_MANAGER: RefCell> = + pub static MEMORY_MANAGER: RefCell> = RefCell::new(MemoryManager::init_with_bucket_size(managed_memory(), STABLE_MEMORY_BUCKET_SIZE)); } diff --git a/core/station/impl/src/core/metrics.rs b/core/station/impl/src/core/metrics.rs index 4294d35c0..908b5285a 100644 --- a/core/station/impl/src/core/metrics.rs +++ b/core/station/impl/src/core/metrics.rs @@ -1,8 +1,9 @@ +use crate::core::ic_cdk::api::print; use crate::{ - models::{Account, AddressBookEntry, Request, RequestPolicy, Transfer, User, UserGroup}, + models::{Account, AddressBookEntry, Asset, Request, RequestPolicy, Transfer, User, UserGroup}, repositories::{ request_policy::REQUEST_POLICY_REPOSITORY, ACCOUNT_REPOSITORY, ADDRESS_BOOK_REPOSITORY, - USER_GROUP_REPOSITORY, USER_REPOSITORY, + ASSET_REPOSITORY, USER_GROUP_REPOSITORY, USER_REPOSITORY, }, SERVICE_NAME, }; @@ -15,6 +16,7 @@ use orbit_essentials::{ repository::Repository, }; use std::{cell::RefCell, collections::BTreeMap, rc::Rc}; +use uuid::Uuid; use super::observer::Observer; @@ -33,6 +35,13 @@ thread_local! { Rc::new(RefCell::new(MetricTotalUserGroups)), ]; + /// A collection of asset related metrics. + /// + /// This list should be updated with new asset metrics as they are added. + pub static ASSET_METRICS: Vec>>> = vec![ + Rc::new(RefCell::new(MetricTotalAssets)), + ]; + /// A collection of account related metrics. /// /// This list should be updated with new account metrics as they are added. @@ -77,6 +86,7 @@ pub fn recompute_metrics() { let users = USER_REPOSITORY.list(); let user_groups = USER_GROUP_REPOSITORY.list(); let accounts = ACCOUNT_REPOSITORY.list(); + let assets = ASSET_REPOSITORY.list(); // To avoid deserialize all the data, we can use the repository length to get the total number of entries of // simple gauge metrics. @@ -95,6 +105,12 @@ pub fn recompute_metrics() { .for_each(|metric| metric.borrow_mut().recalculate(&user_groups)) }); + ASSET_METRICS.with(|metrics| { + metrics + .iter() + .for_each(|metric| metric.borrow_mut().recalculate(&assets)) + }); + ACCOUNT_METRICS.with(|metrics| { metrics .iter() @@ -235,6 +251,30 @@ impl ApplicationMetric for MetricTotalUserGroups { self.dec(SERVICE_NAME, &labels! { "status" => "active" }); } } +/// Metric for the total number of assets. +pub struct MetricTotalAssets; + +impl ApplicationGaugeMetric for MetricTotalAssets {} + +impl ApplicationMetric for MetricTotalAssets { + fn name(&self) -> &'static str { + "total_assets" + } + + fn help(&self) -> &'static str { + "The total number of assets." + } + + fn sum(&mut self, _: &Asset, previous: Option<&Asset>) { + if previous.is_none() { + self.inc(SERVICE_NAME); + } + } + + fn sub(&mut self, _: &Asset) { + self.dec(SERVICE_NAME); + } +} /// Metric for the number of transfers that have been created. pub struct MetricTotalTranfers; @@ -315,17 +355,33 @@ impl ApplicationMetric for MetricAssetsTotalBalance { let mut labeled_totals = BTreeMap::new(); for account in accounts { - let label_key = ( - account.blockchain.to_string().clone(), - account.symbol.clone().to_lowercase(), - ); + for account_asset in &account.assets { + let Some(asset) = ASSET_REPOSITORY.get(&account_asset.asset_id) else { + print(format!( + "Asset `{}` not found in account `{}`", + Uuid::from_bytes(account_asset.asset_id).hyphenated(), + Uuid::from_bytes(account.id).hyphenated() + )); + continue; + }; + + let label_key = ( + asset.blockchain.to_string().clone(), + asset.symbol.clone().to_lowercase(), + ); + + let current_total = labeled_totals.get(&label_key).unwrap_or(&0.0); - let current_total = labeled_totals.get(&label_key).unwrap_or(&0.0); - let balance = account.balance.clone().map(|b| b.to_u64()).unwrap_or(0u64); + let balance = account_asset + .balance + .clone() + .map(|b| b.to_u64()) + .unwrap_or(0u64); - let formatted_balance = amount_to_f64(balance as i128, account.decimals); + let formatted_balance = amount_to_f64(balance as i128, asset.decimals); - labeled_totals.insert(label_key, current_total + formatted_balance); + labeled_totals.insert(label_key, current_total + formatted_balance); + } } for ((blockchain, symbol), total) in labeled_totals.into_iter() { @@ -338,39 +394,66 @@ impl ApplicationMetric for MetricAssetsTotalBalance { } fn sum(&mut self, current: &Account, previous: Option<&Account>) { - let blockchain = current.blockchain.to_string(); - let symbol = current.symbol.clone().to_lowercase(); - let account_labels = - labels! { "blockchain" => blockchain.as_str(), "symbol" => symbol.as_str() }; + if let Some(previous) = previous { + self.sub(previous); + } - let balance = current.balance.clone().map(|b| b.to_u64()).unwrap_or(0u64); + for account_asset in ¤t.assets { + let Some(asset) = ASSET_REPOSITORY.get(&account_asset.asset_id) else { + print(format!( + "Asset `{}` not found in account `{}`", + Uuid::from_bytes(account_asset.asset_id).hyphenated(), + Uuid::from_bytes(current.id).hyphenated() + )); - let previous_balance = previous - .and_then(|p| p.balance.clone().map(|b| b.to_u64())) - .unwrap_or(0u64); + continue; + }; - let diff_balance = balance as i128 - previous_balance as i128; - let current_total = self.get(SERVICE_NAME, &account_labels); + let blockchain = asset.blockchain.to_string(); + let symbol = asset.symbol.clone().to_lowercase(); - let formatted_balance = amount_to_f64(diff_balance, current.decimals); - let new_total = current_total + formatted_balance; + let account_labels = + labels! { "blockchain" => blockchain.as_str(), "symbol" => symbol.as_str() }; - self.set(SERVICE_NAME, &account_labels, new_total.max(0.0)); - } + let balance = account_asset + .balance + .clone() + .map(|b| b.to_u64()) + .unwrap_or(0u64); - fn sub(&mut self, current: &Account) { - let blockchain = current.blockchain.to_string(); - let symbol = current.symbol.clone().to_lowercase(); - let account_labels = - labels! { "blockchain" => blockchain.as_str(), "symbol" => symbol.as_str() }; + let current_total = self.get(SERVICE_NAME, &account_labels); - let balance = current.balance.clone().map(|b| b.to_u64()).unwrap_or(0u64); + let formatted_balance = amount_to_f64(balance as i128, asset.decimals); - let formatted_balance = amount_to_f64(balance as i128, current.decimals); - let current_total = self.get(SERVICE_NAME, &account_labels); + let new_total = current_total + formatted_balance; - let new_total = current_total - formatted_balance; - self.set(SERVICE_NAME, &account_labels, new_total.max(0.0)); + self.set(SERVICE_NAME, &account_labels, new_total.max(0.0)); + } + } + + fn sub(&mut self, current: &Account) { + for account_asset in ¤t.assets { + let Some(asset) = ASSET_REPOSITORY.get(&account_asset.asset_id) else { + continue; + }; + let blockchain = asset.blockchain.to_string(); + let symbol = asset.symbol.clone().to_lowercase(); + + let account_labels = + labels! { "blockchain" => blockchain.as_str(), "symbol" => symbol.as_str() }; + + let balance = account_asset + .balance + .clone() + .map(|b| b.to_u64()) + .unwrap_or(0u64); + + let formatted_balance = amount_to_f64(balance as i128, asset.decimals); + let current_total = self.get(SERVICE_NAME, &account_labels); + + let new_total = current_total - formatted_balance; + self.set(SERVICE_NAME, &account_labels, new_total.max(0.0)); + } } } @@ -464,14 +547,15 @@ mod tests { use crate::{ models::{ account_test_utils::mock_account, - address_book_entry_test_utils::mock_address_book_entry, + address_book_entry_test_utils::mock_address_book_entry, asset_test_utils::mock_asset, request_policy_test_utils::mock_request_policy, request_test_utils::mock_request, transfer_test_utils::mock_transfer, user_group_test_utils, user_test_utils::mock_user, - AccountBalance, Blockchain, RequestStatus, TransferStatus, UserStatus, + AccountAsset, AccountBalance, Blockchain, RequestStatus, TransferStatus, UserStatus, }, repositories::{REQUEST_REPOSITORY, TRANSFER_REPOSITORY}, }; use candid::Nat; + use orbit_essentials::model::ModelKey; #[test] fn test_total_users_metric() { @@ -513,9 +597,12 @@ mod tests { #[test] fn test_total_accounts_metric() { let mut account = mock_account(); - account.blockchain = Blockchain::InternetComputer; - account.symbol = "ICP".to_string(); - + let asset = mock_asset(); + ASSET_REPOSITORY.insert(asset.key(), asset.clone()); + account.assets = vec![AccountAsset { + asset_id: asset.key(), + balance: None, + }]; ACCOUNT_REPOSITORY.insert(account.to_key(), account); assert_eq!( @@ -524,9 +611,14 @@ mod tests { ); let mut account = mock_account(); + let asset = mock_asset(); + + ASSET_REPOSITORY.insert(asset.key(), asset.clone()); + account.assets = vec![AccountAsset { + asset_id: asset.key(), + balance: None, + }]; account.name = "Test2".to_string(); - account.blockchain = Blockchain::InternetComputer; - account.symbol = "ICP".to_string(); ACCOUNT_REPOSITORY.insert(account.to_key(), account); @@ -591,13 +683,15 @@ mod tests { let blockchain_name = Blockchain::InternetComputer.to_string(); let mut account = mock_account(); - account.blockchain = Blockchain::InternetComputer; - account.symbol = "icp".to_string(); - account.balance = Some(AccountBalance { - balance: Nat::from(1_000_000_000u64), - last_modification_timestamp: 0, - }); - account.decimals = 8; + let asset = mock_asset(); + ASSET_REPOSITORY.insert(asset.key(), asset.clone()); + account.assets = vec![AccountAsset { + asset_id: asset.key(), + balance: Some(AccountBalance { + balance: Nat::from(1_000_000_000u64), + last_modification_timestamp: 0, + }), + }]; ACCOUNT_REPOSITORY.insert(account.to_key(), account.clone()); @@ -610,13 +704,15 @@ mod tests { ); let mut account = mock_account(); - account.blockchain = Blockchain::InternetComputer; - account.symbol = "icp".to_string(); - account.balance = Some(AccountBalance { - balance: Nat::from(10_000_000_000u64), - last_modification_timestamp: 0, - }); - account.decimals = 8; + let asset = mock_asset(); + ASSET_REPOSITORY.insert(asset.key(), asset.clone()); + account.assets = vec![AccountAsset { + asset_id: asset.key(), + balance: Some(AccountBalance { + balance: Nat::from(10_000_000_000u64), + last_modification_timestamp: 0, + }), + }]; ACCOUNT_REPOSITORY.insert(account.to_key(), account.clone()); @@ -628,10 +724,13 @@ mod tests { 110.00000000 ); - account.balance = Some(AccountBalance { - balance: Nat::from(100_000_000u64), - last_modification_timestamp: 0, - }); + account.assets = vec![AccountAsset { + asset_id: asset.key(), + balance: Some(AccountBalance { + balance: Nat::from(100_000_000u64), + last_modification_timestamp: 0, + }), + }]; ACCOUNT_REPOSITORY.insert(account.to_key(), account.clone()); diff --git a/core/station/impl/src/core/mod.rs b/core/station/impl/src/core/mod.rs index b8567168c..32c75a4f6 100644 --- a/core/station/impl/src/core/mod.rs +++ b/core/station/impl/src/core/mod.rs @@ -1,8 +1,5 @@ //! Core utility features for the canister. -mod assets; -pub use assets::*; - mod constants; pub use constants::*; @@ -18,7 +15,9 @@ pub use call_context::*; pub mod middlewares; pub mod observer; +pub mod standards; pub mod validation; +pub use standards::*; #[cfg(not(test))] pub use orbit_essentials::cdk as ic_cdk; diff --git a/core/station/impl/src/core/request.rs b/core/station/impl/src/core/request.rs index 98a3b287c..9464e58e2 100644 --- a/core/station/impl/src/core/request.rs +++ b/core/station/impl/src/core/request.rs @@ -404,9 +404,9 @@ mod tests { request_test_utils::mock_request, resource::ResourceIds, user_test_utils::{self, mock_user}, - Account, AccountKey, AddUserGroupOperation, AddUserGroupOperationInput, Blockchain, - BlockchainStandard, EvaluatedRequestPolicyRule, Metadata, MetadataItem, Percentage, - RequestOperation, RequestPolicy, RequestStatus, ADMIN_GROUP_ID, + Account, AccountKey, AddUserGroupOperation, AddUserGroupOperationInput, + EvaluatedRequestPolicyRule, Metadata, MetadataItem, Percentage, RequestOperation, + RequestPolicy, RequestStatus, ADMIN_GROUP_ID, }, repositories::{ request_policy::REQUEST_POLICY_REPOSITORY, ACCOUNT_REPOSITORY, @@ -627,13 +627,10 @@ mod tests { AccountKey { id: [1; 16] }, Account { id: [1; 16], - blockchain: Blockchain::InternetComputer, - address: "a".to_owned(), - standard: BlockchainStandard::Native, - symbol: "S".to_owned(), - decimals: 1, + addresses: vec![], + assets: vec![], + seed: [0; 16], name: "test".to_owned(), - balance: None, metadata: Metadata::default(), transfer_request_policy_id: None, configs_request_policy_id: None, diff --git a/core/station/impl/src/core/standards.rs b/core/station/impl/src/core/standards.rs new file mode 100644 index 000000000..fd1c6a383 --- /dev/null +++ b/core/station/impl/src/core/standards.rs @@ -0,0 +1,17 @@ +use lazy_static::lazy_static; + +use crate::models::{Blockchain, TokenStandard}; + +pub struct SupportedBlockchain { + pub blockchain: Blockchain, + pub supported_standards: Vec, +} + +lazy_static! { + pub static ref SUPPORTED_BLOCKCHAINS: Vec = { + vec![SupportedBlockchain { + blockchain: Blockchain::InternetComputer, + supported_standards: vec![TokenStandard::InternetComputerNative, TokenStandard::ICRC1], + }] + }; +} diff --git a/core/station/impl/src/core/validation.rs b/core/station/impl/src/core/validation.rs index cbb2f4df7..39c738e71 100644 --- a/core/station/impl/src/core/validation.rs +++ b/core/station/impl/src/core/validation.rs @@ -5,24 +5,20 @@ use std::cell::RefCell; use crate::{ errors::{ExternalCanisterValidationError, RecordValidationError}, - factories::blockchains::InternetComputer, models::{ resource::{Resource, ResourceId, ResourceIds}, - AccountKey, AddressBookEntryKey, NotificationKey, RequestKey, UserKey, + AccountKey, AddressBookEntryKey, NotificationKey, RequestKey, TokenStandard, UserKey, }, repositories::{ permission::PERMISSION_REPOSITORY, request_policy::REQUEST_POLICY_REPOSITORY, - ACCOUNT_REPOSITORY, ADDRESS_BOOK_REPOSITORY, NOTIFICATION_REPOSITORY, REQUEST_REPOSITORY, - USER_GROUP_REPOSITORY, USER_REPOSITORY, + ACCOUNT_REPOSITORY, ADDRESS_BOOK_REPOSITORY, ASSET_REPOSITORY, NOTIFICATION_REPOSITORY, + REQUEST_REPOSITORY, USER_GROUP_REPOSITORY, USER_REPOSITORY, }, services::SYSTEM_SERVICE, }; use candid::Principal; use ic_stable_structures::{Memory, Storable}; -#[cfg(not(test))] -pub use orbit_essentials::cdk as ic_cdk; -#[cfg(test)] -pub use orbit_essentials::cdk::mocks as ic_cdk; + use orbit_essentials::repository::Repository; use orbit_essentials::types::UUID; use uuid::Uuid; @@ -199,9 +195,18 @@ impl EnsureExternalCanister { pub fn is_external_canister( principal: Principal, ) -> Result<(), ExternalCanisterValidationError> { - if principal == Principal::management_canister() - || principal == ic_cdk::api::id() - || principal == InternetComputer::ledger_canister_id() + // Check if the target canister is a ledger canister of an asset. + let principal_str = principal.to_text(); + let is_ledger_canister_id = ASSET_REPOSITORY.list().iter().any(|asset| { + asset + .metadata + .get(TokenStandard::METADATA_KEY_LEDGER_CANISTER_ID) + .map_or(false, |canister_id| canister_id == principal_str) + }); + + if is_ledger_canister_id + || principal == Principal::management_canister() + || principal == crate::core::ic_cdk::api::id() || principal == SYSTEM_SERVICE.get_upgrader_canister_id() { return Err(ExternalCanisterValidationError::InvalidExternalCanister { principal }); @@ -227,3 +232,60 @@ impl EnsureIdExists for EnsureNotification { } impl EnsureResourceIdExists for EnsureNotification {} + +pub struct EnsureAsset {} + +impl EnsureIdExists for EnsureAsset { + fn id_exists(id: &UUID) -> Result<(), RecordValidationError> { + ensure_entry_exists(ASSET_REPOSITORY.to_owned(), *id).ok_or( + RecordValidationError::NotFound { + model_name: "Asset".to_string(), + id: Uuid::from_bytes(*id).hyphenated().to_string(), + }, + ) + } +} + +impl EnsureResourceIdExists for EnsureAsset {} + +#[cfg(test)] +mod test { + use std::collections::BTreeMap; + + use candid::Principal; + use orbit_essentials::{model::ModelKey, repository::Repository}; + + use crate::{ + core::test_utils::init_canister_system, + models::{asset_test_utils::mock_asset, TokenStandard}, + repositories::ASSET_REPOSITORY, + }; + + use super::EnsureExternalCanister; + + #[test] + fn test_is_external_canister() { + init_canister_system(); + + let principal = Principal::from_slice(&[1; 29]); + + let is_external_canister = EnsureExternalCanister::is_external_canister(principal); + assert!(is_external_canister.is_ok()); + + let mut asset = mock_asset(); + + asset + .metadata + .change(crate::models::ChangeMetadata::OverrideSpecifiedBy( + BTreeMap::from([( + TokenStandard::METADATA_KEY_LEDGER_CANISTER_ID.to_string(), + principal.to_text(), + )]), + )); + + ASSET_REPOSITORY.insert(asset.key(), asset); + + let is_external_canister = EnsureExternalCanister::is_external_canister(principal); + assert!(is_external_canister.is_err()); + } +} diff --git a/core/station/impl/src/errors/account.rs b/core/station/impl/src/errors/account.rs index 77c0ae8b7..2dc117456 100644 --- a/core/station/impl/src/errors/account.rs +++ b/core/station/impl/src/errors/account.rs @@ -8,6 +8,9 @@ pub enum AccountError { /// The requested account was not found. #[error(r#"The requested account was not found."#)] AccountNotFound { id: String }, + /// The associated asset does not exist. + #[error(r#"The associated asset `{id}` does not exist."#)] + AssetDoesNotExist { id: String }, /// The given blockchain is unknown to the system. #[error(r#"The given blockchain is unknown to the system."#)] UnknownBlockchain { blockchain: String }, @@ -22,6 +25,20 @@ pub enum AccountError { r#"The account address is out of range, it must be between {min_length} and {max_length}."# )] InvalidAddressLength { min_length: u8, max_length: u8 }, + /// The account name is out of range. + #[error( + r#"The account name is out of range, it must be between {min_length} and {max_length}."# + )] + InvalidNameLength { min_length: u8, max_length: u8 }, + /// The address format is unknown to the system. + #[error(r#"The given address format is unknown to the system."#)] + UnknownAddressFormat { address_format: String }, + /// The address is invalid. + #[error(r#"The given address {address} does not comply with {address_format}"#)] + InvalidAddress { + address: String, + address_format: String, + }, /// The account owners selection is out of range. #[error(r#"The account owners selection is out of range, it must be between {min_owners} and {max_owners}."#)] InvalidOwnersRange { min_owners: u8, max_owners: u8 }, diff --git a/core/station/impl/src/errors/asset.rs b/core/station/impl/src/errors/asset.rs new file mode 100644 index 000000000..cbf91cace --- /dev/null +++ b/core/station/impl/src/errors/asset.rs @@ -0,0 +1,102 @@ +use orbit_essentials::api::DetailableError; +use std::collections::HashMap; +use thiserror::Error; + +/// Container for asset errors. +#[derive(Error, Debug, Eq, PartialEq, Clone)] +pub enum AssetError { + /// The asset was not found. + #[error("The asset with id {id} was not found.")] + NotFound { + /// The asset id. + id: String, + }, + /// Invalid decimals value. + #[error(r#"Decimals must be between {min} and {max}."#)] + InvalidDecimals { min: u32, max: u32 }, + /// Invalid name length. + #[error(r#"Name must be between {min_length} and {max_length}."#)] + InvalidNameLength { min_length: u16, max_length: u16 }, + /// Invalid symbol length. + #[error(r#"Symbol must be between {min_length} and {max_length}."#)] + InvalidSymbolLength { min_length: u16, max_length: u16 }, + /// Invalid symbol. + #[error(r#"Symbol must contain only alphanumeric characters."#)] + InvalidSymbol, + /// The given blockchain is unknown to the system. + #[error(r#"The given blockchain is unknown to the system."#)] + UnknownBlockchain { blockchain: String }, + /// The given token standard is unknown to the system. + #[error(r#"The given token standard is unknown to the system."#)] + UnknownTokenStandard { token_standard: String }, + /// The asset has failed validation. + #[error(r#"The account has failed validation."#)] + ValidationError { info: String }, + /// The asset is in use. + #[error(r#"The asset is used by {resource} `{id}`"#)] + AssetInUse { id: String, resource: String }, + /// The asset is not unique. + #[error(r#"The asset already exists."#)] + AlreadyExists { + /// The asset symbol. + symbol: String, + /// The asset blockchain. + blockchain: String, + }, +} + +impl DetailableError for AssetError { + fn details(&self) -> Option> { + let mut details = HashMap::new(); + match self { + AssetError::UnknownBlockchain { blockchain } => { + details.insert("blockchain".to_string(), blockchain.to_string()); + Some(details) + } + AssetError::UnknownTokenStandard { token_standard } => { + details.insert("token_standard".to_string(), token_standard.to_string()); + Some(details) + } + AssetError::ValidationError { info } => { + details.insert("info".to_string(), info.to_string()); + Some(details) + } + AssetError::InvalidDecimals { min, max } => { + details.insert("min".to_string(), min.to_string()); + details.insert("max".to_string(), max.to_string()); + Some(details) + } + AssetError::InvalidNameLength { + min_length, + max_length, + } => { + details.insert("min_length".to_string(), min_length.to_string()); + details.insert("max_length".to_string(), max_length.to_string()); + Some(details) + } + AssetError::InvalidSymbol => Some(details), + AssetError::InvalidSymbolLength { + min_length, + max_length, + } => { + details.insert("min_length".to_string(), min_length.to_string()); + details.insert("max_length".to_string(), max_length.to_string()); + Some(details) + } + AssetError::NotFound { id } => { + details.insert("id".to_string(), id.to_string()); + Some(details) + } + AssetError::AlreadyExists { symbol, blockchain } => { + details.insert("symbol".to_string(), symbol.to_string()); + details.insert("blockchain".to_string(), blockchain.to_string()); + Some(details) + } + AssetError::AssetInUse { id, resource } => { + details.insert("id".to_string(), id.to_string()); + details.insert("resource".to_string(), resource.to_string()); + Some(details) + } + } + } +} diff --git a/core/station/impl/src/errors/blockchain_api.rs b/core/station/impl/src/errors/blockchain_api.rs index 884028534..938ab4efa 100644 --- a/core/station/impl/src/errors/blockchain_api.rs +++ b/core/station/impl/src/errors/blockchain_api.rs @@ -5,9 +5,18 @@ use thiserror::Error; /// Container for blockchain api errors. #[derive(Error, Debug, Eq, PartialEq, Clone)] pub enum BlockchainApiError { - /// Failed to fetch latest account balance from the asset blockchain. - #[error(r#"Failed to fetch latest account balance from the asset blockchain."#)] - FetchBalanceFailed { account_id: String }, + /// Failed to fetch latest asset balance. + #[error(r#"Failed to fetch latest asset balance."#)] + FetchBalanceFailed { asset_id: String, info: String }, + /// Missing metadata key. + #[error(r#"Metadata '{key}' not found."#)] + MissingMetadata { key: String }, + /// Invalid metadata value. + #[error(r#"Metadata data value for key '{key}'"#)] + InvalidMetadata { key: String, value: String }, + /// Invalid address format. + #[error(r#"Invalid address format. Found {found}, expected {expected}"#)] + InvalidAddressFormat { found: String, expected: String }, /// The transaction failed to be submitted. #[error(r#"The transaction failed to be submitted."#)] TransactionSubmitFailed { info: String }, @@ -17,14 +26,21 @@ pub enum BlockchainApiError { /// The to address is invalid. #[error("The to address '{address}' is invalid: {error}")] InvalidToAddress { address: String, error: String }, + /// Missing asset. + #[error(r#"Asset id '{asset_id}' not found."#)] + MissingAsset { asset_id: String }, } impl DetailableError for BlockchainApiError { fn details(&self) -> Option> { let mut details = HashMap::new(); match self { - BlockchainApiError::FetchBalanceFailed { account_id } => { + BlockchainApiError::FetchBalanceFailed { + asset_id: account_id, + info, + } => { details.insert("account_id".to_string(), account_id.to_string()); + details.insert("info".to_string(), info.to_string()); Some(details) } BlockchainApiError::TransactionSubmitFailed { info } => { @@ -40,6 +56,24 @@ impl DetailableError for BlockchainApiError { details.insert("error".to_string(), error.to_string()); Some(details) } + BlockchainApiError::InvalidAddressFormat { found, expected } => { + details.insert("found".to_string(), found.to_string()); + details.insert("expected".to_string(), expected.to_string()); + Some(details) + } + BlockchainApiError::MissingMetadata { key } => { + details.insert("key".to_string(), key.to_string()); + Some(details) + } + BlockchainApiError::InvalidMetadata { key, value } => { + details.insert("key".to_string(), key.to_string()); + details.insert("value".to_string(), value.to_string()); + Some(details) + } + BlockchainApiError::MissingAsset { asset_id } => { + details.insert("asset_id".to_string(), asset_id.to_string()); + Some(details) + } } } } diff --git a/core/station/impl/src/errors/factory.rs b/core/station/impl/src/errors/factory.rs index 675f889ca..6104fe116 100644 --- a/core/station/impl/src/errors/factory.rs +++ b/core/station/impl/src/errors/factory.rs @@ -6,22 +6,15 @@ use thiserror::Error; #[derive(Error, Debug, Eq, PartialEq, Clone)] pub enum FactoryError { /// The selected account is not yet supported by the system. - #[error(r#"The selected account is not yet supported by the system."#)] - UnsupportedBlockchainAccount { - blockchain: String, - standard: String, - }, + #[error(r#"The selected blockchain is not yet supported by the system."#)] + UnsupportedBlockchain { blockchain: String }, } impl DetailableError for FactoryError { fn details(&self) -> Option> { let mut details = HashMap::new(); - let FactoryError::UnsupportedBlockchainAccount { - blockchain, - standard, - } = self; + let FactoryError::UnsupportedBlockchain { blockchain } = self; details.insert("blockchain".to_string(), blockchain.to_string()); - details.insert("standard".to_string(), standard.to_string()); Some(details) } diff --git a/core/station/impl/src/errors/mod.rs b/core/station/impl/src/errors/mod.rs index 346359758..f12035cae 100644 --- a/core/station/impl/src/errors/mod.rs +++ b/core/station/impl/src/errors/mod.rs @@ -70,3 +70,6 @@ pub use validation::*; mod disaster_recovery; pub use disaster_recovery::*; + +mod asset; +pub use asset::*; diff --git a/core/station/impl/src/factories/blockchains/core.rs b/core/station/impl/src/factories/blockchains/core.rs index 6e55a5a10..ea8e725ca 100644 --- a/core/station/impl/src/factories/blockchains/core.rs +++ b/core/station/impl/src/factories/blockchains/core.rs @@ -1,7 +1,10 @@ use super::InternetComputer; use crate::{ errors::FactoryError, - models::{Account, Blockchain, BlockchainStandard, Metadata, Transfer}, + models::{ + Account, AccountAddress, AccountSeed, AddressFormat, Asset, Blockchain, Metadata, + TokenStandard, Transfer, + }, }; use async_trait::async_trait; use num_bigint::BigUint; @@ -49,18 +52,24 @@ pub trait BlockchainApi: Send + Sync { /// Generates a new address for the given account. /// /// This address is used for token transfers. - async fn generate_address(&self, account: &Account) -> Result; + async fn generate_address( + &self, + seed: &AccountSeed, + format: AddressFormat, + ) -> Result; /// Returns the latest balance of the given account. - async fn balance(&self, account: &Account) -> Result; - - /// Returns the decimals of the given account. - async fn decimals(&self, account: &Account) -> Result; + async fn balance( + &self, + asset: &Asset, + addresses: &[AccountAddress], + ) -> Result; /// Returns the latest average transaction fee. async fn transaction_fee( &self, - account: &Account, + asset: &Asset, + standard: TokenStandard, ) -> Result; /// Returns the default network. @@ -78,17 +87,12 @@ pub trait BlockchainApi: Send + Sync { pub struct BlockchainApiFactory {} impl BlockchainApiFactory { - pub fn build( - blockchain: &Blockchain, - standard: &BlockchainStandard, - ) -> Result, FactoryError> { - match (blockchain, standard) { - (Blockchain::InternetComputer, BlockchainStandard::Native) => { - Ok(Box::new(InternetComputer::create())) - } - (blockchain, standard) => Err(FactoryError::UnsupportedBlockchainAccount { + pub fn build(blockchain: &Blockchain) -> Result, FactoryError> { + match blockchain { + Blockchain::InternetComputer => Ok(Box::new(InternetComputer::create())), + + blockchain => Err(FactoryError::UnsupportedBlockchain { blockchain: blockchain.to_string(), - standard: standard.to_string(), }), } } diff --git a/core/station/impl/src/factories/blockchains/internet_computer.rs b/core/station/impl/src/factories/blockchains/internet_computer.rs index ff311cc80..83cdac860 100644 --- a/core/station/impl/src/factories/blockchains/internet_computer.rs +++ b/core/station/impl/src/factories/blockchains/internet_computer.rs @@ -8,22 +8,21 @@ use crate::{ errors::BlockchainApiError, mappers::HelperMapper, models::{ - Account, AccountId, Blockchain, BlockchainStandard, Metadata, Transfer, METADATA_MEMO_KEY, + Account, AccountAddress, AccountSeed, AddressFormat, Asset, Blockchain, Metadata, + TokenStandard, Transfer, METADATA_MEMO_KEY, }, + repositories::ASSET_REPOSITORY, }; use async_trait::async_trait; use byteorder::{BigEndian, ByteOrder}; -use candid::Principal; -use ic_ledger_types::{ - account_balance, query_blocks, transfer, AccountBalanceArgs, AccountIdentifier, GetBlocksArgs, - Memo, QueryBlocksResponse, Subaccount, Timestamp, Tokens, Transaction, TransferArgs, - TransferError as LedgerTransferError, DEFAULT_FEE, -}; +use candid::{CandidType, Principal}; use num_bigint::BigUint; use orbit_essentials::{ api::ApiError, cdk::{self}, + repository::Repository, }; +use serde::Deserialize; use sha2::{Digest, Sha256}; use std::{ fmt::{Display, Formatter}, @@ -65,11 +64,22 @@ pub struct SubmitTransferResponse { pub transaction_hash: Option, } +#[derive(CandidType, Deserialize)] +pub struct ICPLedgerTransferFee { + pub e8s: u64, +} +#[derive(CandidType, Deserialize)] +pub struct ICPLedgerTransferFeeResponse { + pub transfer_fee: ICPLedgerTransferFee, +} + +#[derive(CandidType)] +pub struct ICPLedgerTransferFeeInput {} + impl InternetComputer { pub const BLOCKCHAIN: Blockchain = Blockchain::InternetComputer; - pub const STANDARD: BlockchainStandard = BlockchainStandard::Native; + pub const STANDARD: TokenStandard = TokenStandard::InternetComputerNative; pub const ICP_LEDGER_CANISTER_ID: &'static str = "ryjl3-tyaaa-aaaaa-aaaba-cai"; - pub const DECIMALS: u32 = 8; pub const MAIN_NETWORK: InternetComputerNetwork = InternetComputerNetwork::Mainnet; pub fn create() -> Self { @@ -78,22 +88,20 @@ impl InternetComputer { } } - /// Generates the corresponded subaccount id for the given station_account id. + /// Generates the corresponded subaccount id for the given seed. /// /// The subaccount id is a 32 bytes array that is used to identify a station_account in the ICP ledger. - pub fn subaccount_from_station_account_id(station_account_id: &AccountId) -> [u8; 32] { - let len = station_account_id.len(); + pub fn subaccount_from_seed(seed: &[u8; 16]) -> [u8; 32] { + let len = seed.len(); let mut subaccount_id = [0u8; 32]; - subaccount_id[0..len].copy_from_slice(&station_account_id[0..len]); + subaccount_id[0..len].copy_from_slice(&seed[0..len]); subaccount_id } - pub fn ledger_canister_id() -> Principal { - Principal::from_text(Self::ICP_LEDGER_CANISTER_ID).unwrap() - } - - fn hash_transaction(transaction: &Transaction) -> Result { + fn hash_transaction( + transaction: &ic_ledger_types::Transaction, + ) -> Result { let mut hasher = Sha256::new(); hasher.update(&serde_cbor::ser::to_vec_packed(transaction)?); Ok(hex::encode(hasher.finalize())) @@ -105,51 +113,107 @@ impl InternetComputer { /// The station_account account id is used to identify a station_account in the ICP ledger. pub fn station_account_to_ledger_account( &self, - station_account_id: &AccountId, - ) -> AccountIdentifier { - let subaccount = InternetComputer::subaccount_from_station_account_id(station_account_id); + seed: &AccountSeed, + ) -> ic_ledger_types::AccountIdentifier { + let subaccount = InternetComputer::subaccount_from_seed(seed); - AccountIdentifier::new(&self.station_canister_id, &Subaccount(subaccount)) + ic_ledger_types::AccountIdentifier::new( + &self.station_canister_id, + &ic_ledger_types::Subaccount(subaccount), + ) } - /// Generates the corresponded ledger address for the given station_account id. + /// Generates the corresponded icp ledger address for the given station account seed. /// /// This address is used for token transfers. - pub fn station_account_address(&self, station_account_id: &AccountId) -> String { - let account = self.station_account_to_ledger_account(station_account_id); + pub fn generate_account_identifier(&self, seed: &AccountSeed) -> String { + let account = self.station_account_to_ledger_account(seed); account.to_hex() } - /// Returns the latest balance of the given station_account. - pub async fn balance(&self, station_account: &Account) -> BlockchainApiResult { - let balance = account_balance( - Self::ledger_canister_id(), - AccountBalanceArgs { - account: self.station_account_to_ledger_account(&station_account.id), + /// Generates the corresponded icrc-1 ledger address for the given station account seed. + /// + /// This address is used for token transfers. + pub fn generate_icrc1_address(&self, seed: &AccountSeed) -> String { + let subaccount = Self::subaccount_from_seed(seed); + + let address = icrc_ledger_types::icrc1::account::Account { + owner: self.station_canister_id, + subaccount: Some(subaccount), + }; + + address.to_string() + } + + /// Returns the latest balance of the given icp accountidentifier of a station account. + pub async fn balance_of_account_identifier( + &self, + asset: &Asset, + account_identifier: &ic_ledger_types::AccountIdentifier, + ) -> BlockchainApiResult { + let ledger_canister_id = Self::get_ledger_canister_id_from_metadata(&asset.metadata)?; + + let balance = ic_ledger_types::account_balance( + ledger_canister_id, + ic_ledger_types::AccountBalanceArgs { + account: *account_identifier, }, ) .await - .map_err(|_| BlockchainApiError::FetchBalanceFailed { - account_id: Uuid::from_bytes(station_account.id) - .hyphenated() - .to_string(), + .map_err(|e| BlockchainApiError::FetchBalanceFailed { + asset_id: Uuid::from_bytes(asset.id).hyphenated().to_string(), + info: format!("Could not get balance of asset {}({}) with address {} from canister {}. Reason: {}", asset.name, Uuid::from_bytes(asset.id).hyphenated(), account_identifier.to_hex(), ledger_canister_id, e.1), })?; Ok(balance.e8s()) } - pub fn transaction_fee(&self) -> u64 { - DEFAULT_FEE.e8s() + /// Returns the latest balance of the given icrc1 account of a station account. + pub async fn balance_of_icrc1_account( + &self, + asset: &Asset, + account: &icrc_ledger_types::icrc1::account::Account, + ) -> BlockchainApiResult { + let ledger_canister_id = Self::get_ledger_canister_id_from_metadata(&asset.metadata)?; + + let balance = + ic_cdk::call::<(icrc_ledger_types::icrc1::account::Account,), (candid::Nat,)>( + ledger_canister_id, + "icrc1_balance_of", + // 4. Provide the arguments for the call in a tuple, here `transfer_args` is encapsulated as a single-element tuple. + (*account,), + ) + .await + .map_err(|err| BlockchainApiError::BlockchainNetworkError { + info: format!("rejection_code: {:?}, err: {}", err.0, err.1), + })? + .0; + + Ok(balance.0) } - pub fn decimals(&self) -> u32 { - Self::DECIMALS + fn get_ledger_canister_id_from_metadata(metadata: &Metadata) -> BlockchainApiResult { + let ledger_canister_id_str = metadata + .get(TokenStandard::METADATA_KEY_LEDGER_CANISTER_ID) + .ok_or(BlockchainApiError::MissingMetadata { + key: TokenStandard::METADATA_KEY_LEDGER_CANISTER_ID.to_string(), + })?; + + Ok( + Principal::from_text(ledger_canister_id_str.clone()).map_err(|_| { + BlockchainApiError::InvalidMetadata { + key: TokenStandard::METADATA_KEY_LEDGER_CANISTER_ID.to_string(), + value: ledger_canister_id_str, + } + })?, + ) } - pub async fn submit_transfer( + pub async fn submit_icp_transfer( &self, station_account: Account, + asset: Asset, station_transfer: Transfer, ) -> Result { let current_time = cdk::next_time(); @@ -159,26 +223,26 @@ impl InternetComputer { Some(memo) => HelperMapper::to_u64(memo)?, None => BigEndian::read_u64(&station_transfer.id[0..8]), }; - let to_address = - AccountIdentifier::from_hex(&station_transfer.to_address).map_err(|error| { - BlockchainApiError::InvalidToAddress { - address: station_transfer.to_address.clone(), - error, - } + let to_address = ic_ledger_types::AccountIdentifier::from_hex(&station_transfer.to_address) + .map_err(|error| BlockchainApiError::InvalidToAddress { + address: station_transfer.to_address.clone(), + error, })?; - let block_height = transfer( - Self::ledger_canister_id(), - TransferArgs { - amount: Tokens::from_e8s(amount), - fee: Tokens::from_e8s(transaction_fee), - created_at_time: Some(Timestamp { + let ledger_canister_id = Self::get_ledger_canister_id_from_metadata(&asset.metadata)?; + + let block_height = ic_ledger_types::transfer( + ledger_canister_id, + ic_ledger_types::TransferArgs { + amount: ic_ledger_types::Tokens::from_e8s(amount), + fee: ic_ledger_types::Tokens::from_e8s(transaction_fee), + created_at_time: Some(ic_ledger_types::Timestamp { timestamp_nanos: current_time, }), - from_subaccount: Some(Subaccount( - InternetComputer::subaccount_from_station_account_id(&station_account.id), + from_subaccount: Some(ic_ledger_types::Subaccount( + InternetComputer::subaccount_from_seed(&station_account.seed), )), - memo: Memo(memo), + memo: ic_ledger_types::Memo(memo), to: to_address, }, ) @@ -188,34 +252,36 @@ impl InternetComputer { })? .map_err(|err| BlockchainApiError::TransactionSubmitFailed { info: match err { - LedgerTransferError::BadFee { expected_fee } => { + ic_ledger_types::TransferError::BadFee { expected_fee } => { format!("Bad fee, expected: {}", expected_fee) } - LedgerTransferError::InsufficientFunds { balance } => { + ic_ledger_types::TransferError::InsufficientFunds { balance } => { format!("Insufficient balance, balance: {}", balance) } - LedgerTransferError::TxTooOld { + ic_ledger_types::TransferError::TxTooOld { allowed_window_nanos, } => { format!("Tx too old, allowed_window_nanos: {}", allowed_window_nanos) } - LedgerTransferError::TxCreatedInFuture => "Tx created in future".to_string(), - LedgerTransferError::TxDuplicate { duplicate_of } => { + ic_ledger_types::TransferError::TxCreatedInFuture => { + "Tx created in future".to_string() + } + ic_ledger_types::TransferError::TxDuplicate { duplicate_of } => { format!("Tx duplicate, duplicate_of: {}", duplicate_of) } }, })?; - let transaction_hash = match query_blocks( - Self::ledger_canister_id(), - GetBlocksArgs { + let transaction_hash = match ic_ledger_types::query_blocks( + ledger_canister_id, + ic_ledger_types::GetBlocksArgs { length: 1, start: block_height, }, ) .await { - Ok(QueryBlocksResponse { blocks, .. }) => match blocks.first() { + Ok(ic_ledger_types::QueryBlocksResponse { blocks, .. }) => match blocks.first() { Some(block) => match Self::hash_transaction(&block.transaction) { Ok(transaction_hash) => Some(transaction_hash), Err(_) => { @@ -246,34 +312,258 @@ impl InternetComputer { transaction_hash, }) } + + pub async fn submit_icrc1_transfer( + &self, + station_account: Account, + asset: Asset, + station_transfer: Transfer, + ) -> Result { + let memo = match station_transfer.metadata_map().get(METADATA_MEMO_KEY) { + Some(memo) => HelperMapper::to_u64(memo)?, + None => BigEndian::read_u64(&station_transfer.id[0..8]), + }; + + let to_address = + icrc_ledger_types::icrc1::account::Account::from_str(&station_transfer.to_address) + .map_err(|error| BlockchainApiError::InvalidToAddress { + address: station_transfer.to_address.clone(), + error: error.to_string(), + })?; + + let current_time = cdk::next_time(); + + let transfer_args = icrc_ledger_types::icrc1::transfer::TransferArg { + amount: station_transfer.amount, + fee: Some(station_transfer.fee), + created_at_time: Some(current_time), + from_subaccount: Some(InternetComputer::subaccount_from_seed( + &station_account.seed, + )), + memo: Some(memo.into()), + to: to_address, + }; + + let ledger_canister_id = Self::get_ledger_canister_id_from_metadata(&asset.metadata)?; + + let block_height = ic_cdk::call::< + (icrc_ledger_types::icrc1::transfer::TransferArg,), + ( + Result< + icrc_ledger_types::icrc1::transfer::BlockIndex, + icrc_ledger_types::icrc1::transfer::TransferError, + >, + ), + >( + ledger_canister_id, + "icrc1_transfer", + // 4. Provide the arguments for the call in a tuple, here `transfer_args` is encapsulated as a single-element tuple. + (transfer_args,), + ) + .await + .map_err(|err| BlockchainApiError::BlockchainNetworkError { + info: format!("rejection_code: {:?}, err: {}", err.0, err.1), + })? + .0 + .map_err(|err| BlockchainApiError::TransactionSubmitFailed { + info: match err { + icrc_ledger_types::icrc1::transfer::TransferError::BadFee { expected_fee } => { + format!("Bad fee, expected: {}", expected_fee) + } + icrc_ledger_types::icrc1::transfer::TransferError::InsufficientFunds { + balance, + } => { + format!("Insufficient balance, balance: {}", balance) + } + icrc_ledger_types::icrc1::transfer::TransferError::TooOld => { + "Tx too old".to_string() + } + icrc_ledger_types::icrc1::transfer::TransferError::CreatedInFuture { .. } => { + "Tx created in future".to_string() + } + icrc_ledger_types::icrc1::transfer::TransferError::Duplicate { duplicate_of } => { + format!("Tx duplicate, duplicate_of: {}", duplicate_of) + } + icrc_ledger_types::icrc1::transfer::TransferError::BadBurn { min_burn_amount } => { + format!("Bad burn, min_burn_amount: {}", min_burn_amount) + } + icrc_ledger_types::icrc1::transfer::TransferError::TemporarilyUnavailable => { + "Ledger temporarily unavailable".to_string() + } + icrc_ledger_types::icrc1::transfer::TransferError::GenericError { + error_code, + message, + } => { + format!("Error occurred. Code: {}, message: {}", error_code, message) + } + }, + })?; + + Ok(SubmitTransferResponse { + block_height: block_height.0.iter_u64_digits().next().unwrap_or(0), + transaction_hash: None, + }) + } } #[async_trait] impl BlockchainApi for InternetComputer { - async fn generate_address(&self, station_account: &Account) -> BlockchainApiResult { - Ok(self.station_account_address(&station_account.id)) + async fn generate_address( + &self, + seed: &AccountSeed, + format: AddressFormat, + ) -> BlockchainApiResult { + match format { + AddressFormat::ICPAccountIdentifier => Ok(AccountAddress { + address: self.generate_account_identifier(seed), + format: AddressFormat::ICPAccountIdentifier, + }), + AddressFormat::ICRC1Account => Ok(AccountAddress { + address: self.generate_icrc1_address(seed), + format: AddressFormat::ICRC1Account, + }), + AddressFormat::EthereumAddress + | AddressFormat::BitcoinAddressP2WPKH + | AddressFormat::BitcoinAddressP2TR => Err(BlockchainApiError::InvalidAddressFormat { + found: format.to_string(), + expected: [ + AddressFormat::ICPAccountIdentifier.to_string(), + AddressFormat::ICRC1Account.to_string(), + ] + .join(","), + })?, + } } - async fn balance(&self, station_account: &Account) -> BlockchainApiResult { - let balance = self.balance(station_account).await?; + async fn balance( + &self, + asset: &Asset, + account_addresses: &[AccountAddress], + ) -> BlockchainApiResult { + // all matching addresses should resolve to the same balance, so pick the first one + + let supported_formats = asset + .standards + .iter() + .flat_map(|s| s.get_info().address_formats.clone()) + .collect::>(); + + for account_address in account_addresses { + if !supported_formats.contains(&account_address.format) { + // filter out irrelevant addresses + continue; + } - Ok(BigUint::from(balance)) - } + match account_address.format { + AddressFormat::ICPAccountIdentifier => { + let balance = self + .balance_of_account_identifier( + asset, + &ic_ledger_types::AccountIdentifier::from_hex(&account_address.address) + .map_err(|error| BlockchainApiError::InvalidToAddress { + address: account_address.address.clone(), + error, + })?, + ) + .await?; + + return Ok(BigUint::from(balance)); + } + AddressFormat::ICRC1Account => { + let balance = self + .balance_of_icrc1_account( + asset, + &icrc_ledger_types::icrc1::account::Account::from_str( + &account_address.address, + ) + .map_err(|error| { + BlockchainApiError::InvalidToAddress { + address: account_address.address.clone(), + error: error.to_string(), + } + })?, + ) + .await?; + + return Ok(balance); + } + AddressFormat::EthereumAddress + | AddressFormat::BitcoinAddressP2WPKH + | AddressFormat::BitcoinAddressP2TR => { + // these address formats are not supported for ICP + continue; + } + } + } + + print(format!( + "Warning: no suitable address found for balance lookup in asset {} `{}`", + asset.name, + Uuid::from_bytes(asset.id).hyphenated() + )); - async fn decimals(&self, _station_account: &Account) -> BlockchainApiResult { - Ok(self.decimals()) + Ok(BigUint::from(0u64)) } + #[cfg(not(target_arch = "wasm32"))] async fn transaction_fee( &self, - _station_account: &Account, + _asset: &Asset, + _standard: TokenStandard, ) -> BlockchainApiResult { Ok(BlockchainTransactionFee { - fee: BigUint::from(self.transaction_fee()), + fee: 10_000u64.into(), metadata: Metadata::default(), }) } + #[cfg(target_arch = "wasm32")] + async fn transaction_fee( + &self, + asset: &Asset, + standard: TokenStandard, + ) -> BlockchainApiResult { + match standard { + TokenStandard::InternetComputerNative => { + let ledger_canister_id = + Self::get_ledger_canister_id_from_metadata(&asset.metadata)?; + + let fee = + ic_cdk::call::<(ICPLedgerTransferFeeInput,), (ICPLedgerTransferFeeResponse,)>( + ledger_canister_id, + "transfer_fee", + (ICPLedgerTransferFeeInput {},), + ) + .await + .map_err(|err| BlockchainApiError::BlockchainNetworkError { + info: format!("rejection_code: {:?}, err: {}", err.0, err.1), + })? + .0; + + Ok(BlockchainTransactionFee { + fee: fee.transfer_fee.e8s.into(), + metadata: Metadata::default(), + }) + } + TokenStandard::ICRC1 => { + let ledger_canister_id = + Self::get_ledger_canister_id_from_metadata(&asset.metadata)?; + + let fee = ic_cdk::call::<(), (candid::Nat,)>(ledger_canister_id, "icrc1_fee", ()) + .await + .map_err(|err| BlockchainApiError::BlockchainNetworkError { + info: format!("rejection_code: {:?}, err: {}", err.0, err.1), + })? + .0; + + Ok(BlockchainTransactionFee { + fee: fee.0, + metadata: Metadata::default(), + }) + } + } + } + fn default_network(&self) -> String { Self::MAIN_NETWORK.to_string() } @@ -283,9 +573,24 @@ impl BlockchainApi for InternetComputer { station_account: &Account, transfer: &Transfer, ) -> BlockchainApiResult { - let transfer_response = self - .submit_transfer(station_account.clone(), transfer.clone()) - .await?; + let asset = ASSET_REPOSITORY.get(&transfer.from_asset).ok_or({ + BlockchainApiError::MissingAsset { + asset_id: Uuid::from_bytes(transfer.from_asset) + .hyphenated() + .to_string(), + } + })?; + + let transfer_response = match transfer.with_standard { + TokenStandard::InternetComputerNative => { + self.submit_icp_transfer(station_account.clone(), asset, transfer.clone()) + .await? + } + TokenStandard::ICRC1 => { + self.submit_icrc1_transfer(station_account.clone(), asset, transfer.clone()) + .await? + } + }; Ok(BlockchainTransactionSubmitted { details: vec![ diff --git a/core/station/impl/src/factories/requests/add_asset.rs b/core/station/impl/src/factories/requests/add_asset.rs new file mode 100644 index 000000000..fb52d2cc7 --- /dev/null +++ b/core/station/impl/src/factories/requests/add_asset.rs @@ -0,0 +1,75 @@ +use super::{Create, Execute, RequestExecuteStage}; +use crate::{ + errors::{RequestError, RequestExecuteError}, + models::{AddAssetOperation, Request, RequestExecutionPlan, RequestOperation}, + services::AssetService, +}; +use async_trait::async_trait; +use orbit_essentials::types::UUID; + +pub struct AddAssetRequestCreate {} + +#[async_trait] +impl Create for AddAssetRequestCreate { + async fn create( + &self, + request_id: UUID, + requested_by_user: UUID, + input: station_api::CreateRequestInput, + operation_input: station_api::AddAssetOperationInput, + ) -> Result { + let request = Request::new( + request_id, + requested_by_user, + Request::default_expiration_dt_ns(), + RequestOperation::AddAsset(AddAssetOperation { + asset_id: None, + input: operation_input.into(), + }), + input + .execution_plan + .map(Into::into) + .unwrap_or(RequestExecutionPlan::Immediate), + input.title.unwrap_or_else(|| "Asset creation".to_string()), + input.summary, + ); + + Ok(request) + } +} + +pub struct AddAssetRequestExecute<'p, 'o> { + request: &'p Request, + operation: &'o AddAssetOperation, + asset_service: AssetService, +} + +impl<'p, 'o> AddAssetRequestExecute<'p, 'o> { + pub fn new(request: &'p Request, operation: &'o AddAssetOperation) -> Self { + Self { + request, + operation, + asset_service: AssetService::default(), + } + } +} + +#[async_trait] +impl Execute for AddAssetRequestExecute<'_, '_> { + async fn execute(&self) -> Result { + let asset = self + .asset_service + .create(self.operation.input.clone(), None) + .map_err(|e| RequestExecuteError::Failed { + reason: format!("Failed to create asset: {}", e), + })?; + + let mut operation = self.request.operation.clone(); + + if let RequestOperation::AddAsset(ref mut operation) = operation { + operation.asset_id = Some(asset.id); + } + + Ok(RequestExecuteStage::Completed(operation)) + } +} diff --git a/core/station/impl/src/factories/requests/edit_asset.rs b/core/station/impl/src/factories/requests/edit_asset.rs new file mode 100644 index 000000000..5e7951d12 --- /dev/null +++ b/core/station/impl/src/factories/requests/edit_asset.rs @@ -0,0 +1,69 @@ +use super::{Create, Execute, RequestExecuteStage}; +use crate::{ + errors::{RequestError, RequestExecuteError}, + models::{EditAssetOperation, Request, RequestExecutionPlan, RequestOperation}, + services::AssetService, +}; +use async_trait::async_trait; +use orbit_essentials::types::UUID; + +pub struct EditAssetRequestCreate {} + +#[async_trait] +impl Create for EditAssetRequestCreate { + async fn create( + &self, + request_id: UUID, + requested_by_user: UUID, + input: station_api::CreateRequestInput, + operation_input: station_api::EditAssetOperationInput, + ) -> Result { + let request = Request::new( + request_id, + requested_by_user, + Request::default_expiration_dt_ns(), + RequestOperation::EditAsset(EditAssetOperation { + input: operation_input.into(), + }), + input + .execution_plan + .map(Into::into) + .unwrap_or(RequestExecutionPlan::Immediate), + input.title.unwrap_or_else(|| "Edit asset".to_string()), + input.summary, + ); + + Ok(request) + } +} + +pub struct EditAssetRequestExecute<'p, 'o> { + request: &'p Request, + operation: &'o EditAssetOperation, + asset_service: AssetService, +} + +impl<'p, 'o> EditAssetRequestExecute<'p, 'o> { + pub fn new(request: &'p Request, operation: &'o EditAssetOperation) -> Self { + Self { + request, + operation, + asset_service: AssetService::default(), + } + } +} + +#[async_trait] +impl Execute for EditAssetRequestExecute<'_, '_> { + async fn execute(&self) -> Result { + self.asset_service + .edit(self.operation.input.clone()) + .map_err(|e| RequestExecuteError::Failed { + reason: format!("Failed to edit asset: {}", e), + })?; + + Ok(RequestExecuteStage::Completed( + self.request.operation.clone(), + )) + } +} diff --git a/core/station/impl/src/factories/requests/mod.rs b/core/station/impl/src/factories/requests/mod.rs index 29cdf332d..a3e7fa317 100644 --- a/core/station/impl/src/factories/requests/mod.rs +++ b/core/station/impl/src/factories/requests/mod.rs @@ -18,6 +18,7 @@ use std::sync::Arc; mod add_account; mod add_address_book_entry; +mod add_asset; mod add_request_policy; mod add_user; mod add_user_group; @@ -27,6 +28,7 @@ mod configure_external_canister; mod create_canister; mod edit_account; mod edit_address_book_entry; +mod edit_asset; mod edit_permission; mod edit_request_policy; mod edit_user; @@ -34,6 +36,7 @@ mod edit_user_group; mod fund_external_canister; mod manage_system_info; mod remove_address_book_entry; +mod remove_asset; mod remove_request_policy; mod remove_user_group; mod set_disaster_recovery; @@ -247,6 +250,24 @@ impl RequestFactory { .create(id, requested_by_user, input.clone(), operation.clone()) .await } + RequestOperationInput::AddAsset(operation) => { + let creator = Box::new(add_asset::AddAssetRequestCreate {}); + creator + .create(id, requested_by_user, input.clone(), operation.clone()) + .await + } + RequestOperationInput::EditAsset(operation) => { + let creator = Box::new(edit_asset::EditAssetRequestCreate {}); + creator + .create(id, requested_by_user, input.clone(), operation.clone()) + .await + } + RequestOperationInput::RemoveAsset(operation) => { + let creator = Box::new(remove_asset::RemoveAssetRequestCreate {}); + creator + .create(id, requested_by_user, input.clone(), operation.clone()) + .await + } } } @@ -362,6 +383,15 @@ impl RequestFactory { RequestOperation::ManageSystemInfo(operation) => Box::new( manage_system_info::ManageSystemInfoRequestExecute::new(request, operation), ), + RequestOperation::AddAsset(operation) => { + Box::new(add_asset::AddAssetRequestExecute::new(request, operation)) + } + RequestOperation::EditAsset(operation) => { + Box::new(edit_asset::EditAssetRequestExecute::new(request, operation)) + } + RequestOperation::RemoveAsset(operation) => Box::new( + remove_asset::RemoveAssetRequestExecute::new(request, operation), + ), } } } diff --git a/core/station/impl/src/factories/requests/remove_asset.rs b/core/station/impl/src/factories/requests/remove_asset.rs new file mode 100644 index 000000000..452cdcf70 --- /dev/null +++ b/core/station/impl/src/factories/requests/remove_asset.rs @@ -0,0 +1,69 @@ +use super::{Create, Execute, RequestExecuteStage}; +use crate::{ + errors::{RequestError, RequestExecuteError}, + models::{RemoveAssetOperation, Request, RequestExecutionPlan, RequestOperation}, + services::AssetService, +}; +use async_trait::async_trait; +use orbit_essentials::types::UUID; + +pub struct RemoveAssetRequestCreate {} + +#[async_trait] +impl Create for RemoveAssetRequestCreate { + async fn create( + &self, + request_id: UUID, + requested_by_user: UUID, + input: station_api::CreateRequestInput, + operation_input: station_api::RemoveAssetOperationInput, + ) -> Result { + let request = Request::new( + request_id, + requested_by_user, + Request::default_expiration_dt_ns(), + RequestOperation::RemoveAsset(RemoveAssetOperation { + input: operation_input.into(), + }), + input + .execution_plan + .map(Into::into) + .unwrap_or(RequestExecutionPlan::Immediate), + input.title.unwrap_or_else(|| "Remove asset".to_string()), + input.summary, + ); + + Ok(request) + } +} + +pub struct RemoveAssetRequestExecute<'p, 'o> { + request: &'p Request, + operation: &'o RemoveAssetOperation, + asset_service: AssetService, +} + +impl<'p, 'o> RemoveAssetRequestExecute<'p, 'o> { + pub fn new(request: &'p Request, operation: &'o RemoveAssetOperation) -> Self { + Self { + request, + operation, + asset_service: AssetService::default(), + } + } +} + +#[async_trait] +impl Execute for RemoveAssetRequestExecute<'_, '_> { + async fn execute(&self) -> Result { + self.asset_service + .remove(self.operation.input.clone()) + .map_err(|e| RequestExecuteError::Failed { + reason: format!("Failed to remove asset: {}", e), + })?; + + Ok(RequestExecuteStage::Completed( + self.request.operation.clone(), + )) + } +} diff --git a/core/station/impl/src/factories/requests/transfer.rs b/core/station/impl/src/factories/requests/transfer.rs index c38f51305..98d2f4e19 100644 --- a/core/station/impl/src/factories/requests/transfer.rs +++ b/core/station/impl/src/factories/requests/transfer.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use super::{Create, Execute, RequestExecuteStage}; use crate::{ core::generate_uuid_v4, @@ -5,10 +7,10 @@ use crate::{ factories::blockchains::BlockchainApiFactory, mappers::HelperMapper, models::{ - Account, Metadata, Request, RequestOperation, Transfer, TransferOperation, + Metadata, Request, RequestOperation, TokenStandard, Transfer, TransferOperation, TransferOperationInput, }, - repositories::ACCOUNT_REPOSITORY, + repositories::ASSET_REPOSITORY, services::TransferService, }; use async_trait::async_trait; @@ -17,10 +19,6 @@ use orbit_essentials::repository::Repository; use orbit_essentials::types::UUID; use uuid::Uuid; -fn get_account(from_account_id: &UUID) -> Option { - ACCOUNT_REPOSITORY.get(&Account::key(*from_account_id)) -} - pub struct TransferRequestCreate {} #[async_trait] @@ -39,6 +37,19 @@ impl Create for TransferRequestCreate { } })?; + let from_asset_id = HelperMapper::to_uuid(operation_input.from_asset_id.clone()) + .map_err(|e| RequestError::ValidationError { + info: format!("Invalid from_asset_id: {}", e), + })? + .as_bytes() + .to_owned(); + + let asset = ASSET_REPOSITORY + .get(&from_asset_id) + .ok_or(RequestError::ValidationError { + info: format!("Asset {} does not exist.", operation_input.from_asset_id), + })?; + let request = Request::from_request_creation_input( request_id, requested_by_user, @@ -46,8 +57,14 @@ impl Create for TransferRequestCreate { RequestOperation::Transfer(TransferOperation { transfer_id: None, fee: None, + asset, input: TransferOperationInput { from_account_id: *from_account_id.as_bytes(), + from_asset_id, + with_standard: TokenStandard::from_str(&operation_input.with_standard) + .map_err(|_| RequestError::ValidationError { + info: "Invalid with_standard.".to_owned(), + })?, to: operation_input.to, amount: operation_input.amount, fee: operation_input.fee, @@ -88,29 +105,29 @@ impl<'p, 'o> TransferRequestExecute<'p, 'o> { #[async_trait] impl Execute for TransferRequestExecute<'_, '_> { async fn execute(&self) -> Result { - let account = get_account(&self.operation.input.from_account_id).ok_or( - RequestExecuteError::Failed { + let asset = ASSET_REPOSITORY + .get(&self.operation.input.from_asset_id) + .ok_or(RequestExecuteError::Failed { reason: format!( - "Account {} does not exist.", - Uuid::from_bytes(self.operation.input.from_account_id).hyphenated() + "Asset {} does not exist.", + Uuid::from_bytes(self.operation.input.from_asset_id).hyphenated() ), - }, - )?; + })?; - let blockchain_api = BlockchainApiFactory::build(&account.blockchain, &account.standard) - .map_err(|e| RequestExecuteError::Failed { + let blockchain_api = BlockchainApiFactory::build(&asset.blockchain).map_err(|e| { + RequestExecuteError::Failed { reason: format!("Failed to build blockchain api: {}", e), - })?; + } + })?; let fee = match &self.operation.input.fee { Some(fee) => fee.clone(), None => { - let transaction_fee = - blockchain_api - .transaction_fee(&account) - .await - .map_err(|e| RequestExecuteError::Failed { - reason: format!("Failed to fetch transaction fee: {}", e), - })?; + let transaction_fee = blockchain_api + .transaction_fee(&asset, self.operation.input.with_standard.clone()) + .await + .map_err(|e| RequestExecuteError::Failed { + reason: format!("Failed to fetch transaction fee: {}", e), + })?; candid::Nat(transaction_fee.fee) } @@ -122,6 +139,8 @@ impl Execute for TransferRequestExecute<'_, '_> { *generate_uuid_v4().await.as_bytes(), self.request.requested_by, self.operation.input.from_account_id, + self.operation.input.from_asset_id, + self.operation.input.with_standard.clone(), self.operation.input.to.clone(), self.operation.input.metadata.clone(), self.operation.input.amount.clone(), diff --git a/core/station/impl/src/jobs/execute_created_transfers.rs b/core/station/impl/src/jobs/execute_created_transfers.rs index e8187df1e..9c36b27cc 100644 --- a/core/station/impl/src/jobs/execute_created_transfers.rs +++ b/core/station/impl/src/jobs/execute_created_transfers.rs @@ -7,9 +7,10 @@ use crate::{ TRANSACTION_SUBMITTED_DETAILS_TRANSACTION_HASH_KEY, }, models::{ - Account, Request, RequestOperation, RequestStatus, Transfer, TransferId, TransferStatus, + Account, Asset, Request, RequestOperation, RequestStatus, Transfer, TransferId, + TransferStatus, }, - repositories::{AccountRepository, RequestRepository, TransferRepository}, + repositories::{AccountRepository, AssetRepository, RequestRepository, TransferRepository}, services::RequestService, }; use async_trait::async_trait; @@ -24,6 +25,7 @@ use uuid::Uuid; pub struct Job { transfer_repository: TransferRepository, account_repository: AccountRepository, + asset_repository: AssetRepository, request_repository: RequestRepository, request_service: RequestService, } @@ -110,7 +112,7 @@ impl Job { for (pos, result) in results.iter().enumerate() { match result { Ok((transfer, details)) => { - let mut transfer = transfer.clone(); + let (mut transfer, _account, asset) = transfer.clone(); let transfer_completed_time = next_time(); let maybe_transaction_hash = details .details @@ -133,6 +135,7 @@ impl Job { if let RequestOperation::Transfer(transfer_operation) = &mut request.operation { + transfer_operation.asset = asset; transfer_operation.transfer_id = Some(transfer.id); transfer_operation.fee = Some(transfer.fee); } @@ -184,7 +187,7 @@ impl Job { async fn execute_transfer( &self, transfer: Transfer, - ) -> Result<(Transfer, BlockchainTransactionSubmitted), TransferError> { + ) -> Result<((Transfer, Account, Asset), BlockchainTransactionSubmitted), TransferError> { let account = self .account_repository .get(&Account::key(transfer.from_account)) @@ -195,13 +198,23 @@ impl Job { ), })?; - let blockchain_api = BlockchainApiFactory::build(&account.blockchain, &account.standard) - .map_err(|e| TransferError::ExecutionError { + let asset = self.asset_repository.get(&transfer.from_asset).ok_or( + TransferError::ValidationError { + info: format!( + "Transfer asset not found for id {}", + Uuid::from_bytes(transfer.from_asset).hyphenated() + ), + }, + )?; + + let blockchain_api = BlockchainApiFactory::build(&asset.blockchain).map_err(|e| { + TransferError::ExecutionError { reason: format!("Failed to build blockchain api: {}", e), - })?; + } + })?; match blockchain_api.submit_transaction(&account, &transfer).await { - Ok(details) => Ok((transfer, details)), + Ok(details) => Ok(((transfer, account, asset), details)), Err(error) => Err(TransferError::ExecutionError { reason: error.to_json_string(), diff --git a/core/station/impl/src/jobs/mod.rs b/core/station/impl/src/jobs/mod.rs index f2db8913e..ed6669d39 100644 --- a/core/station/impl/src/jobs/mod.rs +++ b/core/station/impl/src/jobs/mod.rs @@ -294,16 +294,19 @@ mod test { use crate::jobs::scheduler::Scheduler; use crate::jobs::{execute_created_transfers, execute_scheduled_requests}; use crate::models::account_test_utils::mock_account; + use crate::models::asset_test_utils::mock_asset; use crate::models::transfer_test_utils::mock_transfer; - use crate::models::{Account, RequestStatus}; + use crate::models::{Account, AccountAsset, RequestStatus}; use crate::repositories::{ - RequestRepository, TransferRepository, ACCOUNT_REPOSITORY, TRANSFER_REPOSITORY, + RequestRepository, TransferRepository, ACCOUNT_REPOSITORY, ASSET_REPOSITORY, + TRANSFER_REPOSITORY, }; use crate::{ jobs::{cancel_expired_requests, to_coarse_time, JobStateDatabase, ScheduledJob}, models::{request_test_utils::mock_request, Request}, repositories::REQUEST_REPOSITORY, }; + use orbit_essentials::model::ModelKey; use orbit_essentials::repository::Repository; #[tokio::test] @@ -481,9 +484,16 @@ mod test { let expiration_coarse = to_coarse_time(expiration, cancel_expired_requests::Job::JOB_TOLERANCE_NS); + let asset = mock_asset(); + ASSET_REPOSITORY.insert(asset.key(), asset.clone()); + // create one account so transfer requests dont fail let account = Account { id: [1; 16], + assets: vec![AccountAsset { + asset_id: asset.id, + balance: None, + }], ..mock_account() }; ACCOUNT_REPOSITORY.insert(account.to_key(), account); diff --git a/core/station/impl/src/lib.rs b/core/station/impl/src/lib.rs index 03004b3bb..7fe791dc3 100644 --- a/core/station/impl/src/lib.rs +++ b/core/station/impl/src/lib.rs @@ -4,7 +4,7 @@ pub const SERVICE_NAME: &str = "station"; pub const SYSTEM_VERSION: &str = env!("CARGO_PKG_VERSION"); -pub const STABLE_MEMORY_VERSION: u32 = 1; +pub const STABLE_MEMORY_VERSION: u32 = 2; pub mod controllers; pub mod core; @@ -14,6 +14,7 @@ pub mod jobs; mod macros; pub mod mappers; pub mod migration; +pub mod migration_tests; pub mod models; pub mod repositories; pub mod services; diff --git a/core/station/impl/src/mappers/account.rs b/core/station/impl/src/mappers/account.rs index 144af61dc..ca87b2e61 100644 --- a/core/station/impl/src/mappers/account.rs +++ b/core/station/impl/src/mappers/account.rs @@ -1,15 +1,18 @@ +use std::str::FromStr; + use crate::{ core::ic_cdk::next_time, errors::MapperError, models::{ - Account, AccountBalance, AccountCallerPrivileges, AccountId, AddAccountOperationInput, - BlockchainStandard, ACCOUNT_METADATA_SYMBOL_KEY, + Account, AccountAddress, AccountAsset, AccountBalance, AccountCallerPrivileges, AccountId, + AccountSeed, AddAccountOperationInput, AddressFormat, AssetId, BalanceQueryState, + ChangeAssets, }, - repositories::request_policy::REQUEST_POLICY_REPOSITORY, + repositories::{request_policy::REQUEST_POLICY_REPOSITORY, ASSET_REPOSITORY}, }; use ic_cdk::print; use orbit_essentials::{repository::Repository, utils::timestamp_to_rfc3339}; -use station_api::{AccountBalanceDTO, AccountBalanceInfoDTO, AccountDTO}; +use station_api::{AccountAssetDTO, AccountBalanceDTO, AccountDTO}; use uuid::Uuid; #[derive(Default, Clone, Debug)] @@ -20,21 +23,29 @@ impl AccountMapper { AccountDTO { id: Uuid::from_bytes(account.id).hyphenated().to_string(), name: account.name, - decimals: account.decimals, - balance: match account.balance { - Some(balance) => Some(AccountBalanceInfoDTO { - balance: balance.balance, - decimals: account.decimals, - last_update_timestamp: timestamp_to_rfc3339( - &balance.last_modification_timestamp, - ), - }), - None => None, - }, - symbol: account.symbol, - address: account.address, - standard: account.standard.to_string(), - blockchain: account.blockchain.to_string(), + + addresses: account.addresses.into_iter().map(|a| a.into()).collect(), + assets: account + .assets + .into_iter() + .filter_map(|account_asset| { + if let Some(asset) = ASSET_REPOSITORY.get(&account_asset.asset_id) { + Some(AccountMapper::to_account_asset_dto( + account_asset, + asset.decimals, + account.id, + )) + } else { + print(format!( + "Asset {} not found for Account {}", + Uuid::from_bytes(account_asset.asset_id).hyphenated(), + Uuid::from_bytes(account.id).hyphenated() + )); + None + } + }) + .collect(), + metadata: account.metadata.into_vec_dto(), transfer_request_policy: account.transfer_request_policy_id.and_then(|policy_id| { REQUEST_POLICY_REPOSITORY @@ -67,49 +78,23 @@ impl AccountMapper { pub fn from_create_input( input: AddAccountOperationInput, account_id: AccountId, - address: Option, + seed: Option, ) -> Result { - if !input - .blockchain - .supported_standards() - .contains(&input.standard) - { - return Err(MapperError::UnsupportedBlockchainStandard { - blockchain: input.blockchain.to_string(), - supported_standards: input - .blockchain - .supported_standards() - .iter() - .map(|s| s.to_string()) - .collect(), - }); - } - - let symbol = match input.standard { - BlockchainStandard::Native => { - if input.metadata.get(ACCOUNT_METADATA_SYMBOL_KEY).is_some() { - return Err(MapperError::NativeAccountSymbolMetadataNotAllowed); - } - - input.blockchain.native_symbol().to_string() - } - _ => input - .metadata - .get(ACCOUNT_METADATA_SYMBOL_KEY) - .ok_or(MapperError::NonNativeAccountSymbolRequired)?, - }; - let new_account = Account { id: account_id, - blockchain: input.blockchain, - standard: input.standard, name: input.name, - address: address.unwrap_or("".to_string()), - decimals: 0, - symbol, + seed: seed.unwrap_or(account_id), + addresses: vec![], + assets: input + .assets + .iter() + .map(|asset_id| AccountAsset { + asset_id: *asset_id, + balance: None, + }) + .collect(), transfer_request_policy_id: None, configs_request_policy_id: None, - balance: None, metadata: input.metadata, last_modification_timestamp: next_time(), }; @@ -121,12 +106,38 @@ impl AccountMapper { balance: AccountBalance, decimals: u32, account_id: AccountId, + asset_id: AssetId, + query_state: BalanceQueryState, ) -> AccountBalanceDTO { AccountBalanceDTO { account_id: Uuid::from_bytes(account_id).hyphenated().to_string(), + asset_id: Uuid::from_bytes(asset_id).hyphenated().to_string(), balance: balance.balance, decimals, last_update_timestamp: timestamp_to_rfc3339(&balance.last_modification_timestamp), + query_state: query_state.to_string(), + } + } + + pub fn to_account_asset_dto( + account_asset: AccountAsset, + decimals: u32, + account_id: AccountId, + ) -> AccountAssetDTO { + AccountAssetDTO { + asset_id: Uuid::from_bytes(account_asset.asset_id) + .hyphenated() + .to_string(), + balance: account_asset.balance.map(|balance| { + let query_state = BalanceQueryState::from(&balance); + Self::to_balance_dto( + balance, + decimals, + account_id, + account_asset.asset_id, + query_state, + ) + }), } } } @@ -146,3 +157,94 @@ impl From for station_api::AccountCallerPrivilegesDTO { } } } + +impl From for station_api::AccountAddressDTO { + fn from(account_address: AccountAddress) -> Self { + Self { + address: account_address.address, + format: account_address.format.to_string(), + } + } +} + +impl From for AccountAddress { + fn from(address: station_api::AccountAddressDTO) -> Self { + Self { + address: address.address, + format: AddressFormat::from_str(address.format.as_str()) + .expect("Failed to convert string to AddressFormat"), + } + } +} + +impl From for station_api::ChangeAssets { + fn from(change_assets: ChangeAssets) -> Self { + match change_assets { + ChangeAssets::ReplaceWith { assets } => station_api::ChangeAssets::ReplaceWith { + assets: assets + .iter() + .map(|id| Uuid::from_bytes(*id).hyphenated().to_string()) + .collect(), + }, + ChangeAssets::Change { + add_assets, + remove_assets, + } => station_api::ChangeAssets::Change { + add_assets: add_assets + .iter() + .map(|id| Uuid::from_bytes(*id).hyphenated().to_string()) + .collect(), + remove_assets: remove_assets + .iter() + .map(|id| Uuid::from_bytes(*id).hyphenated().to_string()) + .collect(), + }, + } + } +} + +impl From for ChangeAssets { + fn from(change_assets: station_api::ChangeAssets) -> Self { + match change_assets { + station_api::ChangeAssets::ReplaceWith { assets } => ChangeAssets::ReplaceWith { + assets: assets + .iter() + .map(|id| *Uuid::from_str(id).expect("Invalid asset ID").as_bytes()) + .collect(), + }, + station_api::ChangeAssets::Change { + add_assets, + remove_assets, + } => ChangeAssets::Change { + add_assets: add_assets + .iter() + .map(|id| *Uuid::from_str(id).expect("Invalid asset ID").as_bytes()) + .collect(), + remove_assets: remove_assets + .iter() + .map(|id| *Uuid::from_str(id).expect("Invalid asset ID").as_bytes()) + .collect(), + }, + } + } +} + +impl From for upgrader_api::MultiAssetAccount { + fn from(account: Account) -> Self { + Self { + id: Uuid::from_bytes(account.id).hyphenated().to_string(), + seed: account.seed, + assets: account + .assets + .iter() + .map(|account_asset| { + Uuid::from_bytes(account_asset.asset_id) + .hyphenated() + .to_string() + }) + .collect(), + name: account.name.clone(), + metadata: account.metadata.clone().into(), + } + } +} diff --git a/core/station/impl/src/mappers/address_book.rs b/core/station/impl/src/mappers/address_book.rs index e3263bb12..853bb6b91 100644 --- a/core/station/impl/src/mappers/address_book.rs +++ b/core/station/impl/src/mappers/address_book.rs @@ -1,10 +1,12 @@ +use std::str::FromStr; + use super::HelperMapper; use crate::core::ic_cdk::next_time; use crate::errors::MapperError; use crate::mappers::blockchain::BlockchainMapper; use crate::models::{ AddAddressBookEntryOperationInput, AddressBookEntry, AddressBookEntryCallerPrivileges, - ListAddressBookEntriesInput, + AddressFormat, ListAddressBookEntriesInput, }; use orbit_essentials::types::UUID; use orbit_essentials::utils::timestamp_to_rfc3339; @@ -24,6 +26,7 @@ impl AddressBookMapper { .to_string(), address_owner: address_book_entry.address_owner, address: address_book_entry.address, + address_format: address_book_entry.address_format.to_string(), blockchain: address_book_entry.blockchain.to_string(), metadata: address_book_entry.metadata.into_vec_dto(), labels: address_book_entry.labels, @@ -41,6 +44,7 @@ impl AddressBookMapper { id: entry_id, address_owner: input.address_owner, address: input.address, + address_format: input.address_format, blockchain: input.blockchain, labels: input.labels, metadata: input.metadata.into(), @@ -65,6 +69,14 @@ impl From for ListAddressBookEntriesInput { }), labels: input.labels, addresses: input.addresses, + address_formats: input.address_formats.map(|address_formats| { + address_formats + .into_iter() + .map(|address_format| { + AddressFormat::from_str(&address_format).expect("Invalid address format") + }) + .collect() + }), ids: input.ids.map(|ids| { ids.into_iter() .map(|id| { diff --git a/core/station/impl/src/mappers/asset.rs b/core/station/impl/src/mappers/asset.rs index 0577c679a..f34f3dff0 100644 --- a/core/station/impl/src/mappers/asset.rs +++ b/core/station/impl/src/mappers/asset.rs @@ -1,13 +1,42 @@ -use crate::models::Asset; +use station_api::AssetCallerPrivilegesDTO; +use uuid::Uuid; + +use crate::models::{Asset, AssetCallerPrivileges}; impl From for station_api::AssetDTO { fn from(asset: Asset) -> Self { station_api::AssetDTO { + id: Uuid::from_bytes(asset.id).hyphenated().to_string(), blockchain: asset.blockchain.to_string(), symbol: asset.symbol.to_string(), - standard: asset.standard.to_string(), + standards: asset.standards.into_iter().map(|s| s.to_string()).collect(), name: asset.name, metadata: asset.metadata.into_vec_dto(), + decimals: asset.decimals, + } + } +} + +impl From for AssetCallerPrivilegesDTO { + fn from(input: AssetCallerPrivileges) -> AssetCallerPrivilegesDTO { + AssetCallerPrivilegesDTO { + id: Uuid::from_bytes(input.id).hyphenated().to_string(), + can_edit: input.can_edit, + can_delete: input.can_delete, + } + } +} + +impl From for upgrader_api::Asset { + fn from(asset: Asset) -> Self { + upgrader_api::Asset { + id: Uuid::from_bytes(asset.id).hyphenated().to_string(), + blockchain: asset.blockchain.to_string(), + symbol: asset.symbol.clone(), + name: asset.name.clone(), + decimals: asset.decimals, + standards: asset.standards.iter().map(|s| s.to_string()).collect(), + metadata: asset.metadata.clone().into(), } } } diff --git a/core/station/impl/src/mappers/authorization.rs b/core/station/impl/src/mappers/authorization.rs index fdd8c2fa8..97aba3faa 100644 --- a/core/station/impl/src/mappers/authorization.rs +++ b/core/station/impl/src/mappers/authorization.rs @@ -17,7 +17,7 @@ use orbit_essentials::repository::Repository; use orbit_essentials::types::UUID; use station_api::{RequestOperationInput, UserPrivilege}; -pub const USER_PRIVILEGES: [UserPrivilege; 19] = [ +pub const USER_PRIVILEGES: [UserPrivilege; 21] = [ UserPrivilege::Capabilities, UserPrivilege::SystemInfo, UserPrivilege::ManageSystemInfo, @@ -37,6 +37,8 @@ pub const USER_PRIVILEGES: [UserPrivilege; 19] = [ UserPrivilege::CreateExternalCanister, UserPrivilege::ListExternalCanisters, UserPrivilege::CallAnyExternalCanister, + UserPrivilege::AddAsset, + UserPrivilege::ListAssets, ]; impl From for Resource { @@ -72,6 +74,8 @@ impl From for Resource { validation_method: ValidationMethodResourceTarget::No, }), ), + UserPrivilege::AddAsset => Resource::Asset(ResourceAction::Create), + UserPrivilege::ListAssets => Resource::Asset(ResourceAction::List), } } } @@ -144,6 +148,16 @@ impl From<&station_api::GetUserGroupInput> for Resource { } } +impl From<&station_api::GetAssetInput> for Resource { + fn from(input: &station_api::GetAssetInput) -> Self { + Resource::Asset(ResourceAction::Read(ResourceId::Id( + *HelperMapper::to_uuid(input.asset_id.to_owned()) + .expect("Invalid asset id") + .as_bytes(), + ))) + } +} + impl From<&station_api::SubmitRequestApprovalInput> for Resource { fn from(input: &station_api::SubmitRequestApprovalInput) -> Self { Resource::Request(RequestResourceAction::Read(ResourceId::Id( @@ -297,6 +311,21 @@ impl From<&station_api::CreateRequestInput> for Resource { RequestOperationInput::ManageSystemInfo(_) => { Resource::System(SystemResourceAction::ManageSystemInfo) } + RequestOperationInput::AddAsset(_) => Resource::Asset(ResourceAction::Create), + RequestOperationInput::EditAsset(input) => { + Resource::Asset(ResourceAction::Update(ResourceId::Id( + *HelperMapper::to_uuid(input.asset_id.to_owned()) + .expect("Invalid asset id") + .as_bytes(), + ))) + } + RequestOperationInput::RemoveAsset(input) => { + Resource::Asset(ResourceAction::Delete(ResourceId::Id( + *HelperMapper::to_uuid(input.asset_id.to_owned()) + .expect("Invalid asset id") + .as_bytes(), + ))) + } } } } diff --git a/core/station/impl/src/mappers/blockchain.rs b/core/station/impl/src/mappers/blockchain.rs index 1f91aae77..43855e664 100644 --- a/core/station/impl/src/mappers/blockchain.rs +++ b/core/station/impl/src/mappers/blockchain.rs @@ -1,6 +1,6 @@ use crate::{ errors::MapperError, - models::{Blockchain, BlockchainStandard}, + models::{Blockchain, TokenStandard}, }; use std::str::FromStr; @@ -15,8 +15,8 @@ impl BlockchainMapper { Ok(blockchain) } - pub fn to_blockchain_standard(standard: String) -> Result { - let standard = BlockchainStandard::from_str(standard.as_str()).map_err(|_| { + pub fn to_blockchain_standard(standard: String) -> Result { + let standard = TokenStandard::from_str(standard.as_str()).map_err(|_| { MapperError::UnknownBlockchainStandard { blockchain_standard: standard, } diff --git a/core/station/impl/src/mappers/helper.rs b/core/station/impl/src/mappers/helper.rs index f12628b21..e0765b753 100644 --- a/core/station/impl/src/mappers/helper.rs +++ b/core/station/impl/src/mappers/helper.rs @@ -30,6 +30,14 @@ impl HelperMapper { nat: amount.to_string(), }) } + + pub fn nat_to_u128(amount: Nat) -> Result { + (&amount.0) + .try_into() + .map_err(|_| MapperError::NatConversionError { + nat: amount.to_string(), + }) + } } #[cfg(test)] diff --git a/core/station/impl/src/mappers/metadata.rs b/core/station/impl/src/mappers/metadata.rs index f804008d6..c0140e07b 100644 --- a/core/station/impl/src/mappers/metadata.rs +++ b/core/station/impl/src/mappers/metadata.rs @@ -1,5 +1,5 @@ use crate::{ - errors::{AccountError, AddressBookError, MetadataError, TransferError}, + errors::{AccountError, AddressBookError, AssetError, MetadataError, TransferError}, models::{ChangeMetadata, Metadata, MetadataItem}, }; @@ -114,6 +114,14 @@ impl From for AddressBookError { } } +impl From for AssetError { + fn from(metadata_error: MetadataError) -> Self { + match metadata_error { + MetadataError::ValidationError { info: e } => Self::ValidationError { info: e }, + } + } +} + impl From for TransferError { fn from(metadata_error: MetadataError) -> Self { match metadata_error { diff --git a/core/station/impl/src/mappers/notification_type.rs b/core/station/impl/src/mappers/notification_type.rs index 604189412..d49b92412 100644 --- a/core/station/impl/src/mappers/notification_type.rs +++ b/core/station/impl/src/mappers/notification_type.rs @@ -93,7 +93,10 @@ impl TryFrom for NotificationTypeDTO { | RequestOperation::ConfigureExternalCanister(_) | RequestOperation::CreateExternalCanister(_) | RequestOperation::FundExternalCanister(_) - | RequestOperation::CallExternalCanister(_) => None, + | RequestOperation::CallExternalCanister(_) + | RequestOperation::AddAsset(_) + | RequestOperation::EditAsset(_) + | RequestOperation::RemoveAsset(_) => None, }; let user_id: Option<[u8; 16]> = match &request.operation { @@ -119,7 +122,10 @@ impl TryFrom for NotificationTypeDTO { | RequestOperation::ConfigureExternalCanister(_) | RequestOperation::CreateExternalCanister(_) | RequestOperation::FundExternalCanister(_) - | RequestOperation::CallExternalCanister(_) => None, + | RequestOperation::CallExternalCanister(_) + | RequestOperation::AddAsset(_) + | RequestOperation::EditAsset(_) + | RequestOperation::RemoveAsset(_) => None, }; NotificationTypeDTO::RequestCreated(RequestCreatedNotificationDTO { diff --git a/core/station/impl/src/mappers/request_operation.rs b/core/station/impl/src/mappers/request_operation.rs index c559969b4..16bd0c777 100644 --- a/core/station/impl/src/mappers/request_operation.rs +++ b/core/station/impl/src/mappers/request_operation.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use super::{blockchain::BlockchainMapper, HelperMapper}; use crate::{ models::{ @@ -8,8 +10,9 @@ use crate::{ UserResourceAction, }, Account, AccountKey, AddAccountOperation, AddAccountOperationInput, - AddAddressBookEntryOperation, AddAddressBookEntryOperationInput, AddRequestPolicyOperation, - AddRequestPolicyOperationInput, AddUserOperation, AddUserOperationInput, AddressBookEntry, + AddAddressBookEntryOperation, AddAddressBookEntryOperationInput, AddAssetOperation, + AddAssetOperationInput, AddRequestPolicyOperation, AddRequestPolicyOperationInput, + AddUserOperation, AddUserOperationInput, AddressBookEntry, AddressFormat, Asset, CallExternalCanisterOperation, CallExternalCanisterOperationInput, CanisterExecutionAndValidationMethodPairInput, CanisterInstallMode, CanisterInstallModeArgs, CanisterMethod, CanisterReinstallModeArgs, @@ -20,10 +23,11 @@ use crate::{ CreateExternalCanisterOperationKind, CreateExternalCanisterOperationKindAddExisting, CreateExternalCanisterOperationKindCreateNew, CycleObtainStrategy, DefiniteCanisterSettingsInput, DisasterRecoveryCommittee, EditAccountOperation, - EditAccountOperationInput, EditAddressBookEntryOperation, EditPermissionOperation, - EditPermissionOperationInput, EditRequestPolicyOperation, EditRequestPolicyOperationInput, - EditUserGroupOperation, EditUserOperation, EditUserOperationInput, - ExternalCanisterCallPermission, ExternalCanisterCallPermissionExecMethodEntryInput, + EditAccountOperationInput, EditAddressBookEntryOperation, EditAssetOperation, + EditAssetOperationInput, EditPermissionOperation, EditPermissionOperationInput, + EditRequestPolicyOperation, EditRequestPolicyOperationInput, EditUserGroupOperation, + EditUserOperation, EditUserOperationInput, ExternalCanisterCallPermission, + ExternalCanisterCallPermissionExecMethodEntryInput, ExternalCanisterCallPermissionMethodPairInput, ExternalCanisterCallPermissionsExecMethodInput, ExternalCanisterCallRequestPoliciesExecMethodInput, @@ -35,14 +39,15 @@ use crate::{ ExternalCanisterPermissionsUpdateInput, ExternalCanisterRequestPoliciesCreateInput, ExternalCanisterRequestPoliciesUpdateInput, FundExternalCanisterOperation, LogVisibility, ManageSystemInfoOperation, ManageSystemInfoOperationInput, RemoveAddressBookEntryOperation, - RemoveRequestPolicyOperation, RemoveRequestPolicyOperationInput, RemoveUserGroupOperation, - RequestOperation, SetDisasterRecoveryOperation, SetDisasterRecoveryOperationInput, - SystemUpgradeOperation, SystemUpgradeOperationInput, SystemUpgradeTarget, - TransferOperation, User, WasmModuleExtraChunks, + RemoveAssetOperation, RemoveAssetOperationInput, RemoveRequestPolicyOperation, + RemoveRequestPolicyOperationInput, RemoveUserGroupOperation, RequestOperation, + SetDisasterRecoveryOperation, SetDisasterRecoveryOperationInput, SystemUpgradeOperation, + SystemUpgradeOperationInput, SystemUpgradeTarget, TransferOperation, User, + WasmModuleExtraChunks, }, repositories::{ - AccountRepository, AddressBookRepository, UserRepository, ACCOUNT_REPOSITORY, - USER_GROUP_REPOSITORY, + AccountRepository, AddressBookRepository, AssetRepository, UserRepository, + ACCOUNT_REPOSITORY, USER_GROUP_REPOSITORY, }, }; use orbit_essentials::repository::Repository; @@ -59,6 +64,7 @@ impl TransferOperation { pub fn to_dto(self, account: Option) -> TransferOperationDTO { TransferOperationDTO { from_account: account.map(|account| account.to_dto()), + from_asset: self.asset.into(), network: NetworkDTO { id: self.input.network.clone(), name: self.input.network.clone(), @@ -67,6 +73,10 @@ impl TransferOperation { from_account_id: Uuid::from_bytes(self.input.from_account_id) .hyphenated() .to_string(), + from_asset_id: Uuid::from_bytes(self.input.from_asset_id) + .hyphenated() + .to_string(), + with_standard: self.input.with_standard.to_string(), amount: self.input.amount, to: self.input.to, fee: self.input.fee, @@ -90,8 +100,12 @@ impl AddAccountOperation { account: account.map(|account: Account| account.to_dto()), input: station_api::AddAccountOperationInput { name: self.input.name, - blockchain: self.input.blockchain.to_string(), - standard: self.input.standard.to_string(), + assets: self + .input + .assets + .into_iter() + .map(|id| Uuid::from_bytes(id).hyphenated().to_string()) + .collect(), metadata: self.input.metadata.into_vec_dto(), read_permission: self.input.read_permission.into(), transfer_permission: self.input.transfer_permission.into(), @@ -120,10 +134,15 @@ impl From for AddAccountOperationInput { fn from(input: station_api::AddAccountOperationInput) -> AddAccountOperationInput { AddAccountOperationInput { name: input.name, - blockchain: BlockchainMapper::to_blockchain(input.blockchain.clone()) - .expect("Invalid blockchain"), - standard: BlockchainMapper::to_blockchain_standard(input.standard) - .expect("Invalid blockchain standard"), + assets: input + .assets + .iter() + .map(|id| { + *HelperMapper::to_uuid(id.clone()) + .expect("Invalid asset id") + .as_bytes() + }) + .collect(), metadata: input.metadata.into(), read_permission: input.read_permission.into(), configs_permission: input.configs_permission.into(), @@ -142,6 +161,10 @@ impl From for EditAccountOperationDTO { .hyphenated() .to_string(), name: operation.input.name, + change_assets: operation + .input + .change_assets + .map(|change_assets| change_assets.into()), read_permission: operation.input.read_permission.map(|policy| policy.into()), transfer_permission: operation .input @@ -170,6 +193,9 @@ impl From for EditAccountOperationInput account_id: *HelperMapper::to_uuid(input.account_id) .expect("Invalid account id") .as_bytes(), + change_assets: input + .change_assets + .map(|change_assets| change_assets.into()), name: input.name, read_permission: input.read_permission.map(|policy| policy.into()), transfer_permission: input.transfer_permission.map(|policy| policy.into()), @@ -191,6 +217,7 @@ impl AddAddressBookEntryOperation { input: station_api::AddAddressBookEntryOperationInput { address_owner: self.input.address_owner, address: self.input.address, + address_format: self.input.address_format.to_string(), blockchain: self.input.blockchain.to_string(), metadata: self.input.metadata.into_iter().map(Into::into).collect(), labels: self.input.labels, @@ -205,6 +232,8 @@ impl From for AddAddressBookEntr ) -> AddAddressBookEntryOperationInput { AddAddressBookEntryOperationInput { address_owner: input.address_owner, + address_format: AddressFormat::from_str(&input.address_format) + .expect("Invalid address format"), address: input.address, blockchain: BlockchainMapper::to_blockchain(input.blockchain.clone()) .expect("Invalid blockchain"), @@ -1525,6 +1554,117 @@ impl From for ManageSystemInfoOperati } } +impl AddAssetOperation { + pub fn to_dto(self, asset: Option) -> station_api::AddAssetOperationDTO { + station_api::AddAssetOperationDTO { + asset: asset.map(|asset| asset.into()), + input: station_api::AddAssetOperationInput { + name: self.input.name, + blockchain: self.input.blockchain.to_string(), + standards: self.input.standards.iter().map(|s| s.to_string()).collect(), + symbol: self.input.symbol, + decimals: self.input.decimals, + metadata: self.input.metadata.into_vec_dto(), + }, + } + } +} + +impl From for AddAssetOperationInput { + fn from(input: station_api::AddAssetOperationInput) -> AddAssetOperationInput { + AddAssetOperationInput { + name: input.name, + symbol: input.symbol, + decimals: input.decimals, + metadata: input.metadata.into(), + blockchain: input.blockchain.parse().expect("Invalid blockchain"), + standards: input + .standards + .iter() + .map(|s| s.parse().expect("Invalid standard")) + .collect(), + } + } +} + +impl From for station_api::EditAssetOperationDTO { + fn from(operation: EditAssetOperation) -> station_api::EditAssetOperationDTO { + station_api::EditAssetOperationDTO { + input: operation.input.into(), + } + } +} + +impl From for station_api::EditAssetOperationInput { + fn from(input: EditAssetOperationInput) -> station_api::EditAssetOperationInput { + station_api::EditAssetOperationInput { + asset_id: Uuid::from_bytes(input.asset_id).hyphenated().to_string(), + name: input.name, + symbol: input.symbol, + change_metadata: input + .change_metadata + .map(|change_metadata| change_metadata.into()), + blockchain: input.blockchain.map(|blockchain| blockchain.to_string()), + standards: input + .standards + .map(|standards| standards.into_iter().map(|s| s.to_string()).collect()), + } + } +} + +impl From for EditAssetOperationInput { + fn from(input: station_api::EditAssetOperationInput) -> EditAssetOperationInput { + EditAssetOperationInput { + asset_id: *HelperMapper::to_uuid(input.asset_id) + .expect("Invalid asset id") + .as_bytes(), + name: input.name, + symbol: input.symbol, + change_metadata: input + .change_metadata + .map(|change_metadata| change_metadata.into()), + blockchain: input.blockchain.map(|blockchain_dto| { + BlockchainMapper::to_blockchain(blockchain_dto).expect("Invalid blockchain") + }), + standards: input.standards.map(|standards| { + standards + .into_iter() + .map(|s| { + BlockchainMapper::to_blockchain_standard(s) + .expect("Invalid blockchain standard") + }) + .collect() + }), + } + } +} + +impl From for station_api::RemoveAssetOperationDTO { + fn from(operation: RemoveAssetOperation) -> station_api::RemoveAssetOperationDTO { + station_api::RemoveAssetOperationDTO { + input: operation.input.into(), + } + } +} + +impl From for station_api::RemoveAssetOperationInput { + fn from(input: RemoveAssetOperationInput) -> station_api::RemoveAssetOperationInput { + station_api::RemoveAssetOperationInput { + asset_id: Uuid::from_bytes(input.asset_id).hyphenated().to_string(), + } + } +} + +impl From for RemoveAssetOperationInput { + fn from(input: station_api::RemoveAssetOperationInput) -> RemoveAssetOperationInput { + RemoveAssetOperationInput { + asset_id: *HelperMapper::to_uuid(input.asset_id) + .expect("Invalid asset id") + .as_bytes(), + } + } +} + impl From for RequestOperationDTO { fn from(operation: RequestOperation) -> RequestOperationDTO { match operation { @@ -1618,6 +1758,19 @@ impl From for RequestOperationDTO { RequestOperation::ManageSystemInfo(operation) => { RequestOperationDTO::ManageSystemInfo(Box::new(operation.into())) } + RequestOperation::AddAsset(operation) => { + let asset = operation + .asset_id + .and_then(|id| AssetRepository::default().get(&id)); + + RequestOperationDTO::AddAsset(Box::new(operation.to_dto(asset))) + } + RequestOperation::EditAsset(operation) => { + RequestOperationDTO::EditAsset(Box::new(operation.into())) + } + RequestOperation::RemoveAsset(operation) => { + RequestOperationDTO::RemoveAsset(Box::new(operation.into())) + } } } } @@ -1798,6 +1951,21 @@ impl RequestOperation { RequestOperation::ManageSystemInfo(_) => { vec![Resource::System(SystemResourceAction::ManageSystemInfo)] } + RequestOperation::AddAsset(_) => { + vec![Resource::Asset(ResourceAction::Create)] + } + RequestOperation::EditAsset(EditAssetOperation { input }) => { + vec![ + Resource::Asset(ResourceAction::Update(ResourceId::Id(input.asset_id))), + Resource::Asset(ResourceAction::Update(ResourceId::Any)), + ] + } + RequestOperation::RemoveAsset(RemoveAssetOperation { input }) => { + vec![ + Resource::Asset(ResourceAction::Delete(ResourceId::Id(input.asset_id))), + Resource::Asset(ResourceAction::Delete(ResourceId::Any)), + ] + } } } } diff --git a/core/station/impl/src/mappers/request_operation_type.rs b/core/station/impl/src/mappers/request_operation_type.rs index 2ed83634a..1b919d274 100644 --- a/core/station/impl/src/mappers/request_operation_type.rs +++ b/core/station/impl/src/mappers/request_operation_type.rs @@ -78,6 +78,15 @@ impl From for ListRequestsOperationTy station_api::ListRequestsOperationTypeDTO::SetDisasterRecovery => { ListRequestsOperationType::SetDisasterRecovery } + station_api::ListRequestsOperationTypeDTO::AddAsset => { + ListRequestsOperationType::AddAsset + } + station_api::ListRequestsOperationTypeDTO::EditAsset => { + ListRequestsOperationType::EditAsset + } + station_api::ListRequestsOperationTypeDTO::RemoveAsset => { + ListRequestsOperationType::RemoveAsset + } } } } @@ -128,6 +137,9 @@ impl From for RequestOperationType { RequestOperationTypeDTO::ConfigureExternalCanister => { RequestOperationType::ConfigureExternalCanister } + RequestOperationTypeDTO::AddAsset => RequestOperationType::AddAsset, + RequestOperationTypeDTO::EditAsset => RequestOperationType::EditAsset, + RequestOperationTypeDTO::RemoveAsset => RequestOperationType::RemoveAsset, } } } @@ -178,6 +190,9 @@ impl From for RequestOperationTypeDTO { RequestOperationType::ConfigureExternalCanister => { RequestOperationTypeDTO::ConfigureExternalCanister } + RequestOperationType::AddAsset => RequestOperationTypeDTO::AddAsset, + RequestOperationType::EditAsset => RequestOperationTypeDTO::EditAsset, + RequestOperationType::RemoveAsset => RequestOperationTypeDTO::RemoveAsset, } } } @@ -216,6 +231,9 @@ impl From for RequestOperationType { RequestOperation::RemoveRequestPolicy(_) => RequestOperationType::RemoveRequestPolicy, RequestOperation::ManageSystemInfo(_) => RequestOperationType::ManageSystemInfo, RequestOperation::SetDisasterRecovery(_) => RequestOperationType::SetDisasterRecovery, + RequestOperation::AddAsset(_) => RequestOperationType::AddAsset, + RequestOperation::EditAsset(_) => RequestOperationType::EditAsset, + RequestOperation::RemoveAsset(_) => RequestOperationType::RemoveAsset, } } } diff --git a/core/station/impl/src/mappers/request_policy.rs b/core/station/impl/src/mappers/request_policy.rs index 9fd8ba598..23134ed7f 100644 --- a/core/station/impl/src/mappers/request_policy.rs +++ b/core/station/impl/src/mappers/request_policy.rs @@ -282,6 +282,13 @@ impl From for station_api::RequestSpecifierDTO { RequestSpecifier::ManageSystemInfo => { station_api::RequestSpecifierDTO::ManageSystemInfo } + RequestSpecifier::AddAsset => station_api::RequestSpecifierDTO::AddAsset, + RequestSpecifier::EditAsset(resource_ids) => { + station_api::RequestSpecifierDTO::EditAsset(resource_ids.into()) + } + RequestSpecifier::RemoveAsset(resource_ids) => { + station_api::RequestSpecifierDTO::RemoveAsset(resource_ids.into()) + } } } } @@ -347,6 +354,13 @@ impl From for RequestSpecifier { station_api::RequestSpecifierDTO::ManageSystemInfo => { RequestSpecifier::ManageSystemInfo } + station_api::RequestSpecifierDTO::AddAsset => RequestSpecifier::AddAsset, + station_api::RequestSpecifierDTO::EditAsset(resource_ids) => { + RequestSpecifier::EditAsset(resource_ids.into()) + } + station_api::RequestSpecifierDTO::RemoveAsset(resource_ids) => { + RequestSpecifier::RemoveAsset(resource_ids.into()) + } } } } @@ -494,6 +508,22 @@ impl RequestSpecifier { .map(|id| Resource::UserGroup(ResourceAction::Delete(ResourceId::Id(*id)))) .collect::<_>(), }, + + RequestSpecifier::AddAsset => vec![Resource::Asset(ResourceAction::Create)], + RequestSpecifier::EditAsset(resource_ids) => match resource_ids { + ResourceIds::Any => vec![Resource::Asset(ResourceAction::Update(ResourceId::Any))], + ResourceIds::Ids(ids) => ids + .iter() + .map(|id| Resource::Asset(ResourceAction::Update(ResourceId::Id(*id)))) + .collect::<_>(), + }, + RequestSpecifier::RemoveAsset(resource_ids) => match resource_ids { + ResourceIds::Any => vec![Resource::Asset(ResourceAction::Delete(ResourceId::Any))], + ResourceIds::Ids(ids) => ids + .iter() + .map(|id| Resource::Asset(ResourceAction::Delete(ResourceId::Id(*id)))) + .collect::<_>(), + }, } } } diff --git a/core/station/impl/src/mappers/resource.rs b/core/station/impl/src/mappers/resource.rs index acb91cb54..4050b131a 100644 --- a/core/station/impl/src/mappers/resource.rs +++ b/core/station/impl/src/mappers/resource.rs @@ -28,6 +28,7 @@ impl From for Resource { station_api::ResourceDTO::Notification(action) => Resource::Notification(action.into()), station_api::ResourceDTO::Request(action) => Resource::Request(action.into()), station_api::ResourceDTO::System(action) => Resource::System(action.into()), + station_api::ResourceDTO::Asset(action) => Resource::Asset(action.into()), } } } @@ -49,6 +50,7 @@ impl From for station_api::ResourceDTO { Resource::Notification(action) => station_api::ResourceDTO::Notification(action.into()), Resource::Request(action) => station_api::ResourceDTO::Request(action.into()), Resource::System(action) => station_api::ResourceDTO::System(action.into()), + Resource::Asset(action) => station_api::ResourceDTO::Asset(action.into()), } } } diff --git a/core/station/impl/src/migration.rs b/core/station/impl/src/migration.rs index a275dcbec..5f2007c30 100644 --- a/core/station/impl/src/migration.rs +++ b/core/station/impl/src/migration.rs @@ -1,32 +1,35 @@ use crate::core::ic_cdk::api::trap; use crate::core::{read_system_info, write_system_info, Memory}; -use crate::models::permission::{Permission, PermissionKey}; +use crate::factories::blockchains::InternetComputer; +use crate::models::permission::{Allow, AuthScope}; use crate::models::request_specifier::RequestSpecifier; -use crate::models::resource::{ExternalCanisterResourceAction, Resource, SystemResourceAction}; +use crate::models::resource::{Resource, SystemResourceAction}; +use crate::models::resource::{ResourceAction, ResourceId, ResourceIds}; use crate::models::{ - Account, AccountKey, AddressBookEntry, AddressBookEntryKey, ExternalCanister, - ExternalCanisterKey, ListRequestsOperationType, Request, RequestKey, RequestOperation, - RequestPolicy, User, UserGroup, UserKey, + Account, AccountAddress, AccountAsset, AccountBalance, AccountId, AccountKey, AccountSeed, + AddAccountOperationInput, AddAddressBookEntryOperationInput, AddRequestPolicyOperationInput, + AddressBookEntry, AddressBookEntryId, AddressBookEntryKey, AddressFormat, Asset, AssetId, + Blockchain, EditPermissionOperationInput, Metadata, MetadataItem, Request, RequestKey, + RequestPolicyRule, TokenStandard, Transfer, TransferId, TransferKey, TransferOperation, + TransferOperationInput, TransferStatus, UserId, }; -use crate::repositories::permission::{PermissionRepository, PERMISSION_REPOSITORY}; +use crate::repositories::permission::PERMISSION_REPOSITORY; use crate::repositories::{ - AccountRepository, AddressBookRepository, ExternalCanisterRepository, RequestPolicyRepository, - RequestRepository, RequestWhereClause, UserGroupRepository, UserRepository, ACCOUNT_REPOSITORY, - ADDRESS_BOOK_REPOSITORY, EXTERNAL_CANISTER_REPOSITORY, REQUEST_POLICY_REPOSITORY, - USER_GROUP_REPOSITORY, USER_REPOSITORY, + AccountRepository, AddressBookRepository, RequestRepository, TransferRepository, + REQUEST_POLICY_REPOSITORY, USER_GROUP_REPOSITORY, USER_REPOSITORY, }; -use crate::{concat_str_arrays, STABLE_MEMORY_VERSION}; -use crate::{core::with_memory_manager, repositories::REQUEST_REPOSITORY}; -use ic_stable_structures::memory_manager::{MemoryId, VirtualMemory}; -use ic_stable_structures::Memory as DefaultMemoryTrait; +use crate::repositories::{ + ACCOUNT_REPOSITORY, ADDRESS_BOOK_REPOSITORY, ASSET_REPOSITORY, REQUEST_REPOSITORY, + TRANSFER_REPOSITORY, +}; +use crate::services::permission::PERMISSION_SERVICE; +use crate::services::{INITIAL_ICP_ASSET, INITIAL_ICP_ASSET_ID, REQUEST_POLICY_SERVICE}; +use crate::STABLE_MEMORY_VERSION; +use ic_stable_structures::memory_manager::VirtualMemory; use orbit_essentials::model::ModelKey; -use orbit_essentials::repository::{IndexedRepository, RebuildRepository, Repository, StableDb}; -use orbit_essentials::storable; -use orbit_essentials::types::UUID; -use serde::de::{self, EnumAccess, VariantAccess, Visitor}; +use orbit_essentials::repository::{RebuildRepository, Repository}; +use orbit_essentials::types::{Timestamp, UUID}; use serde::{Deserialize, Deserializer}; -use std::fmt; -use strum::VariantNames; /// Handles stable memory schema migrations for the station canister. /// @@ -58,6 +61,13 @@ impl MigrationHandler { )); } + if stored_version != STABLE_MEMORY_VERSION - 1 { + trap(&format!( + "Cannot skip upgrades between station memory layout version {} to {}", + stored_version, STABLE_MEMORY_VERSION + )); + } + apply_migration(); // Update the stable memory version to the latest version. @@ -71,532 +81,585 @@ impl MigrationHandler { /// If there is a check that needs to be run on every upgrade, regardless if the memory version has changed, /// it should be added here. -fn post_run() { - // Deserialization of the all requests to make sure an incompatible memory will panic and avoids - // putting the station in an inconsistent state. - // - // This is a temporary addition only for the next release since we've added a breaking change to - // the `ConfigureExternalCanisterSettingsInput` which had a new API not yet used in production. - let where_clause = RequestWhereClause { - operation_types: vec![ListRequestsOperationType::ConfigureExternalCanister(None)], - ..Default::default() - }; - - let ids = REQUEST_REPOSITORY - .find_ids_where(where_clause, None) - .expect("Failed to search for requests with the external canister operation types"); - - for id in ids { - REQUEST_REPOSITORY - .get(&RequestKey { id }) - .expect("Failed to deserialize the request from the stable memory"); - } -} +fn post_run() {} /// The migration to apply to the station canister stable memory. /// /// Please include the migration steps in the `apply_migration` function. fn apply_migration() { - // step 1: clear unused memory ids - with_memory_manager(|memory_manager| { - for memory_id in [ - MemoryId::new(3), // USER_IDENTITY_INDEX_MEMORY_ID, - MemoryId::new(5), // REQUEST_EXPIRATION_TIME_INDEX_MEMORY_ID - MemoryId::new(8), // REQUEST_APPROVER_INDEX_MEMORY_ID - MemoryId::new(9), // REQUEST_STATUS_INDEX_MEMORY_ID - MemoryId::new(10), // REQUEST_SCHEDULED_INDEX_MEMORY_ID - MemoryId::new(15), // USER_GROUP_NAME_INDEX_MEMORY_ID - MemoryId::new(18), // USER_STATUS_GROUP_INDEX_MEMORY_ID - MemoryId::new(20), // ADDRESS_BOOK_INDEX_MEMORY_ID - MemoryId::new(21), // REQUEST_REQUESTER_INDEX_MEMORY_ID - MemoryId::new(22), // REQUEST_CREATION_TIME_INDEX_MEMORY_ID - MemoryId::new(23), // REQUEST_KEY_CREATION_TIME_INDEX_MEMORY_ID - MemoryId::new(24), // REQUEST_KEY_EXPIRATION_TIME_INDEX_MEMORY_ID - MemoryId::new(25), // REQUEST_SORT_INDEX_MEMORY_ID - MemoryId::new(26), // REQUEST_STATUS_MODIFICATION_INDEX_MEMORY_ID - MemoryId::new(27), // NAME_TO_ACCOUNT_ID_INDEX_MEMORY_ID - MemoryId::new(28), // NAME_TO_USER_ID_INDEX_MEMORY_ID - MemoryId::new(29), // OPERATION_TYPE_TO_REQUEST_ID_INDEX_MEMORY_ID - MemoryId::new(34), // EXTERNAL_CANISTER_INDEX_MEMORY_ID - // The following memory ids are still in use for the same purpose, but the datatype - // have changed and the memory needs to be cleaned up and rebuilt later. - MemoryId::new(30), // REQUEST_RESOURCE_INDEX_MEMORY_ID - MemoryId::new(31), // POLICY_RESOURCE_INDEX_MEMORY_ID - ] { - // This cleans up the memory by writing a single zero byte to the memory id, - // this will make the memory id available for reuse in the future. - // - // This makes sure that if `init` is called on the memory id, it will make sure - // it can be reused with a different datatype. - let memory = memory_manager.get(memory_id); - if memory.size() > 0 { - // This marks the memory as unused, this is because the StableBTreeMap - // implementation uses the first three bytes of the memory to store the MAGIC value [66, 84, 82] - // that indicates that the memory is used by the StableBTreeMap, so adding a single different byte - // in those first three bytes will make the memory available for reuse. - memory.write(0, &[0]); + // add new asset permissions: resources available to all users + let public_resources = [ + Resource::Asset(ResourceAction::List), + Resource::Asset(ResourceAction::Read(ResourceId::Any)), + ]; + + // build cache so that model validation can pass + USER_GROUP_REPOSITORY.build_cache(); + USER_REPOSITORY.build_cache(); + PERMISSION_REPOSITORY.build_cache(); + + for resource in public_resources { + let _ = PERMISSION_SERVICE.edit_permission(EditPermissionOperationInput { + resource, + auth_scope: Some(AuthScope::Authenticated), + user_groups: None, + users: None, + }); + } + + // add new asset permissions: inherit config from ManageSystemInfo + let manage_system_info_permissions_allow = PERMISSION_SERVICE + .get_permission(&Resource::System(SystemResourceAction::ManageSystemInfo)) + .allow; + + let sensitive_resources = [ + Resource::Asset(ResourceAction::Create), + Resource::Asset(ResourceAction::Update(ResourceId::Any)), + Resource::Asset(ResourceAction::Delete(ResourceId::Any)), + ]; + + for resource in sensitive_resources { + if let Err(err) = PERMISSION_SERVICE.edit_permission(EditPermissionOperationInput { + resource, + auth_scope: Some(manage_system_info_permissions_allow.auth_scope.clone()), + user_groups: Some(manage_system_info_permissions_allow.user_groups.clone()), + users: Some(manage_system_info_permissions_allow.users.clone()), + }) { + ic_cdk::println!("Failed to create new asset permission: {:?}", err); + } + } + + // add new asset policies + let policy_specifiers = [ + RequestSpecifier::AddAsset, + RequestSpecifier::EditAsset(ResourceIds::Any), + RequestSpecifier::RemoveAsset(ResourceIds::Any), + ]; + + let policies_to_copy = REQUEST_POLICY_REPOSITORY + .find_by_resource(Resource::System(SystemResourceAction::ManageSystemInfo)); + + for policy in policies_to_copy { + for specifier in policy_specifiers.iter() { + if let Err(err) = + REQUEST_POLICY_SERVICE.add_request_policy(AddRequestPolicyOperationInput { + specifier: specifier.clone(), + rule: policy.rule.clone(), + }) + { + ic_cdk::println!("Failed to create new asset policy: {:?}", err); } } - }); + } - // step 2: rebuilds the repositories to ensure the data is up-to-date - USER_GROUP_REPOSITORY.rebuild(); - USER_REPOSITORY.rebuild(); - ACCOUNT_REPOSITORY.rebuild(); - EXTERNAL_CANISTER_REPOSITORY.rebuild(); + ASSET_REPOSITORY.insert(INITIAL_ICP_ASSET.key(), INITIAL_ICP_ASSET.clone()); + + // rebuild repositories to apply the changes ADDRESS_BOOK_REPOSITORY.rebuild(); - PERMISSION_REPOSITORY.rebuild(); - REQUEST_POLICY_REPOSITORY.rebuild(); + TRANSFER_REPOSITORY.rebuild(); + ACCOUNT_REPOSITORY.rebuild(); REQUEST_REPOSITORY.rebuild(); } -impl<'de> Deserialize<'de> for Resource { +#[cfg(test)] +thread_local! { + pub static MIGRATED_ENTRIES: std::cell::RefCell = const { std::cell::RefCell::new(0) }; + + pub static MIGRATED_ACCOUNTS: std::cell::RefCell> = const { std::cell::RefCell::new(vec![]) }; +} + +#[derive(Debug, Deserialize)] +pub enum BlockchainStandard { + Native, + ICRC1, + ERC20, +} + +impl<'de> Deserialize<'de> for AddressBookEntry { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - const ENUM_NAME: &str = "Resource"; + #[derive(Debug, Deserialize)] + struct PreMigrationAddressBookEntry { + pub id: AddressBookEntryId, + pub address_owner: String, + pub address: String, + pub blockchain: Blockchain, + pub address_format: Option, + pub metadata: Metadata, + #[serde(default)] + pub labels: Vec, + pub last_modification_timestamp: Timestamp, + } - const CURRENT_VARIANTS: &[&str] = Resource::VARIANTS; - const REMOVED_VARIANTS: [&str; 1] = ["ChangeCanister"]; + let mut pre_migration_entry = PreMigrationAddressBookEntry::deserialize(deserializer)?; - // IMPORTANT: The size of the array must be hardcoded, to make sure it can be checked at compile-time. - static EXPECTED_VARIANTS: [&str; 11] = { - let variants: [&str; CURRENT_VARIANTS.len() + REMOVED_VARIANTS.len()] = [""; 11]; - concat_str_arrays!(CURRENT_VARIANTS, REMOVED_VARIANTS); + #[cfg(test)] + if pre_migration_entry.address_format.is_none() { + MIGRATED_ENTRIES.with(|entries| { + *entries.borrow_mut() += 1; + }); + } - variants - }; + // the frontend used to add BlockchainStandard.Native = "native" label to new address book entries + // this label is not needed anymore + pre_migration_entry.labels.retain(|label| label != "native"); + + Ok(AddressBookEntry { + id: pre_migration_entry.id, + address_owner: pre_migration_entry.address_owner, + address: pre_migration_entry.address, + blockchain: pre_migration_entry.blockchain, + address_format: pre_migration_entry + .address_format + .unwrap_or(AddressFormat::ICPAccountIdentifier), + metadata: pre_migration_entry.metadata, + labels: pre_migration_entry.labels, + last_modification_timestamp: pre_migration_entry.last_modification_timestamp, + }) + } +} - // Define the old version of the types for migration purposes - #[storable] - #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] - pub enum OldExternalCanisterResourceAction { - Create(OldCreateCanisterTarget), +impl<'de> Deserialize<'de> for Transfer { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Debug, Deserialize)] + struct PreMigrationTransfer { + pub id: TransferId, + pub initiator_user: UserId, + pub from_account: AccountId, + pub to_address: String, + pub status: TransferStatus, + pub amount: candid::Nat, + pub request_id: UUID, + pub fee: candid::Nat, + pub blockchain_network: String, + pub metadata: Metadata, + pub last_modification_timestamp: Timestamp, + pub created_timestamp: Timestamp, + pub from_asset: Option, + pub with_standard: Option, } - #[storable] - #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] - pub enum OldCreateCanisterTarget { - Any, - } + let pre_migration_entry = PreMigrationTransfer::deserialize(deserializer)?; - #[storable] - #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] - pub enum OldChangeCanisterResourceAction { - Create, + #[cfg(test)] + if pre_migration_entry.from_asset.is_none() || pre_migration_entry.with_standard.is_none() { + MIGRATED_ENTRIES.with(|entries| { + *entries.borrow_mut() += 1; + }); } - /// This enum facilitates the deserialization of the ExternalCanisterResourceAction enum. - /// - /// By creating it as an untagged enum, we can handle both the old and new formats of the enum and - /// serde will automatically choose the correct format based on the input data. - #[derive(Deserialize)] - #[serde(untagged)] - enum ExternalCanisterActionWrapper { - NewFormat(ExternalCanisterResourceAction), - OldFormat(OldExternalCanisterResourceAction), - } + Ok(Transfer { + id: pre_migration_entry.id, + initiator_user: pre_migration_entry.initiator_user, + from_account: pre_migration_entry.from_account, + to_address: pre_migration_entry.to_address, + status: pre_migration_entry.status, + amount: pre_migration_entry.amount, + request_id: pre_migration_entry.request_id, + fee: pre_migration_entry.fee, + blockchain_network: pre_migration_entry.blockchain_network, + metadata: pre_migration_entry.metadata, + last_modification_timestamp: pre_migration_entry.last_modification_timestamp, + created_timestamp: pre_migration_entry.created_timestamp, + from_asset: pre_migration_entry + .from_asset + .unwrap_or(INITIAL_ICP_ASSET_ID), + with_standard: pre_migration_entry + .with_standard + .unwrap_or(TokenStandard::InternetComputerNative), + }) + } +} - struct ResourceVisitor; +impl<'de> Deserialize<'de> for Account { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[allow(dead_code)] + #[derive(Debug, Deserialize)] + struct PreMigrationAccount { + pub id: AccountId, + pub name: String, + pub metadata: Metadata, + pub transfer_request_policy_id: Option, + pub configs_request_policy_id: Option, + pub last_modification_timestamp: Timestamp, + + // removed fields + pub balance: Option>, + pub blockchain: Option, + pub address: Option, + pub standard: Option, + pub symbol: Option, + pub decimals: Option, + + // new fields + pub seed: Option, + pub assets: Option>, + pub addresses: Option>, + } - impl<'de> Visitor<'de> for ResourceVisitor { - type Value = Resource; + let pre_migration_entry = PreMigrationAccount::deserialize(deserializer)?; - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str(&format!("a valid {} enum variant", ENUM_NAME)) - } + #[cfg(test)] + if pre_migration_entry.seed.is_none() { + MIGRATED_ENTRIES.with(|entries| { + *entries.borrow_mut() += 1; + }); - fn visit_enum(self, data: A) -> Result - where - A: EnumAccess<'de>, - { - let (variant, variant_access) = data.variant::()?; - - // Due to the fact that serde serialization uses a string representation of the enum variant, - // it is not possible to do a compile-time check for all variants of the enum. - match variant.as_str() { - // First the new formats - "ExternalCanister" => { - // Deserialize into the wrapper, which can handle both formats - let wrapper = - variant_access.newtype_variant::()?; - - // Try deserializing as the new format - match wrapper { - ExternalCanisterActionWrapper::NewFormat(new_format) => { - Ok(Resource::ExternalCanister(new_format)) - } - ExternalCanisterActionWrapper::OldFormat(_) => Ok( - Resource::ExternalCanister(ExternalCanisterResourceAction::Create), - ), - } - } - // `ChangeCanister` does not exist anymore, so we need to handle it here - "ChangeCanister" => { - // Consume the old format variant, this is to make sure there is no - // trailing data is left in the end of the deserialization, which would cause an error. - // - // The use of `Option`` is to make sure that the deserialization is successful. - let _ = variant_access.newtype_variant::(); - // The `ChangeCanister` variant was removed, so we need to handle it here - // and map it to the correct variant. - Ok(Resource::System(SystemResourceAction::Upgrade)) - } - // Then all the default cases - "Permission" => { - let value = variant_access.newtype_variant()?; - Ok(Resource::Permission(value)) - } - "Account" => { - let value = variant_access.newtype_variant()?; - Ok(Resource::Account(value)) - } - "AddressBook" => { - let value = variant_access.newtype_variant()?; - Ok(Resource::AddressBook(value)) - } - "Notification" => { - let value = variant_access.newtype_variant()?; - Ok(Resource::Notification(value)) - } - "Request" => { - let value = variant_access.newtype_variant()?; - Ok(Resource::Request(value)) - } - "RequestPolicy" => { - let value = variant_access.newtype_variant()?; - Ok(Resource::RequestPolicy(value)) - } - "System" => { - let value = variant_access.newtype_variant()?; - Ok(Resource::System(value)) - } - "User" => { - let value = variant_access.newtype_variant()?; - Ok(Resource::User(value)) - } - "UserGroup" => { - let value = variant_access.newtype_variant()?; - Ok(Resource::UserGroup(value)) - } - _ => Err(de::Error::unknown_variant(&variant, &EXPECTED_VARIANTS)), - } + if let Some(address) = &pre_migration_entry.address { + MIGRATED_ACCOUNTS.with(|accounts| { + accounts + .borrow_mut() + .push((pre_migration_entry.id, address.clone())); + }); } } - deserializer.deserialize_enum(ENUM_NAME, &EXPECTED_VARIANTS, ResourceVisitor) + let seed = pre_migration_entry.seed.unwrap_or(pre_migration_entry.id); + + Ok(Account { + id: pre_migration_entry.id, + name: pre_migration_entry.name, + metadata: pre_migration_entry.metadata, + transfer_request_policy_id: pre_migration_entry.transfer_request_policy_id, + configs_request_policy_id: pre_migration_entry.configs_request_policy_id, + last_modification_timestamp: pre_migration_entry.last_modification_timestamp, + seed, + assets: pre_migration_entry.assets.unwrap_or(vec![AccountAsset { + asset_id: INITIAL_ICP_ASSET_ID, + balance: pre_migration_entry.balance.unwrap_or(None), + }]), + addresses: pre_migration_entry.addresses.unwrap_or_else(|| { + let blockchain = InternetComputer::create(); + vec![ + AccountAddress { + address: pre_migration_entry + .address + .unwrap_or(blockchain.generate_account_identifier(&seed)), + format: AddressFormat::ICPAccountIdentifier, + }, + AccountAddress { + address: blockchain.generate_icrc1_address(&seed), + format: AddressFormat::ICRC1Account, + }, + ] + }), + }) } } -impl<'de> Deserialize<'de> for RequestSpecifier { +impl<'de> Deserialize<'de> for TransferOperationInput { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - const ENUM_NAME: &str = "RequestSpecifier"; - - const CURRENT_VARIANTS: &[&str] = RequestSpecifier::VARIANTS; - const REMOVED_VARIANTS: [&str; 1] = ["ChangeCanister"]; - - // IMPORTANT: The size of the array must be hardcoded, to make sure it can be checked at compile-time. - static EXPECTED_VARIANTS: [&str; 23] = { - let variants: [&str; CURRENT_VARIANTS.len() + REMOVED_VARIANTS.len()] = - concat_str_arrays!(CURRENT_VARIANTS, REMOVED_VARIANTS); - - variants - }; - - // Define the old version of the types for migration purposes - #[derive(Deserialize)] - enum OldCreateExternalCanisterTarget { - Any, + #[derive(Debug, Deserialize)] + struct PreMigrationTransferOperationInput { + pub from_account_id: AccountId, + pub to: String, + pub amount: candid::Nat, + pub metadata: Metadata, + pub network: String, + pub fee: Option, + + pub from_asset_id: Option, + pub with_standard: Option, } - struct RequestSpecifierVisitor; - - impl<'de> Visitor<'de> for RequestSpecifierVisitor { - type Value = RequestSpecifier; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str(&format!("a valid {} enum variant", ENUM_NAME)) - } + let pre_migration_entry = PreMigrationTransferOperationInput::deserialize(deserializer)?; - fn visit_enum(self, data: A) -> Result - where - A: EnumAccess<'de>, - { - let (variant, variant_access) = data.variant::()?; - - // Due to the fact that serde serialization uses a string representation of the enum variant, - // it is not possible to do a compile-time check for all variants of the enum. - match variant.as_str() { - // First the new formats - "CreateExternalCanister" => { - // Even though the value of the variant is not used, we still need to consume it - // to make sure there is no trailing data left in the end of the deserialization. - let _ = variant_access - .newtype_variant::>(); - - Ok(RequestSpecifier::CreateExternalCanister) - } - // `ChangeCanister` does not exist anymore, so we need to handle it here - "ChangeCanister" => Ok(RequestSpecifier::SystemUpgrade), - // Then all the default cases - "AddAccount" => Ok(RequestSpecifier::AddAccount), - "AddUser" => Ok(RequestSpecifier::AddUser), - "EditAccount" => { - let value = variant_access.newtype_variant()?; - Ok(RequestSpecifier::EditAccount(value)) - } - "EditUser" => { - let value = variant_access.newtype_variant()?; - Ok(RequestSpecifier::EditUser(value)) - } - "AddAddressBookEntry" => Ok(RequestSpecifier::AddAddressBookEntry), - "EditAddressBookEntry" => { - let value = variant_access.newtype_variant()?; - Ok(RequestSpecifier::EditAddressBookEntry(value)) - } - "RemoveAddressBookEntry" => { - let value = variant_access.newtype_variant()?; - Ok(RequestSpecifier::RemoveAddressBookEntry(value)) - } - "Transfer" => { - let value = variant_access.newtype_variant()?; - Ok(RequestSpecifier::Transfer(value)) - } - "SystemUpgrade" => Ok(RequestSpecifier::SystemUpgrade), - "SetDisasterRecovery" => Ok(RequestSpecifier::SetDisasterRecovery), - "ChangeExternalCanister" => { - let value = variant_access.newtype_variant()?; - Ok(RequestSpecifier::ChangeExternalCanister(value)) - } - "CallExternalCanister" => { - let value = variant_access.newtype_variant()?; - Ok(RequestSpecifier::CallExternalCanister(value)) - } - "EditPermission" => { - let value = variant_access.newtype_variant()?; - Ok(RequestSpecifier::EditPermission(value)) - } - "AddRequestPolicy" => Ok(RequestSpecifier::AddRequestPolicy), - "EditRequestPolicy" => { - let value = variant_access.newtype_variant()?; - Ok(RequestSpecifier::EditRequestPolicy(value)) - } - "RemoveRequestPolicy" => { - let value = variant_access.newtype_variant()?; - Ok(RequestSpecifier::RemoveRequestPolicy(value)) - } - "AddUserGroup" => Ok(RequestSpecifier::AddUserGroup), - "EditUserGroup" => { - let value = variant_access.newtype_variant()?; - Ok(RequestSpecifier::EditUserGroup(value)) - } - "RemoveUserGroup" => { - let value = variant_access.newtype_variant()?; - Ok(RequestSpecifier::RemoveUserGroup(value)) - } - "ManageSystemInfo" => Ok(RequestSpecifier::ManageSystemInfo), - "FundExternalCanister" => { - let value = variant_access.newtype_variant()?; - Ok(RequestSpecifier::FundExternalCanister(value)) - } - _ => Err(de::Error::unknown_variant(&variant, &EXPECTED_VARIANTS)), - } - } + #[cfg(test)] + if pre_migration_entry.from_asset_id.is_none() { + MIGRATED_ENTRIES.with(|entries| { + *entries.borrow_mut() += 1; + }); } - deserializer.deserialize_enum(ENUM_NAME, &EXPECTED_VARIANTS, RequestSpecifierVisitor) + Ok(TransferOperationInput { + from_account_id: pre_migration_entry.from_account_id, + to: pre_migration_entry.to, + amount: pre_migration_entry.amount, + metadata: pre_migration_entry.metadata, + network: pre_migration_entry.network, + fee: pre_migration_entry.fee, + from_asset_id: pre_migration_entry + .from_asset_id + .unwrap_or(INITIAL_ICP_ASSET_ID), + with_standard: pre_migration_entry + .with_standard + .unwrap_or(TokenStandard::InternetComputerNative), + }) } } -impl<'de> Deserialize<'de> for RequestOperation { +impl<'de> Deserialize<'de> for TransferOperation { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - const ENUM_NAME: &str = "RequestOperation"; + #[derive(Debug, Deserialize)] + struct PreMigrationTransferOperation { + pub transfer_id: Option, + pub input: TransferOperationInput, + pub fee: Option, - const CURRENT_VARIANTS: &[&str] = RequestOperation::VARIANTS; - const REMOVED_VARIANTS: [&str; 1] = ["ChangeCanister"]; + pub asset: Option, + } - // IMPORTANT: The size of the array must be hardcoded, to make sure it can be checked at compile-time. - static EXPECTED_VARIANTS: [&str; 24] = { - let variants: [&str; CURRENT_VARIANTS.len() + REMOVED_VARIANTS.len()] = - concat_str_arrays!(CURRENT_VARIANTS, REMOVED_VARIANTS); + let pre_migration_entry = PreMigrationTransferOperation::deserialize(deserializer)?; - variants - }; + #[cfg(test)] + if pre_migration_entry.asset.is_none() { + MIGRATED_ENTRIES.with(|entries| { + *entries.borrow_mut() += 1; + }); + } - struct RequestOperationVisitor; + Ok(TransferOperation { + transfer_id: pre_migration_entry.transfer_id, + input: pre_migration_entry.input, + fee: pre_migration_entry.fee, + asset: pre_migration_entry + .asset + .unwrap_or_else(|| INITIAL_ICP_ASSET.clone()), + }) + } +} - impl<'de> Visitor<'de> for RequestOperationVisitor { - type Value = RequestOperation; +impl<'de> Deserialize<'de> for AddAccountOperationInput { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[allow(dead_code)] + #[derive(Debug, Deserialize)] + struct PreMigrationAddAccountOperationInput { + pub name: String, + pub metadata: Metadata, + pub read_permission: Allow, + pub configs_permission: Allow, + pub transfer_permission: Allow, + pub configs_request_policy: Option, + pub transfer_request_policy: Option, + + // removed fields + pub blockchain: Option, + pub standard: Option, + + // new fields + pub assets: Option>, + } - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str(&format!("a valid {} enum variant", ENUM_NAME)) - } + let pre_migration_entry = PreMigrationAddAccountOperationInput::deserialize(deserializer)?; - fn visit_enum(self, data: A) -> Result - where - A: EnumAccess<'de>, - { - let (variant, variant_access) = data.variant::()?; - - // Due to the fact that serde serialization uses a string representation of the enum variant, - // it is not possible to do a compile-time check for all variants of the enum. - match variant.as_str() { - // First the new formats - // `ChangeCanister` does not exist anymore, so we need to handle it here - "ChangeCanister" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::SystemUpgrade(value)) - } - // Then all the default cases - "Transfer" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::Transfer(value)) - } - "AddAccount" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::AddAccount(value)) - } - "EditAccount" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::EditAccount(value)) - } - "AddAddressBookEntry" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::AddAddressBookEntry(value)) - } - "EditAddressBookEntry" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::EditAddressBookEntry(value)) - } - "RemoveAddressBookEntry" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::RemoveAddressBookEntry(value)) - } - "AddUser" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::AddUser(value)) - } - "EditUser" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::EditUser(value)) - } - "EditPermission" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::EditPermission(value)) - } - "AddUserGroup" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::AddUserGroup(value)) - } - "EditUserGroup" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::EditUserGroup(value)) - } - "RemoveUserGroup" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::RemoveUserGroup(value)) - } - "SystemUpgrade" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::SystemUpgrade(value)) - } - "ChangeExternalCanister" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::ChangeExternalCanister(value)) - } - "ConfigureExternalCanister" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::ConfigureExternalCanister(value)) - } - "CreateExternalCanister" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::CreateExternalCanister(value)) - } - "CallExternalCanister" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::CallExternalCanister(value)) - } - "FundExternalCanister" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::FundExternalCanister(value)) - } - "AddRequestPolicy" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::AddRequestPolicy(value)) - } - "EditRequestPolicy" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::EditRequestPolicy(value)) - } - "RemoveRequestPolicy" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::RemoveRequestPolicy(value)) - } - "ManageSystemInfo" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::ManageSystemInfo(value)) - } - "SetDisasterRecovery" => { - let value = variant_access.newtype_variant()?; - Ok(RequestOperation::SetDisasterRecovery(value)) - } - _ => Err(de::Error::unknown_variant(&variant, &EXPECTED_VARIANTS)), - } - } + #[cfg(test)] + if pre_migration_entry.assets.is_none() { + MIGRATED_ENTRIES.with(|entries| { + *entries.borrow_mut() += 1; + }); } - deserializer.deserialize_enum(ENUM_NAME, &EXPECTED_VARIANTS, RequestOperationVisitor) + Ok(AddAccountOperationInput { + name: pre_migration_entry.name, + metadata: pre_migration_entry.metadata, + read_permission: pre_migration_entry.read_permission, + configs_permission: pre_migration_entry.configs_permission, + transfer_permission: pre_migration_entry.transfer_permission, + configs_request_policy: pre_migration_entry.configs_request_policy, + transfer_request_policy: pre_migration_entry.transfer_request_policy, + assets: pre_migration_entry + .assets + .unwrap_or_else(|| vec![INITIAL_ICP_ASSET_ID]), + }) } } -// Repositories should only implement the `RebuildRepository` trait if they are affected by the migration, -// otherwise, they should not implement the trait. -// -// The ones affected should have the implementation here. - -impl RebuildRepository> for RequestRepository { - fn rebuild(&self) { - let mut requests = Vec::with_capacity(self.len()); - Self::with_db(|db| db.iter().for_each(|(_, v)| requests.push(v))); - - // Then clear the repository to drop the existing data. - Self::with_db(|db| db.clear_new()); - - // Clear the indexes to avoid duplicates. - self.clear_indexes(); - - for mut request in requests.into_iter() { - // Then add the updated indexes. - self.add_entry_indexes(&request); - // Clear the module field if the request is finalized to save memory. - if request.is_finalized() { - if let RequestOperation::SystemUpgrade(operation) = &mut request.operation { - operation.input.module = Vec::new(); - } - } +impl<'de> Deserialize<'de> for AddAddressBookEntryOperationInput { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[allow(dead_code)] + #[derive(Debug, Deserialize)] + struct PreMigrationAddAddressBookEntryOperationInput { + pub address_owner: String, + pub address: String, + pub blockchain: Blockchain, + #[serde(default)] + pub labels: Vec, + pub metadata: Vec, + + // added fields + pub address_format: Option, + } - Self::with_db(|db| db.insert(request.key(), request)); + let pre_migration_entry = + PreMigrationAddAddressBookEntryOperationInput::deserialize(deserializer)?; + + #[cfg(test)] + if pre_migration_entry.address_format.is_none() { + MIGRATED_ENTRIES.with(|entries| { + *entries.borrow_mut() += 1; + }); } + + Ok(AddAddressBookEntryOperationInput { + address_owner: pre_migration_entry.address_owner, + address: pre_migration_entry.address, + blockchain: pre_migration_entry.blockchain, + labels: pre_migration_entry.labels, + metadata: pre_migration_entry.metadata, + address_format: pre_migration_entry + .address_format + .unwrap_or(AddressFormat::ICPAccountIdentifier), + }) } } -impl RebuildRepository> for PermissionRepository {} -impl RebuildRepository> for AccountRepository {} impl RebuildRepository> for AddressBookRepository { } -impl RebuildRepository> - for ExternalCanisterRepository -{ + +impl RebuildRepository> for TransferRepository {} +impl RebuildRepository> for AccountRepository {} +impl RebuildRepository> for RequestRepository {} + +#[cfg(test)] +mod test { + use std::{borrow::BorrowMut, fs}; + + use ic_stable_structures::{memory_manager::MemoryId, Memory}; + use orbit_essentials::repository::{RebuildRepository, Repository}; + + use crate::{ + core::{ + ACCOUNT_MEMORY_ID, ADDRESS_BOOK_MEMORY_ID, MEMORY_MANAGER, REQUEST_MEMORY_ID, + TRANSFER_MEMORY_ID, WASM_PAGE_SIZE, + }, + migration::{INITIAL_ICP_ASSET_ID, MIGRATED_ACCOUNTS, MIGRATED_ENTRIES}, + models::AddressFormat, + repositories::{ + address_book, ACCOUNT_REPOSITORY, ADDRESS_BOOK_REPOSITORY, REQUEST_REPOSITORY, + TRANSFER_REPOSITORY, + }, + STABLE_MEMORY_VERSION, + }; + + fn restore_snapshot(label: &str, memory_id: MemoryId) { + let snapshot = fs::read(format!( + "src/migration_tests/snapshots/{}_v{}.bin", + label, + STABLE_MEMORY_VERSION - 1 + )) + .unwrap(); + + let mut memory = MEMORY_MANAGER.with(|mm| mm.borrow_mut().get(memory_id)); + memory.grow(snapshot.len() as u64 / WASM_PAGE_SIZE as u64 + 1u64); + memory.borrow_mut().write(0, &snapshot); + } + + #[test] + fn test_address_book_migration() { + restore_snapshot("address_book_repository", ADDRESS_BOOK_MEMORY_ID); + + address_book::ADDRESS_BOOK_REPOSITORY.list(); + assert!(MIGRATED_ENTRIES.with(|entries| *entries.borrow_mut()) > 0); + + ADDRESS_BOOK_REPOSITORY.rebuild(); + + MIGRATED_ENTRIES.with(|entries| { + *entries.borrow_mut() = 0; + }); + + address_book::ADDRESS_BOOK_REPOSITORY.list(); + assert!(MIGRATED_ENTRIES.with(|entries| *entries.borrow_mut()) == 0); + } + + #[test] + fn test_transfer_migration() { + restore_snapshot("transfer_repository", TRANSFER_MEMORY_ID); + + TRANSFER_REPOSITORY.list(); + assert!(MIGRATED_ENTRIES.with(|entries| *entries.borrow_mut()) > 0); + + TRANSFER_REPOSITORY.rebuild(); + + MIGRATED_ENTRIES.with(|entries| { + *entries.borrow_mut() = 0; + }); + + TRANSFER_REPOSITORY.list(); + assert!(MIGRATED_ENTRIES.with(|entries| *entries.borrow_mut()) == 0); + } + + #[test] + fn test_account_migration() { + restore_snapshot("account_repository", ACCOUNT_MEMORY_ID); + + ACCOUNT_REPOSITORY.list(); + assert!(MIGRATED_ACCOUNTS.with(|entries| entries.borrow_mut().len()) > 0); + + ACCOUNT_REPOSITORY.rebuild(); + + let accounts = ACCOUNT_REPOSITORY.list(); + for account in accounts { + assert!(account.seed == account.id); + assert!( + account.assets.first().expect("No assets found").asset_id == INITIAL_ICP_ASSET_ID + ); + + assert!(account.addresses.len() == 2); + + let migrated_account = MIGRATED_ACCOUNTS.with(|accounts| { + accounts + .borrow() + .iter() + .find(|(id, _)| *id == account.id) + .expect("Account not found in migrated accounts") + .clone() + }); + + assert!(account + .addresses + .iter() + .any(|address| address.address == migrated_account.1 + && address.format == AddressFormat::ICPAccountIdentifier)); + assert!(account + .addresses + .iter() + .any(|address| address.format == AddressFormat::ICRC1Account)); + } + + MIGRATED_ACCOUNTS.with(|entries| { + entries.borrow_mut().clear(); + }); + + ACCOUNT_REPOSITORY.list(); + assert!(MIGRATED_ACCOUNTS.with(|entries| entries.borrow_mut().len()) == 0); + } + + #[test] + fn test_request_migration() { + restore_snapshot("request_repository", REQUEST_MEMORY_ID); + + REQUEST_REPOSITORY.list(); + assert!(MIGRATED_ENTRIES.with(|entries| *entries.borrow_mut()) > 0); + + REQUEST_REPOSITORY.rebuild(); + + MIGRATED_ENTRIES.with(|entries| { + *entries.borrow_mut() = 0; + }); + + REQUEST_REPOSITORY.list(); + assert!(MIGRATED_ENTRIES.with(|entries| *entries.borrow_mut()) == 0); + } } -impl RebuildRepository> for UserGroupRepository {} -impl RebuildRepository> for UserRepository {} -impl RebuildRepository> for RequestPolicyRepository {} diff --git a/core/station/impl/src/migration_tests/mod.rs b/core/station/impl/src/migration_tests/mod.rs new file mode 100644 index 000000000..c21637cfb --- /dev/null +++ b/core/station/impl/src/migration_tests/mod.rs @@ -0,0 +1,280 @@ +#[cfg(test)] +mod test { + + use std::fs; + + use ic_cdk::api::stable::WASM_PAGE_SIZE_IN_BYTES; + use ic_stable_structures::{memory_manager::MemoryId, Memory}; + use orbit_essentials::{model::ModelKey, repository::Repository}; + + use crate::{ + core::{ + with_memory_manager, ACCOUNT_MEMORY_ID, ADDRESS_BOOK_MEMORY_ID, REQUEST_MEMORY_ID, + TRANSFER_MEMORY_ID, + }, + models::{ + permission::Allow, Account, AccountAddress, AccountAsset, AccountBalance, + AddAccountOperation, AddAccountOperationInput, AddAddressBookEntryOperation, + AddAddressBookEntryOperationInput, AddressBookEntry, AddressFormat, Blockchain, + ChangeAssets, EditAccountOperation, EditAccountOperationInput, Metadata, Request, + RequestExecutionPlan, RequestOperation, RequestPolicyRule, RequestPolicyRuleInput, + RequestStatus, TokenStandard, Transfer, TransferOperation, TransferOperationInput, + TransferStatus, + }, + repositories::{ + ACCOUNT_REPOSITORY, ADDRESS_BOOK_REPOSITORY, REQUEST_REPOSITORY, TRANSFER_REPOSITORY, + }, + services::{INITIAL_ICP_ASSET, INITIAL_ICP_ASSET_ID}, + STABLE_MEMORY_VERSION, + }; + + fn save_memory_snapshot(label: &str, memory_id: MemoryId) { + let snapshot = with_memory_manager(|memory_manager| { + let mem = memory_manager.get(memory_id); + let mut snapshot = vec![0; mem.size() as usize * WASM_PAGE_SIZE_IN_BYTES as usize]; + mem.read(0, &mut snapshot); + snapshot + }); + + fs::write( + format!( + "src/migration_tests/snapshots/{}_v{}.bin", + label, STABLE_MEMORY_VERSION + ), + snapshot, + ) + .unwrap(); + } + + fn generate_address_book_repo_snapshot() { + let entries: Vec = vec![ + AddressBookEntry { + id: [0u8; 16], + address: "0x1234567890abcdef".to_string(), + address_format: AddressFormat::ICPAccountIdentifier, + address_owner: "Alice".to_string(), + blockchain: crate::models::Blockchain::InternetComputer, + labels: vec!["Alice".to_string(), "Bob".to_string()], + last_modification_timestamp: 0, + metadata: Metadata::default(), + }, + AddressBookEntry { + id: [1u8; 16], + address: "0x1234567890abcdef".to_string(), + address_format: AddressFormat::ICPAccountIdentifier, + address_owner: "Alice".to_string(), + blockchain: crate::models::Blockchain::InternetComputer, + labels: vec!["Alice".to_string(), "Bob".to_string()], + last_modification_timestamp: 0, + metadata: Metadata::new( + [ + ("key1".to_string(), "value1".to_string()), + ("key2".to_string(), "value2".to_string()), + ] + .into_iter() + .collect(), + ), + }, + ]; + + for entry in entries { + ADDRESS_BOOK_REPOSITORY.insert(entry.key(), entry); + } + + save_memory_snapshot("address_book_repository", ADDRESS_BOOK_MEMORY_ID); + } + + fn generate_transfer_repo_snapshot() { + let entries: Vec = vec![Transfer { + id: [0u8; 16], + initiator_user: [0u8; 16], + from_account: [0u8; 16], + from_asset: [0u8; 16], + with_standard: TokenStandard::InternetComputerNative, + to_address: "0x1234567890abcdef".to_string(), + status: TransferStatus::Completed { + signature: None, + hash: None, + completed_at: 0, + }, + amount: 100u64.into(), + request_id: [0u8; 16], + fee: 100u64.into(), + blockchain_network: "mainnet".to_string(), + metadata: Metadata::default(), + last_modification_timestamp: 0, + created_timestamp: 0, + }]; + + for entry in entries { + TRANSFER_REPOSITORY.insert(entry.key(), entry); + } + + save_memory_snapshot("transfer_repository", TRANSFER_MEMORY_ID); + } + + fn generate_account_repo_snapshot() { + let entries: Vec = vec![Account { + id: [0u8; 16], + name: "Test account".to_string(), + assets: vec![AccountAsset { + asset_id: [0u8; 16], + balance: Some(AccountBalance { + balance: 100u64.into(), + last_modification_timestamp: 0, + }), + }], + seed: [0u8; 16], + addresses: vec![ + AccountAddress { + address: "0x1234567890abcdef".to_string(), + format: AddressFormat::ICPAccountIdentifier, + }, + AccountAddress { + address: "0x1234567890abcdef".to_string(), + format: AddressFormat::ICRC1Account, + }, + ], + metadata: Metadata::default(), + transfer_request_policy_id: None, + configs_request_policy_id: None, + last_modification_timestamp: 0, + }]; + + for entry in entries { + ACCOUNT_REPOSITORY.insert(entry.key(), entry); + } + + save_memory_snapshot("account_repository", ACCOUNT_MEMORY_ID); + } + + fn generate_request_repo_snapshot() { + let entries: Vec = vec![ + Request { + id: [0u8; 16], + title: "Test transfer".to_string(), + summary: None, + requested_by: [0u8; 16], + status: RequestStatus::Approved, + operation: RequestOperation::Transfer(TransferOperation { + fee: None, + transfer_id: Some([0u8; 16]), + asset: INITIAL_ICP_ASSET.clone(), + input: TransferOperationInput { + from_account_id: [0u8; 16], + from_asset_id: INITIAL_ICP_ASSET_ID, + with_standard: TokenStandard::InternetComputerNative, + to: "0x1234567890abcdef".to_string(), + amount: 100u64.into(), + metadata: Metadata::default(), + network: "mainnet".to_string(), + fee: None, + }, + }), + expiration_dt: 0, + execution_plan: RequestExecutionPlan::Immediate, + approvals: vec![], + created_timestamp: 0, + last_modification_timestamp: 0, + }, + Request { + id: [1u8; 16], + title: "Test add account".to_string(), + summary: None, + requested_by: [0u8; 16], + status: RequestStatus::Approved, + operation: RequestOperation::AddAccount(AddAccountOperation { + account_id: None, + input: AddAccountOperationInput { + name: "Test account".to_string(), + assets: vec![[0u8; 16]], + metadata: Metadata::new( + [ + ("key1".to_string(), "value1".to_string()), + ("key2".to_string(), "value2".to_string()), + ] + .into_iter() + .collect(), + ), + read_permission: Allow::default(), + configs_permission: Allow::default(), + transfer_permission: Allow::default(), + configs_request_policy: Some(RequestPolicyRule::AutoApproved), + transfer_request_policy: Some(RequestPolicyRule::AutoApproved), + }, + }), + expiration_dt: 0, + execution_plan: RequestExecutionPlan::Immediate, + approvals: vec![], + created_timestamp: 0, + last_modification_timestamp: 0, + }, + Request { + id: [2u8; 16], + title: "Test edit account".to_string(), + summary: None, + requested_by: [0u8; 16], + status: RequestStatus::Approved, + operation: RequestOperation::EditAccount(EditAccountOperation { + input: EditAccountOperationInput { + account_id: [0u8; 16], + name: Some("Test account".to_string()), + change_assets: Some(ChangeAssets::Change { + add_assets: vec![[0u8; 16], [1u8; 16]], + remove_assets: vec![[2u8; 16], [3u8; 16]], + }), + read_permission: Some(Allow::default()), + configs_permission: Some(Allow::default()), + transfer_permission: Some(Allow::default()), + configs_request_policy: Some(RequestPolicyRuleInput::Set( + RequestPolicyRule::AutoApproved, + )), + transfer_request_policy: Some(RequestPolicyRuleInput::Remove), + }, + }), + expiration_dt: 0, + execution_plan: RequestExecutionPlan::Immediate, + approvals: vec![], + created_timestamp: 0, + last_modification_timestamp: 0, + }, + Request { + id: [3u8; 16], + title: "Test add address book entry".to_string(), + summary: None, + requested_by: [0u8; 16], + status: RequestStatus::Approved, + operation: RequestOperation::AddAddressBookEntry(AddAddressBookEntryOperation { + input: AddAddressBookEntryOperationInput { + address_owner: "Alice".to_string(), + address: "0x1234567890abcdef".to_string(), + address_format: AddressFormat::ICPAccountIdentifier, + blockchain: Blockchain::InternetComputer, + labels: vec!["label_1".to_string(), "label_2".to_string()], + metadata: vec![], + }, + address_book_entry_id: Some([0u8; 16]), + }), + expiration_dt: 0, + execution_plan: RequestExecutionPlan::Immediate, + approvals: vec![], + created_timestamp: 0, + last_modification_timestamp: 0, + }, + ]; + + for entry in entries { + REQUEST_REPOSITORY.insert(entry.key(), entry); + } + + save_memory_snapshot("request_repository", REQUEST_MEMORY_ID); + } + + #[test] + fn make_repository_snapshots() { + generate_address_book_repo_snapshot(); + generate_transfer_repo_snapshot(); + generate_account_repo_snapshot(); + generate_request_repo_snapshot(); + } +} diff --git a/core/station/impl/src/migration_tests/snapshots/account_repository_v1.bin b/core/station/impl/src/migration_tests/snapshots/account_repository_v1.bin new file mode 100644 index 0000000000000000000000000000000000000000..bcf4004722ed360e209ece896afdf973a8893a06 GIT binary patch literal 65536 zcmeIy%Wl&^6aY}SV!;m}@!0ee${Us>8!DsFckbM4kIurQ$%n)2PL};1B4xWcxLp6Xj3#e(EyLV4_WkK{zpGw9$8X;q z?0dG=(d6{-YADO@4W;v0S$w;m*kwn<(9fF=KF+E(|CBF7*{n{Q6uTy-aa*s}L%Ik> z(Z#;+jyI2=Jbm{3#miT(kHai4;(Xbs&=jF7=BFW*U*fz!t7mPMpN!uxiC7sEYB?LJCA#3=2-Gze14qW%d#JROWC#^?cRTT4aR5NC4Jte?boMWe>-~n9KL;b zv|qCs9gN=}-y~(({eI|rS`}YzGq%yPPx^M(MA3vg&d0G!PePtAE}K+zG5%Sg3|$vf zceTFw6-@5fV>S)*(ByHw53U{*tH<-urAfUgs_8nC3++%W~1yA*Ipq!`b$Xqarq`uNd3^$eWLb=fl%o=0zP- zC_)O?H__MCqtu3`D`PupWab6aZi+>J?aVi6;Cnn{GfzEKv5**q*d*{SzfqC|gdz5hz0965I_OoHkMf z3#4}Uy@lt^n;$=YmOPukA7@8d_N$MS4Kd!0{~BiVlhL2G{@gZ(NtUJEe5mf9r?20R zZue}fv-!JmwtvZ^zI2mU<(K`5A-h``9mMy3{m0)ydHU@6ia>xR`bM_2W8h(xU0gYFQN_Rb9JCRTDQUG^_0AZJ!7Ij~=$qZ{=nDG+BNK^;Mi~ z^XZUJ|F{3c4`NQ>9t5`kgL}Bf6$A(nAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ vfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk42_tes?l|b(Wnjxxd@GUY5n@Q$?TM9t`fp_ucuo-&NG1D7v`WEZPrE?Bcj8^Z53# z*PUMtM-Lx8e)9C$^YgIGi@18XtlIoCzX)Zso;4|UO-$3aUT^p5@l~s~t3yh&>FaTx zx7#MoirA!bRmSe3jwuu&h3nhw%c=^?SZzM_m*t zP_MHe$6oIAIQaLyd@U~H$KmRIsJ3yq&qsYe`r97+&uJuZBe4G;pddhi009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly QK!5-N0t5&U_&)`H0iqD9YXATM literal 0 HcmV?d00001 diff --git a/core/station/impl/src/migration_tests/snapshots/request_repository_v1.bin b/core/station/impl/src/migration_tests/snapshots/request_repository_v1.bin new file mode 100644 index 0000000000000000000000000000000000000000..a7c10969bf1151a70ebc29dc4d0c8388e7cab180 GIT binary patch literal 65536 zcmeIz%Wm677=~fl>7q9fpg=ahgxGC!Sjs_)1_fNe)v^FF;{22-aX9qM(3W;?k#*5a zs2rq4k^A$u9UdeHvfvbnQ53~H3zH`2z}|Epjs2~*x$$7D6>2MRBq~j9U4~u##=*MBi^HQYzxw*g(`U~gt8uC&%amyb z{PcyM8VO3PpjMl+-zKGm3+v_+qw+GIr4i1%2N5@odn~nb zIy;FwJC_aX4e*to%knTgS4Ay{&G}JxeqE0#Ds_Y%ijEDoURYwstep7?`7~-<+vTj|5<2O z=?@bAQF5|VF|l}lFf;cMj9=E@fUR%CgDx10M>j=DYbV|xjIEs?NEzJn)9aIO>QguG zah&#LGwW3yOJK87(yWxUEA6WPU8LTcb#Hr7ij!e{J!R5g+wpz4tH5Q28cX4Sp0vm0 zusW(a8465aO)MuuXNW5Wz|I>p|20|YhM2N=b0Rxo@{sC=yd*Yo9$GaJH2x| zI_++i1?qx3b?K*XZm!n*^s}w1LUsS>^tkBrtGfDI?&@+7$*Xql)s3yzwz{$1+EzDq zTHCMNozC-3eJC5*Sm{%h**$ASl7-3)vs`Oo7B{g`v)tWL(jP3=h8Jd5*gP9`r&E)j zDH)|xWrU5=WH~ymoBDpzH0p17Koup^-2Rw`Ce^+Of;3O;rgFL}kY%d8?ajBnU%cnu zyWTtAvDf#0_N4dB`^Njt)86mi+xk4RBi{`!k;2HceqxnLlsgdYHTMINxE_^p*z?`lXt2=xGvT)V;-~x#XZwMOOswS46?>_?S&ZXH z`*!hC`GF|Pmm*rtr%@FIU)oNh<|@dGSoqV}_1o9lWkpyOi845Ds28)qD0fBrHqtI{ zLQgyM-B@JS*Qt!cC@5p~!rgw8=hm08RpohF=2=L&SEuUrBR%d)*{vdaJ-*HAO`YNL zZNyc#RjIHHM)qXwyBW3TgX7OXdHSIG_}5c+EhlPm5T1!RR|m!MVRd|%Ix^|IPfkZ! z=5)Usi@eBv;6B6giBmLD;7C=TDU)4Hik&|+X+F&^OpqpFG|cW-+qGQH6Zflr)^uJ^ zF8(x)qhJwtbDK6($@!{_I|}_9Is3ZFS!<%*u5vajU$~32{pW%bI6wda1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ p0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5cp38{sj6Qe5?Qf literal 0 HcmV?d00001 diff --git a/core/station/impl/src/migration_tests/snapshots/transfer_repository_v1.bin b/core/station/impl/src/migration_tests/snapshots/transfer_repository_v1.bin new file mode 100644 index 0000000000000000000000000000000000000000..8ba23bd6d57a5838d5f31665146e58035af4bf8a GIT binary patch literal 65536 zcmeIyJ#N%M6aZi+;tEJeTq68K$)-UxZ+Gc3MpFvW+>^ zA&1RP^z-uhDopQd`#LqL3OV&%nNvHR5!&TNxK0db%DB%+{DvyJ4xEa({{@ZI07T~9IspOX!=03 z?7IBCi0%L3SYM><9(>)do>bNEGIF&UTi5gdrrm>&o08JE9yj&*dcTR@J@-C+*}AD& zM|ThQw{M%O>S+nB4paO6u4A)0D^0&1O7y8uIfXoo^>T{iy~f3C9J)F*&9LnAz3lNO zJ5BL^uDeKiUQapnZ5Z40&wY+#AM@VOoi59AKjd^CkMmH6wjJX%jXRewUcP$$=Iy)p zA9liF)5h7Xbm!%?O6#UY%(4BMrgYSo;25vkd6?!`i{>U*hg{7, + /// The seed for address creation. + pub seed: AccountSeed, + /// The list of assets this account holds. + pub assets: Vec, + /// The list of addresses that belong to this account. + pub addresses: Vec, /// The account metadata, which is a list of key-value pairs, /// where the key is unique and the first entry in the tuple, /// and the value is the second entry in the tuple. @@ -72,40 +69,109 @@ impl ModelKey for Account { } } -#[derive(CandidType, Deserialize, Debug, Clone)] -pub struct AccountCallerPrivileges { - pub id: UUID, - pub can_edit: bool, - pub can_transfer: bool, +#[storable] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AccountAsset { + pub asset_id: AssetId, + pub balance: Option, } -fn validate_symbol(symbol: &str) -> ModelValidatorResult { - if (symbol.len() < Account::SYMBOL_RANGE.0 as usize) - || (symbol.len() > Account::SYMBOL_RANGE.1 as usize) - { - return Err(AccountError::ValidationError { - info: format!( - "Account symbol length must be between {} and {}", - Account::SYMBOL_RANGE.0, - Account::SYMBOL_RANGE.1 - ), - }); +#[storable] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AccountAddress { + pub address: String, + pub format: AddressFormat, +} + +#[storable] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum AddressFormat { + ICPAccountIdentifier, + ICRC1Account, + EthereumAddress, + BitcoinAddressP2WPKH, + BitcoinAddressP2TR, +} + +impl fmt::Display for AddressFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AddressFormat::ICPAccountIdentifier => write!(f, "icp_account_identifier"), + AddressFormat::ICRC1Account => write!(f, "icrc1_account"), + AddressFormat::EthereumAddress => write!(f, "ethereum_address"), + AddressFormat::BitcoinAddressP2WPKH => write!(f, "bitcoin_address_p2wpkh"), + AddressFormat::BitcoinAddressP2TR => write!(f, "bitcoin_address_p2tr"), + } } +} - Ok(()) +impl FromStr for AddressFormat { + type Err = AccountError; + + fn from_str(s: &str) -> Result { + match s { + "icp_account_identifier" => Ok(AddressFormat::ICPAccountIdentifier), + "icrc1_account" => Ok(AddressFormat::ICRC1Account), + "ethereum_address" => Ok(AddressFormat::EthereumAddress), + "bitcoin_address_p2wpkh" => Ok(AddressFormat::BitcoinAddressP2WPKH), + "bitcoin_address_p2tr" => Ok(AddressFormat::BitcoinAddressP2TR), + _ => Err(AccountError::UnknownAddressFormat { + address_format: s.to_string(), + }), + } + } } -fn validate_address(address: &str) -> ModelValidatorResult { - if (address.len() < Account::ADDRESS_RANGE.0 as usize) - || (address.len() > Account::ADDRESS_RANGE.1 as usize) - { - return Err(AccountError::InvalidAddressLength { - min_length: Account::ADDRESS_RANGE.0, - max_length: Account::ADDRESS_RANGE.1, - }); +impl AddressFormat { + pub fn validate_address(&self, address: &str) -> ModelValidatorResult { + match self { + AddressFormat::ICPAccountIdentifier => AccountIdentifier::from_hex(address) + .map_err(|_| AccountError::InvalidAddress { + address: address.to_string(), + address_format: self.to_string(), + }) + .map(|_| ()), + AddressFormat::ICRC1Account => { + icrc_ledger_types::icrc1::account::Account::from_str(address) + .map_err(|_| AccountError::InvalidAddress { + address: address.to_string(), + address_format: self.to_string(), + }) + .map(|_| ()) + } + AddressFormat::EthereumAddress => todo!(), + AddressFormat::BitcoinAddressP2WPKH => todo!(), + AddressFormat::BitcoinAddressP2TR => todo!(), + } } +} - Ok(()) +impl AccountAddress { + const ADDRESS_RANGE: (u8, u8) = (1, 255); +} + +impl ModelValidator for AccountAddress { + fn validate(&self) -> ModelValidatorResult { + if (self.address.len() < AccountAddress::ADDRESS_RANGE.0 as usize) + || (self.address.len() > AccountAddress::ADDRESS_RANGE.1 as usize) + { + return Err(AccountError::InvalidAddressLength { + min_length: AccountAddress::ADDRESS_RANGE.0, + max_length: AccountAddress::ADDRESS_RANGE.1, + }); + } + + self.format.validate_address(&self.address)?; + + Ok(()) + } +} + +#[derive(CandidType, Deserialize, Debug, Clone)] +pub struct AccountCallerPrivileges { + pub id: UUID, + pub can_edit: bool, + pub can_transfer: bool, } fn validate_policy_id(policy_id: &UUID, field_name: &str) -> ModelValidatorResult { @@ -117,11 +183,40 @@ fn validate_policy_id(policy_id: &UUID, field_name: &str) -> ModelValidatorResul Ok(()) } +fn validate_asset_id(asset_id: &AssetId) -> ModelValidatorResult { + EnsureAsset::id_exists(asset_id).map_err(|err| match err { + RecordValidationError::NotFound { id, .. } => AccountError::AssetDoesNotExist { id }, + })?; + + Ok(()) +} + +fn validate_account_name(name: &str) -> ModelValidatorResult { + if (name.len() < Account::NAME_RANGE.0 as usize) + || (name.len() > Account::NAME_RANGE.1 as usize) + { + return Err(AccountError::InvalidNameLength { + min_length: Account::NAME_RANGE.0, + max_length: Account::NAME_RANGE.1, + }); + } + + Ok(()) +} + impl ModelValidator for Account { fn validate(&self) -> ModelValidatorResult { self.metadata.validate()?; - validate_symbol(&self.symbol)?; - validate_address(&self.address)?; + + validate_account_name(&self.name)?; + + for asset in &self.assets { + validate_asset_id(&asset.asset_id)?; + } + + for address in &self.addresses { + address.validate()?; + } if let Some(transfer_request_policy_id) = &self.transfer_request_policy_id { validate_policy_id(transfer_request_policy_id, "transfer_request_policy_id")?; @@ -137,6 +232,7 @@ impl ModelValidator for Account { impl Account { pub const OWNERS_RANGE: (u8, u8) = (1, 10); pub const ADDRESS_RANGE: (u8, u8) = (1, 255); + pub const NAME_RANGE: (u8, u8) = (1, 64); pub const SYMBOL_RANGE: (u8, u8) = (1, 8); pub const MAX_POLICIES: u8 = 10; @@ -154,59 +250,63 @@ impl Account { } } -#[cfg(test)] -mod tests { - use super::account_test_utils::mock_account; - use super::*; - - #[test] - fn fail_symbol_validation_too_short() { - let mut account = mock_account(); - account.symbol = "a".repeat(0); - - let result = validate_symbol(&account.symbol); +pub enum BalanceQueryState { + StaleRefreshing, + Stale, + Fresh, +} - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - AccountError::ValidationError { - info: "Account symbol length must be between 1 and 8".to_string() - } - ); +impl fmt::Display for BalanceQueryState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BalanceQueryState::StaleRefreshing => write!(f, "stale_refreshing"), + BalanceQueryState::Stale => write!(f, "stale"), + BalanceQueryState::Fresh => write!(f, "fresh"), + } } +} - #[test] - fn fail_symbol_validation_too_long() { - let mut account = mock_account(); - account.symbol = "a".repeat(Account::SYMBOL_RANGE.1 as usize + 1); +impl From<&AccountBalance> for BalanceQueryState { + fn from(balance: &AccountBalance) -> Self { + let balance_age_ms = crate::core::ic_cdk::api::time() + .saturating_sub(balance.last_modification_timestamp) + / 1_000_000; + if balance_age_ms <= ACCOUNT_BALANCE_FRESHNESS_IN_MS { + BalanceQueryState::Fresh + } else { + BalanceQueryState::Stale + } + } +} - let result = validate_symbol(&account.symbol); +#[cfg(test)] +mod tests { + use super::account_test_utils::mock_account; + use super::*; - assert!(result.is_err()); - assert_eq!( - result.unwrap_err(), - AccountError::ValidationError { - info: "Account symbol length must be between 1 and 8".to_string() - } - ); - } + const VALID_ACCOUNT_IDENTIFIER: &str = + "5c76bc95e544204de4928e4d901e52b49df248b9c346807040e7af75aa61f4b3"; #[test] - fn test_symbol_validation() { - let mut account = mock_account(); - account.symbol = "a".to_string(); + fn fail_address_format_invalid() { + let format = AddressFormat::ICPAccountIdentifier; - let result = validate_symbol(&account.symbol); + format + .validate_address("foo") + .expect_err("foo is not a valid AccountIdentifier"); - assert!(result.is_ok()); + format + .validate_address(VALID_ACCOUNT_IDENTIFIER) + .expect("The address is valid"); } - #[test] - fn fail_address_too_short() { - let mut account = mock_account(); - account.address = "".to_string(); + fn fail_address_length_invalid() { + let mut account_address: AccountAddress = AccountAddress { + address: "".to_string(), + format: AddressFormat::ICPAccountIdentifier, + }; - let result = validate_address(&account.address); + let result = account_address.validate(); assert!(result.is_err()); assert_eq!( @@ -216,14 +316,10 @@ mod tests { max_length: 255 } ); - } - #[test] - fn fail_address_too_long() { - let mut account = mock_account(); - account.address = "a".repeat(Account::ADDRESS_RANGE.1 as usize + 1); + account_address.address = "a".repeat(Account::ADDRESS_RANGE.1 as usize + 1); - let result = validate_address(&account.address); + let result = account_address.validate(); assert!(result.is_err()); assert_eq!( @@ -235,16 +331,6 @@ mod tests { ); } - #[test] - fn test_address_validation() { - let mut account = mock_account(); - account.address = "a".to_string(); - - let result = validate_address(&account.address); - - assert!(result.is_ok()); - } - #[test] fn fail_missing_policy_id() { let mut account = mock_account(); @@ -279,21 +365,33 @@ mod tests { pub mod account_test_utils { use super::*; use crate::repositories::ACCOUNT_REPOSITORY; + use candid::Principal; + use ic_ledger_types::Subaccount; use orbit_essentials::repository::Repository; use uuid::Uuid; pub fn mock_account() -> Account { + let id = *Uuid::new_v4().as_bytes(); + Account { - id: *Uuid::new_v4().as_bytes(), - address: "0x1234".to_string(), - balance: None, - blockchain: Blockchain::InternetComputer, - decimals: 0u32, + id, name: "foo".to_string(), - standard: BlockchainStandard::Native, + + seed: id, + + assets: vec![AccountAsset { + asset_id: [0; 16], + balance: None, + }], + + addresses: vec![AccountAddress { + address: AccountIdentifier::new(&Principal::anonymous(), &Subaccount([0; 32])) + .to_hex(), + format: AddressFormat::ICPAccountIdentifier, + }], + last_modification_timestamp: 0, metadata: Metadata::mock(), - symbol: "ICP".to_string(), transfer_request_policy_id: None, configs_request_policy_id: None, } diff --git a/core/station/impl/src/models/address_book.rs b/core/station/impl/src/models/address_book.rs index fc4120dfd..ca658bb9a 100644 --- a/core/station/impl/src/models/address_book.rs +++ b/core/station/impl/src/models/address_book.rs @@ -1,4 +1,4 @@ -use super::Blockchain; +use super::{AddressFormat, Blockchain}; use crate::errors::AddressBookError; use crate::models::Metadata; use candid::{CandidType, Deserialize}; @@ -14,7 +14,7 @@ use std::{collections::HashMap, hash::Hash}; pub type AddressBookEntryId = UUID; /// Represents an address book entry in the system. -#[storable] +#[storable(skip_deserialize = true)] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct AddressBookEntry { /// The address book entry id, which is a UUID. @@ -25,6 +25,8 @@ pub struct AddressBookEntry { pub address: String, /// The blockchain type (e.g. `icp`, `eth`, `btc`) pub blockchain: Blockchain, + /// The address' format. + pub address_format: AddressFormat, /// The address' metadata. pub metadata: Metadata, /// The labels associated with the address. @@ -135,12 +137,13 @@ impl AddressBookEntry { } } -#[derive(CandidType, Deserialize, Debug, Clone)] +#[derive(Deserialize, Debug, Clone)] pub struct ListAddressBookEntriesInput { pub ids: Option>, pub addresses: Option>, pub blockchain: Option, pub labels: Option>, + pub address_formats: Option>, } #[derive(CandidType, Deserialize, Debug, Clone)] @@ -265,6 +268,7 @@ pub mod address_book_entry_test_utils { id: *Uuid::new_v4().as_bytes(), address_owner: "foo".to_string(), address: "0x1234".to_string(), + address_format: AddressFormat::ICPAccountIdentifier, labels: Vec::new(), blockchain: Blockchain::InternetComputer, metadata: Metadata::mock(), diff --git a/core/station/impl/src/models/asset.rs b/core/station/impl/src/models/asset.rs index 883ab4319..11071a846 100644 --- a/core/station/impl/src/models/asset.rs +++ b/core/station/impl/src/models/asset.rs @@ -1,28 +1,53 @@ -use super::{Blockchain, BlockchainStandard}; -use crate::models::Metadata; -use std::hash::{Hash, Hasher}; +use orbit_essentials::{ + model::{ModelKey, ModelValidator, ModelValidatorResult}, + storable, + types::UUID, +}; -#[derive(Clone, Debug, PartialEq, Eq)] +use super::{Blockchain, TokenStandard}; +use crate::{errors::AssetError, models::Metadata, repositories::ASSET_REPOSITORY}; +use std::{ + collections::BTreeSet, + hash::{Hash, Hasher}, +}; + +pub type AssetId = UUID; + +#[storable] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct Asset { + pub id: AssetId, /// The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) pub blockchain: Blockchain, - // The asset standard that is supported (e.g. `erc20`, `native`, etc.), canonically + // The asset standard that is supported (e.g. `erc20`, `icp_native`, etc.), canonically // represented as a lowercase string with spaces replaced with underscores. - pub standard: BlockchainStandard, + pub standards: BTreeSet, /// The asset symbol (e.g. `ICP`, `BTC`, `ETH`, etc.) pub symbol: String, /// The asset name (e.g. `Internet Computer`, `Bitcoin`, `Ethereum`, etc.) pub name: String, - /// The asset metadata (e.g. `{"logo": "https://example.com/logo.png"}`), - /// also, in the case of non-native assets, it can contain other required - /// information (e.g. `{"address": "0x1234"}`). + /// The number of decimal places that the asset supports (e.g. `8` for `BTC`, `18` for `ETH`, etc.) + pub decimals: u32, + /// The asset metadata (e.g. `{"logo": "https://example.com/logo.png"}`). pub metadata: Metadata, } +impl Asset { + pub const DECIMALS_RANGE: (u32, u32) = (0, 18); + pub const SYMBOL_RANGE: (u16, u16) = (1, 32); + pub const NAME_RANGE: (u16, u16) = (1, 64); +} + +impl ModelKey for Asset { + fn key(&self) -> AssetId { + self.id + } +} + impl Hash for Asset { fn hash(&self, state: &mut H) { self.blockchain.hash(state); - self.standard.hash(state); + self.standards.hash(state); self.symbol.hash(state); self.name.hash(state); @@ -32,3 +57,191 @@ impl Hash for Asset { keys.hash(state); } } + +#[derive(Debug, Clone)] +pub struct AssetCallerPrivileges { + pub id: AssetId, + pub can_edit: bool, + pub can_delete: bool, +} + +#[storable] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AssetEntryKey { + /// The address book entry id, which is a UUID. + pub id: AssetId, +} + +fn validate_symbol(symbol: &str) -> ModelValidatorResult { + if (symbol.len() < Asset::SYMBOL_RANGE.0 as usize) + || (symbol.len() > Asset::SYMBOL_RANGE.1 as usize) + { + return Err(AssetError::InvalidSymbolLength { + min_length: Asset::SYMBOL_RANGE.0, + max_length: Asset::SYMBOL_RANGE.1, + }); + } + + if !symbol.chars().all(|c| c.is_ascii_alphanumeric()) { + return Err(AssetError::InvalidSymbol); + } + + Ok(()) +} + +fn validate_name(name: &str) -> ModelValidatorResult { + if (name.len() < Asset::NAME_RANGE.0 as usize) || (name.len() > Asset::NAME_RANGE.1 as usize) { + return Err(AssetError::InvalidNameLength { + min_length: Asset::NAME_RANGE.0, + max_length: Asset::NAME_RANGE.1, + }); + } + + Ok(()) +} + +fn validate_decimals(decimals: u32) -> ModelValidatorResult { + if (decimals < Asset::DECIMALS_RANGE.0) || (decimals > Asset::DECIMALS_RANGE.1) { + return Err(AssetError::InvalidDecimals { + min: Asset::DECIMALS_RANGE.0, + max: Asset::DECIMALS_RANGE.1, + }); + } + + Ok(()) +} + +fn validate_uniqueness( + asset_id: &AssetId, + symbol: &str, + blockchain: &Blockchain, +) -> ModelValidatorResult { + if let Some(existing_asset_id) = + ASSET_REPOSITORY.exists_unique(blockchain.to_string().as_str(), symbol) + { + if existing_asset_id != *asset_id { + return Err(AssetError::AlreadyExists { + symbol: symbol.to_string(), + blockchain: blockchain.to_string(), + }); + } + } + + Ok(()) +} + +impl ModelValidator for Asset { + fn validate(&self) -> ModelValidatorResult { + validate_symbol(&self.symbol)?; + validate_name(&self.name)?; + validate_decimals(self.decimals)?; + validate_uniqueness(&self.id, &self.symbol, &self.blockchain)?; + + self.metadata.validate()?; + + Ok(()) + } +} + +#[cfg(any(test, feature = "canbench"))] +pub mod asset_test_utils { + + use std::collections::{BTreeMap, BTreeSet}; + + use crate::models::{Blockchain, Metadata, TokenStandard}; + + use super::Asset; + + pub fn mock_asset() -> Asset { + Asset { + id: [0; 16], + blockchain: Blockchain::InternetComputer, + standards: BTreeSet::from([TokenStandard::InternetComputerNative]), + symbol: "ICP".to_string(), + name: "Internet Computer".to_string(), + metadata: Metadata::new(BTreeMap::from([ + ( + "ledger_canister_id".to_string(), + "ryjl3-tyaaa-aaaaa-aaaba-cai".to_string(), + ), + ( + "index_canister_id".to_string(), + "qhbym-qaaaa-aaaaa-aaafq-cai".to_string(), + ), + ])), + decimals: 8, + } + } + + pub fn mock_asset_b() -> Asset { + Asset { + id: [1; 16], + blockchain: Blockchain::InternetComputer, + standards: BTreeSet::from([TokenStandard::InternetComputerNative]), + symbol: "TEST".to_string(), + name: "Other Test Asset".to_string(), + decimals: 8, + metadata: Metadata::default(), + } + } +} + +#[cfg(test)] +mod test { + + use orbit_essentials::repository::Repository; + + use super::*; + + #[test] + fn test_name_validation() { + let mut asset = asset_test_utils::mock_asset(); + assert!(asset.validate().is_ok()); + + asset.name = "".to_string(); + assert!(asset.validate().is_err()); + + asset.name = "a".repeat(Asset::NAME_RANGE.1 as usize + 1); + assert!(asset.validate().is_err()); + } + + #[test] + fn test_symbol_validation() { + let mut asset = asset_test_utils::mock_asset(); + assert!(asset.validate().is_ok()); + + asset.symbol = "".to_string(); + assert!(asset.validate().is_err()); + + asset.symbol = "a".repeat(Asset::SYMBOL_RANGE.1 as usize + 1); + assert!(asset.validate().is_err()); + } + + #[test] + fn test_decimals_validation() { + let mut asset = asset_test_utils::mock_asset(); + assert!(asset.validate().is_ok()); + + asset.decimals = Asset::DECIMALS_RANGE.1 + 1; + assert!(asset.validate().is_err()); + } + + #[test] + fn test_validate_uniqueness() { + let mut asset = asset_test_utils::mock_asset(); + assert!(asset.validate().is_ok()); + + ASSET_REPOSITORY.insert(asset.key(), asset.clone()); + + // this passes uniqueness test because the asset id is the same + assert!(asset.validate().is_ok()); + + // this fails uniqueness test because the asset id is different + asset.id = [1; 16]; + + assert!(matches!( + asset.validate().expect_err("Asset should not be unique"), + AssetError::AlreadyExists { .. } + )); + } +} diff --git a/core/station/impl/src/models/blockchain.rs b/core/station/impl/src/models/blockchain.rs index 224e67866..205b74be3 100644 --- a/core/station/impl/src/models/blockchain.rs +++ b/core/station/impl/src/models/blockchain.rs @@ -1,4 +1,4 @@ -use super::BlockchainStandard; +use super::TokenStandard; use candid::CandidType; use orbit_essentials::storable; use std::fmt::{Display, Formatter}; @@ -23,13 +23,13 @@ impl Blockchain { } /// The list of standards that the blockchain supports. - pub fn supported_standards(&self) -> Vec { + pub fn supported_standards(&self) -> Vec { match self { Blockchain::InternetComputer => { - vec![BlockchainStandard::Native, BlockchainStandard::ICRC1] + vec![TokenStandard::InternetComputerNative, TokenStandard::ICRC1] } - Blockchain::Ethereum => vec![BlockchainStandard::Native, BlockchainStandard::ERC20], - Blockchain::Bitcoin => vec![BlockchainStandard::Native], + Blockchain::Ethereum => vec![], + Blockchain::Bitcoin => vec![], } } } @@ -85,18 +85,9 @@ mod tests { fn match_supported_standards() { assert!(Blockchain::InternetComputer .supported_standards() - .contains(&BlockchainStandard::Native)); + .contains(&TokenStandard::InternetComputerNative)); assert!(Blockchain::InternetComputer .supported_standards() - .contains(&BlockchainStandard::ICRC1)); - assert!(Blockchain::Ethereum - .supported_standards() - .contains(&BlockchainStandard::Native)); - assert!(Blockchain::Ethereum - .supported_standards() - .contains(&BlockchainStandard::ERC20)); - assert!(Blockchain::Bitcoin - .supported_standards() - .contains(&BlockchainStandard::Native)); + .contains(&TokenStandard::ICRC1)); } } diff --git a/core/station/impl/src/models/blockchain_standard.rs b/core/station/impl/src/models/blockchain_standard.rs index b5d801498..866df4165 100644 --- a/core/station/impl/src/models/blockchain_standard.rs +++ b/core/station/impl/src/models/blockchain_standard.rs @@ -1,37 +1,98 @@ -use candid::CandidType; use orbit_essentials::storable; use std::{ fmt::{Display, Formatter}, str::FromStr, }; +use super::AddressFormat; + +#[storable] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct TokenStandardInfo { + pub name: String, + pub address_formats: Vec, +} + #[storable] -#[derive(CandidType, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub enum BlockchainStandard { - Native, +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum TokenStandard { + InternetComputerNative, ICRC1, - ERC20, + // ERC20, +} + +impl TokenStandard { + pub fn get_info(&self) -> TokenStandardInfo { + match self { + TokenStandard::InternetComputerNative => TokenStandardInfo { + name: "icp_native".to_owned(), + address_formats: vec![AddressFormat::ICPAccountIdentifier], + }, + TokenStandard::ICRC1 => TokenStandardInfo { + name: "icrc1".to_owned(), + address_formats: vec![AddressFormat::ICRC1Account], + }, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum StandardOperation { + Balance, + Transfer, + ListTransfers, +} +impl std::fmt::Display for StandardOperation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StandardOperation::Balance => write!(f, "balance"), + StandardOperation::Transfer => write!(f, "transfer"), + StandardOperation::ListTransfers => write!(f, "list_transfers"), + } + } +} + +impl TokenStandard { + pub const METADATA_KEY_LEDGER_CANISTER_ID: &'static str = "ledger_canister_id"; + pub const METADATA_KEY_INDEX_CANISTER_ID: &'static str = "index_canister_id"; + + pub fn get_required_metadata(&self) -> Vec { + match self { + TokenStandard::ICRC1 | TokenStandard::InternetComputerNative => vec![ + Self::METADATA_KEY_LEDGER_CANISTER_ID.to_string(), + // index canister is optional + ], + } + } + + pub fn get_supported_operations(&self) -> Vec { + match self { + TokenStandard::InternetComputerNative | TokenStandard::ICRC1 => vec![ + StandardOperation::Balance, + StandardOperation::Transfer, + StandardOperation::ListTransfers, + ], + } + } } -impl FromStr for BlockchainStandard { +impl FromStr for TokenStandard { type Err = (); - fn from_str(variant: &str) -> Result { + fn from_str(variant: &str) -> Result { match variant { - "native" => Ok(BlockchainStandard::Native), - "icrc1" => Ok(BlockchainStandard::ICRC1), - "erc20" => Ok(BlockchainStandard::ERC20), + "icp_native" => Ok(TokenStandard::InternetComputerNative), + "icrc1" => Ok(TokenStandard::ICRC1), _ => Err(()), } } } -impl Display for BlockchainStandard { +impl Display for TokenStandard { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { - BlockchainStandard::Native => write!(f, "native"), - BlockchainStandard::ERC20 => write!(f, "erc20"), - BlockchainStandard::ICRC1 => write!(f, "icrc1"), + TokenStandard::InternetComputerNative => write!(f, "icp_native"), + TokenStandard::ICRC1 => write!(f, "icrc1"), } } } @@ -42,20 +103,18 @@ mod tests { #[test] fn blockchain_standard_match_string_representation() { - assert_eq!(BlockchainStandard::Native.to_string(), "native"); assert_eq!( - BlockchainStandard::from_str("native").unwrap(), - BlockchainStandard::Native + TokenStandard::InternetComputerNative.to_string(), + "icp_native" ); - assert_eq!(BlockchainStandard::ICRC1.to_string(), "icrc1"); assert_eq!( - BlockchainStandard::from_str("icrc1").unwrap(), - BlockchainStandard::ICRC1 + TokenStandard::from_str("icp_native").unwrap(), + TokenStandard::InternetComputerNative ); - assert_eq!(BlockchainStandard::ERC20.to_string(), "erc20"); + assert_eq!(TokenStandard::ICRC1.to_string(), "icrc1"); assert_eq!( - BlockchainStandard::from_str("erc20").unwrap(), - BlockchainStandard::ERC20 + TokenStandard::from_str("icrc1").unwrap(), + TokenStandard::ICRC1 ); } } diff --git a/core/station/impl/src/models/indexes/transfer_account_index.rs b/core/station/impl/src/models/indexes/transfer_account_index.rs index 6c901dab3..08ce941dd 100644 --- a/core/station/impl/src/models/indexes/transfer_account_index.rs +++ b/core/station/impl/src/models/indexes/transfer_account_index.rs @@ -53,6 +53,8 @@ mod tests { request_id: [0; 16], fee: candid::Nat(BigUint::from(0u32)), from_account: [1; 16], + from_asset: [2; 16], + with_standard: crate::models::TokenStandard::InternetComputerNative, to_address: "0x1234".to_string(), status: TransferStatus::Created, initiator_user: [2; 16], diff --git a/core/station/impl/src/models/indexes/unique_index.rs b/core/station/impl/src/models/indexes/unique_index.rs index 384a97e21..3056208f6 100644 --- a/core/station/impl/src/models/indexes/unique_index.rs +++ b/core/station/impl/src/models/indexes/unique_index.rs @@ -1,6 +1,6 @@ use crate::{ core::utils::format_unique_string, - models::{Account, AddressBookEntry, ExternalCanister, User, UserGroup}, + models::{Account, AddressBookEntry, Asset, ExternalCanister, User, UserGroup}, }; use candid::Principal; use orbit_essentials::{storable, types::UUID}; @@ -18,6 +18,10 @@ pub enum UniqueIndexKey { UserGroupName(String), UserIdentity(Principal), UserName(String), + AssetSymbolBlockchain( + String, // Blockchain + String, // Symbol + ), } impl AddressBookEntry { @@ -80,6 +84,28 @@ impl UserGroup { } } +impl Asset { + /// Converts the asset to it's unique index by name. + fn to_unique_index(&self) -> (UniqueIndexKey, UUID) { + ( + Self::to_unique_index_by_symbol_blockchain(&self.symbol, self.blockchain.to_string()), + self.id, + ) + } + + pub fn to_unique_index_by_symbol_blockchain( + symbol: &str, + blockchain: String, + ) -> UniqueIndexKey { + UniqueIndexKey::AssetSymbolBlockchain(symbol.to_uppercase(), blockchain.to_string()) + } + + /// Extracts all unique indexes for the asset. + pub fn to_unique_indexes(&self) -> Vec<(UniqueIndexKey, UUID)> { + vec![self.to_unique_index()] + } +} + impl ExternalCanister { /// Converts the external canister to it's unique index by name. fn to_unique_index_by_name(&self) -> (UniqueIndexKey, UUID) { @@ -123,6 +149,7 @@ impl Account { #[cfg(test)] mod tests { + use super::*; use crate::models::{ account_test_utils::mock_account, address_book_entry_test_utils::mock_address_book_entry, diff --git a/core/station/impl/src/models/request.rs b/core/station/impl/src/models/request.rs index a9aa28593..057b61727 100644 --- a/core/station/impl/src/models/request.rs +++ b/core/station/impl/src/models/request.rs @@ -1,7 +1,7 @@ use super::request_policy_rule::{RequestEvaluationResult, RequestPolicyRuleInput}; use super::{ - ConfigureExternalCanisterOperationKind, DisplayUser, EvaluationStatus, RequestApproval, - RequestApprovalStatus, RequestOperation, RequestStatus, UserId, UserKey, + ChangeAssets, ConfigureExternalCanisterOperationKind, DisplayUser, EvaluationStatus, + RequestApproval, RequestApprovalStatus, RequestOperation, RequestStatus, UserId, UserKey, }; use crate::core::evaluation::{ Evaluate, REQUEST_APPROVE_RIGHTS_REQUEST_POLICY_RULE_EVALUATOR, REQUEST_POLICY_RULE_EVALUATOR, @@ -13,8 +13,8 @@ use crate::core::request::{ RequestApprovalRightsEvaluator, RequestEvaluator, RequestPossibleApproversFinder, }; use crate::core::validation::{ - EnsureAccount, EnsureAddressBookEntry, EnsureIdExists, EnsureRequestPolicy, EnsureUser, - EnsureUserGroup, + EnsureAccount, EnsureAddressBookEntry, EnsureAsset, EnsureIdExists, EnsureRequestPolicy, + EnsureUser, EnsureUserGroup, }; use crate::errors::{EvaluateError, RequestError, ValidationError}; use crate::models::resource::{ExecutionMethodResourceTarget, ValidationMethodResourceTarget}; @@ -198,6 +198,7 @@ fn validate_request_operation_foreign_keys( RequestOperation::ManageSystemInfo(_) => (), RequestOperation::Transfer(op) => { EnsureAccount::id_exists(&op.input.from_account_id)?; + EnsureAsset::id_exists(&op.input.from_asset_id)?; } RequestOperation::AddAccount(op) => { op.input.read_permission.validate()?; @@ -236,6 +237,19 @@ fn validate_request_operation_foreign_keys( { policy_rule.validate()?; } + + if let Some(ChangeAssets::ReplaceWith { assets }) = &op.input.change_assets { + EnsureAsset::id_list_exists(assets)?; + } + + if let Some(ChangeAssets::Change { + add_assets, + remove_assets, + }) = &op.input.change_assets + { + EnsureAsset::id_list_exists(add_assets)?; + EnsureAsset::id_list_exists(remove_assets)?; + } } RequestOperation::AddAddressBookEntry(_) => (), RequestOperation::EditAddressBookEntry(op) => { @@ -318,6 +332,13 @@ fn validate_request_operation_foreign_keys( EnsureUserGroup::id_exists(&committee.user_group_id)?; } } + RequestOperation::AddAsset(_) => (), + RequestOperation::EditAsset(op) => { + EnsureAsset::id_exists(&op.input.asset_id)?; + } + RequestOperation::RemoveAsset(op) => { + EnsureAsset::id_exists(&op.input.asset_id)?; + } } Ok(()) } @@ -484,12 +505,15 @@ impl Request { #[cfg(test)] mod tests { use crate::core::validation::disable_mock_resource_validation; + use crate::models::asset_test_utils::mock_asset; use crate::models::permission::Allow; use crate::models::{ - AddAccountOperationInput, AddUserOperation, AddUserOperationInput, Metadata, - TransferOperation, TransferOperationInput, + Account, AccountKey, AddAccountOperationInput, AddAssetOperationInput, AddUserOperation, + AddUserOperationInput, Blockchain, Metadata, TokenStandard, TransferOperation, + TransferOperationInput, }; - use crate::services::AccountService; + use crate::repositories::ACCOUNT_REPOSITORY; + use crate::services::{AccountService, AssetService}; use super::request_test_utils::mock_request; use super::*; @@ -658,13 +682,26 @@ mod tests { async fn test_request_operation_is_valid() { disable_mock_resource_validation(); + let asset = AssetService::default() + .create( + AddAssetOperationInput { + name: "a".to_owned(), + symbol: "a".to_owned(), + decimals: 0, + metadata: Metadata::default(), + blockchain: Blockchain::InternetComputer, + standards: vec![TokenStandard::InternetComputerNative], + }, + None, + ) + .expect("Failed to create asset"); + let account_service = AccountService::default(); let account = account_service .create_account( AddAccountOperationInput { name: "a".to_owned(), - blockchain: crate::models::Blockchain::InternetComputer, - standard: crate::models::BlockchainStandard::Native, + assets: vec![asset.id], metadata: Metadata::default(), read_permission: Allow::default(), configs_permission: Allow::default(), @@ -688,7 +725,10 @@ mod tests { metadata: Metadata::default(), to: "0x1234".to_string(), from_account_id: account.id, + from_asset_id: asset.id, + with_standard: TokenStandard::InternetComputerNative, }, + asset, }); let result = validate_request_operation_foreign_keys(&operation); @@ -710,7 +750,10 @@ mod tests { metadata: Metadata::default(), to: "0x1234".to_string(), from_account_id: [0; 16], + from_asset_id: [0; 16], + with_standard: TokenStandard::InternetComputerNative, }, + asset: mock_asset(), })) .expect_err("Invalid account id should fail"); @@ -779,8 +822,7 @@ mod tests { account_id: None, input: crate::models::AddAccountOperationInput { name: "a".to_owned(), - blockchain: crate::models::Blockchain::InternetComputer, - standard: crate::models::BlockchainStandard::Native, + assets: vec![], metadata: Metadata::default(), read_permission: Allow { auth_scope: crate::models::permission::AuthScope::Restricted, @@ -800,6 +842,7 @@ mod tests { crate::models::EditAccountOperation { input: crate::models::EditAccountOperationInput { account_id: [0; 16], + change_assets: None, read_permission: None, configs_permission: None, transfer_permission: None, @@ -811,6 +854,41 @@ mod tests { )) .expect_err("Invalid account id should fail"); + ACCOUNT_REPOSITORY.insert( + AccountKey { id: [0; 16] }, + Account { + id: [0; 16], + name: "a".to_owned(), + seed: [0; 16], + assets: vec![], + addresses: vec![], + metadata: Metadata::default(), + transfer_request_policy_id: None, + configs_request_policy_id: None, + last_modification_timestamp: 0, + }, + ); + + validate_request_operation_foreign_keys(&RequestOperation::EditAccount( + crate::models::EditAccountOperation { + input: crate::models::EditAccountOperationInput { + account_id: [0; 16], + change_assets: Some(ChangeAssets::ReplaceWith { + assets: vec![[0; 16]], + }), + read_permission: None, + configs_permission: None, + transfer_permission: None, + configs_request_policy: None, + transfer_request_policy: None, + name: None, + }, + }, + )) + .expect_err("Invalid asset id should fail"); + + ACCOUNT_REPOSITORY.clear(); + validate_request_operation_foreign_keys(&RequestOperation::EditAddressBookEntry( crate::models::EditAddressBookEntryOperation { input: crate::models::EditAddressBookEntryOperationInput { @@ -868,7 +946,8 @@ mod tests { pub mod request_test_utils { use super::*; use crate::models::{ - Metadata, RequestApprovalStatus, TransferOperation, TransferOperationInput, + asset_test_utils::mock_asset, Metadata, RequestApprovalStatus, TokenStandard, + TransferOperation, TransferOperationInput, }; use num_bigint::BigUint; use uuid::Uuid; @@ -892,7 +971,10 @@ pub mod request_test_utils { metadata: Metadata::default(), to: "0x1234".to_string(), from_account_id: [1; 16], + from_asset_id: [0; 16], + with_standard: TokenStandard::InternetComputerNative, }, + asset: mock_asset(), }), approvals: vec![RequestApproval { approver_id: [1; 16], diff --git a/core/station/impl/src/models/request_operation.rs b/core/station/impl/src/models/request_operation.rs index b4b3041cc..505e5d077 100644 --- a/core/station/impl/src/models/request_operation.rs +++ b/core/station/impl/src/models/request_operation.rs @@ -3,9 +3,9 @@ use super::{ request_policy_rule::{RequestPolicyRule, RequestPolicyRuleInput}, request_specifier::RequestSpecifier, resource::{Resource, ValidationMethodResourceTarget}, - AccountId, AddressBookEntryId, Blockchain, BlockchainStandard, ChangeMetadata, - CycleObtainStrategy, DisasterRecoveryCommittee, ExternalCanisterCallPermission, - ExternalCanisterState, MetadataItem, UserGroupId, UserId, UserStatus, + AccountAsset, AccountId, AddressBookEntryId, AddressFormat, Asset, AssetId, Blockchain, + ChangeMetadata, CycleObtainStrategy, DisasterRecoveryCommittee, ExternalCanisterCallPermission, + ExternalCanisterState, MetadataItem, TokenStandard, UserGroupId, UserId, UserStatus, }; use crate::core::validation::EnsureExternalCanister; use crate::errors::ValidationError; @@ -15,9 +15,9 @@ use orbit_essentials::cdk::api::management_canister::main::{self as mgmt}; use orbit_essentials::cmc::SubnetSelection; use orbit_essentials::model::{ModelValidator, ModelValidatorResult}; use orbit_essentials::{storable, types::UUID}; -use std::fmt::Display; +use std::{collections::HashSet, fmt::Display}; -#[storable(skip_deserialize = true)] +#[storable] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, strum::VariantNames)] #[strum(serialize_all = "PascalCase")] pub enum RequestOperation { @@ -44,6 +44,9 @@ pub enum RequestOperation { RemoveRequestPolicy(RemoveRequestPolicyOperation), ManageSystemInfo(ManageSystemInfoOperation), SetDisasterRecovery(SetDisasterRecoveryOperation), + AddAsset(AddAssetOperation), + EditAsset(EditAssetOperation), + RemoveAsset(RemoveAssetOperation), } impl Display for RequestOperation { @@ -74,22 +77,75 @@ impl Display for RequestOperation { RequestOperation::RemoveRequestPolicy(_) => write!(f, "remove_request_policy"), RequestOperation::ManageSystemInfo(_) => write!(f, "manage_system_info"), RequestOperation::SetDisasterRecovery(_) => write!(f, "set_disaster_recovery"), + RequestOperation::AddAsset(_) => write!(f, "add_asset"), + RequestOperation::EditAsset(_) => write!(f, "edit_asset"), + RequestOperation::RemoveAsset(_) => write!(f, "remove_asset"), } } } #[storable] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AddAssetOperation { + pub asset_id: Option, + pub input: AddAssetOperationInput, +} + +#[storable] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AddAssetOperationInput { + pub name: String, + pub symbol: String, + pub decimals: u32, + pub metadata: Metadata, + pub blockchain: Blockchain, + pub standards: Vec, +} + +#[storable] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct EditAssetOperation { + pub input: EditAssetOperationInput, +} + +#[storable] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct EditAssetOperationInput { + pub asset_id: AssetId, + pub name: Option, + pub symbol: Option, + pub change_metadata: Option, + pub blockchain: Option, + pub standards: Option>, +} + +#[storable] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct RemoveAssetOperation { + pub input: RemoveAssetOperationInput, +} + +#[storable] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct RemoveAssetOperationInput { + pub asset_id: AssetId, +} + +#[storable(skip_deserialize = true)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct TransferOperation { pub transfer_id: Option, pub input: TransferOperationInput, + pub asset: Asset, pub fee: Option, } -#[storable] +#[storable(skip_deserialize = true)] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct TransferOperationInput { pub from_account_id: AccountId, + pub from_asset_id: AssetId, + pub with_standard: TokenStandard, pub to: String, pub amount: candid::Nat, pub metadata: Metadata, @@ -105,12 +161,11 @@ pub struct AddAccountOperation { pub input: AddAccountOperationInput, } -#[storable] +#[storable(skip_deserialize = true)] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct AddAccountOperationInput { pub name: String, - pub blockchain: Blockchain, - pub standard: BlockchainStandard, + pub assets: Vec, pub metadata: Metadata, pub read_permission: Allow, pub configs_permission: Allow, @@ -125,10 +180,55 @@ pub struct EditAccountOperation { pub input: EditAccountOperationInput, } +#[storable] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum ChangeAssets { + ReplaceWith { + assets: Vec, + }, + Change { + add_assets: Vec, + remove_assets: Vec, + }, +} + +impl ChangeAssets { + pub fn apply(&self, assets: &mut Vec) { + match self { + ChangeAssets::ReplaceWith { assets: new_assets } => { + *assets = new_assets + .iter() + .map(|asset_id| AccountAsset { + asset_id: *asset_id, + balance: None, + }) + .collect(); + } + ChangeAssets::Change { + add_assets, + remove_assets, + } => { + let existing_assets: HashSet<_> = assets.iter().map(|a| a.asset_id).collect(); + for asset_id in add_assets { + if !existing_assets.contains(asset_id) { + assets.push(AccountAsset { + asset_id: *asset_id, + balance: None, + }); + } + } + + assets.retain(|a| !remove_assets.contains(&a.asset_id)); + } + } + } +} + #[storable] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct EditAccountOperationInput { pub account_id: AccountId, + pub change_assets: Option, pub name: Option, pub read_permission: Option, pub configs_permission: Option, @@ -145,11 +245,12 @@ pub struct AddAddressBookEntryOperation { pub input: AddAddressBookEntryOperationInput, } -#[storable] +#[storable(skip_deserialize = true)] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct AddAddressBookEntryOperationInput { pub address_owner: String, pub address: String, + pub address_format: AddressFormat, pub blockchain: Blockchain, #[serde(default)] pub labels: Vec, @@ -678,3 +779,42 @@ pub struct ManageSystemInfoOperationInput { pub struct ManageSystemInfoOperation { pub input: ManageSystemInfoOperationInput, } + +#[cfg(test)] +mod test { + use crate::models::AccountAsset; + + use super::ChangeAssets; + + #[test] + fn test_change_assets() { + let mut assets: Vec = [[3; 16], [9; 16], [10; 16], [11; 16], [13; 16]] + .into_iter() + .map(|id| AccountAsset { + asset_id: id, + balance: None, + }) + .collect(); + + ChangeAssets::Change { + // 3 already exists, should not be added twice + add_assets: vec![[0; 16], [1; 16], [2; 16], [3; 16]], + // 12 doesn't exist, should not be in an issue + remove_assets: vec![[10; 16], [11; 16], [12; 16]], + } + .apply(&mut assets); + + assert_eq!(assets.len(), 5 + 3 - 2); + + assert!(!assets.iter().any(|a| a.asset_id == [10; 16])); + assert!(!assets.iter().any(|a| a.asset_id == [11; 16])); + assert!(!assets.iter().any(|a| a.asset_id == [12; 16])); + + assert!(assets.iter().any(|a| a.asset_id == [0; 16])); + assert!(assets.iter().any(|a| a.asset_id == [1; 16])); + assert!(assets.iter().any(|a| a.asset_id == [2; 16])); + assert!(assets.iter().any(|a| a.asset_id == [3; 16])); + + assert_eq!(assets.iter().filter(|a| a.asset_id == [3; 16]).count(), 1); + } +} diff --git a/core/station/impl/src/models/request_operation_filter_type.rs b/core/station/impl/src/models/request_operation_filter_type.rs index e7221cb66..746120ba1 100644 --- a/core/station/impl/src/models/request_operation_filter_type.rs +++ b/core/station/impl/src/models/request_operation_filter_type.rs @@ -28,6 +28,9 @@ pub enum RequestOperationFilterType { ManageSystemInfo, ConfigureExternalCanister(Principal), FundExternalCanister(Principal), + AddAsset, + EditAsset, + RemoveAsset, } impl From for RequestOperationFilterType { @@ -80,6 +83,9 @@ impl From for RequestOperationFilterType { RequestOperation::FundExternalCanister(operation) => { RequestOperationFilterType::FundExternalCanister(operation.canister_id) } + RequestOperation::AddAsset(_) => RequestOperationFilterType::AddAsset, + RequestOperation::EditAsset(_) => RequestOperationFilterType::EditAsset, + RequestOperation::RemoveAsset(_) => RequestOperationFilterType::RemoveAsset, } } } diff --git a/core/station/impl/src/models/request_operation_type.rs b/core/station/impl/src/models/request_operation_type.rs index bf027f6a3..90f6fd86f 100644 --- a/core/station/impl/src/models/request_operation_type.rs +++ b/core/station/impl/src/models/request_operation_type.rs @@ -33,6 +33,9 @@ pub enum RequestOperationType { SetDisasterRecovery = 23, ConfigureExternalCanister = 24, FundExternalCanister = 25, + AddAsset = 26, + EditAsset = 27, + RemoveAsset = 28, } /// A helper enum to filter the requests based on the operation type and @@ -62,6 +65,9 @@ pub enum ListRequestsOperationType { EditAddressBookEntry, RemoveAddressBookEntry, ManageSystemInfo, + AddAsset, + EditAsset, + RemoveAsset, } impl PartialEq for RequestOperationFilterType { @@ -164,6 +170,15 @@ impl PartialEq for RequestOperationFilterType { ListRequestsOperationType::ManageSystemInfo => { matches!(self, RequestOperationFilterType::ManageSystemInfo) } + ListRequestsOperationType::AddAsset => { + matches!(self, RequestOperationFilterType::AddAsset) + } + ListRequestsOperationType::EditAsset => { + matches!(self, RequestOperationFilterType::EditAsset) + } + ListRequestsOperationType::RemoveAsset => { + matches!(self, RequestOperationFilterType::RemoveAsset) + } } } } @@ -231,6 +246,9 @@ impl Display for RequestOperationType { write!(f, "configure_external_canister") } RequestOperationType::FundExternalCanister => write!(f, "fund_external_canister"), + RequestOperationType::AddAsset => write!(f, "add_asset"), + RequestOperationType::EditAsset => write!(f, "edit_asset"), + RequestOperationType::RemoveAsset => write!(f, "remove_asset"), } } } diff --git a/core/station/impl/src/models/request_policy_rule.rs b/core/station/impl/src/models/request_policy_rule.rs index bf805c464..167e068df 100644 --- a/core/station/impl/src/models/request_policy_rule.rs +++ b/core/station/impl/src/models/request_policy_rule.rs @@ -8,14 +8,18 @@ use super::{ use crate::{ core::{ic_cdk::api::print, utils::calculate_minimum_threshold}, errors::{MatchError, ValidationError}, - repositories::{UserWhereClause, ADDRESS_BOOK_REPOSITORY, USER_REPOSITORY}, + repositories::{UserWhereClause, ADDRESS_BOOK_REPOSITORY, ASSET_REPOSITORY, USER_REPOSITORY}, services::ACCOUNT_SERVICE, }; -use orbit_essentials::model::{ModelKey, ModelValidator, ModelValidatorResult}; use orbit_essentials::storable; +use orbit_essentials::{ + model::{ModelKey, ModelValidator, ModelValidatorResult}, + repository::Repository, +}; use station_api::EvaluationSummaryReasonDTO; use std::{cmp, hash::Hash}; use std::{collections::HashSet, sync::Arc}; +use uuid::Uuid; #[storable] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -413,14 +417,27 @@ impl }); } Ok(account) => { - let is_in_address_book = ADDRESS_BOOK_REPOSITORY - .exists(account.blockchain, transfer.input.to.clone()); - - if is_in_address_book { - return Ok(RequestPolicyRuleResult { - status: EvaluationStatus::Approved, - evaluated_rule: EvaluatedRequestPolicyRule::AllowListed, - }); + for account_asset in account.assets { + let Some(asset) = ASSET_REPOSITORY.get(&account_asset.asset_id) + else { + print(format!( + "Asset `{}` not found in account `{}`.", + Uuid::from_bytes(account_asset.asset_id).hyphenated(), + Uuid::from_bytes(account.id).hyphenated() + )); + + continue; + }; + + let is_in_address_book = ADDRESS_BOOK_REPOSITORY + .exists(asset.blockchain, transfer.input.to.clone()); + + if is_in_address_book { + return Ok(RequestPolicyRuleResult { + status: EvaluationStatus::Approved, + evaluated_rule: EvaluatedRequestPolicyRule::AllowListed, + }); + } } } } diff --git a/core/station/impl/src/models/request_specifier.rs b/core/station/impl/src/models/request_specifier.rs index 646520bca..07b316a40 100644 --- a/core/station/impl/src/models/request_specifier.rs +++ b/core/station/impl/src/models/request_specifier.rs @@ -1,19 +1,21 @@ use super::resource::{Resource, ResourceIds}; use super::{MetadataItem, Request, RequestId, RequestOperation, RequestOperationType}; use crate::core::validation::{ - EnsureAccount, EnsureAddressBookEntry, EnsureIdExists, EnsureRequestPolicy, + EnsureAccount, EnsureAddressBookEntry, EnsureAsset, EnsureIdExists, EnsureRequestPolicy, EnsureResourceIdExists, EnsureUser, EnsureUserGroup, }; use crate::errors::ValidationError; use crate::models::resource::{CallExternalCanisterResourceTarget, ExternalCanisterId}; use crate::models::user::User; -use crate::repositories::ADDRESS_BOOK_REPOSITORY; +use crate::repositories::{ADDRESS_BOOK_REPOSITORY, ASSET_REPOSITORY}; use crate::services::ACCOUNT_SERVICE; use crate::{errors::MatchError, repositories::USER_REPOSITORY}; +use orbit_essentials::cdk::api::print; use orbit_essentials::model::{ModelValidator, ModelValidatorResult}; use orbit_essentials::repository::Repository; use orbit_essentials::storable; use orbit_essentials::types::UUID; +use uuid::Uuid; #[storable] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -50,7 +52,7 @@ pub enum ResourceSpecifier { Resource(Resource), } -#[storable(skip_deserialize = true)] +#[storable] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, strum::VariantNames)] #[strum(serialize_all = "PascalCase")] pub enum RequestSpecifier { @@ -76,6 +78,9 @@ pub enum RequestSpecifier { RemoveUserGroup(ResourceIds), ManageSystemInfo, SystemUpgrade, + AddAsset, + EditAsset(ResourceIds), + RemoveAsset(ResourceIds), } impl ModelValidator for RequestSpecifier { @@ -91,7 +96,8 @@ impl ModelValidator for RequestSpecifier { | RequestSpecifier::AddRequestPolicy | RequestSpecifier::ManageSystemInfo | RequestSpecifier::SetDisasterRecovery - | RequestSpecifier::AddUserGroup => (), + | RequestSpecifier::AddUserGroup + | RequestSpecifier::AddAsset => (), RequestSpecifier::CallExternalCanister(target) => { target.validate()?; @@ -121,6 +127,11 @@ impl ModelValidator for RequestSpecifier { | RequestSpecifier::RemoveUserGroup(resource_ids) => { EnsureUserGroup::resource_ids_exist(resource_ids)? } + + RequestSpecifier::EditAsset(resource_ids) + | RequestSpecifier::RemoveAsset(resource_ids) => { + EnsureAsset::resource_ids_exist(resource_ids)? + } } Ok(()) } @@ -157,6 +168,10 @@ impl From<&RequestSpecifier> for RequestOperationType { RequestSpecifier::RemoveUserGroup(_) => RequestOperationType::RemoveUserGroup, RequestSpecifier::ManageSystemInfo => RequestOperationType::ManageSystemInfo, RequestSpecifier::SetDisasterRecovery => RequestOperationType::SetDisasterRecovery, + + RequestSpecifier::AddAsset => RequestOperationType::AddAsset, + RequestSpecifier::EditAsset(_) => RequestOperationType::EditAsset, + RequestSpecifier::RemoveAsset(_) => RequestOperationType::RemoveAsset, } } } @@ -234,13 +249,30 @@ impl Match for AddressBookMetadataMatcher { Ok(match request.operation.to_owned() { RequestOperation::Transfer(transfer) => { if let Ok(account) = ACCOUNT_SERVICE.get_account(&transfer.input.from_account_id) { - if let Some(address_book_entry) = ADDRESS_BOOK_REPOSITORY - .find_by_address(account.blockchain, transfer.input.to) - { - address_book_entry.metadata.contains(&metadata) - } else { - false + let mut found = false; + + for account_asset in account.assets { + let Some(asset) = ASSET_REPOSITORY.get(&account_asset.asset_id) else { + print(format!( + "Could not load asset `{}` in account `{}`", + Uuid::from_bytes(account_asset.asset_id).hyphenated(), + Uuid::from_bytes(account.id).hyphenated(), + )); + + continue; + }; + + if let Some(address_book_entry) = ADDRESS_BOOK_REPOSITORY + .find_by_address(asset.blockchain, transfer.input.to.clone()) + { + if address_book_entry.metadata.contains(&metadata) { + found = true; + break; + } + } } + + found } else { false } @@ -255,6 +287,7 @@ mod tests { use crate::{ core::{validation::disable_mock_resource_validation, write_system_info}, models::{ + asset_test_utils::mock_asset, request_specifier::{ Match, RequestSpecifier, UserInvolvedInPolicyRuleForRequestResource, UserMatcher, UserSpecifier, @@ -267,11 +300,11 @@ mod tests { system::SystemInfo, CanisterMethod, RequestKey, }, - repositories::REQUEST_REPOSITORY, + repositories::{ASSET_REPOSITORY, REQUEST_REPOSITORY}, }; use candid::Principal; - use orbit_essentials::cdk::mocks::api::id; use orbit_essentials::cdk::mocks::TEST_CANISTER_ID; + use orbit_essentials::{cdk::mocks::api::id, model::ModelKey}; use orbit_essentials::{model::ModelValidator, repository::Repository}; #[tokio::test] @@ -345,6 +378,9 @@ mod tests { let system_info = SystemInfo::new(upgrader_canister_id, Vec::new()); write_system_info(system_info); + let icp_asset = mock_asset(); + ASSET_REPOSITORY.insert(icp_asset.key(), icp_asset); + RequestSpecifier::AddAccount .validate() .expect("AddAccount should be valid"); diff --git a/core/station/impl/src/models/resource.rs b/core/station/impl/src/models/resource.rs index 9ffd9dcb7..a93fa52d9 100644 --- a/core/station/impl/src/models/resource.rs +++ b/core/station/impl/src/models/resource.rs @@ -7,6 +7,7 @@ use orbit_essentials::{ use std::fmt::{Display, Formatter}; use uuid::Uuid; +use crate::core::validation::EnsureAsset; use crate::{ core::validation::{ EnsureAccount, EnsureAddressBookEntry, EnsureNotification, EnsureRequest, @@ -19,7 +20,7 @@ use crate::{ /// The deserile implementation is available in the migration module for the `Resource` enum, this is /// because the enum had a backward incompatible change in the past and the migration module is handling /// the deserialization of the old data. -#[storable(skip_deserialize = true)] +#[storable] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, strum::VariantNames)] #[strum(serialize_all = "PascalCase")] pub enum Resource { @@ -33,6 +34,7 @@ pub enum Resource { System(SystemResourceAction), User(UserResourceAction), UserGroup(ResourceAction), + Asset(ResourceAction), } impl ModelValidator for Resource { @@ -106,6 +108,14 @@ impl ModelValidator for Resource { EnsureUserGroup::resource_id_exists(resource_id)? } }, + Resource::Asset(action) => match action { + ResourceAction::List | ResourceAction::Create => (), + ResourceAction::Read(resource_id) + | ResourceAction::Update(resource_id) + | ResourceAction::Delete(resource_id) => { + EnsureAsset::resource_id_exists(resource_id)? + } + }, } Ok(()) } @@ -614,6 +624,51 @@ impl Resource { vec![Resource::UserGroup(ResourceAction::Delete(ResourceId::Any))] } }, + + Resource::Asset(action) => match action { + ResourceAction::Create => vec![Resource::Asset(ResourceAction::Create)], + ResourceAction::List => vec![Resource::Asset(ResourceAction::List)], + + // Any resource id + ResourceAction::Update(ResourceId::Any) => { + vec![Resource::Asset(ResourceAction::Update(ResourceId::Any))] + } + ResourceAction::Read(ResourceId::Any) => { + vec![Resource::Asset(ResourceAction::Read(ResourceId::Any))] + } + ResourceAction::Delete(ResourceId::Any) => { + vec![Resource::Asset(ResourceAction::Delete(ResourceId::Any))] + } + + // Specific resource id + ResourceAction::Delete(ResourceId::Id(id)) => { + let mut associated_resources = + Resource::Asset(ResourceAction::Delete(ResourceId::Any)).to_expanded_list(); + + associated_resources + .push(Resource::Asset(ResourceAction::Delete(ResourceId::Id(*id)))); + + associated_resources + } + ResourceAction::Read(ResourceId::Id(id)) => { + let mut associated_resources = + Resource::Asset(ResourceAction::Read(ResourceId::Any)).to_expanded_list(); + + associated_resources + .push(Resource::Asset(ResourceAction::Read(ResourceId::Id(*id)))); + + associated_resources + } + ResourceAction::Update(ResourceId::Id(id)) => { + let mut associated_resources = + Resource::Asset(ResourceAction::Update(ResourceId::Any)).to_expanded_list(); + + associated_resources + .push(Resource::Asset(ResourceAction::Update(ResourceId::Id(*id)))); + + associated_resources + } + }, } } } @@ -633,6 +688,7 @@ impl Display for Resource { Resource::System(action) => write!(f, "System({})", action), Resource::User(action) => write!(f, "User({})", action), Resource::UserGroup(action) => write!(f, "UserGroup({})", action), + Resource::Asset(action) => write!(f, "Asset({})", action), } } } diff --git a/core/station/impl/src/models/transfer.rs b/core/station/impl/src/models/transfer.rs index 746ed3b6f..b326dbf41 100644 --- a/core/station/impl/src/models/transfer.rs +++ b/core/station/impl/src/models/transfer.rs @@ -1,4 +1,4 @@ -use super::{AccountId, UserId}; +use super::{AccountId, AssetId, TokenStandard, UserId}; use crate::core::ic_cdk::next_time; use crate::core::validation::{EnsureAccount, EnsureIdExists, EnsureRequest, EnsureUser}; use crate::errors::{RecordValidationError, TransferError}; @@ -49,7 +49,7 @@ impl Display for TransferStatus { } /// Represents a transfer in the system. -#[storable] +#[storable(skip_deserialize = true)] #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Transfer { /// The transfer id, which is a UUID. @@ -58,6 +58,10 @@ pub struct Transfer { pub initiator_user: UserId, /// The account id that the transfer is from. pub from_account: AccountId, + /// The asset id that the transfer is from. + pub from_asset: AssetId, + /// The token standard that the transfer is associated with. + pub with_standard: TokenStandard, /// The destination address of the transfer. pub to_address: String, /// The current status of the transfer. @@ -114,6 +118,8 @@ impl Transfer { transfer_id: UUID, initiator_user: UUID, from_account: UUID, + from_asset: UUID, + with_standard: TokenStandard, to_address: String, metadata: Metadata, amount: candid::Nat, @@ -126,6 +132,8 @@ impl Transfer { id: transfer_id, initiator_user, from_account, + from_asset, + with_standard, to_address, request_id, status: TransferStatus::Created, @@ -318,6 +326,8 @@ pub mod transfer_test_utils { id: *Uuid::new_v4().as_bytes(), initiator_user: [0; 16], from_account: [0; 16], + from_asset: [0; 16], + with_standard: TokenStandard::InternetComputerNative, request_id: [2; 16], to_address: "x".repeat(255), status: TransferStatus::Created, diff --git a/core/station/impl/src/repositories/account.rs b/core/station/impl/src/repositories/account.rs index 5b1733c97..9fa81c3fd 100644 --- a/core/station/impl/src/repositories/account.rs +++ b/core/station/impl/src/repositories/account.rs @@ -1,11 +1,14 @@ -use super::indexes::unique_index::UniqueIndexRepository; +use super::{indexes::unique_index::UniqueIndexRepository, InsertEntryObserverArgs}; use crate::{ core::{ metrics::ACCOUNT_METRICS, observer::Observer, utils::format_unique_string, with_memory_manager, Memory, ACCOUNT_MEMORY_ID, }, models::{indexes::unique_index::UniqueIndexKey, Account, AccountId, AccountKey}, - services::disaster_recovery_observes_insert_account, + services::{ + disaster_recovery_sync_accounts_and_assets_on_insert, + disaster_recovery_sync_accounts_and_assets_on_remove, + }, }; use ic_stable_structures::{memory_manager::VirtualMemory, StableBTreeMap}; use lazy_static::lazy_static; @@ -30,16 +33,21 @@ lazy_static! { #[derive(Debug)] pub struct AccountRepository { unique_index: UniqueIndexRepository, - change_observer: Observer<(Account, Option)>, + insert_observer: Observer>, + remove_observer: Observer<()>, } impl Default for AccountRepository { fn default() -> Self { - let mut change_observer = Observer::default(); - disaster_recovery_observes_insert_account(&mut change_observer); + let mut remove_observer = Observer::default(); + disaster_recovery_sync_accounts_and_assets_on_remove(&mut remove_observer); + + let mut insert_observer = Observer::default(); + disaster_recovery_sync_accounts_and_assets_on_insert(&mut insert_observer); Self { - change_observer, + insert_observer, + remove_observer, unique_index: UniqueIndexRepository::default(), } } @@ -94,10 +102,14 @@ impl Repository> for AccountRepositor self.save_entry_indexes(&value, prev.as_ref()); - let args = (value, prev); - self.change_observer.notify(&args); + let args = InsertEntryObserverArgs { + current: value, + prev, + }; - args.1 + self.insert_observer.notify(&args); + + args.prev }) } @@ -118,6 +130,8 @@ impl Repository> for AccountRepositor self.remove_entry_indexes(prev); } + self.remove_observer.notify(&()); + prev }) } @@ -152,13 +166,6 @@ impl AccountRepository { self.unique_index .get(&UniqueIndexKey::AccountName(format_unique_string(name))) } - - pub fn with_empty_observers() -> Self { - Self { - change_observer: Observer::default(), - ..Default::default() - } - } } #[derive(Debug, Clone)] diff --git a/core/station/impl/src/repositories/address_book.rs b/core/station/impl/src/repositories/address_book.rs index 69499bb1b..ee0a057f6 100644 --- a/core/station/impl/src/repositories/address_book.rs +++ b/core/station/impl/src/repositories/address_book.rs @@ -6,7 +6,7 @@ use crate::{ }, models::{ indexes::unique_index::UniqueIndexKey, AddressBookEntry, AddressBookEntryId, - AddressBookEntryKey, Blockchain, + AddressBookEntryKey, AddressFormat, Blockchain, }, }; use ic_stable_structures::{memory_manager::VirtualMemory, StableBTreeMap}; @@ -207,6 +207,10 @@ impl AddressBookRepository { entries.retain(|entry| addresses.contains(&entry.address)); } + if let Some(address_formats) = where_clause.address_formats { + entries.retain(|entry| address_formats.contains(&entry.address_format)); + } + entries.sort(); entries @@ -219,6 +223,7 @@ pub struct AddressBookWhereClause { pub labels: Option>, pub addresses: Option>, pub ids: Option>, + pub address_formats: Option>, } #[cfg(test)] @@ -280,4 +285,39 @@ mod tests { assert!(result.contains(&address_book_entry_0)); assert!(result.contains(&address_book_entry_1)); } + + #[test] + fn test_find_by_address_formats() { + let repository = AddressBookRepository::default(); + let mut address_book_entry_0 = address_book_entry_test_utils::mock_address_book_entry(); + let mut address_book_entry_1 = address_book_entry_test_utils::mock_address_book_entry(); + address_book_entry_0.id = [1; 16]; + address_book_entry_1.id = [2; 16]; + + address_book_entry_0.address_format = AddressFormat::ICPAccountIdentifier; + address_book_entry_1.address_format = AddressFormat::ICRC1Account; + + repository.insert(address_book_entry_0.to_key(), address_book_entry_0.clone()); + repository.insert(address_book_entry_1.to_key(), address_book_entry_1.clone()); + + let result = repository.find_where(AddressBookWhereClause { + blockchain: None, + labels: None, + addresses: None, + ids: None, + address_formats: Some(vec![AddressFormat::ICPAccountIdentifier]), + }); + assert!(result.contains(&address_book_entry_0)); + assert_eq!(result.len(), 1); + + let result = repository.find_where(AddressBookWhereClause { + blockchain: None, + labels: None, + addresses: None, + ids: None, + address_formats: Some(vec![AddressFormat::ICRC1Account]), + }); + assert!(result.contains(&address_book_entry_1)); + assert_eq!(result.len(), 1); + } } diff --git a/core/station/impl/src/repositories/asset.rs b/core/station/impl/src/repositories/asset.rs new file mode 100644 index 000000000..e77c689e8 --- /dev/null +++ b/core/station/impl/src/repositories/asset.rs @@ -0,0 +1,255 @@ +use super::{indexes::unique_index::UniqueIndexRepository, InsertEntryObserverArgs}; +use crate::{ + core::{ + cache::Cache, ic_cdk::api::print, metrics::ASSET_METRICS, observer::Observer, + with_memory_manager, Memory, ASSET_MEMORY_ID, + }, + models::{indexes::unique_index::UniqueIndexKey, Asset, AssetId}, + services::{ + disaster_recovery_sync_accounts_and_assets_on_insert, + disaster_recovery_sync_accounts_and_assets_on_remove, + }, +}; +use ic_stable_structures::{memory_manager::VirtualMemory, StableBTreeMap}; +use lazy_static::lazy_static; +use orbit_essentials::{ + repository::{IndexedRepository, Repository, StableDb}, + types::UUID, +}; +use std::{cell::RefCell, sync::Arc}; + +thread_local! { + static DB: RefCell>> = with_memory_manager(|memory_manager| { + RefCell::new( + StableBTreeMap::init(memory_manager.get(ASSET_MEMORY_ID)) + ) + }); + + static CACHE: RefCell> = RefCell::new(Cache::new(AssetRepository::MAX_CACHE_SIZE)); +} + +lazy_static! { + pub static ref ASSET_REPOSITORY: Arc = Arc::new(AssetRepository::default()); +} + +/// A repository that enables managing assets in stable memory. +#[derive(Debug)] +pub struct AssetRepository { + unique_index: UniqueIndexRepository, + insert_observer: Observer>, + remove_observer: Observer<()>, +} + +impl Default for AssetRepository { + fn default() -> Self { + let mut remove_observer = Observer::default(); + disaster_recovery_sync_accounts_and_assets_on_remove(&mut remove_observer); + + let mut insert_observer = Observer::default(); + disaster_recovery_sync_accounts_and_assets_on_insert(&mut insert_observer); + + Self { + insert_observer, + remove_observer, + unique_index: UniqueIndexRepository::default(), + } + } +} + +impl StableDb> for AssetRepository { + fn with_db(f: F) -> R + where + F: FnOnce(&mut StableBTreeMap>) -> R, + { + DB.with(|m| f(&mut m.borrow_mut())) + } +} + +impl IndexedRepository> for AssetRepository { + fn remove_entry_indexes(&self, entry: &Asset) { + entry + .to_unique_indexes() + .into_iter() + .for_each(|(index, _)| { + self.unique_index.remove(&index); + }); + } + + fn add_entry_indexes(&self, entry: &Asset) { + entry + .to_unique_indexes() + .into_iter() + .for_each(|(index, id)| { + self.unique_index.insert(index, id); + }); + } + + /// Clears all the indexes for the asset. + fn clear_indexes(&self) { + CACHE.with(|cache| cache.borrow_mut().clear()); + + self.unique_index + .clear_when(|key| matches!(key, UniqueIndexKey::AssetSymbolBlockchain(_, _))); + } +} + +impl Repository> for AssetRepository { + fn list(&self) -> Vec { + let mut assets = Vec::with_capacity(self.len()); + + if self.use_only_cache() { + CACHE.with(|cache| { + cache.borrow().iter().for_each(|(_, asset)| { + assets.push(asset.clone()); + }); + }); + } else { + Self::with_db(|db| { + db.iter().for_each(|(_, asset)| { + assets.push(asset); + }); + }); + } + + assets + } + + fn get(&self, key: &AssetId) -> Option { + let maybe_cache_hit = CACHE.with(|cache| cache.borrow().get(key).cloned()); + + match self.use_only_cache() { + true => maybe_cache_hit, + false => maybe_cache_hit.or_else(|| Self::with_db(|db| db.get(key))), + } + } + + fn insert(&self, key: AssetId, value: Asset) -> Option { + DB.with(|m| { + CACHE.with(|cache| cache.borrow_mut().insert(key, value.clone())); + + let prev = m.borrow_mut().insert(key, value.clone()); + + // Update metrics when an asset is upserted. + ASSET_METRICS.with(|metrics| { + metrics + .iter() + .for_each(|metric| metric.borrow_mut().sum(&value, prev.as_ref())) + }); + + self.save_entry_indexes(&value, prev.as_ref()); + + let args = InsertEntryObserverArgs { + current: value, + prev, + }; + + self.insert_observer.notify(&args); + + args.prev + }) + } + + fn remove(&self, key: &AssetId) -> Option { + DB.with(|m| { + CACHE.with(|cache| cache.borrow_mut().remove(key)); + + let prev = m.borrow_mut().remove(key); + + // Update metrics when a asset is removed. + if let Some(prev) = &prev { + ASSET_METRICS.with(|metrics| { + metrics + .iter() + .for_each(|metric| metric.borrow_mut().sub(prev)) + }); + + self.remove_entry_indexes(prev); + } + + self.remove_observer.notify(&()); + + prev + }) + } +} + +impl AssetRepository { + /// Currently the cache uses around 100 bytes per entry (UUID, Asset), + /// so the max cache storage size is around 10MiB. + pub const MAX_CACHE_SIZE: usize = 100_000; + + /// Checks if every asset in the repository is in the cache. + fn use_only_cache(&self) -> bool { + self.len() <= Self::MAX_CACHE_SIZE + } + + /// Builds the cache from the stable memory repository. + /// + /// This method should only be called during init or upgrade hooks to ensure that the cache is + /// up-to-date with the repository and that we have enough instructions to rebuild the cache. + pub fn build_cache(&self) { + if self.len() > Self::MAX_CACHE_SIZE { + print(format!( + "Only the first {} assets will be added to the cache, the reposity has {} assets.", + Self::MAX_CACHE_SIZE, + self.len(), + )); + } + + CACHE.with(|cache| { + cache.borrow_mut().clear(); + + DB.with(|db| { + for (_, asset) in db.borrow().iter().take(Self::MAX_CACHE_SIZE) { + cache.borrow_mut().insert(asset.id, asset); + } + }); + }); + } + + pub fn exists_unique(&self, blockchain: &str, symbol: &str) -> Option { + let key = Asset::to_unique_index_by_symbol_blockchain(symbol, blockchain.to_owned()); + + self.unique_index.get(&key) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::asset_test_utils; + + #[test] + fn test_crud() { + let repository = AssetRepository::default(); + let asset = asset_test_utils::mock_asset(); + + assert!(repository.get(&asset.id).is_none()); + + repository.insert(asset.id.to_owned(), asset.clone()); + + assert!(repository.get(&asset.id).is_some()); + assert!(repository.remove(&asset.id).is_some()); + assert!(repository.get(&asset.id).is_none()); + } + + #[test] + fn test_unqiueness() { + let repository = AssetRepository::default(); + let asset = asset_test_utils::mock_asset(); + + assert!(repository + .exists_unique(&asset.blockchain.to_string(), &asset.symbol) + .is_none()); + + repository.insert(asset.id.to_owned(), asset.clone()); + + assert!(repository.exists_unique("icp", "icp").is_some()); + + assert!(repository.exists_unique("icp", "ICP").is_some()); + + assert!(repository.exists_unique("icp", "ICP2").is_none()); + + assert!(repository.exists_unique("eth", "ICP").is_none()); + } +} diff --git a/core/station/impl/src/repositories/mod.rs b/core/station/impl/src/repositories/mod.rs index af9c21929..17e5d68ad 100644 --- a/core/station/impl/src/repositories/mod.rs +++ b/core/station/impl/src/repositories/mod.rs @@ -30,6 +30,14 @@ pub use request_policy::*; pub mod request_evaluation_result; pub use request_evaluation_result::*; +pub mod asset; +pub use asset::*; + pub mod permission; pub mod indexes; + +pub struct InsertEntryObserverArgs { + pub current: T, + pub prev: Option, +} diff --git a/core/station/impl/src/repositories/user_group.rs b/core/station/impl/src/repositories/user_group.rs index 256a00b7d..87d9cc6fb 100644 --- a/core/station/impl/src/repositories/user_group.rs +++ b/core/station/impl/src/repositories/user_group.rs @@ -29,7 +29,7 @@ lazy_static! { Arc::new(UserGroupRepository::default()); } -/// A repository that enables managing users in stable memory. +/// A repository that enables managing user groups in stable memory. #[derive(Default, Debug)] pub struct UserGroupRepository { unique_index: UniqueIndexRepository, diff --git a/core/station/impl/src/services/account.rs b/core/station/impl/src/services/account.rs index 44c56b707..c84c86822 100644 --- a/core/station/impl/src/services/account.rs +++ b/core/station/impl/src/services/account.rs @@ -2,7 +2,7 @@ use crate::{ core::{ authorization::Authorization, generate_uuid_v4, - ic_cdk::next_time, + ic_cdk::{api::time, next_time}, read_system_info, utils::{paginated_items, retain_accessible_resources, PaginatedData, PaginatedItemsArgs}, write_system_info, CallContext, ACCOUNT_BALANCE_FRESHNESS_IN_MS, @@ -14,22 +14,37 @@ use crate::{ request_policy_rule::RequestPolicyRuleInput, request_specifier::RequestSpecifier, resource::{AccountResourceAction, Resource, ResourceId, ResourceIds}, - Account, AccountBalance, AccountCallerPrivileges, AccountId, AddAccountOperationInput, - AddRequestPolicyOperationInput, Blockchain, BlockchainStandard, CycleObtainStrategy, - EditAccountOperationInput, EditPermissionOperationInput, + Account, AccountAddress, AccountBalance, AccountCallerPrivileges, AccountId, AccountKey, + AddAccountOperationInput, AddRequestPolicyOperationInput, AddressFormat, AssetId, + BalanceQueryState, Blockchain, CycleObtainStrategy, EditAccountOperationInput, + EditPermissionOperationInput, MetadataItem, TokenStandard, + }, + repositories::{ + AccountRepository, AccountWhereClause, AssetRepository, ACCOUNT_REPOSITORY, + ASSET_REPOSITORY, }, - repositories::{AccountRepository, AccountWhereClause, ACCOUNT_REPOSITORY}, services::{ permission::{PermissionService, PERMISSION_SERVICE}, RequestPolicyService, REQUEST_POLICY_SERVICE, }, }; +use ic_ledger_types::MAINNET_LEDGER_CANISTER_ID; use lazy_static::lazy_static; use orbit_essentials::{ - api::ServiceResult, model::ModelValidator, repository::Repository, types::UUID, + api::ServiceResult, + model::ModelValidator, + repository::Repository, + types::UUID, + utils::{CallerGuard, State}, }; use station_api::{AccountBalanceDTO, FetchAccountBalancesInput, ListAccountsInput}; -use std::sync::Arc; +use std::{ + cell::RefCell, + collections::{BTreeMap, HashSet}, + rc::Rc, + sync::Arc, + time::Duration, +}; use uuid::Uuid; use super::SYSTEM_SERVICE; @@ -39,14 +54,29 @@ lazy_static! { Arc::clone(&REQUEST_POLICY_SERVICE), Arc::clone(&PERMISSION_SERVICE), Arc::clone(&ACCOUNT_REPOSITORY), + Arc::clone(&ASSET_REPOSITORY) )); } +thread_local! { + + pub static BALANCE_FETCH_GUARD_STATE: + Rc>> + = Rc::new(RefCell::new(State::default())); +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct BalanceFetchGuardKey { + account_id: AccountId, + asset_id: AssetId, +} + #[derive(Default, Debug)] pub struct AccountService { request_policy_service: Arc, permission_service: Arc, account_repository: Arc, + asset_repository: Arc, } impl AccountService { @@ -57,11 +87,13 @@ impl AccountService { request_policy_service: Arc, permission_service: Arc, account_repository: Arc, + asset_repository: Arc, ) -> Self { Self { request_policy_service, permission_service, account_repository, + asset_repository, } } @@ -143,16 +175,49 @@ impl AccountService { info: format!("Account with id {} already exists", uuid.hyphenated()), })? } - let blockchain_api = - BlockchainApiFactory::build(&input.blockchain.clone(), &input.standard.clone())?; + let mut new_account = AccountMapper::from_create_input(input.to_owned(), *uuid.as_bytes(), None)?; - // The account address is generated after the account is created from the user input and - // all the validations are successfully completed. - if new_account.address.is_empty() { - let account_address = blockchain_api.generate_address(&new_account).await?; - new_account.address = account_address; + let deduplicated_asset_ids = input.assets.iter().cloned().collect::>(); + + for asset_id in deduplicated_asset_ids.iter() { + let asset = self.asset_repository.get(asset_id).ok_or_else(|| { + AccountError::ValidationError { + info: format!( + "Asset with id {} not found", + Uuid::from_bytes(*asset_id).hyphenated() + ), + } + })?; + + for standard in asset.standards.iter() { + let blockchain_api = BlockchainApiFactory::build(&asset.blockchain.clone())?; + + let mut account_addresses = Vec::::new(); + + for address_format in standard.get_info().address_formats.into_iter() { + if account_addresses + .iter() + .any(|address| address.format == address_format) + || new_account + .addresses + .iter() + .any(|address| address.format == address_format) + { + // the account already has this address + continue; + } + + let address = blockchain_api + .generate_address(&new_account.seed, address_format.clone()) + .await?; + + account_addresses.push(address); + } + + new_account.addresses.extend(account_addresses); + } } if let Some(criteria) = &input.transfer_request_policy { @@ -166,10 +231,6 @@ impl AccountService { input.configs_permission.validate()?; input.transfer_permission.validate()?; - // The decimals of the asset are fetched from the blockchain and stored in the account, - // depending on the blockchain standard used by the account the decimals used by each asset can vary. - new_account.decimals = blockchain_api.decimals(&new_account).await?; - // Validate here before database operations. new_account.validate()?; @@ -248,23 +309,41 @@ impl AccountService { // if this is the first account created, and there is no cycle minting account set, set this account as the cycle minting account if system_info.get_cycle_obtain_strategy() == &CycleObtainStrategy::Disabled && ACCOUNT_REPOSITORY.len() == 1 - && matches!(new_account.blockchain, Blockchain::InternetComputer) - && new_account.standard == BlockchainStandard::Native - && new_account.symbol == "ICP" { - ic_cdk::println!("Setting cycle minting account to {}", uuid); - - system_info.set_cycle_obtain_strategy(CycleObtainStrategy::MintFromNativeToken { - account_id: *uuid.as_bytes(), - }); - write_system_info(system_info); - - #[cfg(target_arch = "wasm32")] - crate::services::SYSTEM_SERVICE.set_fund_manager_obtain_cycles( - &CycleObtainStrategy::MintFromNativeToken { - account_id: new_account.id, - }, - ); + // find the mainnet ICP asset that minting can be done from + if let Some(icp_asset) = self.asset_repository.list().iter().find(|asset| { + asset.blockchain == Blockchain::InternetComputer + && asset + .standards + .contains(&TokenStandard::InternetComputerNative) + && asset.metadata.contains(&MetadataItem { + key: TokenStandard::METADATA_KEY_LEDGER_CANISTER_ID.to_string(), + value: MAINNET_LEDGER_CANISTER_ID.to_string(), + }) + }) { + // check if the new account has the ICP asset + if new_account + .assets + .iter() + .any(|account_asset| account_asset.asset_id == icp_asset.id) + { + ic_cdk::println!("Setting cycle minting account to {}", uuid); + + system_info.set_cycle_obtain_strategy( + CycleObtainStrategy::MintFromNativeToken { + account_id: *uuid.as_bytes(), + }, + ); + write_system_info(system_info); + + #[cfg(target_arch = "wasm32")] + crate::services::SYSTEM_SERVICE.set_fund_manager_obtain_cycles( + &CycleObtainStrategy::MintFromNativeToken { + account_id: new_account.id, + }, + ); + } + } } } @@ -289,6 +368,56 @@ impl AccountService { } } + if let Some(change_assets) = input.change_assets { + change_assets.apply(&mut account.assets); + + // get all supported address formats of the account + let mut current_address_formats: HashSet<(Blockchain, AddressFormat)> = HashSet::new(); + + for account_asset in account.assets.iter() { + let Some(asset) = self.asset_repository.get(&account_asset.asset_id) else { + ic_cdk::println!( + "Asset `{}` does not exist in account `{}`", + Uuid::from_bytes(account_asset.asset_id).hyphenated(), + Uuid::from_bytes(account.id).hyphenated() + ); + continue; + }; + + for standard in asset.standards.iter() { + standard.get_info().address_formats.iter().for_each(|f| { + current_address_formats.insert((asset.blockchain.clone(), f.to_owned())); + }); + } + } + + // remove addresses which don't belong to any account_assets any more + account.addresses.retain(|account_address| { + current_address_formats + .iter() + .any(|(_, format)| &account_address.format == format) + }); + + for (blockchain, address_format) in current_address_formats { + if account + .addresses + .iter() + .any(|address| address.format == address_format) + { + // the account already has this address + continue; + } + + let blockchain_api = BlockchainApiFactory::build(&blockchain)?; + + let address = blockchain_api + .generate_address(&account.seed, address_format.clone()) + .await?; + + account.addresses.push(address); + } + } + if let Some(RequestPolicyRuleInput::Set(criteria)) = &input.transfer_request_policy { criteria.validate()?; }; @@ -373,7 +502,7 @@ impl AccountService { pub async fn fetch_account_balances( &self, input: FetchAccountBalancesInput, - ) -> ServiceResult> { + ) -> ServiceResult>> { if input.account_ids.is_empty() || input.account_ids.len() > 5 { Err(AccountError::AccountBalancesBatchRange { min: 1, max: 5 })? } @@ -382,46 +511,182 @@ impl AccountService { .account_ids .iter() .map(|id| HelperMapper::to_uuid(id.clone())) - .collect::, _>>()?; + .collect::, _>>()?; let accounts = self .account_repository .find_by_ids(account_ids.iter().map(|id| *id.as_bytes()).collect()); - let mut balances = Vec::new(); - for mut account in accounts { - let balance_considered_fresh = match &account.balance { - Some(balance) => { - let balance_age_ns = next_time() - balance.last_modification_timestamp; - (balance_age_ns / 1_000_000) < ACCOUNT_BALANCE_FRESHNESS_IN_MS - } - None => false, - }; - let balance: AccountBalance = match (&account.balance, balance_considered_fresh) { - (None, _) | (_, false) => { - let blockchain_api = - BlockchainApiFactory::build(&account.blockchain, &account.standard)?; - let fetched_balance = blockchain_api.balance(&account).await?; - let new_balance = AccountBalance { - balance: candid::Nat(fetched_balance), - last_modification_timestamp: next_time(), + struct BalanceQueryResult { + balance: Option, + update: Option<(AccountId, AssetId, AccountBalance)>, + } + + let queries = accounts + .iter() + .flat_map(|account| { + account.assets.iter().map(|account_asset| async { + let balance_considered_fresh = match &account_asset.balance { + Some(balance) => { + let balance_age_ns = next_time() - balance.last_modification_timestamp; + (balance_age_ns / 1_000_000) < ACCOUNT_BALANCE_FRESHNESS_IN_MS + } + None => false, + }; + + let Some(asset) = self.asset_repository.get(&account_asset.asset_id) else { + return BalanceQueryResult { + balance: None, + update: None, + }; }; - account.balance = Some(new_balance.clone()); + match (&account_asset.balance, balance_considered_fresh) { + (None, _) | (_, false) => { + let balance_update_guard_key = BalanceFetchGuardKey { + account_id: account.id, + asset_id: asset.id, + }; + + let _lock = BALANCE_FETCH_GUARD_STATE.with(|state| { + CallerGuard::new( + state.clone(), + balance_update_guard_key, + Some(time() + Duration::from_secs(5 * 60).as_nanos() as u64), + ) + }); + + if _lock.is_none() { + if let Some(stale_balance) = &account_asset.balance { + BalanceQueryResult { + balance: Some(AccountMapper::to_balance_dto( + stale_balance.to_owned(), + asset.decimals, + account.id, + asset.id, + BalanceQueryState::StaleRefreshing, + )), + update: None, + } + } else { + BalanceQueryResult { + balance: None, + update: None, + } + } + } else { + let blockchain_api = + match BlockchainApiFactory::build(&asset.blockchain) { + Ok(api) => api, + Err(err) => { + ic_cdk::println!( + "Could not build blockchain api for asset with id {}. Error: {}", + Uuid::from_bytes(asset.id).hyphenated(), + err + ); + return BalanceQueryResult { + balance: None, + update: None, + }; + } + }; + + let fetched_balance = match blockchain_api + .balance(&asset, &account.addresses) + .await + { + Ok(balance) => balance, + Err(err) => { + ic_cdk::println!( + "Could not fetch balance for account with id {} and asset with id {}. Error: {}", + Uuid::from_bytes(account.id).hyphenated(), + Uuid::from_bytes(asset.id).hyphenated(), + err + ); + return BalanceQueryResult { + balance: None, + update: None, + }; + } + }; + + let new_balance = AccountBalance { + balance: candid::Nat(fetched_balance), + last_modification_timestamp: next_time(), + }; + + BalanceQueryResult { + update: Some((account.id, asset.id, new_balance.clone())), + balance: Some(AccountMapper::to_balance_dto( + new_balance, + asset.decimals, + account.id, + asset.id, + BalanceQueryState::Fresh, + )), + } + } + } + (Some(balance), true) => BalanceQueryResult { + balance: Some(AccountMapper::to_balance_dto( + balance.to_owned(), + asset.decimals, + account.id, + asset.id, + BalanceQueryState::Fresh, + )), + update: None, + }, + } + }) + }) + .collect::>(); + + let balance_query_results = futures::future::join_all(queries).await; + + let mut account_balance_updates: BTreeMap> = + Default::default(); + let mut balances = Vec::new(); + + for result in balance_query_results { + // group updates by account id to avoid multiple updates to the same account + if let Some((account_id, asset_id, balance)) = result.update { + account_balance_updates + .entry(account_id) + .or_default() + .push((asset_id, balance)); + } - self.account_repository - .insert(account.to_key(), account.clone()); + balances.push(result.balance); + } - new_balance + for (account_id, asset_balances) in account_balance_updates.into_iter() { + if let Some(mut account) = self.account_repository.get(&AccountKey { id: account_id }) { + for (asset_id, account_balance) in asset_balances.into_iter() { + if let Some(account_asset) = account + .assets + .iter_mut() + .find(|asset| asset.asset_id == asset_id) + { + account_asset.balance = Some(account_balance); + } else { + // Account no longer has the asset after the balance was fetched + ic_cdk::println!( + "Could not store new balance. Account with id {} no longer has asset with id {}", + Uuid::from_bytes(account_id).hyphenated(), + Uuid::from_bytes(asset_id).hyphenated() + ); + } } - (Some(balance), _) => balance.to_owned(), - }; - - balances.push(AccountMapper::to_balance_dto( - balance, - account.decimals, - account.id, - )); + + self.account_repository.insert(account.to_key(), account); + } else { + // Account no longer exists after the balance was fetched + ic_cdk::println!( + "Could not store new balance. Account with id {} no longer exists", + Uuid::from_bytes(account_id).hyphenated() + ); + } } Ok(balances) @@ -431,21 +696,27 @@ impl AccountService { #[cfg(test)] mod tests { use candid::Principal; + use orbit_essentials::model::ModelKey; use super::*; use crate::{ core::{test_utils, validation::disable_mock_resource_validation, CallContext}, models::{ - account_test_utils::mock_account, permission::Allow, - request_policy_rule::RequestPolicyRule, request_specifier::UserSpecifier, - user_test_utils::mock_user, AddAccountOperation, AddAccountOperationInput, Blockchain, - BlockchainStandard, Metadata, User, + account_test_utils::mock_account, + asset_test_utils::{mock_asset, mock_asset_b}, + permission::Allow, + request_policy_rule::RequestPolicyRule, + request_specifier::UserSpecifier, + user_test_utils::mock_user, + AddAccountOperation, AddAccountOperationInput, ChangeAssets, Metadata, User, }, repositories::UserRepository, + services::ASSET_SERVICE, }; struct TestContext { repository: AccountRepository, + asset_repository: AssetRepository, service: AccountService, caller_user: User, } @@ -462,6 +733,7 @@ mod tests { TestContext { repository: AccountRepository::default(), service: AccountService::default(), + asset_repository: AssetRepository::default(), caller_user: user, } } @@ -485,8 +757,7 @@ mod tests { account_id: None, input: AddAccountOperationInput { name: "foo".to_string(), - blockchain: Blockchain::InternetComputer, - standard: BlockchainStandard::Native, + assets: vec![], metadata: Metadata::default(), read_permission: Allow::users(vec![ctx.caller_user.id]), configs_permission: Allow::users(vec![ctx.caller_user.id]), @@ -516,8 +787,7 @@ mod tests { account_id: None, input: AddAccountOperationInput { name: account.name, - blockchain: Blockchain::InternetComputer, - standard: BlockchainStandard::Native, + assets: vec![], metadata: Metadata::default(), read_permission: Allow::users(vec![ctx.caller_user.id]), configs_permission: Allow::users(vec![ctx.caller_user.id]), @@ -540,8 +810,7 @@ mod tests { let base_input = AddAccountOperationInput { name: "foo".to_string(), - blockchain: Blockchain::InternetComputer, - standard: BlockchainStandard::Native, + assets: vec![], metadata: Metadata::default(), read_permission: Allow::users(vec![ctx.caller_user.id]), configs_permission: Allow::users(vec![ctx.caller_user.id]), @@ -627,8 +896,7 @@ mod tests { let input = AddAccountOperationInput { name: "foo2".to_string(), - blockchain: Blockchain::InternetComputer, - standard: BlockchainStandard::Native, + assets: vec![], metadata: Metadata::default(), read_permission: Allow::users(vec![ctx.caller_user.id]), configs_permission: Allow::users(vec![ctx.caller_user.id]), @@ -656,6 +924,7 @@ mod tests { let operation = EditAccountOperationInput { account_id: account.id, name: Some("test_edit".to_string()), + change_assets: None, read_permission: None, transfer_permission: None, configs_permission: None, @@ -672,6 +941,89 @@ mod tests { assert_eq!(updated_account.name, "test_edit"); } + #[tokio::test] + async fn edit_account_assets() { + let ctx = setup(); + + let asset_a = mock_asset(); + ctx.asset_repository.insert(asset_a.key(), asset_a.clone()); + + let asset_b = mock_asset_b(); + ctx.asset_repository.insert(asset_b.key(), asset_b.clone()); + + let mut account = mock_account(); + account.assets = vec![]; + account.addresses = vec![]; + ctx.repository.insert(account.to_key(), account.clone()); + + let operation = EditAccountOperationInput { + account_id: account.id, + name: None, + change_assets: Some(ChangeAssets::Change { + add_assets: vec![asset_a.id], + remove_assets: vec![], + }), + read_permission: None, + transfer_permission: None, + configs_permission: None, + transfer_request_policy: None, + configs_request_policy: None, + }; + + let updated_account = ctx + .service + .edit_account(operation) + .await + .expect("edit account should be successful"); + assert_eq!(updated_account.assets.len(), 1); + assert_eq!(updated_account.assets[0].asset_id, asset_a.id); + + let operation = EditAccountOperationInput { + account_id: account.id, + name: None, + change_assets: Some(ChangeAssets::Change { + add_assets: vec![asset_b.id], + remove_assets: vec![asset_a.id], + }), + read_permission: None, + transfer_permission: None, + configs_permission: None, + transfer_request_policy: None, + configs_request_policy: None, + }; + + let updated_account = ctx + .service + .edit_account(operation) + .await + .expect("edit account should be successful"); + assert_eq!(updated_account.assets.len(), 1); + assert_eq!(updated_account.assets[0].asset_id, asset_b.id); + + let operation = EditAccountOperationInput { + account_id: account.id, + name: None, + change_assets: Some(ChangeAssets::ReplaceWith { + assets: vec![asset_a.id, asset_b.id], + }), + read_permission: None, + transfer_permission: None, + configs_permission: None, + transfer_request_policy: None, + configs_request_policy: None, + }; + + let updated_account = ctx + .service + .edit_account(operation) + .await + .expect("edit account should be successful"); + + assert_eq!(updated_account.assets.len(), 2); + assert_eq!(updated_account.assets[0].asset_id, asset_a.id); + assert_eq!(updated_account.assets[1].asset_id, asset_b.id); + } + #[tokio::test] async fn edit_account_with_duplicate_name_should_fail() { let ctx = setup(); @@ -686,6 +1038,7 @@ mod tests { let operation = EditAccountOperationInput { account_id: account.id, name: Some("bar".to_string()), + change_assets: None, read_permission: None, transfer_permission: None, configs_permission: None, @@ -698,29 +1051,6 @@ mod tests { assert!(result.is_err()); } - #[tokio::test] - async fn fail_create_account_invalid_blockchain_standard() { - let ctx = setup(); - let operation = AddAccountOperation { - account_id: None, - input: AddAccountOperationInput { - name: "foo".to_string(), - blockchain: Blockchain::InternetComputer, - standard: BlockchainStandard::ERC20, - metadata: Metadata::default(), - read_permission: Allow::users(vec![ctx.caller_user.id]), - configs_permission: Allow::users(vec![ctx.caller_user.id]), - transfer_permission: Allow::users(vec![ctx.caller_user.id]), - configs_request_policy: Some(RequestPolicyRule::AutoApproved), - transfer_request_policy: Some(RequestPolicyRule::AutoApproved), - }, - }; - - let result = ctx.service.create_account(operation.input, None).await; - - assert!(result.is_err()); - } - #[tokio::test] async fn edit_account_with_missing_policy_should_fail() { let ctx = setup(); @@ -729,11 +1059,15 @@ mod tests { let account = mock_account(); + let asset = mock_asset(); + ASSET_REPOSITORY.insert(asset.key(), asset.clone()); + ctx.repository.insert(account.to_key(), account.clone()); let base_input = EditAccountOperationInput { account_id: account.id, name: Some("test_edit".to_string()), + change_assets: None, read_permission: None, transfer_permission: None, configs_permission: None, @@ -787,4 +1121,40 @@ mod tests { .await .expect_err("transfer_request_policy should be invalid"); } + + #[tokio::test] + async fn can_add_icrc1_asset() { + disable_mock_resource_validation(); + + let asset = ASSET_SERVICE + .create( + crate::models::AddAssetOperationInput { + name: "Test ICRC1 token".to_owned(), + symbol: "TEST".to_owned(), + decimals: 4, + metadata: Metadata::default(), + blockchain: Blockchain::InternetComputer, + standards: vec![TokenStandard::ICRC1], + }, + None, + ) + .expect("asset creation should be successful"); + + ACCOUNT_SERVICE + .create_account( + AddAccountOperationInput { + name: "Test account".to_owned(), + assets: vec![asset.id], + metadata: Metadata::default(), + read_permission: Allow::authenticated(), + configs_permission: Allow::authenticated(), + transfer_permission: Allow::authenticated(), + configs_request_policy: Some(RequestPolicyRule::AutoApproved), + transfer_request_policy: Some(RequestPolicyRule::AutoApproved), + }, + None, + ) + .await + .expect("account creation should be successful"); + } } diff --git a/core/station/impl/src/services/address_book.rs b/core/station/impl/src/services/address_book.rs index 932682d36..eca4df689 100644 --- a/core/station/impl/src/services/address_book.rs +++ b/core/station/impl/src/services/address_book.rs @@ -87,6 +87,7 @@ impl AddressBookService { addresses: input.addresses, blockchain: input.blockchain, labels: input.labels, + address_formats: input.address_formats, }); Ok(paginated_items(PaginatedItemsArgs { @@ -169,7 +170,8 @@ mod tests { core::test_utils, models::{ address_book_entry_test_utils::mock_address_book_entry, AddAddressBookEntryOperation, - AddAddressBookEntryOperationInput, Blockchain, ChangeMetadata, Metadata, MetadataItem, + AddAddressBookEntryOperationInput, AddressFormat, Blockchain, ChangeMetadata, Metadata, + MetadataItem, }, }; use station_api::MetadataDTO; @@ -201,6 +203,7 @@ mod tests { blockchain: Blockchain::InternetComputer, metadata: address_book_entry.metadata.clone().into(), labels: vec![], + address_format: AddressFormat::ICPAccountIdentifier, }, }; diff --git a/core/station/impl/src/services/asset.rs b/core/station/impl/src/services/asset.rs new file mode 100644 index 000000000..be07c252f --- /dev/null +++ b/core/station/impl/src/services/asset.rs @@ -0,0 +1,347 @@ +use std::sync::Arc; + +use crate::{ + core::{authorization::Authorization, utils::retain_accessible_resources, CallContext}, + errors::AssetError, + models::{ + resource::{Resource, ResourceAction, ResourceId}, + AddAssetOperationInput, Asset, AssetCallerPrivileges, AssetId, EditAssetOperationInput, + RemoveAssetOperationInput, + }, + repositories::{AssetRepository, ACCOUNT_REPOSITORY, ASSET_REPOSITORY}, +}; +use lazy_static::lazy_static; +use orbit_essentials::{ + api::ServiceResult, + model::ModelValidator, + pagination::{paginated_items, PaginatedData, PaginatedItemsArgs}, + repository::Repository, +}; +use station_api::ListAssetsInput; +use uuid::Uuid; + +lazy_static! { + pub static ref ASSET_SERVICE: Arc = + Arc::new(AssetService::new(Arc::clone(&ASSET_REPOSITORY),)); +} + +#[derive(Default, Debug)] +pub struct AssetService { + asset_repository: Arc, +} + +impl AssetService { + pub const DEFAULT_LIST_ASSETS_LIMIT: u16 = 100; + pub const MAX_LIST_ASSETS_LIMIT: u16 = 1000; + + pub fn new(asset_repository: Arc) -> Self { + Self { asset_repository } + } + + pub fn get(&self, asset_id: &AssetId) -> ServiceResult { + let asset = self + .asset_repository + .get(asset_id) + .ok_or(AssetError::NotFound { + id: Uuid::from_bytes(*asset_id).hyphenated().to_string(), + })?; + + Ok(asset) + } + + pub fn create( + &self, + input: AddAssetOperationInput, + with_asset_id: Option, + ) -> ServiceResult { + let id = with_asset_id.unwrap_or(*Uuid::new_v4().as_bytes()); + + let asset = Asset { + id, + blockchain: input.blockchain, + standards: input.standards.into_iter().collect(), + symbol: input.symbol, + name: input.name, + decimals: input.decimals, + metadata: input.metadata, + }; + + asset.validate()?; + + self.asset_repository.insert(asset.id, asset.clone()); + + Ok(asset) + } + + pub fn edit(&self, input: EditAssetOperationInput) -> ServiceResult { + let mut asset = self.get(&input.asset_id)?; + + if let Some(name) = input.name { + asset.name = name; + } + + if let Some(symbol) = input.symbol { + asset.symbol = symbol; + } + + if let Some(change_metadata) = input.change_metadata { + asset.metadata.change(change_metadata); + } + + if let Some(blockchain) = input.blockchain { + asset.blockchain = blockchain; + } + + if let Some(standards) = input.standards { + asset.standards = standards.into_iter().collect(); + } + + asset.validate()?; + + self.asset_repository.insert(asset.id, asset.clone()); + + Ok(asset) + } + + pub fn remove(&self, input: RemoveAssetOperationInput) -> ServiceResult { + let asset = self.get(&input.asset_id)?; + + let accounts = ACCOUNT_REPOSITORY.list(); + + for account in accounts { + if account + .assets + .iter() + .any(|account_asset| account_asset.asset_id == asset.id) + { + return Err(AssetError::AssetInUse { + id: Uuid::from_bytes(account.id).hyphenated().to_string(), + resource: "account".to_string(), + })?; + } + } + + self.asset_repository.remove(&input.asset_id); + + Ok(asset) + } + + pub async fn get_caller_privileges_for_asset( + &self, + asset_id: &AssetId, + ctx: &CallContext, + ) -> ServiceResult { + Ok(AssetCallerPrivileges { + id: *asset_id, + can_edit: Authorization::is_allowed( + ctx, + &Resource::Asset(ResourceAction::Update(ResourceId::Id(*asset_id))), + ), + can_delete: Authorization::is_allowed( + ctx, + &Resource::Asset(ResourceAction::Delete(ResourceId::Id(*asset_id))), + ), + }) + } + + pub fn list( + &self, + input: ListAssetsInput, + ctx: Option<&CallContext>, + ) -> ServiceResult> { + let mut assets = self.asset_repository.list(); + + if let Some(ctx) = ctx { + // filter out assets that the caller does not have access to read + retain_accessible_resources(ctx, &mut assets, |asset| { + Resource::Asset(crate::models::resource::ResourceAction::Read( + crate::models::resource::ResourceId::Id(asset.id), + )) + }); + } + + let result = paginated_items(PaginatedItemsArgs { + offset: input.paginate.to_owned().and_then(|p| p.offset), + limit: input.paginate.and_then(|p| p.limit), + default_limit: Some(Self::DEFAULT_LIST_ASSETS_LIMIT), + max_limit: Some(Self::MAX_LIST_ASSETS_LIMIT), + items: &assets, + })?; + + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use orbit_essentials::repository::Repository; + use station_api::ListAssetsInput; + + use crate::{ + models::{ + account_test_utils::mock_account, asset_test_utils::mock_asset, AddAssetOperationInput, + TokenStandard, + }, + repositories::{ACCOUNT_REPOSITORY, ASSET_REPOSITORY}, + }; + + use super::AssetService; + + #[tokio::test] + async fn test_asset_creation() { + let service = AssetService::default(); + + service + .create( + AddAssetOperationInput { + blockchain: crate::models::Blockchain::InternetComputer, + standards: vec![TokenStandard::InternetComputerNative], + decimals: 8, + metadata: Default::default(), + name: "ICP".to_string(), + symbol: "ICP".to_string(), + }, + None, + ) + .expect("Failed to create asset"); + + let assets = ASSET_REPOSITORY.list(); + + assert_eq!(assets.len(), 1); + assert_eq!(assets[0].name, "ICP"); + } + + #[tokio::test] + async fn test_asset_edit() { + let service = AssetService::default(); + let mut mock_asset = mock_asset(); + mock_asset.name = "Bitcoin".to_string(); + ASSET_REPOSITORY.insert(mock_asset.id, mock_asset.clone()); + + service + .edit(crate::models::EditAssetOperationInput { + asset_id: mock_asset.id, + name: Some("Internet Computer".to_string()), + symbol: Some("ICP".to_string()), + change_metadata: None, + blockchain: None, + standards: None, + }) + .expect("Failed to edit asset"); + + let assets = ASSET_REPOSITORY.list(); + + assert_eq!(assets.len(), 1); + assert_eq!(assets[0].name, "Internet Computer"); + } + + #[tokio::test] + async fn test_unused_asset_remove() { + let service = AssetService::default(); + let mock_asset = mock_asset(); + ASSET_REPOSITORY.insert(mock_asset.id, mock_asset.clone()); + + service + .remove(crate::models::RemoveAssetOperationInput { + asset_id: mock_asset.id, + }) + .expect("Failed to remove asset"); + + let assets = ASSET_REPOSITORY.list(); + + assert_eq!(assets.len(), 0); + } + + #[tokio::test] + async fn test_used_asset_remove_fails() { + let service = AssetService::default(); + let mock_asset = mock_asset(); + ASSET_REPOSITORY.insert(mock_asset.id, mock_asset.clone()); + + let mock_account = mock_account(); + + ACCOUNT_REPOSITORY.insert(mock_account.to_key(), mock_account.clone()); + + service + .remove(crate::models::RemoveAssetOperationInput { + asset_id: mock_asset.id, + }) + .expect_err("Asset should not be removed"); + + let assets = ASSET_REPOSITORY.list(); + + assert_eq!(assets.len(), 1); + } + + #[tokio::test] + async fn test_asset_list() { + let service = AssetService::default(); + let mock_asset = mock_asset(); + ASSET_REPOSITORY.insert(mock_asset.id, mock_asset.clone()); + + let assets = service + .list(ListAssetsInput { paginate: None }, None) + .expect("Failed to list assets"); + + assert_eq!(assets.items.len(), 1); + assert_eq!(assets.items[0].name, "Internet Computer"); + } + + #[tokio::test] + async fn test_asset_get() { + let service = AssetService::default(); + let mock_asset = mock_asset(); + ASSET_REPOSITORY.insert(mock_asset.id, mock_asset.clone()); + + let asset = service.get(&mock_asset.id).expect("Failed to get asset"); + + assert_eq!(asset.name, "Internet Computer"); + } + + #[tokio::test] + async fn test_asset_uniqueness() { + let service = AssetService::default(); + + service + .create( + AddAssetOperationInput { + blockchain: crate::models::Blockchain::InternetComputer, + standards: vec![TokenStandard::InternetComputerNative], + decimals: 8, + metadata: Default::default(), + name: "ICP".to_string(), + symbol: "ICP".to_string(), + }, + None, + ) + .expect("Failed to create asset"); + + service + .create( + AddAssetOperationInput { + blockchain: crate::models::Blockchain::InternetComputer, + standards: vec![TokenStandard::InternetComputerNative], + decimals: 8, + metadata: Default::default(), + name: "ICP".to_string(), + symbol: "ICP".to_string(), + }, + None, + ) + .expect_err("Asset with the same symbol and blockchain should not be allowed"); + + service + .create( + AddAssetOperationInput { + blockchain: crate::models::Blockchain::InternetComputer, + standards: vec![TokenStandard::InternetComputerNative], + decimals: 8, + metadata: Default::default(), + name: "ICP".to_string(), + symbol: "ICP2".to_string(), + }, + None, + ) + .expect("Failed to create asset"); + } +} diff --git a/core/station/impl/src/services/disaster_recovery.rs b/core/station/impl/src/services/disaster_recovery.rs index 308f6d74a..f03205d8b 100644 --- a/core/station/impl/src/services/disaster_recovery.rs +++ b/core/station/impl/src/services/disaster_recovery.rs @@ -8,8 +8,11 @@ use super::{SystemService, UserService, USER_SERVICE}; use crate::{ core::observer::Observer, errors::DisasterRecoveryError, - models::{Account, User, UserStatus}, - repositories::{AccountRepository, ACCOUNT_REPOSITORY}, + models::{Account, Asset, User, UserStatus}, + repositories::{ + AccountRepository, AssetRepository, InsertEntryObserverArgs, ACCOUNT_REPOSITORY, + ASSET_REPOSITORY, + }, services::SYSTEM_SERVICE, }; use orbit_essentials::repository::Repository; @@ -19,6 +22,7 @@ lazy_static! { system_service: Arc::clone(&SYSTEM_SERVICE), user_service: Arc::clone(&USER_SERVICE), account_repository: Arc::clone(&ACCOUNT_REPOSITORY), + asset_repository: Arc::clone(&ASSET_REPOSITORY), }); } @@ -26,31 +30,22 @@ pub struct DisasterRecoveryService { system_service: Arc, user_service: Arc, account_repository: Arc, + asset_repository: Arc, } impl DisasterRecoveryService { - pub async fn sync_accounts(&self) -> ServiceResult<()> { + pub async fn sync_accounts_and_assets(&self) -> ServiceResult<()> { let upgrader_canister_id = self.system_service.get_upgrader_canister_id(); let accounts = self.account_repository.list(); + let assets = self.asset_repository.list(); ic_cdk::call( upgrader_canister_id, - "set_disaster_recovery_accounts", - (upgrader_api::SetDisasterRecoveryAccountsInput { - accounts: accounts - .iter() - .map(|account| upgrader_api::Account { - id: Uuid::from_bytes(account.id).hyphenated().to_string(), - blockchain: account.blockchain.to_string(), - address: account.address.clone(), - standard: account.standard.to_string(), - symbol: account.symbol.clone(), - decimals: account.decimals, - name: account.name.clone(), - metadata: account.metadata.clone().into(), - }) - .collect(), + "set_disaster_recovery_accounts_and_assets", + (upgrader_api::SetDisasterRecoveryAccountsAndAssetsInput { + accounts: accounts.into_iter().map(Into::into).collect(), + assets: assets.into_iter().map(Into::into).collect(), },), ) .await @@ -106,7 +101,7 @@ impl DisasterRecoveryService { if let Err(error) = DISASTER_RECOVERY_SERVICE.sync_committee().await { crate::core::ic_cdk::api::print(format!("Failed to sync committee: {}", error,)); } - if let Err(error) = DISASTER_RECOVERY_SERVICE.sync_accounts().await { + if let Err(error) = DISASTER_RECOVERY_SERVICE.sync_accounts_and_assets().await { crate::core::ic_cdk::api::print(format!("Failed to sync accounts: {}", error,)); } } @@ -181,19 +176,226 @@ pub fn disaster_recovery_observes_remove_user(observer: &mut Observer) { })); } -pub fn disaster_recovery_observes_insert_account( - observer: &mut Observer<(Account, Option)>, -) { - observer.add_listener(Box::new(|(_account, _prev)| { +#[cfg(test)] +thread_local! { + static SYNC_CALLED: std::cell::RefCell = const { std::cell::RefCell::new(0) }; +} + +pub fn disaster_recovery_sync_accounts_and_assets_on_remove(observer: &mut Observer<()>) { + observer.add_listener(Box::new(|_| { if !SYSTEM_SERVICE.is_healthy() { - // Skip syncing accounts during system init + // Skip syncing during system init return; } + #[cfg(test)] + SYNC_CALLED.with(|sync_called| { + *sync_called.borrow_mut() += 1; + }); + crate::core::ic_cdk::spawn(async { - if let Err(error) = DISASTER_RECOVERY_SERVICE.sync_accounts().await { - crate::core::ic_cdk::api::print(format!("Failed to sync accounts: {}", error,)); + if let Err(error) = DISASTER_RECOVERY_SERVICE.sync_accounts_and_assets().await { + crate::core::ic_cdk::api::print(format!( + "Failed to sync accounts and assets: {}", + error, + )); } }); })); } + +/// A trait for comparing two values for equality in the context of Disaster Recovery. +/// Two values are considered equal if they are the same when serialized into the format +/// stored by the Upgrader. +pub trait SyncEq { + fn sync_eq(&self) -> bool; +} + +impl SyncEq for InsertEntryObserverArgs { + fn sync_eq(&self) -> bool { + if let Some(prev) = &self.prev { + let current_synced: upgrader_api::MultiAssetAccount = self.current.clone().into(); + let prev_synced: upgrader_api::MultiAssetAccount = prev.clone().into(); + + current_synced == prev_synced + } else { + false + } + } +} + +impl SyncEq for InsertEntryObserverArgs { + fn sync_eq(&self) -> bool { + if let Some(prev) = &self.prev { + let current_synced: upgrader_api::Asset = self.current.clone().into(); + let prev_synced: upgrader_api::Asset = prev.clone().into(); + + current_synced == prev_synced + } else { + false + } + } +} + +pub fn disaster_recovery_sync_accounts_and_assets_on_insert(observer: &mut Observer) +where + T: SyncEq, +{ + observer.add_listener(Box::new(|sync_cmp| { + if !SYSTEM_SERVICE.is_healthy() { + // Skip syncing during system init + return; + } + + if sync_cmp.sync_eq() { + // Skip syncing if the account or asset hasn't changed + return; + } + + #[cfg(test)] + SYNC_CALLED.with(|sync_called| { + *sync_called.borrow_mut() += 1; + }); + + crate::core::ic_cdk::spawn(async { + if let Err(error) = DISASTER_RECOVERY_SERVICE.sync_accounts_and_assets().await { + crate::core::ic_cdk::api::print(format!( + "Failed to sync accounts and assets: {}", + error, + )); + } + }); + })); +} + +#[cfg(test)] +mod tests { + + use orbit_essentials::{model::ModelKey, repository::Repository}; + + use crate::{ + core::test_utils::init_canister_system, + models::{ + account_test_utils::mock_account, asset_test_utils::mock_asset, AccountAsset, + AccountBalance, + }, + repositories::{InsertEntryObserverArgs, ACCOUNT_REPOSITORY, ASSET_REPOSITORY}, + services::SyncEq, + }; + + use super::SYNC_CALLED; + + #[test] + fn test_account_eq() { + let prev_account = mock_account(); + let mut current_account = prev_account.clone(); + + assert!(!InsertEntryObserverArgs { + current: current_account.clone(), + prev: None, + } + .sync_eq()); + + assert!(InsertEntryObserverArgs { + current: current_account.clone(), + prev: Some(prev_account.clone()), + } + .sync_eq()); + + current_account.assets[0].balance = Some(AccountBalance { + balance: 1000u64.into(), + last_modification_timestamp: 1, + }); + + // Account has not changed as far as the sync is concerned + assert!(InsertEntryObserverArgs { + current: current_account.clone(), + prev: Some(prev_account.clone()), + } + .sync_eq()); + + current_account.assets.push(AccountAsset { + asset_id: [1; 16], + balance: None, + }); + + // Account has changed + assert!(!InsertEntryObserverArgs { + current: current_account.clone(), + prev: Some(prev_account.clone()), + } + .sync_eq()); + } + + #[test] + fn test_asset_eq() { + let prev_asset = mock_asset(); + let mut current_asset = prev_asset.clone(); + + assert!(!InsertEntryObserverArgs { + current: current_asset.clone(), + prev: None, + } + .sync_eq()); + + assert!(InsertEntryObserverArgs { + current: current_asset.clone(), + prev: Some(prev_asset.clone()), + } + .sync_eq()); + + current_asset + .metadata + .change(crate::models::ChangeMetadata::RemoveKeys(vec![ + "index_canister_id".to_string(), + ])); + + // Asset has changed + assert!(!InsertEntryObserverArgs { + current: current_asset.clone(), + prev: Some(prev_asset.clone()), + } + .sync_eq()); + } + + #[test] + fn test_sync_call() { + init_canister_system(); + + let mut asset = mock_asset(); + ASSET_REPOSITORY.insert(asset.key(), asset.clone()); + assert_eq!(SYNC_CALLED.with(|sync_called| *sync_called.borrow()), 1); + + let mut account = mock_account(); + ACCOUNT_REPOSITORY.insert(account.to_key(), account.clone()); + assert_eq!(SYNC_CALLED.with(|sync_called| *sync_called.borrow()), 2); + + account.assets[0].balance = Some(AccountBalance { + balance: 1000u64.into(), + last_modification_timestamp: 1, + }); + ACCOUNT_REPOSITORY.insert(account.to_key(), account.clone()); + // Account has not changed as far as the sync is concerned + assert_eq!(SYNC_CALLED.with(|sync_called| *sync_called.borrow()), 2); + + account.assets.push(AccountAsset { + asset_id: [1; 16], + balance: None, + }); + ACCOUNT_REPOSITORY.insert(account.to_key(), account.clone()); + // Account has changed + assert_eq!(SYNC_CALLED.with(|sync_called| *sync_called.borrow()), 3); + + asset + .metadata + .change(crate::models::ChangeMetadata::RemoveKeys(vec![ + "index_canister_id".to_string(), + ])); + ASSET_REPOSITORY.insert(asset.key(), asset.clone()); + // Asset has changed + assert_eq!(SYNC_CALLED.with(|sync_called| *sync_called.borrow()), 4); + + ASSET_REPOSITORY.remove(&asset.key()); + assert_eq!(SYNC_CALLED.with(|sync_called| *sync_called.borrow()), 5); + } +} diff --git a/core/station/impl/src/services/external_canister.rs b/core/station/impl/src/services/external_canister.rs index a9b7a4021..b9da0316a 100644 --- a/core/station/impl/src/services/external_canister.rs +++ b/core/station/impl/src/services/external_canister.rs @@ -330,7 +330,7 @@ impl ExternalCanisterService { }, ); - // filter out requests that the caller does not have access to read + // filter out external canisters that the caller does not have access to read retain_accessible_resources(ctx, &mut found_ids, |id| { Resource::ExternalCanister(ExternalCanisterResourceAction::Read( ExternalCanisterId::Canister(*id), diff --git a/core/station/impl/src/services/mod.rs b/core/station/impl/src/services/mod.rs index 568f7a67c..ea80e5553 100644 --- a/core/station/impl/src/services/mod.rs +++ b/core/station/impl/src/services/mod.rs @@ -37,3 +37,6 @@ pub mod permission; mod disaster_recovery; pub use disaster_recovery::*; + +mod asset; +pub use asset::*; diff --git a/core/station/impl/src/services/request.rs b/core/station/impl/src/services/request.rs index 04ca27e51..da47cf903 100644 --- a/core/station/impl/src/services/request.rs +++ b/core/station/impl/src/services/request.rs @@ -542,6 +542,7 @@ mod tests { core::test_utils, models::{ account_test_utils::mock_account, + asset_test_utils::mock_asset, permission::Allow, request_policy_rule::RequestPolicyRule, request_policy_test_utils::mock_request_policy, @@ -550,16 +551,16 @@ mod tests { resource::ResourceIds, user_test_utils::mock_user, AddAccountOperationInput, AddAddressBookEntryOperation, - AddAddressBookEntryOperationInput, AddUserOperation, AddUserOperationInput, Blockchain, - BlockchainStandard, Metadata, Percentage, RequestApproval, RequestOperation, - RequestPolicy, RequestStatus, TransferOperation, TransferOperationInput, User, - UserGroup, UserStatus, ADMIN_GROUP_ID, + AddAddressBookEntryOperationInput, AddAssetOperationInput, AddUserOperation, + AddUserOperationInput, AddressFormat, Asset, Blockchain, Metadata, Percentage, + RequestApproval, RequestOperation, RequestPolicy, RequestStatus, TokenStandard, + TransferOperation, TransferOperationInput, User, UserGroup, UserStatus, ADMIN_GROUP_ID, }, repositories::{ - request_policy::REQUEST_POLICY_REPOSITORY, AccountRepository, NOTIFICATION_REPOSITORY, - USER_GROUP_REPOSITORY, USER_REPOSITORY, + request_policy::REQUEST_POLICY_REPOSITORY, AccountRepository, AssetRepository, + NOTIFICATION_REPOSITORY, USER_GROUP_REPOSITORY, USER_REPOSITORY, }, - services::AccountService, + services::{AccountService, ASSET_SERVICE}, }; use candid::Principal; use orbit_essentials::{api::ApiError, model::ModelKey}; @@ -570,6 +571,7 @@ mod tests { struct TestContext { repository: RequestRepository, account_repository: AccountRepository, + asset_repository: AssetRepository, service: RequestService, caller_user: User, call_context: CallContext, @@ -600,6 +602,7 @@ mod tests { TestContext { repository: RequestRepository::default(), account_repository: AccountRepository::default(), + asset_repository: AssetRepository::default(), service: RequestService::default(), account_service: AccountService::default(), caller_user: user, @@ -620,12 +623,15 @@ mod tests { fee: None, input: TransferOperationInput { from_account_id: *account_id.as_bytes(), + from_asset_id: [1; 16], + with_standard: TokenStandard::InternetComputerNative, amount: candid::Nat(100u32.into()), fee: None, metadata: Metadata::default(), network: "mainnet".to_string(), to: "0x1234".to_string(), }, + asset: mock_asset(), }); ctx.account_repository @@ -651,12 +657,15 @@ mod tests { fee: None, input: TransferOperationInput { from_account_id: *account_id.as_bytes(), + from_asset_id: [1; 16], + with_standard: TokenStandard::InternetComputerNative, amount: candid::Nat(100u32.into()), fee: None, metadata: Metadata::default(), network: "mainnet".to_string(), to: "0x1234".to_string(), }, + asset: mock_asset(), }); request.approvals = vec![]; let mut request_policy = mock_request_policy(); @@ -710,6 +719,13 @@ mod tests { USER_REPOSITORY.insert(unrelated_user.to_key(), unrelated_user.clone()); // creates the account for the transfer + let asset = Asset { + id: [1; 16], + ..mock_asset() + }; + + ctx.asset_repository.insert(asset.key(), asset.clone()); + let account = mock_account(); ctx.account_repository @@ -733,6 +749,8 @@ mod tests { from_account_id: Uuid::from_bytes(account.id.to_owned()) .hyphenated() .to_string(), + from_asset_id: Uuid::from_bytes([1; 16]).hyphenated().to_string(), + with_standard: TokenStandard::InternetComputerNative.to_string(), amount: candid::Nat(100u32.into()), fee: None, metadata: vec![], @@ -781,6 +799,7 @@ mod tests { blockchain: "icp".to_owned(), metadata: vec![], labels: vec![], + address_format: AddressFormat::ICPAccountIdentifier.to_string(), }, ), title: None, @@ -806,8 +825,11 @@ mod tests { request.operation = RequestOperation::Transfer(TransferOperation { transfer_id: None, fee: None, + asset: mock_asset(), input: TransferOperationInput { from_account_id: [9; 16], + from_asset_id: [1; 16], + with_standard: TokenStandard::InternetComputerNative, amount: candid::Nat(100u32.into()), fee: None, metadata: Metadata::default(), @@ -841,8 +863,11 @@ mod tests { request.operation = RequestOperation::Transfer(TransferOperation { transfer_id: None, fee: None, + asset: mock_asset(), input: TransferOperationInput { from_account_id: [9; 16], + from_asset_id: [1; 16], + with_standard: TokenStandard::InternetComputerNative, amount: candid::Nat(100u32.into()), fee: None, metadata: Metadata::default(), @@ -876,8 +901,11 @@ mod tests { request.operation = RequestOperation::Transfer(TransferOperation { transfer_id: None, fee: None, + asset: mock_asset(), input: TransferOperationInput { from_account_id: [9; 16], + from_asset_id: [1; 16], + with_standard: TokenStandard::InternetComputerNative, amount: candid::Nat(100u32.into()), fee: None, metadata: Metadata::default(), @@ -932,6 +960,7 @@ mod tests { blockchain: Blockchain::InternetComputer, metadata: vec![], labels: vec![], + address_format: AddressFormat::ICPAccountIdentifier, }, }); request.approvals = vec![ @@ -999,12 +1028,15 @@ mod tests { fee: None, input: TransferOperationInput { from_account_id: [9; 16], + from_asset_id: [1; 16], + with_standard: TokenStandard::InternetComputerNative, amount: candid::Nat(100u32.into()), fee: None, metadata: Metadata::default(), network: "mainnet".to_string(), to: "0x1234".to_string(), }, + asset: mock_asset(), }); request.created_timestamp = 10; request.approvals = vec![]; @@ -1060,6 +1092,20 @@ mod tests { no_access_user.identities = vec![Principal::from_slice(&[2; 29])]; USER_REPOSITORY.insert(no_access_user.to_key(), no_access_user.clone()); + let asset = ASSET_SERVICE + .create( + AddAssetOperationInput { + name: "foo".to_string(), + symbol: "FOO".to_string(), + decimals: 18, + metadata: Metadata::default(), + blockchain: Blockchain::InternetComputer, + standards: vec![TokenStandard::InternetComputerNative], + }, + None, + ) + .expect("Failed to create asset"); + // create account let account_owners = vec![ctx.caller_user.id, transfer_requester_user.id]; let account = ctx @@ -1067,8 +1113,7 @@ mod tests { .create_account( AddAccountOperationInput { name: "foo".to_string(), - blockchain: Blockchain::InternetComputer, - standard: BlockchainStandard::Native, + assets: vec![asset.id], metadata: Metadata::default(), transfer_request_policy: Some(RequestPolicyRule::QuorumPercentage( UserSpecifier::Id(vec![ctx.caller_user.id, transfer_requester_user.id]), @@ -1116,12 +1161,15 @@ mod tests { fee: None, input: TransferOperationInput { from_account_id: account.id, + from_asset_id: asset.id, + with_standard: TokenStandard::InternetComputerNative, amount: candid::Nat(100u32.into()), fee: None, metadata: Metadata::default(), network: "mainnet".to_string(), to: "0x1234".to_string(), }, + asset: mock_asset(), }); transfer.created_timestamp = 10 + i as u64; transfer.approvals = vec![RequestApproval { diff --git a/core/station/impl/src/services/system.rs b/core/station/impl/src/services/system.rs index 1dd54d956..3e49186eb 100644 --- a/core/station/impl/src/services/system.rs +++ b/core/station/impl/src/services/system.rs @@ -11,13 +11,13 @@ use crate::{ factories::blockchains::InternetComputer, models::{ system::{DisasterRecoveryCommittee, SystemInfo, SystemState}, - CanisterInstallMode, CanisterUpgradeModeArgs, CycleObtainStrategy, - ManageSystemInfoOperationInput, RequestId, RequestKey, RequestOperation, RequestStatus, - SystemUpgradeTarget, WasmModuleExtraChunks, + AccountKey, Asset, Blockchain, CanisterInstallMode, CanisterUpgradeModeArgs, + CycleObtainStrategy, ManageSystemInfoOperationInput, Metadata, RequestId, RequestKey, + RequestOperation, RequestStatus, SystemUpgradeTarget, TokenStandard, WasmModuleExtraChunks, }, repositories::{ - permission::PERMISSION_REPOSITORY, RequestRepository, REQUEST_REPOSITORY, - USER_GROUP_REPOSITORY, USER_REPOSITORY, + permission::PERMISSION_REPOSITORY, RequestRepository, ACCOUNT_REPOSITORY, ASSET_REPOSITORY, + REQUEST_REPOSITORY, USER_GROUP_REPOSITORY, USER_REPOSITORY, }, services::{ change_canister::{ChangeCanisterService, CHANGE_CANISTER_SERVICE}, @@ -37,16 +37,46 @@ use lazy_static::lazy_static; use orbit_essentials::api::ServiceResult; use orbit_essentials::repository::Repository; use station_api::{HealthStatus, SystemInit, SystemInstall, SystemUpgrade}; -use std::sync::Arc; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; use upgrader_api::UpgradeParams; use uuid::Uuid; +pub const INITIAL_ICP_ASSET_ID: [u8; 16] = [ + 0x78, 0x02, 0xcb, 0xab, 0x22, 0x1d, 0x4e, 0x49, 0xb7, 0x64, 0xa6, 0x95, 0xea, 0x6d, 0xef, 0x1a, +]; + lazy_static! { pub static ref SYSTEM_SERVICE: Arc = Arc::new(SystemService::new( Arc::clone(&REQUEST_REPOSITORY), Arc::clone(&REQUEST_SERVICE), Arc::clone(&CHANGE_CANISTER_SERVICE) )); + pub static ref INITIAL_ICP_ASSET: Asset = Asset { + id: INITIAL_ICP_ASSET_ID, + blockchain: Blockchain::InternetComputer, + decimals: 8, + name: "Internet Computer".to_string(), + symbol: "ICP".to_string(), + + standards: BTreeSet::from([TokenStandard::InternetComputerNative, TokenStandard::ICRC1,]), + metadata: Metadata::new(BTreeMap::from([ + ( + "ledger_canister_id".to_string(), + "ryjl3-tyaaa-aaaaa-aaaba-cai".to_string(), + ), + ( + "index_canister_id".to_string(), + "qhbym-qaaaa-aaaaa-aaafq-cai".to_string(), + ), + ])), + }; +} + +thread_local! { + pub static INITIALIZING: std::cell::RefCell = const { std::cell::RefCell::new(false) }; } #[derive(Default, Debug)] @@ -92,7 +122,13 @@ impl SystemService { let state = read_system_state(); match state { - SystemState::Initialized(_) => HealthStatus::Healthy, + SystemState::Initialized(_) => { + if INITIALIZING.with_borrow(|init| *init) { + HealthStatus::Uninitialized + } else { + HealthStatus::Healthy + } + } SystemState::Uninitialized => HealthStatus::Uninitialized, } } @@ -192,18 +228,29 @@ impl SystemService { ) -> Option { match strategy { CycleObtainStrategy::Disabled => None, - CycleObtainStrategy::MintFromNativeToken { account_id } => Some(ObtainCyclesOptions { - obtain_cycles: Arc::new(MintCycles { - ledger: Arc::new(IcLedgerCanister::new(MAINNET_LEDGER_CANISTER_ID)), - cmc: Arc::new(IcCyclesMintingCanister::new( - MAINNET_CYCLES_MINTING_CANISTER_ID, - )), - from_subaccount: Subaccount( - InternetComputer::subaccount_from_station_account_id(account_id), - ), - }), - top_up_self: true, - }), + CycleObtainStrategy::MintFromNativeToken { account_id } => { + if let Some(account) = ACCOUNT_REPOSITORY.get(&AccountKey { id: *account_id }) { + Some(ObtainCyclesOptions { + obtain_cycles: Arc::new(MintCycles { + ledger: Arc::new(IcLedgerCanister::new(MAINNET_LEDGER_CANISTER_ID)), + cmc: Arc::new(IcCyclesMintingCanister::new( + MAINNET_CYCLES_MINTING_CANISTER_ID, + )), + from_subaccount: Subaccount(InternetComputer::subaccount_from_seed( + &account.seed, + )), + }), + top_up_self: true, + }) + } else { + print(format!( + "Account with id `{}` not found, cannot create ObtainCyclesOptions", + Uuid::from_bytes(*account_id).hyphenated() + )); + + None + } + } } } #[cfg(target_arch = "wasm32")] @@ -257,6 +304,10 @@ impl SystemService { system_info.update_last_upgrade_timestamp(); write_system_info(system_info.to_owned()); + + INITIALIZING.with_borrow_mut(|initializing| { + *initializing = false; + }); } async fn install_canister_post_process_work( @@ -292,10 +343,17 @@ impl SystemService { let admin_count = init.admins.len() as u16; let quorum = calc_initial_quorum(admin_count, init.quorum); + // if provided, creates the initial assets + if let Some(assets) = init.assets.clone() { + print("Adding initial assets"); + install_canister_handlers::set_initial_assets(assets).await?; + } + // if provided, creates the initial accounts if let Some(accounts) = init.accounts { print("Adding initial accounts"); - install_canister_handlers::set_initial_accounts(accounts, quorum).await?; + install_canister_handlers::set_initial_accounts(accounts, &init.assets, quorum) + .await?; } if SYSTEM_SERVICE.is_healthy() { @@ -351,6 +409,7 @@ impl SystemService { USER_GROUP_REPOSITORY.build_cache(); USER_REPOSITORY.build_cache(); PERMISSION_REPOSITORY.build_cache(); + ASSET_REPOSITORY.build_cache(); } /// Initializes the canister with the given owners and settings. @@ -375,6 +434,9 @@ impl SystemService { // registers the admins of the canister init_canister_sync_handlers::set_admins(input.admins.clone())?; + // add initial assets + init_canister_sync_handlers::add_initial_assets(); + // sets the name of the canister system_info.set_name(input.name.clone()); @@ -499,17 +561,21 @@ impl SystemService { mod init_canister_sync_handlers { use crate::core::ic_cdk::{api::print, next_time}; - use crate::models::{AddUserOperationInput, UserStatus}; + use crate::models::{AddUserOperationInput, Asset, UserStatus}; + use crate::repositories::ASSET_REPOSITORY; use crate::services::USER_SERVICE; use crate::{ models::{UserGroup, ADMIN_GROUP_ID}, repositories::USER_GROUP_REPOSITORY, }; use orbit_essentials::api::ApiError; + use orbit_essentials::model::ModelKey; use orbit_essentials::repository::Repository; use station_api::AdminInitInput; use uuid::Uuid; + use super::INITIAL_ICP_ASSET; + pub fn add_admin_group() { // adds the admin group which is used as the default group for admins during the canister instantiation USER_GROUP_REPOSITORY.insert( @@ -522,6 +588,15 @@ mod init_canister_sync_handlers { ); } + pub fn add_initial_assets() { + let initial_assets: Vec = vec![INITIAL_ICP_ASSET.clone()]; + + for asset in initial_assets { + print(format!("Adding initial asset: {}", asset.name)); + ASSET_REPOSITORY.insert(asset.key(), asset); + } + } + /// Registers the newly added admins of the canister. pub fn set_admins(admins: Vec) -> Result<(), ApiError> { print(format!("Registering {} admin users", admins.len())); @@ -560,12 +635,13 @@ mod install_canister_handlers { use crate::models::permission::Allow; use crate::models::request_specifier::UserSpecifier; use crate::models::{ - AddAccountOperationInput, AddRequestPolicyOperationInput, CycleObtainStrategy, - EditPermissionOperationInput, RequestPolicyRule, ADMIN_GROUP_ID, + AddAccountOperationInput, AddAssetOperationInput, AddRequestPolicyOperationInput, + CycleObtainStrategy, EditPermissionOperationInput, RequestPolicyRule, ADMIN_GROUP_ID, }; + use crate::repositories::ASSET_REPOSITORY; use crate::services::permission::PERMISSION_SERVICE; - use crate::services::ACCOUNT_SERVICE; use crate::services::REQUEST_POLICY_SERVICE; + use crate::services::{ACCOUNT_SERVICE, ASSET_SERVICE}; use candid::{Encode, Principal}; use canfund::manager::options::{EstimatedRuntime, FundManagerOptions, FundStrategy}; use canfund::manager::RegisterOpts; @@ -573,9 +649,12 @@ mod install_canister_handlers { use ic_cdk::api::management_canister::main::{self as mgmt}; use ic_cdk::id; + use orbit_essentials::api::ApiError; + use orbit_essentials::repository::Repository; use orbit_essentials::types::UUID; - use station_api::{InitAccountInput, SystemInit}; + use station_api::{InitAccountInput, InitAssetInput, SystemInit}; use std::cell::RefCell; + use uuid::Uuid; use super::SYSTEM_SERVICE; @@ -618,6 +697,7 @@ mod install_canister_handlers { // Registers the initial accounts of the canister during the canister initialization. pub async fn set_initial_accounts( accounts: Vec, + initial_assets: &Option>, quorum: u16, ) -> Result<(), String> { let add_accounts = accounts @@ -625,10 +705,15 @@ mod install_canister_handlers { .map(|account| { let input = AddAccountOperationInput { name: account.name, - blockchain: BlockchainMapper::to_blockchain(account.blockchain.clone()) - .expect("Invalid blockchain"), - standard: BlockchainMapper::to_blockchain_standard(account.standard) - .expect("Invalid blockchain standard"), + assets: account + .assets + .into_iter() + .map(|asset| { + *HelperMapper::to_uuid(asset) + .expect("Invalid UUID") + .as_bytes() + }) + .collect(), metadata: account.metadata.into(), transfer_request_policy: Some(RequestPolicyRule::Quorum( UserSpecifier::Group(vec![*ADMIN_GROUP_ID]), @@ -652,7 +737,55 @@ mod install_canister_handlers { }) .collect::)>>(); - for (new_account, with_account_id) in add_accounts { + // + // In case there are assets existing in the Asset repository at the time of recovering the assets + // some of the assets might not be able to be recreated, in this case we try to find the same asset + // in the existing assets and replace the asset_id in the recreated account with the existing one. + // + for (mut new_account, with_account_id) in add_accounts { + if let Some(initial_assets) = initial_assets { + let mut new_account_assets = new_account.assets.clone(); + for asset_id in new_account.assets.iter() { + if ASSET_REPOSITORY.get(asset_id).is_none() { + // the asset could not be recreated, try to find the same asset in the existing assets + let asset_id_str = Uuid::from_bytes(*asset_id).hyphenated().to_string(); + let Some(original_asset_to_create) = initial_assets + .iter() + .find(|initial_asset| initial_asset.id == asset_id_str) + else { + // the asset does not exist and it could not be recreated, skip + continue; + }; + + if let Some(existing_asset_id) = ASSET_REPOSITORY.exists_unique( + &original_asset_to_create.blockchain, + &original_asset_to_create.symbol, + ) { + // replace the asset_id in the recreated account with the existing one + new_account_assets.retain(|id| asset_id != id); + new_account_assets.push(existing_asset_id); + + print(format!( + "Asset {} could not be recreated, replaced with existing asset {}", + asset_id_str, + Uuid::from_bytes(existing_asset_id).hyphenated() + )); + } else { + // the asset does not exist and it could not be recreated, skip + + print(format!( + "Asset {} could not be recreated and does not exist in the existing assets, skipping", + asset_id_str + )); + + continue; + } + } + } + + new_account.assets = new_account_assets; + } + ACCOUNT_SERVICE .create_account(new_account, with_account_id) .await @@ -661,6 +794,53 @@ mod install_canister_handlers { Ok(()) } + // Registers the initial accounts of the canister during the canister initialization. + pub async fn set_initial_assets(assets: Vec) -> Result<(), String> { + let add_assets = assets + .into_iter() + .map(|asset| { + let input = AddAssetOperationInput { + name: asset.name, + blockchain: BlockchainMapper::to_blockchain(asset.blockchain.clone()) + .expect("Invalid blockchain"), + standards: asset + .standards + .iter() + .map(|standard| { + BlockchainMapper::to_blockchain_standard(standard.clone()) + .expect("Invalid blockchain standard") + }) + .collect(), + decimals: asset.decimals, + symbol: asset.symbol, + metadata: asset.metadata.into(), + }; + + ( + input, + *HelperMapper::to_uuid(asset.id) + .expect("Invalid UUID") + .as_bytes(), + ) + }) + .collect::>(); + + for (new_asset, with_asset_id) in add_assets { + match ASSET_SERVICE.create(new_asset, Some(with_asset_id)) { + Err(ApiError { code, details, .. }) if &code == "ALREADY_EXISTS" => { + // asset already exists, can skip safely + print(format!( + "Asset already exists, skipping. Details: {:?}", + details.unwrap_or_default() + )); + } + Err(e) => Err(format!("Failed to add asset: {:?}", e))?, + Ok(_) => {} + } + } + + Ok(()) + } pub async fn init_upgrader( input: station_api::SystemUpgraderInput, @@ -790,6 +970,7 @@ mod tests { upgrader: station_api::SystemUpgraderInput::WasmModule(vec![]), fallback_controller: None, accounts: None, + assets: None, }) .await; diff --git a/core/station/impl/src/services/transfer.rs b/core/station/impl/src/services/transfer.rs index bf5896ce4..39d97b196 100644 --- a/core/station/impl/src/services/transfer.rs +++ b/core/station/impl/src/services/transfer.rs @@ -104,12 +104,15 @@ mod tests { use crate::{ core::{test_utils, validation::disable_mock_resource_validation}, models::{ - account_test_utils::mock_account, request_test_utils::mock_request, - transfer_test_utils::mock_transfer, user_test_utils::mock_user, Account, User, + account_test_utils::mock_account, asset_test_utils::mock_asset, permission::Allow, + request_test_utils::mock_request, transfer_test_utils::mock_transfer, + user_test_utils::mock_user, Account, Metadata, User, }, repositories::{ - ACCOUNT_REPOSITORY, REQUEST_REPOSITORY, TRANSFER_REPOSITORY, USER_REPOSITORY, + ACCOUNT_REPOSITORY, ASSET_REPOSITORY, REQUEST_REPOSITORY, TRANSFER_REPOSITORY, + USER_REPOSITORY, }, + services::ACCOUNT_SERVICE, }; use candid::Principal; @@ -121,7 +124,7 @@ mod tests { call_context: CallContext, } - fn setup() -> TestContext { + async fn setup() -> TestContext { test_utils::init_canister_system(); let call_context = CallContext::new(Principal::from_slice(&[9; 29])); @@ -130,9 +133,28 @@ mod tests { USER_REPOSITORY.insert(user.to_key(), user.clone()); + let asset = mock_asset(); + + ASSET_REPOSITORY.insert(asset.id, asset.clone()); + let account = mock_account(); - ACCOUNT_REPOSITORY.insert(account.to_key(), account.clone()); + ACCOUNT_SERVICE + .create_account( + crate::models::AddAccountOperationInput { + name: "foo".to_owned(), + assets: vec![asset.id], + metadata: Metadata::default(), + read_permission: Allow::default(), + configs_permission: Allow::default(), + transfer_permission: Allow::default(), + configs_request_policy: None, + transfer_request_policy: None, + }, + Some(account.id), + ) + .await + .expect("Failed to create account"); let mut request = mock_request(); request.id = [2; 16]; @@ -148,9 +170,9 @@ mod tests { } } - #[test] - fn add_transfer_successfully() { - let ctx = setup(); + #[tokio::test] + async fn add_transfer_successfully() { + let ctx = setup().await; disable_mock_resource_validation(); @@ -163,9 +185,9 @@ mod tests { assert!(result.is_ok()); } - #[test] - fn fail_add_transfer_missing_initiator_user() { - let ctx = setup(); + #[tokio::test] + async fn fail_add_transfer_missing_initiator_user() { + let ctx = setup().await; disable_mock_resource_validation(); @@ -185,9 +207,9 @@ mod tests { ); } - #[test] - fn fail_add_transfer_missing_from_account() { - let ctx = setup(); + #[tokio::test] + async fn fail_add_transfer_missing_from_account() { + let ctx = setup().await; disable_mock_resource_validation(); @@ -206,9 +228,9 @@ mod tests { ); } - #[test] - fn fail_add_transfer_missing_request_id() { - let ctx = setup(); + #[tokio::test] + async fn fail_add_transfer_missing_request_id() { + let ctx = setup().await; disable_mock_resource_validation(); @@ -226,9 +248,9 @@ mod tests { ); } - #[test] - fn get_transfer() { - let ctx = setup(); + #[tokio::test] + async fn get_transfer() { + let ctx = setup().await; let mut transfer = mock_transfer(); transfer.from_account = ctx.account.id; transfer.initiator_user = ctx.caller_user.id; @@ -240,9 +262,9 @@ mod tests { assert!(result.is_ok()); } - #[test] - fn fail_get_transfer_not_allowed() { - let ctx = setup(); + #[tokio::test] + async fn fail_get_transfer_not_allowed() { + let ctx = setup().await; let mut user = mock_user(); user.identities = vec![Principal::from_slice(&[10; 29])]; diff --git a/core/upgrader/api/spec.did b/core/upgrader/api/spec.did index d66a786b5..e8f087033 100644 --- a/core/upgrader/api/spec.did +++ b/core/upgrader/api/spec.did @@ -39,7 +39,18 @@ type TriggerUpgradeError = variant { // Metadata for an account in the station canister. type Metadata = record { key : text; value : text }; -// Backup of Account in the station canister. +// Backup of Asset in the station canister. +type Asset = record { + id : text; + name : text; + symbol : text; + decimals : nat32; + blockchain : text; + standards : vec text; + metadata : vec Metadata; +}; + +// Backup of a legacy Account in the station canister. type Account = record { id : text; decimals : nat32; @@ -51,6 +62,15 @@ type Account = record { symbol : text; }; +// Backup of a multi asset Account in the station canister. +type MultiAssetAccount = record { + id : text; + name : text; + seed : blob; + assets : vec text; + metadata : vec Metadata; +}; + // Backup of admin user in the station canister. type AdminUser = record { id : text; name : text; identities : vec principal }; @@ -92,12 +112,24 @@ type GetDisasterRecoveryAccountsResponse = record { accounts : vec Account; }; +// Response to a successful get_disaster_recovery_accounts_and_assets query. +type GetDisasterRecoveryAccountsAndAssetsResponse = record { + accounts : vec MultiAssetAccount; + assets : vec Asset; +}; + // Result of the get_disaster_recovery_accounts query. type GetDisasterRecoveryAccountsResult = variant { Ok : GetDisasterRecoveryAccountsResponse; Err : Error; }; +// Result of the get_disaster_recovery_accounts_and_assets query. +type GetDisasterRecoveryAccountsAndAssetsResult = variant { + Ok : GetDisasterRecoveryAccountsAndAssetsResponse; + Err : Error; +}; + // Response to a successful get_disaster_recovery_committee query. type GetDisasterRecoveryCommitteeResponse = record { committee : opt DisasterRecoveryCommittee; @@ -121,6 +153,13 @@ type SetDisasterRecoveryAccountsInput = record { accounts : vec Account; }; +// Set the disaster recovery accounts and assets. Called by the station canister +// when accounts are added. +type SetDisasterRecoveryAccountsAndAssetsInput = record { + accounts : vec MultiAssetAccount; + assets : vec Asset; +}; + // Request to trigger disaster recovery. Requests are stored in the Upgrader // canister, and when at least `quorum` of the committee members // agree on the exact module, args, and install mode, the request is processed. @@ -224,8 +263,12 @@ type RecoveryResult = variant { type GetDisasterRecoveryStateResponse = record { // The disaster recovery committee. committee : opt DisasterRecoveryCommittee; - // The backup of the station accounts. + // The backup of the legacy station accounts. accounts : vec Account; + // The backup of the station multi asset accounts. + multi_asset_accounts : vec MultiAssetAccount; + // The backup of the station assets. + assets : vec Asset; // The current list of recovery requests. recovery_requests : vec StationRecoveryRequest; // The current recovery status. @@ -245,10 +288,13 @@ service : (InitArg) -> { "trigger_upgrade" : (UpgradeParams) -> (TriggerUpgradeResponse); "set_disaster_recovery_committee" : (SetDisasterRecoveryCommitteeInput) -> (SetDisasterRecoveryResult); "set_disaster_recovery_accounts" : (SetDisasterRecoveryAccountsInput) -> (SetDisasterRecoveryResult); + "set_disaster_recovery_accounts_and_assets" : (SetDisasterRecoveryAccountsAndAssetsInput) -> (SetDisasterRecoveryResult); "is_committee_member" : () -> (IsCommitteeMemberResult) query; "get_disaster_recovery_accounts" : () -> (GetDisasterRecoveryAccountsResult) query; + "get_disaster_recovery_accounts_and_assets" : () -> (GetDisasterRecoveryAccountsAndAssetsResult) query; "get_disaster_recovery_committee" : () -> (GetDisasterRecoveryCommitteeResult) query; "get_disaster_recovery_state" : () -> (GetDisasterRecoveryStateResult) query; "request_disaster_recovery" : (RequestDisasterRecoveryInput) -> (RequestDisasterRecoveryResult); "get_logs" : (GetLogsInput) -> (GetLogsResult) query; + "deprecated_get_logs" : (GetLogsInput) -> (GetLogsResult) query; }; diff --git a/core/upgrader/api/src/lib.rs b/core/upgrader/api/src/lib.rs index 7874f3d2a..ef42fecb3 100644 --- a/core/upgrader/api/src/lib.rs +++ b/core/upgrader/api/src/lib.rs @@ -1,5 +1,6 @@ use candid::{CandidType, Deserialize, Principal}; use orbit_essentials::types::WasmModuleExtraChunks; +use station_api::AccountSeedDTO; use station_api::TimestampRfc3339; pub use station_api::{MetadataDTO, UuidDTO}; @@ -64,6 +65,43 @@ pub struct Account { pub metadata: Vec, } +#[derive(Clone, Debug, CandidType, Deserialize, PartialEq, Eq)] +pub struct Asset { + /// The asset id, which is a UUID. + pub id: UuidDTO, + /// The asset name (e.g. `Internet Computer`, `Bitcoin`, `Ethereum`, etc.) + pub name: String, + /// The asset symbol (e.g. `ICP`, `BTC`, `ETH`, etc.) + pub symbol: String, + /// The number of decimal places that the asset supports (e.g. `8` for `BTC`, `18` for `ETH`, etc.) + pub decimals: u32, + /// The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) + pub blockchain: String, + // The asset standard that is supported (e.g. `erc20`, `native`, etc.), canonically + // represented as a lowercase string with spaces replaced with underscores. + pub standards: Vec, + /// The account metadata, which is a list of key-value pairs, + /// where the key is unique and the first entry in the tuple, + /// and the value is the second entry in the tuple. + pub metadata: Vec, +} + +#[derive(Clone, Debug, CandidType, Deserialize, PartialEq, Eq)] +pub struct MultiAssetAccount { + /// The account id, which is a UUID. + pub id: UuidDTO, + /// The seed for address generation. + pub seed: AccountSeedDTO, + /// The account name. + pub name: String, + /// The account assets. + pub assets: Vec, + /// The account metadata, which is a list of key-value pairs, + /// where the key is unique and the first entry in the tuple, + /// and the value is the second entry in the tuple. + pub metadata: Vec, +} + #[derive(Clone, Debug, CandidType)] pub enum DisasterRecoveryError { Unauthorized, @@ -79,6 +117,12 @@ pub struct GetDisasterRecoveryAccountsResponse { pub accounts: Vec, } +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct GetDisasterRecoveryAccountsAndAssetsResponse { + pub accounts: Vec, + pub assets: Vec, +} + #[derive(Clone, Debug, CandidType, Deserialize)] pub struct GetDisasterRecoveryCommitteeResponse { pub committee: Option, @@ -94,6 +138,12 @@ pub struct SetDisasterRecoveryAccountsInput { pub accounts: Vec, } +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct SetDisasterRecoveryAccountsAndAssetsInput { + pub accounts: Vec, + pub assets: Vec, +} + #[derive(Clone, Debug, CandidType, Deserialize)] pub enum InstallMode { /// Install the module. @@ -188,6 +238,9 @@ pub struct GetDisasterRecoveryStateResponse { pub committee: Option, pub accounts: Vec, + pub multi_asset_accounts: Vec, + pub assets: Vec, + pub recovery_requests: Vec, pub recovery_status: RecoveryStatus, pub last_recovery_result: Option, diff --git a/core/upgrader/impl/src/controllers/disaster_recovery.rs b/core/upgrader/impl/src/controllers/disaster_recovery.rs index 4ac7a18f1..7545c9a0a 100644 --- a/core/upgrader/impl/src/controllers/disaster_recovery.rs +++ b/core/upgrader/impl/src/controllers/disaster_recovery.rs @@ -31,6 +31,13 @@ fn set_disaster_recovery_accounts( CONTROLLER.set_disaster_recovery_accounts(input) } +#[update] +fn set_disaster_recovery_accounts_and_assets( + input: upgrader_api::SetDisasterRecoveryAccountsAndAssetsInput, +) -> ApiResult { + CONTROLLER.set_disaster_recovery_accounts_and_assets(input) +} + #[update] fn request_disaster_recovery(input: upgrader_api::RequestDisasterRecoveryInput) -> ApiResult { CONTROLLER.request_disaster_recovery(input) @@ -47,6 +54,12 @@ fn get_disaster_recovery_accounts() -> ApiResult ApiResult { + CONTROLLER.get_disaster_recovery_accounts_and_assets() +} + #[query] fn get_disaster_recovery_committee() -> ApiResult { @@ -70,24 +83,38 @@ impl DisasterRecoveryController { let caller = caller(); if !is_controller(&caller) { Err(UpgraderApiError::NotController)? - } else { - self.disaster_recovery_service - .set_committee(input.committee.into()) } + + self.disaster_recovery_service + .set_committee(input.committee.into()) } fn set_disaster_recovery_accounts( &self, - input: upgrader_api::SetDisasterRecoveryAccountsInput, ) -> ApiResult { let caller = caller(); if !is_controller(&caller) { Err(UpgraderApiError::NotController)? - } else { - self.disaster_recovery_service - .set_accounts(input.accounts.into_iter().map(Into::into).collect()) } + + self.disaster_recovery_service + .set_accounts(input.accounts.into_iter().map(Into::into).collect()) + } + + fn set_disaster_recovery_accounts_and_assets( + &self, + input: upgrader_api::SetDisasterRecoveryAccountsAndAssetsInput, + ) -> ApiResult { + let caller = caller(); + if !is_controller(&caller) { + Err(UpgraderApiError::NotController)? + } + + self.disaster_recovery_service.set_accounts_and_assets( + input.accounts.into_iter().map(Into::into).collect(), + input.assets.into_iter().map(Into::into).collect(), + ) } fn request_disaster_recovery( @@ -98,14 +125,13 @@ impl DisasterRecoveryController { let caller = caller(); if !self.disaster_recovery_service.is_committee_member(&caller) { Err(UpgraderApiError::Unauthorized)? - } else { - self.disaster_recovery_service - .request_recovery(caller, input); + } - self.disaster_recovery_service.check_requests(); + self.disaster_recovery_service + .request_recovery(caller, input); + self.disaster_recovery_service.check_requests(); - Ok(()) - } + Ok(()) } fn is_committee_member(&self) -> ApiResult { @@ -113,11 +139,11 @@ impl DisasterRecoveryController { if caller == Principal::anonymous() { Err(UpgraderApiError::Unauthorized)? - } else { - Ok(upgrader_api::IsCommitteeMemberResponse { - is_committee_member: self.disaster_recovery_service.is_committee_member(&caller), - }) } + + Ok(upgrader_api::IsCommitteeMemberResponse { + is_committee_member: self.disaster_recovery_service.is_committee_member(&caller), + }) } fn can_query_state(&self, caller: &Principal) -> bool { @@ -128,18 +154,43 @@ impl DisasterRecoveryController { &self, ) -> ApiResult { let caller = caller(); + if !self.can_query_state(&caller) { Err(UpgraderApiError::Unauthorized)? - } else { - Ok(upgrader_api::GetDisasterRecoveryAccountsResponse { - accounts: self - .disaster_recovery_service - .get_accounts() - .into_iter() - .map(Into::into) - .collect(), - }) } + + Ok(upgrader_api::GetDisasterRecoveryAccountsResponse { + accounts: self + .disaster_recovery_service + .get_accounts() + .into_iter() + .map(Into::into) + .collect(), + }) + } + + fn get_disaster_recovery_accounts_and_assets( + &self, + ) -> ApiResult { + let caller = caller(); + if !is_controller(&caller) { + Err(UpgraderApiError::NotController)? + } + + Ok(upgrader_api::GetDisasterRecoveryAccountsAndAssetsResponse { + accounts: self + .disaster_recovery_service + .get_multi_asset_accounts() + .into_iter() + .map(Into::into) + .collect(), + assets: self + .disaster_recovery_service + .get_assets() + .into_iter() + .map(Into::into) + .collect(), + }) } fn get_disaster_recovery_committee( @@ -148,14 +199,14 @@ impl DisasterRecoveryController { let caller = caller(); if !self.can_query_state(&caller) { Err(UpgraderApiError::Unauthorized)? - } else { - Ok(upgrader_api::GetDisasterRecoveryCommitteeResponse { - committee: self - .disaster_recovery_service - .get_committee() - .map(Into::into), - }) } + + Ok(upgrader_api::GetDisasterRecoveryCommitteeResponse { + committee: self + .disaster_recovery_service + .get_committee() + .map(Into::into), + }) } fn get_disaster_recovery_state( @@ -164,9 +215,9 @@ impl DisasterRecoveryController { let caller = caller(); if !self.can_query_state(&caller) { Err(UpgraderApiError::Unauthorized)? - } else { - Ok(self.disaster_recovery_service.get_state().into()) } + + Ok(self.disaster_recovery_service.get_state().into()) } } diff --git a/core/upgrader/impl/src/controllers/logs.rs b/core/upgrader/impl/src/controllers/logs.rs index 6e55bc133..962f01c2a 100644 --- a/core/upgrader/impl/src/controllers/logs.rs +++ b/core/upgrader/impl/src/controllers/logs.rs @@ -28,6 +28,13 @@ fn get_logs(input: upgrader_api::GetLogsInput) -> ApiResult ApiResult { + CONTROLLER.deprecated_get_logs(input) +} + pub struct LogsController { disaster_recover_service: Arc, logger_service: Arc, @@ -59,4 +66,31 @@ impl LogsController { Err(UpgraderApiError::Unauthorized.into()) } } + + // Supports fetching the logs from the deprecated log storage. + pub fn deprecated_get_logs( + &self, + input: upgrader_api::GetLogsInput, + ) -> ApiResult { + let caller = caller(); + + if is_controller(&caller) || self.disaster_recover_service.is_committee_member(&caller) { + let GetLogsResult { + logs, + next_offset, + total, + } = self.logger_service.deprecated_get_logs( + input.pagination.as_ref().and_then(|p| p.offset), + input.pagination.as_ref().and_then(|p| p.limit), + ); + + Ok(upgrader_api::GetLogsResponse { + logs: logs.into_iter().map(|l| l.into()).collect(), + total, + next_offset, + }) + } else { + Err(UpgraderApiError::Unauthorized.into()) + } + } } diff --git a/core/upgrader/impl/src/errors/mod.rs b/core/upgrader/impl/src/errors/mod.rs index bfcfbb40c..101129711 100644 --- a/core/upgrader/impl/src/errors/mod.rs +++ b/core/upgrader/impl/src/errors/mod.rs @@ -4,6 +4,7 @@ pub enum UpgraderApiError { NotController, Unauthorized, DisasterRecoveryInProgress, + EmptyCommittee, } impl From for ApiError { @@ -24,6 +25,11 @@ impl From for ApiError { message: Some("Disaster recovery is in progress.".to_owned()), details: None, }, + UpgraderApiError::EmptyCommittee => ApiError { + code: "EMPTY_COMMITTEE".to_owned(), + message: Some("Committee cannot be empty.".to_owned()), + details: None, + }, } } } diff --git a/core/upgrader/impl/src/lib.rs b/core/upgrader/impl/src/lib.rs index b6c7379d5..4f3ea2a15 100644 --- a/core/upgrader/impl/src/lib.rs +++ b/core/upgrader/impl/src/lib.rs @@ -33,8 +33,9 @@ type LocalRef = &'static LocalKey>; const MEMORY_ID_TARGET_CANISTER_ID: u8 = 0; const MEMORY_ID_DISASTER_RECOVERY: u8 = 1; -const MEMORY_ID_LOG_INDEX: u8 = 2; -const MEMORY_ID_LOG_DATA: u8 = 3; +const DEPRECATED_MEMORY_ID_LOG_INDEX: u8 = 2; +const DEPRECATED_MEMORY_ID_LOG_DATA: u8 = 3; +const MEMORY_ID_LOGS: u8 = 4; thread_local! { static MEMORY_MANAGER: RefCell> = diff --git a/core/upgrader/impl/src/model/disaster_recovery.rs b/core/upgrader/impl/src/model/disaster_recovery.rs index efab159e6..63cb96f42 100644 --- a/core/upgrader/impl/src/model/disaster_recovery.rs +++ b/core/upgrader/impl/src/model/disaster_recovery.rs @@ -246,6 +246,62 @@ impl From for upgrader_api::AdminUser { } } +#[storable] +#[derive(Clone, Debug)] +pub struct Asset { + /// The asset id, which is a UUID. + pub id: UUID, + /// The asset name (e.g. `Internet Computer`, `Bitcoin`, `Ethereum`, etc.) + pub name: String, + /// The asset symbol (e.g. `ICP`, `BTC`, `ETH`, etc.) + pub symbol: String, + /// The number of decimal places that the asset supports (e.g. `8` for `BTC`, `18` for `ETH`, etc.) + pub decimals: u32, + /// The blockchain identifier (e.g., `ethereum`, `bitcoin`, `icp`, etc.) + pub blockchain: String, + // The asset standard that is supported (e.g. `erc20`, `native`, etc.), canonically + // represented as a lowercase string with spaces replaced with underscores. + pub standards: Vec, + /// The account metadata, which is a list of key-value pairs, + /// where the key is unique and the first entry in the tuple, + /// and the value is the second entry in the tuple. + pub metadata: Vec, +} + +impl From for Asset { + fn from(value: upgrader_api::Asset) -> Self { + Asset { + id: *HelperMapper::to_uuid(value.id) + .expect("Invalid asset ID") + .as_bytes(), + name: value.name, + symbol: value.symbol, + decimals: value.decimals, + blockchain: value.blockchain, + standards: value.standards, + metadata: value.metadata.into_iter().map(Metadata::from).collect(), + } + } +} + +impl From for upgrader_api::Asset { + fn from(value: Asset) -> Self { + upgrader_api::Asset { + id: Uuid::from_bytes(value.id).hyphenated().to_string(), + name: value.name, + symbol: value.symbol, + decimals: value.decimals, + blockchain: value.blockchain, + standards: value.standards, + metadata: value + .metadata + .into_iter() + .map(upgrader_api::MetadataDTO::from) + .collect(), + } + } +} + #[storable] #[derive(Clone, Debug)] pub struct Account { @@ -269,6 +325,24 @@ pub struct Account { pub metadata: Vec, } +type AccountSeed = [u8; 16]; +#[storable] +#[derive(Clone, Debug)] +pub struct MultiAssetAccount { + /// The account id, which is a UUID. + pub id: UUID, + /// The blockchain type (e.g. `icp`, `eth`, `btc`) + pub name: String, + /// The address generation seed. + pub seed: AccountSeed, + /// Assets + pub assets: Vec, + /// The account metadata, which is a list of key-value pairs, + /// where the key is unique and the first entry in the tuple, + /// and the value is the second entry in the tuple. + pub metadata: Vec, +} + impl From for Account { fn from(value: upgrader_api::Account) -> Self { Account { @@ -305,10 +379,58 @@ impl From for upgrader_api::Account { } } +impl From for MultiAssetAccount { + fn from(value: upgrader_api::MultiAssetAccount) -> Self { + MultiAssetAccount { + id: *HelperMapper::to_uuid(value.id) + .expect("Invalid account ID") + .as_bytes(), + assets: value + .assets + .into_iter() + .map(|asset_id| { + *HelperMapper::to_uuid(asset_id) + .expect("Invalid asset ID") + .as_bytes() + }) + .collect(), + seed: value.seed, + name: value.name, + metadata: value.metadata.into_iter().map(Metadata::from).collect(), + } + } +} + +impl From for upgrader_api::MultiAssetAccount { + fn from(value: MultiAssetAccount) -> Self { + upgrader_api::MultiAssetAccount { + id: Uuid::from_bytes(value.id).hyphenated().to_string(), + name: value.name, + seed: value.seed, + assets: value + .assets + .into_iter() + .map(|asset_id| Uuid::from_bytes(asset_id).hyphenated().to_string()) + .collect(), + metadata: value + .metadata + .into_iter() + .map(upgrader_api::MetadataDTO::from) + .collect(), + } + } +} + #[storable] #[derive(Clone, Debug)] pub struct DisasterRecovery { pub accounts: Vec, + + #[serde(default)] + pub multi_asset_accounts: Vec, + #[serde(default)] + pub assets: Vec, + pub committee: Option, pub recovery_requests: Vec, @@ -320,6 +442,8 @@ impl Default for DisasterRecovery { fn default() -> Self { DisasterRecovery { accounts: vec![], + multi_asset_accounts: vec![], + assets: vec![], committee: None, recovery_requests: vec![], recovery_status: RecoveryStatus::Idle, @@ -336,6 +460,18 @@ impl From for upgrader_api::GetDisasterRecoveryStateResponse { .into_iter() .map(upgrader_api::Account::from) .collect(), + + multi_asset_accounts: value + .multi_asset_accounts + .into_iter() + .map(upgrader_api::MultiAssetAccount::from) + .collect(), + assets: value + .assets + .into_iter() + .map(upgrader_api::Asset::from) + .collect(), + committee: value .committee .map(upgrader_api::DisasterRecoveryCommittee::from), @@ -354,6 +490,8 @@ impl From for upgrader_api::GetDisasterRecoveryStateResponse { pub mod tests { use candid::Principal; + use crate::model::{Asset, MultiAssetAccount}; + use super::{Account, AdminUser, DisasterRecoveryCommittee}; pub fn mock_committee_member() -> Principal { @@ -411,4 +549,46 @@ pub mod tests { }, ] } + + pub fn mock_multi_asset_accounts() -> Vec { + vec![ + MultiAssetAccount { + id: [1; 16], + assets: vec![[1; 16], [2; 16]], + seed: [0; 16], + name: "Main Account".to_owned(), + metadata: vec![], + }, + MultiAssetAccount { + id: [2; 16], + assets: vec![[1; 16]], + seed: [0; 16], + name: "Secondary Account".to_owned(), + metadata: vec![], + }, + ] + } + + pub fn mock_assets() -> Vec { + vec![ + Asset { + id: [1; 16], + name: "Internet Computer".to_owned(), + symbol: "ICP".to_owned(), + decimals: 8, + blockchain: "icp".to_owned(), + standards: vec!["icp_native".to_owned()], + metadata: vec![], + }, + Asset { + id: [2; 16], + name: "Ethereum".to_owned(), + symbol: "ETH".to_owned(), + decimals: 18, + blockchain: "eth".to_owned(), + standards: vec!["erc20".to_owned()], + metadata: vec![], + }, + ] + } } diff --git a/core/upgrader/impl/src/model/logging.rs b/core/upgrader/impl/src/model/logging.rs index 617df02ac..6ce78bbb5 100644 --- a/core/upgrader/impl/src/model/logging.rs +++ b/core/upgrader/impl/src/model/logging.rs @@ -1,8 +1,10 @@ -use crate::upgrader_ic_cdk::api::time; +use crate::upgrader_ic_cdk::next_time; use orbit_essentials::{storable, types::Timestamp, utils::timestamp_to_rfc3339}; use serde::Serialize; -use super::{Account, AdminUser, DisasterRecoveryCommittee, RecoveryResult}; +use super::{ + Account, AdminUser, Asset, DisasterRecoveryCommittee, MultiAssetAccount, RecoveryResult, +}; #[derive(Serialize)] pub enum UpgradeResultLog { @@ -20,6 +22,12 @@ pub struct SetAccountsLog { pub accounts: Vec, } +#[derive(Serialize)] +pub struct SetAccountsAndAssetsLog { + pub multi_asset_accounts: Vec, + pub assets: Vec, +} + #[derive(Serialize)] pub struct RequestDisasterRecoveryLog { pub user: AdminUser, @@ -48,6 +56,7 @@ pub struct DisasterRecoveryInProgressLog { pub enum LogEntryType { SetCommittee(SetCommitteeLog), SetAccounts(SetAccountsLog), + SetAccountsAndAssets(SetAccountsAndAssetsLog), RequestDisasterRecovery(RequestDisasterRecoveryLog), DisasterRecoveryStart(DisasterRecoveryStartLog), DisasterRecoveryResult(DisasterRecoveryResultLog), @@ -80,6 +89,7 @@ impl LogEntryType { LogEntryType::DisasterRecoveryInProgressExpired(_) => { "disaster_recovery_in_progress_expired".to_owned() } + LogEntryType::SetAccountsAndAssets(_) => "set_accounts_and_assets".to_owned(), } } @@ -96,7 +106,7 @@ impl LogEntryType { data.committee.quorum ), LogEntryType::SetAccounts(data) => { - format!("Set {} disaster recovery account(s)", data.accounts.len()) + format!("Set {} disaster recovery account(s)", data.accounts.len(),) } LogEntryType::RequestDisasterRecovery(data) => format!( "{} requested disaster recovery with wasm hash {} and arg hash {}", @@ -132,6 +142,13 @@ impl LogEntryType { data.operation ) } + LogEntryType::SetAccountsAndAssets(data) => { + format!( + "Set {} multi-asset account(s) and {} asset(s)", + data.multi_asset_accounts.len(), + data.assets.len() + ) + } } } @@ -145,6 +162,7 @@ impl LogEntryType { LogEntryType::UpgradeResult(data) => serde_json::to_string(data), LogEntryType::DisasterRecoveryInProgress(data) => serde_json::to_string(data), LogEntryType::DisasterRecoveryInProgressExpired(data) => serde_json::to_string(data), + LogEntryType::SetAccountsAndAssets(data) => serde_json::to_string(data), } .map_err(|err| format!("Failed to serialize log entry: {}", err)) } @@ -153,7 +171,7 @@ impl LogEntryType { impl LogEntry { pub fn try_from_entry_type(entry_type: LogEntryType) -> Result { Ok(LogEntry { - time: time(), + time: next_time(), entry_type: entry_type.to_type_string(), message: entry_type.to_message(), data_json: entry_type.to_json_string()?, diff --git a/core/upgrader/impl/src/services/disaster_recovery.rs b/core/upgrader/impl/src/services/disaster_recovery.rs index 66a610ba3..44d5ef46d 100644 --- a/core/upgrader/impl/src/services/disaster_recovery.rs +++ b/core/upgrader/impl/src/services/disaster_recovery.rs @@ -3,8 +3,9 @@ use std::{cell::RefCell, collections::HashMap, sync::Arc}; use crate::{ errors::UpgraderApiError, model::{ - DisasterRecoveryInProgressLog, DisasterRecoveryResultLog, DisasterRecoveryStartLog, - LogEntryType, RequestDisasterRecoveryLog, SetAccountsLog, SetCommitteeLog, + Asset, DisasterRecoveryInProgressLog, DisasterRecoveryResultLog, DisasterRecoveryStartLog, + LogEntryType, MultiAssetAccount, RequestDisasterRecoveryLog, SetAccountsAndAssetsLog, + SetAccountsLog, SetCommitteeLog, }, services::LOGGER_SERVICE, upgrader_ic_cdk::{api::time, spawn}, @@ -98,24 +99,37 @@ pub struct DisasterRecoveryService { } impl DisasterRecoveryService { - pub fn set_committee(&self, committee: DisasterRecoveryCommittee) -> ServiceResult { - let mut value = self.storage.get(); - + fn ensure_not_in_progress( + logger: &Arc, + value: &mut DisasterRecovery, + operation: &str, + ) -> ServiceResult { if let RecoveryStatus::InProgress { since } = &value.recovery_status { let log = DisasterRecoveryInProgressLog { - operation: "set_committee".to_owned(), + operation: operation.to_owned(), }; if since + DISASTER_RECOVERY_IN_PROGESS_EXPIRATION_NS > time() { - self.logger - .log(LogEntryType::DisasterRecoveryInProgress(log)); + logger.log(LogEntryType::DisasterRecoveryInProgress(log)); return Err(UpgraderApiError::DisasterRecoveryInProgress.into()); } - self.logger - .log(LogEntryType::DisasterRecoveryInProgressExpired(log)); + logger.log(LogEntryType::DisasterRecoveryInProgressExpired(log)); value.recovery_status = RecoveryStatus::Idle; } + Ok(()) + } + + pub fn set_committee(&self, committee: DisasterRecoveryCommittee) -> ServiceResult { + let mut value = self.storage.get(); + + Self::ensure_not_in_progress(&self.logger, &mut value, "set_committee")?; + + // Ensure committee is not empty due to some error + if committee.users.is_empty() { + return Err(UpgraderApiError::EmptyCommittee.into()); + } + value.committee = Some(committee.clone()); self.storage.set(value); @@ -129,22 +143,10 @@ impl DisasterRecoveryService { pub fn set_accounts(&self, accounts: Vec) -> ServiceResult { let mut value = self.storage.get(); - if let RecoveryStatus::InProgress { since } = &value.recovery_status { - let log = DisasterRecoveryInProgressLog { - operation: "set_accounts".to_owned(), - }; - if since + DISASTER_RECOVERY_IN_PROGESS_EXPIRATION_NS > time() { - self.logger - .log(LogEntryType::DisasterRecoveryInProgress(log)); - return Err(UpgraderApiError::DisasterRecoveryInProgress.into()); - } - - self.logger - .log(LogEntryType::DisasterRecoveryInProgressExpired(log)); - value.recovery_status = RecoveryStatus::Idle; - } + Self::ensure_not_in_progress(&self.logger, &mut value, "set_accounts")?; value.accounts = accounts.clone(); + self.storage.set(value); self.logger @@ -153,10 +155,42 @@ impl DisasterRecoveryService { Ok(()) } + pub fn set_accounts_and_assets( + &self, + multi_asset_accounts: Vec, + assets: Vec, + ) -> ServiceResult { + let mut value = self.storage.get(); + + Self::ensure_not_in_progress(&self.logger, &mut value, "set_accounts_and_assets")?; + + value.multi_asset_accounts = multi_asset_accounts.clone(); + value.assets = assets.clone(); + + self.storage.set(value); + + self.logger.log(LogEntryType::SetAccountsAndAssets( + SetAccountsAndAssetsLog { + multi_asset_accounts, + assets, + }, + )); + + Ok(()) + } + pub fn get_accounts(&self) -> Vec { self.storage.get().accounts } + pub fn get_multi_asset_accounts(&self) -> Vec { + self.storage.get().multi_asset_accounts + } + + pub fn get_assets(&self) -> Vec { + self.storage.get().assets + } + pub fn get_committee(&self) -> Option { self.storage.get().committee } @@ -244,18 +278,8 @@ impl DisasterRecoveryService { }, )); - if let RecoveryStatus::InProgress { since } = &value.recovery_status { - let log = DisasterRecoveryInProgressLog { - operation: "do_recovery".to_owned(), - }; - - if since + DISASTER_RECOVERY_IN_PROGESS_EXPIRATION_NS > time() { - logger.log(LogEntryType::DisasterRecoveryInProgress(log)); - return; - } - - logger.log(LogEntryType::DisasterRecoveryInProgressExpired(log)); - value.recovery_status = RecoveryStatus::Idle; + if Self::ensure_not_in_progress(&logger, &mut value, "do_recovery").is_err() { + return; } let Some(station_canister_id) = @@ -384,7 +408,7 @@ mod tests { use crate::{ model::{ - tests::{mock_accounts, mock_committee}, + tests::{mock_accounts, mock_assets, mock_committee, mock_multi_asset_accounts}, InstallMode, RecoveryEvaluationResult, RecoveryResult, RecoveryStatus, StationRecoveryRequest, }, @@ -748,8 +772,20 @@ mod tests { }; storage.set(value); + let error = DISASTER_RECOVERY_SERVICE + .set_accounts_and_assets(mock_multi_asset_accounts(), mock_assets()) + .expect_err("Setting accounts and assets during recovery should fail"); + + assert_eq!(error.code, "DISASTER_RECOVERY_IN_PROGRESS".to_string(),); + let error = DISASTER_RECOVERY_SERVICE .set_accounts(mock_accounts()) + .expect_err("Setting accounts during recovery should fail"); + + assert_eq!(error.code, "DISASTER_RECOVERY_IN_PROGRESS".to_string(),); + + let error = DISASTER_RECOVERY_SERVICE + .set_committee(mock_committee()) .expect_err("Setting committee during recovery should fail"); assert_eq!(error.code, "DISASTER_RECOVERY_IN_PROGRESS".to_string(),); diff --git a/core/upgrader/impl/src/services/logger.rs b/core/upgrader/impl/src/services/logger.rs index a32fe0419..0ec46e613 100644 --- a/core/upgrader/impl/src/services/logger.rs +++ b/core/upgrader/impl/src/services/logger.rs @@ -1,25 +1,32 @@ use std::{cell::RefCell, sync::Arc}; -use ic_stable_structures::{memory_manager::MemoryId, Log}; +use ic_stable_structures::{memory_manager::MemoryId, BTreeMap, Log}; use lazy_static::lazy_static; +use orbit_essentials::types::Timestamp; use crate::{ model::{LogEntry, LogEntryType}, - Memory, MEMORY_ID_LOG_DATA, MEMORY_ID_LOG_INDEX, MEMORY_MANAGER, + Memory, DEPRECATED_MEMORY_ID_LOG_DATA, DEPRECATED_MEMORY_ID_LOG_INDEX, MEMORY_ID_LOGS, + MEMORY_MANAGER, }; pub const MAX_GET_LOGS_LIMIT: u64 = 100; pub const DEFAULT_GET_LOGS_LIMIT: u64 = 10; +pub const MAX_LOG_ENTRIES: u64 = 25000; thread_local! { - - static STORAGE: RefCell> = RefCell::new( - Log::init( - MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(MEMORY_ID_LOG_INDEX))), - MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(MEMORY_ID_LOG_DATA))), - ).expect("Failed to initialize log storage") - ); - + static DEPRECATED_STORAGE: RefCell> = RefCell::new( + Log::init( + MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(DEPRECATED_MEMORY_ID_LOG_INDEX))), + MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(DEPRECATED_MEMORY_ID_LOG_DATA))), + ).expect("Failed to initialize deprecated log storage") + ); + + static STORAGE: RefCell> = RefCell::new( + BTreeMap::init( + MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(MEMORY_ID_LOGS))), + ) + ); } lazy_static! { @@ -40,12 +47,12 @@ impl LoggerService { /// Tries to log an entry to the storage. pub fn try_log(&self, entry_type: LogEntryType) -> Result<(), String> { let entry = LogEntry::try_from_entry_type(entry_type)?; - STORAGE.with(|storage| { - storage - .borrow_mut() - .append(&entry) - .map_err(|err| format!("Failed to log entry: {:?}", err)) - })?; + STORAGE.with_borrow_mut(|storage| { + if storage.len() >= MAX_LOG_ENTRIES { + let _ = storage.pop_first(); + } + storage.insert(entry.time, entry); + }); Ok(()) } @@ -71,6 +78,47 @@ impl LoggerService { }; } + let offset = offset.unwrap_or(0); + let limit = limit + .unwrap_or(DEFAULT_GET_LOGS_LIMIT) + .min(MAX_GET_LOGS_LIMIT); + + let logs = borrowed + .iter() + .rev() + .skip(offset as usize) + .take(limit as usize) + .map(|(_, v)| v) + .collect::>(); + + let next_offset = if total > offset + limit { + Some(offset + limit) + } else { + None + }; + GetLogsResult { + logs, + total, + next_offset, + } + }) + } + + /// Returns logs from the deprecated storage starting from the end of the log. + pub fn deprecated_get_logs(&self, offset: Option, limit: Option) -> GetLogsResult { + DEPRECATED_STORAGE.with(|storage| { + let borrowed = storage.borrow(); + + let total = borrowed.len(); + + if total == 0 { + return GetLogsResult { + logs: vec![], + total, + next_offset: None, + }; + } + let offset = offset.unwrap_or(0); let limit = limit .unwrap_or(DEFAULT_GET_LOGS_LIMIT) @@ -102,8 +150,8 @@ impl LoggerService { mod tests { use crate::model::{ - tests::{mock_accounts, mock_committee}, - DisasterRecoveryResultLog, RecoveryResult, SetAccountsLog, SetCommitteeLog, + tests::{mock_assets, mock_committee, mock_multi_asset_accounts}, + DisasterRecoveryResultLog, RecoveryResult, SetAccountsAndAssetsLog, SetCommitteeLog, UpgradeResultLog, }; @@ -121,15 +169,21 @@ mod tests { result: RecoveryResult::Success, }, )); - logger_service.log(LogEntryType::SetAccounts(SetAccountsLog { - accounts: mock_accounts(), - })); + logger_service.log(LogEntryType::SetAccountsAndAssets( + SetAccountsAndAssetsLog { + multi_asset_accounts: mock_multi_asset_accounts(), + assets: mock_assets(), + }, + )); let result = logger_service.get_logs(None, None); - println!("{:?}", result); + assert_eq!(result.logs.len(), 4); assert_eq!(result.total, 4); assert_eq!(result.logs[3].entry_type, "set_committee".to_owned()); - assert_eq!(result.logs[0].entry_type, "set_accounts".to_owned()); + assert_eq!( + result.logs[0].entry_type, + "set_accounts_and_assets".to_owned() + ); let result = logger_service.get_logs(Some(1), Some(2)); assert_eq!(result.logs.len(), 2); @@ -147,4 +201,43 @@ mod tests { assert_eq!(result.next_offset, None); assert_eq!(result.logs[0].entry_type, "set_committee".to_owned()); } + + #[test] + fn test_log_trimming() { + for _ in 0..MAX_LOG_ENTRIES { + LOGGER_SERVICE.log(LogEntryType::SetCommittee(SetCommitteeLog { + committee: mock_committee(), + })); + } + + let result = LOGGER_SERVICE.get_logs(None, None); + assert_eq!(result.total, MAX_LOG_ENTRIES); + + let latest_log_time = result.logs.last().unwrap().time; + + LOGGER_SERVICE.log(LogEntryType::SetCommittee(SetCommitteeLog { + committee: mock_committee(), + })); + + let result = LOGGER_SERVICE.get_logs(None, None); + + assert_eq!(result.total, MAX_LOG_ENTRIES); + assert_ne!(result.logs.last().unwrap().time, latest_log_time); + } + + #[test] + fn test_deprecated_storage() { + let logger_service = LoggerService::default(); + logger_service.log(LogEntryType::SetCommittee(SetCommitteeLog { + committee: mock_committee(), + })); + + // new logs should be in the new storage + let result = logger_service.get_logs(None, None); + assert_eq!(result.total, 1); + + // deprecated logs should not get new logs + let result = logger_service.deprecated_get_logs(None, None); + assert_eq!(result.total, 0); + } } diff --git a/dfx.json b/dfx.json index 97c5ff986..d8b9b663f 100644 --- a/dfx.json +++ b/dfx.json @@ -9,6 +9,10 @@ "id": { "ic": "ryjl3-tyaaa-aaaaa-aaaba-cai" } + }, + "declarations": { + "output": "apps/wallet/src/generated/icp_ledger", + "node_compatibility": true } }, "icp_index": { @@ -61,9 +65,29 @@ }, "app_wallet": { "type": "assets", - "source": ["apps/wallet/dist/"], + "source": [ + "apps/wallet/dist/" + ], "build": "pnpm --filter 'wallet-dapp' build" }, + "icrc1_index_canister": { + "type": "custom", + "candid": "https://raw.githubusercontent.com/dfinity/ic/d87954601e4b22972899e9957e800406a0a6b929/rs/rosetta-api/icrc1/index-ng/index-ng.did", + "wasm": "https://download.dfinity.systems/ic/d87954601e4b22972899e9957e800406a0a6b929/canisters/ic-icrc1-index-ng.wasm.gz", + "declarations": { + "output": "apps/wallet/src/generated/icrc1_index", + "node_compatibility": true + } + }, + "icrc1_ledger_canister": { + "type": "custom", + "candid": "https://raw.githubusercontent.com/dfinity/ic/d87954601e4b22972899e9957e800406a0a6b929/rs/rosetta-api/icrc1/ledger/ledger.did", + "wasm": "https://download.dfinity.systems/ic/d87954601e4b22972899e9957e800406a0a6b929/canisters/ic-icrc1-ledger.wasm.gz", + "declarations": { + "output": "apps/wallet/src/generated/icrc1_ledger", + "node_compatibility": true + } + }, "wasm_chunk_store": { "type": "assets", "source": [], @@ -78,28 +102,36 @@ }, "networks": { "production": { - "providers": ["https://icp0.io"], + "providers": [ + "https://icp0.io" + ], "type": "persistent", "replica": { "subnet_type": "application" } }, "staging": { - "providers": ["https://icp0.io"], + "providers": [ + "https://icp0.io" + ], "type": "persistent", "replica": { "subnet_type": "application" } }, "playground": { - "providers": ["https://icp0.io"], + "providers": [ + "https://icp0.io" + ], "type": "persistent", "replica": { "subnet_type": "application" } }, "testing": { - "providers": ["https://icp0.io"], + "providers": [ + "https://icp0.io" + ], "type": "persistent", "replica": { "subnet_type": "application" @@ -115,4 +147,4 @@ }, "dfx": "0.23.0", "version": 1 -} +} \ No newline at end of file diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 2a252ed74..f66245d13 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -131,21 +131,29 @@ Request Policy Approval is the process of approving a request. Users can add the Permissions are rules that define the functionality that a user can access in a station canister. Permissions are defined by the authorized station users and can be customized to fit the needs of different use cases. Permissions can be granted to individual users, groups of users or any (un)authenticated user and can be revoked at any time. +### Asset + +An Orbit Asset holds metadata about an asset on the blockchain. Besides typical properties like Name and Symbol, it also stores which blockchain the asset is on, what token standards it supports, the contract address(es), etc. Assets can be added/changed/removed through requests. The native ICP asset is added by default to new Orbit stations. + ### Account -An account is a record in the station canister that represents a user's ownership of a specific asset. Accounts can hold different types of assets, such as tokens, NFTs, or other fungible or non-fungible assets. Accounts can be created, updated, and archived by the station users through requests. +An account is a record in the station canister that represents a user's ownership of specific assets. Accounts can hold multiple types of assets, such as tokens, NFTs, or other fungible or non-fungible assets. Accounts can be created, updated, and archived by the station users through requests. #### Account Name An account name is a human-readable name that represents the account in the station canister. Account names can be customized by privileged users and are unique within the station canister. +#### Account Asset + +An account can hold multiple types of assets, on any supported blockchains, and any supported standards. Assets can be added and removed from accounts. Removing an asset does not result in losing funds, readding the asset restores access. + #### Account Address -An account address is a unique identifier that represents the account address in relation to the asset it holds. +An account address is a unique identifier that represents the account address in relation to the asset it holds. An account can have many addresses derived from the account seed, as assets support different token standards and address formats. #### Account Balance -An account balance is the amount of a specific asset that an account holds. Account balances are updated when assets are deposited or withdrawn from the account. +An account asset's balance is the amount of a specific asset that an account holds. Account balances are updated when assets are deposited or withdrawn from the account. ### Address Book diff --git a/libs/orbit-essentials/src/utils/lock.rs b/libs/orbit-essentials/src/utils/lock.rs index fe7c8ec3e..48b916775 100644 --- a/libs/orbit-essentials/src/utils/lock.rs +++ b/libs/orbit-essentials/src/utils/lock.rs @@ -1,19 +1,22 @@ use std::cell::RefCell; use std::cmp::Ord; -use std::collections::BTreeSet; +use std::collections::BTreeMap; use std::rc::Rc; -// The following code implementing canister locks is adapted from +use crate::cdk::api::time; +use std::fmt::Debug; + +// The following code implementing canister locks with optional expiration is adapted from // https://internetcomputer.org/docs/current/developer-docs/security/rust-canister-development-security-best-practices#recommendation-10 pub struct State { - pending_requests: BTreeSet, + pending_requests: BTreeMap>, } impl Default for State { fn default() -> Self { Self { - pending_requests: BTreeSet::new(), + pending_requests: BTreeMap::new(), } } } @@ -23,15 +26,27 @@ pub struct CallerGuard { lock: T, } -impl CallerGuard { - pub fn new(state: Rc>>, lock: T) -> Option { +impl CallerGuard { + pub fn new(state: Rc>>, lock: T, expires_at_ns: Option) -> Option { { let pending_requests = &mut state.borrow_mut().pending_requests; - if pending_requests.contains(&lock) { - return None; + if let Some(existing_request) = pending_requests.get(&lock) { + if let Some(expires_at_ns) = existing_request { + if expires_at_ns > &time() { + // Lock is already held by another caller and has not expired. + return None; + } else { + // Lock has expired, fall through to update the lock. + crate::cdk::api::print(format!("Lock has expired for {:?}", lock)); + } + } else { + // Lock is held indefinitely. + return None; + } } - pending_requests.insert(lock.clone()); + pending_requests.insert(lock.clone(), expires_at_ns); } + Some(Self { state, lock }) } } diff --git a/orbit b/orbit index 71b9829cc..f4b08cf6b 100755 --- a/orbit +++ b/orbit @@ -16,6 +16,8 @@ CANISTER_ID_ICP_LEDGER="ryjl3-tyaaa-aaaaa-aaaba-cai" CANISTER_ID_INTERNET_IDENTITY="rdmx6-jaaaa-aaaaa-aaadq-cai" CANISTER_ID_UI="werw6-ayaaa-aaaaa-774aa-cai" CANISTER_ID_WALLET="wkt3w-3iaaa-aaaaa-774ba-cai" +CANISTER_ID_TEST_ICRC1_LEDGER="bw4dl-smaaa-aaaaa-qaacq-cai" +CANISTER_ID_TEST_ICRC1_INDEX="br5f7-7uaaa-aaaaa-qaaca-cai" # Default identity store path DFX_DEFAULT_IDENTITY_STORE_PATH=${DFX_DEFAULT_IDENTITY_STORE_PATH:-"$HOME/.config/dfx/identity"} @@ -51,6 +53,7 @@ Options: --init-app-wallet fresh installs the Orbit Wallet application --candid-generate generates the code for the candid specifications that the Orbit applications are using --approve-waiting-list approves the given principal to the waiting list + --deploy-icrc1-token deploys an ICRC1 token canister for local development EOF } @@ -253,6 +256,66 @@ function approve_waiting_list() { dfx canister call control_panel update_waiting_list "record { users = vec { principal \"$principal\" }; new_status = variant {Approved} }" } +function deploy_icrc1_token() { + uninstall_test_icrc1_canisters + install_test_icrc1_canisters +} + +function uninstall_test_icrc1_canisters() { + # Uninstall the ICRC1 Ledger canister + dfx canister delete icrc1_ledger_canister -y >/dev/null 2>&1 || true + dfx canister delete icrc1_index_canister -y >/dev/null 2>&1 || true +} + +function install_test_icrc1_canisters() { + if [ "$MINTER_IDENTITY_NAME" == "$WHOAMI" ]; then + echo "You can't run this script as the minter identity. Please run it as a different identity." + exit 1 + fi + + if ! dfx identity list | grep -q $MINTER_IDENTITY_NAME; then + dfx identity new $MINTER_IDENTITY_NAME --storage-mode plaintext + fi + + dfx deploy --specified-id $CANISTER_ID_TEST_ICRC1_LEDGER icrc1_ledger_canister --argument " + (variant { + Init = record { + minting_account = record { + owner = principal \"$(dfx identity get-principal --identity $MINTER_IDENTITY_NAME)\"; + }; + initial_balances = vec { + record { + record { + owner = principal \"$(dfx identity get-principal)\"; + }; + 1_000_000_000_000 : nat; + }; + }; + token_symbol = \"TEST\"; + token_name = \"Test ICRC1 Token\"; + + metadata = vec {}; + + transfer_fee = 20 : nat; + + archive_options = record { + num_blocks_to_archive = 100 : nat64; + trigger_threshold = 100 : nat64; + controller_id = principal \"$CANISTER_ID_TEST_ICRC1_LEDGER\"; + }; + } + }) +" + + dfx deploy --specified-id $CANISTER_ID_TEST_ICRC1_INDEX icrc1_index_canister --argument " + (opt variant { + Init = record { + ledger_id = principal \"$CANISTER_ID_TEST_ICRC1_LEDGER\" + } + }) +" +} + ############################################# # SCRIPT OPTIONS # ############################################# @@ -281,6 +344,11 @@ while [[ $# -gt 0 ]]; do shift # Remove the principal ID from the arguments echo ;; + --deploy-icrc1-token) + shift + exec_function deploy_icrc1_token + echo + ;; --init) shift exec_function setup_devenv diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83ca3e26c..4fe019342 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,9 +74,15 @@ importers: '@dfinity/identity': specifier: 1.4.0 version: 1.4.0(@dfinity/agent@1.4.0(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0)(@peculiar/webcrypto@1.4.3) + '@dfinity/ledger-icrc': + specifier: 2.3.3 + version: 2.3.3(@dfinity/agent@1.4.0(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0))(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0)(@dfinity/utils@2.3.1(@dfinity/agent@1.4.0(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0))(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0)) '@dfinity/principal': specifier: 1.4.0 version: 1.4.0 + '@dfinity/utils': + specifier: 2.3.1 + version: 2.3.1(@dfinity/agent@1.4.0(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0))(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0) '@mdi/font': specifier: 7.4.47 version: 7.4.47 @@ -277,16 +283,16 @@ packages: resolution: {integrity: sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.25.7': - resolution: {integrity: sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==} + '@babel/helper-string-parser@7.25.9': + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} engines: {node: '>=6.9.0'} '@babel/helper-validator-identifier@7.24.5': resolution: {integrity: sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.25.7': - resolution: {integrity: sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==} + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} engines: {node: '>=6.9.0'} '@babel/helper-validator-option@7.23.5': @@ -310,8 +316,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.25.7': - resolution: {integrity: sha512-aZn7ETtQsjjGG5HruveUK06cU3Hljuhd9Iojm4M8WWv3wLE6OkE5PWbDUkItmMgegmccaITudyuW5RPYrYlgWw==} + '@babel/parser@7.25.9': + resolution: {integrity: sha512-aI3jjAAO1fh7vY/pBGsn1i9LDbRP43+asrRlkPuTXW5yHXtd1NgTEMudbBoDDxrf1daEEfPJqR+JBMakzrR4Dg==} engines: {node: '>=6.0.0'} hasBin: true @@ -801,8 +807,8 @@ packages: resolution: {integrity: sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ==} engines: {node: '>=6.9.0'} - '@babel/types@7.25.7': - resolution: {integrity: sha512-vwIVdXG+j+FOpkwqHRcBgHLYNL7XMkufrlaFvL9o6Ai9sJn9+PdyIL5qa0XzTZw084c+u9LOls53eoZWP/W5WQ==} + '@babel/types@7.25.9': + resolution: {integrity: sha512-OwS2CM5KocvQ/k7dFJa8i5bNGJP0hXWfVCfDkqRFP1IreH1JDC7wG6eCYCi0+McbfT8OR/kNqsI0UU0xP9H6PQ==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@0.2.3': @@ -841,9 +847,24 @@ packages: '@dfinity/principal': ^1.4.0 '@peculiar/webcrypto': ^1.4.0 + '@dfinity/ledger-icrc@2.3.3': + resolution: {integrity: sha512-ASF9A/FcyHlEsFVENEZ1/f/PFcdvtEg75zD70zL/n7FUSN+3I8c82tbt5TunraRCGBQjDgBigUoRY2+k5J18rQ==} + peerDependencies: + '@dfinity/agent': ^1.3.0 + '@dfinity/candid': ^1.3.0 + '@dfinity/principal': ^1.3.0 + '@dfinity/utils': ^2.3.1 + '@dfinity/principal@1.4.0': resolution: {integrity: sha512-SuTBVlc71ub89ji0WN5/T100zUG2uIMn5x4+We4vS4nJ0R3/Xt89XJsHepjd5SQTSQPOvP7eQ+S8cQKWRz/RkA==} + '@dfinity/utils@2.3.1': + resolution: {integrity: sha512-FvMBwlKBJGJhugGRn13U0Jvfldu01P2QZXDqogkWTHnQs+IJ8N1sVLQ8DQaT+bnOaT5Ii8kEes0ecaMaeTdHgw==} + peerDependencies: + '@dfinity/agent': ^1.3.0 + '@dfinity/candid': ^1.3.0 + '@dfinity/principal': ^1.3.0 + '@emnapi/core@1.3.0': resolution: {integrity: sha512-9hRqVlhwqBqCoToZ3hFcNVqL+uyHV06Y47ax4UB8L6XgVRqYz7MFnfessojo6+5TK89pKwJnpophwjTMOeKI9Q==} @@ -2619,7 +2640,6 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -2713,7 +2733,6 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2927,8 +2946,8 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - magic-string@0.30.11: - resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} + magic-string@0.30.12: + resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==} magic-string@0.30.8: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} @@ -3158,8 +3177,8 @@ packages: picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} - picocolors@1.1.0: - resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} @@ -4070,11 +4089,11 @@ snapshots: '@babel/helper-string-parser@7.24.1': {} - '@babel/helper-string-parser@7.25.7': {} + '@babel/helper-string-parser@7.25.9': {} '@babel/helper-validator-identifier@7.24.5': {} - '@babel/helper-validator-identifier@7.25.7': {} + '@babel/helper-validator-identifier@7.25.9': {} '@babel/helper-validator-option@7.23.5': {} @@ -4103,9 +4122,9 @@ snapshots: dependencies: '@babel/types': 7.24.5 - '@babel/parser@7.25.7': + '@babel/parser@7.25.9': dependencies: - '@babel/types': 7.25.7 + '@babel/types': 7.25.9 '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.24.5(@babel/core@7.24.5)': dependencies: @@ -4695,11 +4714,10 @@ snapshots: '@babel/helper-validator-identifier': 7.24.5 to-fast-properties: 2.0.0 - '@babel/types@7.25.7': + '@babel/types@7.25.9': dependencies: - '@babel/helper-string-parser': 7.25.7 - '@babel/helper-validator-identifier': 7.25.7 - to-fast-properties: 2.0.0 + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 '@bcoe/v8-coverage@0.2.3': {} @@ -4740,10 +4758,23 @@ snapshots: '@peculiar/webcrypto': 1.4.3 borc: 2.1.2 + '@dfinity/ledger-icrc@2.3.3(@dfinity/agent@1.4.0(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0))(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0)(@dfinity/utils@2.3.1(@dfinity/agent@1.4.0(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0))(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0))': + dependencies: + '@dfinity/agent': 1.4.0(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0) + '@dfinity/candid': 1.4.0(@dfinity/principal@1.4.0) + '@dfinity/principal': 1.4.0 + '@dfinity/utils': 2.3.1(@dfinity/agent@1.4.0(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0))(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0) + '@dfinity/principal@1.4.0': dependencies: '@noble/hashes': 1.4.0 + '@dfinity/utils@2.3.1(@dfinity/agent@1.4.0(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0))(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0)': + dependencies: + '@dfinity/agent': 1.4.0(@dfinity/candid@1.4.0(@dfinity/principal@1.4.0))(@dfinity/principal@1.4.0) + '@dfinity/candid': 1.4.0(@dfinity/principal@1.4.0) + '@dfinity/principal': 1.4.0 + '@emnapi/core@1.3.0': dependencies: '@emnapi/wasi-threads': 1.0.1 @@ -5772,7 +5803,7 @@ snapshots: '@vue/compiler-core@3.5.11': dependencies: - '@babel/parser': 7.25.7 + '@babel/parser': 7.25.9 '@vue/shared': 3.5.11 entities: 4.5.0 estree-walker: 2.0.2 @@ -5790,13 +5821,13 @@ snapshots: '@vue/compiler-sfc@3.5.11': dependencies: - '@babel/parser': 7.25.7 + '@babel/parser': 7.25.9 '@vue/compiler-core': 3.5.11 '@vue/compiler-dom': 3.5.11 '@vue/compiler-ssr': 3.5.11 '@vue/shared': 3.5.11 estree-walker: 2.0.2 - magic-string: 0.30.11 + magic-string: 0.30.12 postcss: 8.4.47 source-map-js: 1.2.0 @@ -6918,7 +6949,7 @@ snapshots: dependencies: yallist: 4.0.0 - magic-string@0.30.11: + magic-string@0.30.12: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -7178,7 +7209,7 @@ snapshots: picocolors@1.0.0: {} - picocolors@1.1.0: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -7225,7 +7256,7 @@ snapshots: postcss@8.4.47: dependencies: nanoid: 3.3.7 - picocolors: 1.1.0 + picocolors: 1.1.1 source-map-js: 1.2.1 prelude-ls@1.2.1: {} diff --git a/scripts/benchmark-canister.sh b/scripts/benchmark-canister.sh index 87626bdf3..5781fa05c 100755 --- a/scripts/benchmark-canister.sh +++ b/scripts/benchmark-canister.sh @@ -25,7 +25,7 @@ print_message "Benchmarking canister at $CANISTER_PATH" # Install canbench if not already installed if ! cargo install --list | grep -q canbench; then print_message "Installing canbench..." - cargo install canbench --version 0.1.4 + cargo install canbench --version 0.1.8 fi # Changes to the canister path @@ -42,8 +42,8 @@ canbench --less-verbose >"$CANBENCH_TMP_OUTPUT" if grep -q "(regress\|(improved by \|(new)" "$CANBENCH_TMP_OUTPUT"; then # Check if running in GitHub Actions and print the CANBENCH_TMP_OUTPUT file if so if [ "${GITHUB_ACTIONS:-}" = "true" ]; then - print_message "Review the benchmark results below:" - cat "$CANBENCH_TMP_OUTPUT" + print_message "Review the benchmark results below:" + cat "$CANBENCH_TMP_OUTPUT" fi print_message "Benchmarking completed. diff --git a/scripts/run-integration-tests.sh b/scripts/run-integration-tests.sh index 5a15ab1a9..9a5533914 100755 --- a/scripts/run-integration-tests.sh +++ b/scripts/run-integration-tests.sh @@ -42,6 +42,7 @@ if [ $DOWNLOAD_NNS_CANISTERS == "true" ]; then ./scripts/download-nns-canister-wasm.sh icp_ledger ledger-canister ./scripts/download-nns-canister-wasm.sh icp_index ic-icp-index-canister ./scripts/download-nns-canister-wasm.sh cmc cycles-minting-canister + ./scripts/download-nns-canister-wasm.sh icrc1_ledger ic-icrc1-ledger fi if [ $DOWNLOAD_ASSET_CANISTER == "true" ]; then diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index 1f6e1901d..af00c73f0 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -13,6 +13,7 @@ hex = { workspace = true } orbit-essentials = { path = '../../libs/orbit-essentials', version = '0.0.2-alpha.6' } ic-certified-assets = { workspace = true } ic-ledger-types = { workspace = true } +icrc-ledger-types = { workspace = true } itertools = { workspace = true } lazy_static = { workspace = true } num-bigint = { workspace = true } diff --git a/tests/integration/assets/station-memory-v1.bin b/tests/integration/assets/station-memory-v1.bin new file mode 100644 index 0000000000000000000000000000000000000000..c35b27d399f49af42b2c943afd171bf26efd1d34 GIT binary patch literal 280334 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%0D0+{fgHL_O6%U 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�RE!gA)0N# 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 z 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 z zQ}|~&GRT%^jFkjyw*reg@IAAFf}&hI=MU&7=aQ|#x|9-(XTr*0&y5=@q|=q3O+K8z z#lMmgJoR2QNg?Ff0?g*FokpLOY;G>Ct6&jw?%@rLg)CAj`E!)h#pN1luP=MXgiHRd zbwBr#E{W-GINb4YitW_@_%RD(I-qk)LC6nTN-)j1tJjrcU2IJ%O(z{fa*bV{oD@0j zTWm15CyEwj%sgO5B%Or?{}%DSxRz0>af_lta#VT7UhbHg`Ob-~$$Br9v4N=VHEAV@ zksEFmJds*G<6Gi9yk3< z77vfNTrgYEV|6%?u33aZ+B);FIe@TWE|b{|2pGcq;S%W+5j^9~d=EoGUA?t5_gj|T zja)TeKGi**;S&sNq^-{L!=&5xdtkwWB^0}QZ#%0SrvHg7HoY0QS!#$e#I3!%0MO%q z{Dq8*;m=SLgQBM~BA_=b;HZTT2eHFtfvMOuyGK;y8GH2jV*H0Xa`LiZG4Z8>$_k6p z??sFO?Yzt9fC~@!MiGXrCMa}lqq)y8FFFuMsfjP5r(l9YW<%XaC;e9$RFom3Q~Jc| zCYDOyGz%>jTw_?;H;)lK|MABY_27770Oq@?=EXcJ&2K}}IFs`{TE(Bi**g>@87{J> zyoa5Senx*rQx8lnd--pJK%h>W+!=O$CSf6wUrZsb2HyVT%@HMIa&=li8V_SsW&MGd z&CRS%D%bvX){E=Yq(wl=XL+O($o2YY58V)X{L0S+dV>?$s+3487;(4 z+^01E>cRm`IIZWOCtsBB`su*p2y302asMHkkfts>&kyqPZ$%z2xy6uY{h>Lh5es}# zo?H01yBI2qqbVCm{<%A^mGRozk{W?Mzb2fLBCU8;k1;w2sT(>^sX+b6osyb$bxw{V02WHjPv6+ujM3^d!zCG*Xq)eR=G+CA5_#5PX+8rp0*6iMn{$ zYkVd6q!&-R50fxmyRc(>^{ZEbFYRwaMRXZu(+LgqI>QmG)jAq!uTkgzG$L!V-X+Qg zjH!%{TNHX85ReYcn7NCx<|9klSKZAPnEtTg7IHne1Pfij(U2M2zya}GZH8^zKCJR}h9;s9Ewi;1Z zbU_lLZGY+4qY0mSC;7UQH9BmeC`-1|EFM*=dZ$Fb+If~3^Q+YS*N?}Rvw&l_g!A_% z1dJ|}?+$Qr zhlyMi1;##O83mv(?XJ={>w1e`!gi-qjuG`2rogCaM(LB_{Li-EJCC7b@oaK{jNa0; z6l`b2H5VYWU04{bUlvKp7n9NOT8a@7h8Ct|O0$_+KLxZJV1sspJ?LA60BTG0vmt|$ z%7*r&7;E}I54?8Jv2qM89lyc{22nS^g5C7?O4f(?hf>{|u_VY=u!vx$KnmtW5mrk! z5^B=LS?iw8tS(ea8W(*8o~zQFp^TqZU6l#1hK7MZ%xw&j3VIq6?4dUzD07Um^jCH* z)`ND(o<1&=VC!mzlo+B__ewckF9L7h)zd#bqVEsHI%Anh zk-aru#n8;8w6wVog4?as1gio4YU>Ydh;UzH^=s*J`}{pZryAuCPAtB`YgdijjqkLL zF_x+jh;^Rzdv8CY^gDTlV;uItx?^Og5iHm)#?$Su?lk0sEEUl*d6WmfKtV zY#6A(eY;XHh-%HgLY{g_x-~`iMsONVojQDa<;S%fVQ94D)w5B1{Z1z1Kv9sFzE0f8 zRkjFndV@UQ!XLVFP+{jGv;W;k?)`>f5cCpLLmc0VkdF)RvCZG1GwY!VEjt1jG?34% zOr*xG6r@Pi9BzcSM$mVrXT{uc>qKazAc=yCj9>e%4aNKVtyckKSSj;F96RE*(&G9E z#`Iy`6hOe8aeA&V*Lt;VM_$$2B#wUKqY*WUNwlr9L;#zhq0|Kn`?_0(isLg+}De0%|XRoSctX+f7I_RVukOM>h z+D%6J_Pk(><<&lZ5woz@ph^g%dFCkytY}59m7?y_rZ2+okIE9dd;85)1NeXQAzBjI zji{>&-?Oe(!LJ>@&5CHd9gVCfNwMhlsx5wA!KH8w7CEc(QjUUNchAb=C2b@EJ5%kX z35&>S(tH&+fRyAlPUw zFzR_lfp|C2QOz@#zOEIUNYB&QPZ9VC=Q@_Bb$oKGHZJS#&nZu#or60r$PEeU;JkIs zxP^1Lpq5#qdQqJ=0a$r5e4-dUv0IuCP-n`oDm4Uz%y#}24}TeH_K=q=accaftgDXU zi3vdGTV~BL#xV89?In+$>`3IzCkr=?$V4qq%-Q_M^6u%AgP%+=TuK@A#jxrR0 z|A+iLecQAva&hz$Eb!8~K+x0Y-9j9l3`8G1zK7V)Trq}PPAE$&zMAOz4|!-l771U% zPTR63=7d$+3X+Y3jwBJ*m&?Nv>X~LifNtNHk{oq1p1v|7B0@KqPx!J zotGw!+i0V#5=$_1O&({#T<3mCADBc;N7w{BK8945iu5cA^MrY3P7E3xYmh)g)IM+DPTcO zg>2eS#*VQ~(=nSeu|0%L`VVNsKpT*mowm8*VaPyY@|q3=+CegH-HjE};Ur};aBTK_ zRgDw0FQAbQ*;1BAGx>~0x#?vfce%b<)$vtq;ylBh8YGNQA&=W3Z^x z6a21c2R^EH8VV+$zqx$O_KFETiO0DwrKg!`svj-8RF-J^aZ2lCxIK2wz2KDKS5Io1 z#hzyG>z8Yao{PSHVU`QIdd3S=a#jPSk-M1ji71YRK=PvolJ$`V^HecqJEDIt3YKGZ z4c6*v%IPlVH#UEZ6*v1K4;gY=0m#sE0Y{a~mOOwg#Jip3pqg+wJNJa%4Ijmgr1L`c8w<)p zy35m>ErvVHBzAhOUqm=Ha?(fDC+;^NOrse=Idj+g{q3dnk$^(z_^$~A;G%wM*?iR9 z9O=TL@&6Ufm~g`4m0!H!7dixA95)e0?pCfXu2ED!emG|huA^tUj6JPJ8_w_QT+=hL z)zdf6Xin+h3ft^uwC%Nb2*aK%?Hs;7j%*Jo!FjpGKUfx*J?UtgUuX9)O{jQdXxgGC zp06i&d$bE$M3cxY>_W2g$^vKjX1RoCBK4z|{N)bL!( z5m?K8o`347H!9?5+5Y_-5pve+s+WP6eBEs0E zqgYgb6Qhk{TD99p2eV>@qRYUj?dIjy!zYQ-L6Mx-hAN_l{3KB^cfp1M&XPC0?FHS? ztc_0$&1-2`?p6^-4%Eg+*9)43G8&Pf&3yZ-tbM(J5x21IFV-1XcE8tB_^tf!!$Z|e z_^)z}8hnX&K2ND(duoi}lzcJNwT=FZ;n$VA-v9!|8_O?qrb;JuL9|0<*(y~Po%l`#&6?FkX9Y^24Kb$J9Ikz`RjA|?Lvx{nx5N{z0By9g09e#PI3aIqc0YXCAZ`#=7~Cw#5RuPZr&cdl z=0>;CLN|wNt`hka+?%@shWe>)zh#s1Fk>_ax2t+(>`sElYPbDXSxQ88Cd#E2sbrfg zIdti~@z}MDv;UJ#t9`WdR9`48;OFMH)n`eT4O+OCP`rv@{%cQtuzKpFzZ{;k&;>s= zlv=3Pg5Df5!`wd~;g=(Gtb#K4OMs8c#TMoDD;E6#<)zu4ebq5Tm6C(OvUamwK zl>BvS!O0G;Fx8bh;S9m#F(~~PdsaB+yS9B5g&JO-vnz}+pY{!QTtqnCC43br9Bo@^ z5CmTlOa@blhmF5DhU3}mXW&9gz0a4o?L&;6gQYn9`qsVg-`__NLOSR{QQL16=?0%T zf^U%;>Jr6TR)6D!f!2hqG{Csxi6BYSKAwj3L|50pGrQDwwu(JDi}eewhQuH%2kZO6#Aq(yGO5&F&Xi5;`}K zN!euAf{YL62nRvG-72<-Omc5x{oe5s!N!^Qz7zIj`Sa95tKuHgeKN{XaK~u9qssiN z83^(ht}RP9tQmJ%`ZIcfK}utUeWdNa3*W? z!@1V}nDH2L@WsNHeKaQsDmf_Rp~gl-!HY9U<);xvYBi?x?I~=82G4O~tThX@ei_kY zHwV$X|D#D;jg>3AdwBg&nCxdHqP&!J)UpRCEa#HZ6UIxd$1Q6OHozeBiS9p^`3&TK zTO?U9S_4sy|4}x@+XsO6ACJ8m-wY%OY@`kvIXp3d49lPYq;F)SVSRAO&Vh2krP%>L2 z_3p`rA;amP*N|TV*~l*zP7n`7@h;)|Zq0>&Do5kZJHNJGJcjX_gYsi5wCRKE0wl3t z&@_%+6plRU_=p}_)qasK5|8gjYYY3DkA@t8R&sc7P#tUvU}fNi@KBl7ezNxwFsWO` zz2^v|umN#2NdxG&@`=N>F7NoLX{;dPASyplRG+v!Evt=>=H`(j_CB)vjKh65?{2T8 zxL!S6dLlV$$TN8yYk%^4qtCo6DaM z3{W!!$V3TW9u{e){4Z>eSjOk5+?V){JuY_5S5_>^xBub)LS${mY%GqtCl6I3)DPlQ z0oWuFn&bgGyH7weY`MdocOY&O1@1>qEqByTGo-qyNx9LMaYI#M3s4{v*ZoUO9)8H80_C!qV2KkRP^C>X zr?KBK+`xm4X^Qsg34e-W{w4Pqf!L;Cruc~N8l#0*648%q<8HU0P_O^o?0WI#R;@Yr zX=Qj|YLLsfO=l+R)<#y{t0`6QqyGc(A3vCOr}ovdTB^udP8SK+OxPEm#J8saT_CG*{Dr%R{$)F_fnsu*~6{@ssZ$`;`P?Ad| z_(K-d%d8TQ>`aiFd*a&Z+@uy0^*C?@4=9N#Y;1kk_@DNEHj;1MpO8F7v%xu?jyKfhqHP_p zg+^X^UIeT~B_H@!(BBn613g-i5yV8c@vAMoiJ9v*UTXtYFYU6Fx1|tQc}1f2;LqKsjk3opwZUyQc+N zfJ-XKD%613M=t7Bc^e+e(kT(Boe>p-P=MUBE^TMlo{t zV68gaVy#kWLKtk>^Y?J~siPt#yKXC5u&3Z%G8r>!Jzh}h_bhIz39=I&vmk5|{!t)6 z_y_ox2b43*%dhzN_8}?|eTscja7F)siV7)(iV7yYx1zj4F(5#px3`>^A)cP$AW0QZ z2T>%_B`NllOvknVw`mLgUMFg-1Hy9IA)m0yxf2x~OIUuzgHVntfXaO}| zUDt;QJ`FA_+n|%~U@*wh&D|E{F!{*N!QS1$-o|C>k%x=3tE01rv#q;}tAmT1jjf}j zvx}{}tHabIchV1BU2q_Md#+8+xVXU^(fq@kKDDtlp%Sb+#P&bxeQCxvT+gIt+B<+K z8Y7+Gm1}M)mRFWX~{yH^dyo=Kv-Exte(g7~FS{qvLyX>VA$0;aT|i@<1J`{x_?XRTL^JgZxSrs#jed0F)A4 z-kSt?PF=!wUFsZZZoAD}(_7Ox9C5UO4cN>(Duwf}R@uY|5&t&KR@3yISoyX3fga{E zqY1mTeT3&hDt3yS(rqQVRX3|Et4fuWYRZ(Am8;-?i949&gWWtFY;6IWr6rY(Z6%F$ z<>kR4?PL-0Pa#?A0S%osXE&~J_p=e&rG`V5#V&g{t@dzX_ zTl>i{y1O7Jr@XCLT(cpruh-n)LK1q0F4%G4$&9+KSYJPT^#km!%mXP5j8WYq8+?>C z!n%*_T9aI>y4pvh|Lo>DKary|E+i7nI1;6iZi78PekIAI$R$g&; zayY$~mvqTdTvO1(8PaHt@}K>)M<^Bu2p3{|{-P%>2n(?HMv?uW)@K-nfFD z>`pUCe*x6OEgO*}7_T_19OGyO7iP+En((vfX>Qxf<(IVg?*S1(5B;Zzp3#b}SJ;Nu z#$&|06qAa>>w)zTqbsA}>wj?~%9Hg!D&P}SAa18qaq!p~roH6d*^it^_O7zIcE=B9 zCZjIt!m&bdv$9r9M07|ombuNySSk&JL@S-K5-G9HM7gc*Jg9{tBwKIArOPC1#MrXu zgOK3-bmh&n6IZhg5hF(n5*lbf1Hm0PciNY_r*e0#gm95FNHsA_sjkw9$mPi75Z0bL z8M~CAKU7HaPo>g+Ex{HPlsC2+k~qDo30XA6vD+)Yab;k6X2{ zU@{deu9%x!DEOQAdhR(o*fuvM@{Ebm5q!yRP08rI_)az&ek_)fnB|H#Fgnm*)F;yXb zA~|;W{gJUzxcjh6tvWfwJ6v_GT2Tq5(ORUYPH_ZF@4NLs8F3I#r#hM*1l_DkTDWl| ziWyK5;K{81bnQfpL&IV?g71b?%OBfY9~F2U4m4J1IKGV18UR%s?X3(7fy6;9pN6UC zyW6B>Qxy^8>sdbnOK&SyR3b%Nno6#j7^u?SB`yaW^+%;=NIHslk2ujuG(4}@m2AjjcwTGsbu-dVfG&T#R?2Gv)$%24Ox zoF7JX{T3PM&>!#)#Hm95{;sRXCj{-==v;iTmHFPnVDt01f0-*H)CSqt=T%^M!cQ&^ zaoxD$CgNxt%|jG8_f}j#J%a)=hS^G9psO-m?avQ?!eU#BZkGJ+X=9qdhX;m-IM_H?`uyuivb6DH)r6{YxShMJ&F~{BuTgwfch$`rbW!Ea z8xfvM+6kNImSpdPls0Y+ki>=i)WX5)TZsKn{cR)mUH^F!66b6j-Y+&v)gF|Om@N8L z=Jtk%?`zA|jmkrCZ|7q(Dl_s^C;=t40DCt0G5?{M@`bcmXh$qHDNC4r8A4uSm_jmF z)O;}9_on}5JOAuAJ=202qy|P$0?Dt!0(t&rSm*z&OU_-B?DX%iu^lkCz%=A^i?ctZ z2EoSkv!4 z57VB49NZzsHV`vs2jj|#k@WOVUWA#q*_%|)a6%ovrRACg&|J!VRjt|X&G}x07gMh! zW))E`,) = + update_candid_as(&env, canister_ids.station, WALLET_ADMIN_USER, "me", ()).unwrap(); + let user_dto = res.0.unwrap().me; + + let icp_asset = get_icp_asset(&env, canister_ids.station, WALLET_ADMIN_USER); + + let permission = AllowDTO { + auth_scope: station_api::AuthScopeDTO::Restricted, + user_groups: vec![], + users: vec![user_dto.id.clone()], + }; + + let account = create_account( + &env, + canister_ids.station, + user_dto.identities[0], + AddAccountOperationInput { + name: "test account".to_owned(), + assets: vec![icp_asset.id.clone()], + metadata: vec![], + read_permission: permission.clone(), + configs_permission: permission.clone(), + transfer_permission: permission.clone(), + configs_request_policy: Some(RequestPolicyRuleDTO::AutoApproved), + transfer_request_policy: Some(RequestPolicyRuleDTO::AutoApproved), + }, + ); + + let icp_account_identifier = AccountIdentifier::from_hex( + &account + .addresses + .iter() + .find(|a| a.format == "icp_account_identifier") + .expect("cannot get ICP account identifier") + .address, + ) + .expect("cannot parse ICP account identifier"); + + mint_icp(&env, minter, &icp_account_identifier, 10 * 100_000_000) + .expect("failed to mint ICP to account"); + + let messages_ids = [ + env.submit_call( + canister_ids.station, + user_dto.identities[0], + "fetch_account_balances", + Encode!(&FetchAccountBalancesInput { + account_ids: vec![account.id.clone()], + }) + .unwrap(), + ) + .expect("failed to submit call"), + env.submit_call( + canister_ids.station, + user_dto.identities[0], + "fetch_account_balances", + Encode!(&FetchAccountBalancesInput { + account_ids: vec![account.id.clone()], + }) + .unwrap(), + ) + .expect("failed to submit call"), + ]; + + let results = messages_ids + .into_iter() + .map(|message_id| { + expect_await_call_result::<(ApiResult,)>( + env.await_call(message_id).expect("failed to await call"), + ) + .0 + .expect("failed to get result") + }) + .collect::>(); + + results.iter().any(|result| { + result.balances[0] + .as_ref() + .map_or(false, |account_balance| { + account_balance.query_state == "fresh" + }) + }); + results.iter().any(|result| result.balances[0].is_none()); + + let messages_ids = [ + env.submit_call( + canister_ids.station, + user_dto.identities[0], + "fetch_account_balances", + Encode!(&FetchAccountBalancesInput { + account_ids: vec![account.id.clone()], + }) + .unwrap(), + ) + .expect("failed to submit call"), + env.submit_call( + canister_ids.station, + user_dto.identities[0], + "fetch_account_balances", + Encode!(&FetchAccountBalancesInput { + account_ids: vec![account.id.clone()], + }) + .unwrap(), + ) + .expect("failed to submit call"), + ]; + + let results = messages_ids + .into_iter() + .map(|message_id| { + expect_await_call_result::<(ApiResult,)>( + env.await_call(message_id).expect("failed to await call"), + ) + .0 + .expect("failed to get result") + }) + .collect::>(); + + results.iter().all(|result| { + result.balances[0] + .as_ref() + .map_or(false, |account_balance| { + account_balance.query_state == "fresh" + }) + }); + + env.advance_time(Duration::from_secs(10)); + + let messages_ids = [ + env.submit_call( + canister_ids.station, + user_dto.identities[0], + "fetch_account_balances", + Encode!(&FetchAccountBalancesInput { + account_ids: vec![account.id.clone()], + }) + .unwrap(), + ) + .expect("failed to submit call"), + env.submit_call( + canister_ids.station, + user_dto.identities[0], + "fetch_account_balances", + Encode!(&FetchAccountBalancesInput { + account_ids: vec![account.id.clone()], + }) + .unwrap(), + ) + .expect("failed to submit call"), + ]; + + let results = messages_ids + .into_iter() + .map(|message_id| { + expect_await_call_result::<(ApiResult,)>( + env.await_call(message_id).expect("failed to await call"), + ) + .0 + .expect("failed to get result") + }) + .collect::>(); + + results.iter().any(|result| { + result.balances[0] + .as_ref() + .map_or(false, |account_balance| { + account_balance.query_state == "fresh" + }) + }); + results.iter().any(|result| { + result.balances[0] + .as_ref() + .map_or(false, |account_balance| { + account_balance.query_state == "stale_refreshing" + }) + }); +} diff --git a/tests/integration/src/address_book_tests.rs b/tests/integration/src/address_book_tests.rs index c678e1458..3a28a0541 100644 --- a/tests/integration/src/address_book_tests.rs +++ b/tests/integration/src/address_book_tests.rs @@ -1,6 +1,8 @@ use crate::interfaces::{default_account, get_icp_balance, send_icp_to_account, ICP, ICP_FEE}; use crate::setup::{setup_new_env, WALLET_ADMIN_USER}; -use crate::utils::{execute_request, get_user, user_test_id}; +use crate::utils::{ + execute_request, get_icp_account_identifier, get_icp_asset, get_user, user_test_id, +}; use crate::TestEnv; use ic_ledger_types::AccountIdentifier; use pocket_ic::update_candid_as; @@ -24,8 +26,9 @@ fn address_book_entry_lifecycle() { RequestOperationInput::AddAddressBookEntry(AddAddressBookEntryOperationInput { address_owner: "John Doe".to_string(), address: "0x1234".to_string(), + address_format: "icp_account_identifier".to_string(), blockchain: "icp".to_string(), - labels: vec!["native".to_string()], + labels: vec!["icp_native".to_string()], metadata: vec![MetadataDTO { key: "kyc".to_string(), value: "false".to_string(), @@ -47,7 +50,7 @@ fn address_book_entry_lifecycle() { assert_eq!(address_book_entry.address_owner, "John Doe".to_string()); assert_eq!(address_book_entry.address, "0x1234".to_string()); assert_eq!(address_book_entry.blockchain, "icp".to_string()); - assert_eq!(address_book_entry.labels, vec!["native".to_string()]); + assert_eq!(address_book_entry.labels, vec!["icp_native".to_string()]); assert_eq!( address_book_entry.metadata, vec![MetadataDTO { @@ -60,9 +63,11 @@ fn address_book_entry_lifecycle() { let add_address_book_entry = RequestOperationInput::AddAddressBookEntry(AddAddressBookEntryOperationInput { address_owner: "Max Mustermann".to_string(), + address_format: "icp_account_identifier".to_string(), + address: "0x1234".to_string(), blockchain: "icp".to_string(), - labels: vec!["native".to_string()], + labels: vec!["icp_native".to_string()], metadata: vec![MetadataDTO { key: "kyc".to_string(), value: "true".to_string(), @@ -80,9 +85,10 @@ fn address_book_entry_lifecycle() { let add_address_book_entry = RequestOperationInput::AddAddressBookEntry(AddAddressBookEntryOperationInput { address_owner: "Max Mustermann".to_string(), + address_format: "icp_account_identifier".to_string(), address: "0x5678".to_string(), blockchain: "icp".to_string(), - labels: vec!["native".to_string()], + labels: vec!["icp_native".to_string()], metadata: vec![MetadataDTO { key: "kyc".to_string(), value: "true".to_string(), @@ -107,7 +113,10 @@ fn address_book_entry_lifecycle() { ); assert_eq!(next_address_book_entry.address, "0x5678".to_string()); assert_eq!(next_address_book_entry.blockchain, "icp".to_string()); - assert_eq!(next_address_book_entry.labels, vec!["native".to_string()]); + assert_eq!( + next_address_book_entry.labels, + vec!["icp_native".to_string()] + ); assert_eq!( next_address_book_entry.metadata, vec![MetadataDTO { @@ -123,6 +132,7 @@ fn address_book_entry_lifecycle() { addresses: None, ids: None, paginate: None, + address_formats: None, }; let res: (Result,) = update_candid_as( &env, @@ -196,6 +206,7 @@ fn address_book_entry_lifecycle() { addresses: None, ids: None, paginate: None, + address_formats: None, }; let res: (Result,) = update_candid_as( &env, @@ -230,9 +241,10 @@ fn check_address_book_for_transfer() { let add_address_book_entry = RequestOperationInput::AddAddressBookEntry(AddAddressBookEntryOperationInput { address_owner: "John Doe".to_string(), + address_format: "icp_account_identifier".to_string(), address: john_doe_account.clone(), blockchain: "icp".to_string(), - labels: vec!["native".to_string()], + labels: vec!["icp_native".to_string()], metadata: vec![MetadataDTO { key: "kyc".to_string(), value: "false".to_string(), @@ -255,11 +267,12 @@ fn check_address_book_for_transfer() { // get admin user let admin_user = get_user(&env, WALLET_ADMIN_USER, canister_ids.station); + let icp = get_icp_asset(&env, canister_ids.station, WALLET_ADMIN_USER); + // create account for admin user let add_account = RequestOperationInput::AddAccount(AddAccountOperationInput { name: "admin".to_string(), - blockchain: "icp".to_string(), - standard: "native".to_string(), + assets: vec![icp.id.clone()], read_permission: AllowDTO { auth_scope: station_api::AuthScopeDTO::Restricted, user_groups: vec![], @@ -289,8 +302,10 @@ fn check_address_book_for_transfer() { _ => panic!("unexpected request operation"), }; + let icp_address = get_icp_account_identifier(&admin_account.addresses).expect("no icp address"); + // send ICP to admin user's station account - let admin_account_address = AccountIdentifier::from_hex(&admin_account.address).unwrap(); + let admin_account_address = AccountIdentifier::from_hex(&icp_address).unwrap(); send_icp_to_account( &env, controller, @@ -298,6 +313,7 @@ fn check_address_book_for_transfer() { ICP + ICP_FEE, 0, None, + None, ) .unwrap(); @@ -305,6 +321,8 @@ fn check_address_book_for_transfer() { // and check that transfer request gets rejected let transfer = RequestOperationInput::Transfer(TransferOperationInput { from_account_id: admin_account.id, + from_asset_id: icp.id, + with_standard: "icp_native".to_string(), to: john_doe_account, amount: ICP.into(), fee: None, diff --git a/tests/integration/src/asset_tests.rs b/tests/integration/src/asset_tests.rs new file mode 100644 index 000000000..5b0fdf491 --- /dev/null +++ b/tests/integration/src/asset_tests.rs @@ -0,0 +1,123 @@ +use candid::Principal; + +use crate::{ + setup::{setup_new_env, WALLET_ADMIN_USER}, + test_data::{ + asset::{ + add_asset, add_asset_with_input, edit_asset_name, get_asset, list_assets, remove_asset, + }, + user::add_user, + }, + TestEnv, +}; + +#[test] +fn asset_lifecycle_test() { + let TestEnv { + env, canister_ids, .. + } = setup_new_env(); + + // create asset + let asset = add_asset(&env, canister_ids.station, WALLET_ADMIN_USER); + + // edit asset + edit_asset_name( + &env, + canister_ids.station, + WALLET_ADMIN_USER, + asset.id.clone(), + "test".to_string(), + ); + + // remove asset + remove_asset(&env, canister_ids.station, WALLET_ADMIN_USER, asset.id); +} + +#[test] +#[should_panic] +fn asset_uniqeness_test() { + // assets with the same symbol and blockchain are not allowed + + let TestEnv { + env, canister_ids, .. + } = setup_new_env(); + + add_asset_with_input( + &env, + canister_ids.station, + WALLET_ADMIN_USER, + station_api::AddAssetOperationInput { + name: "asset".to_string(), + blockchain: "icp".to_string(), + standards: vec!["icp_native".to_string()], + metadata: Vec::new(), + symbol: "SYM".to_string(), + decimals: 8, + }, + ); + + add_asset_with_input( + &env, + canister_ids.station, + WALLET_ADMIN_USER, + station_api::AddAssetOperationInput { + name: "asset".to_string(), + blockchain: "icp".to_string(), + standards: vec!["icp_native".to_string()], + metadata: Vec::new(), + symbol: "SYM".to_string(), + decimals: 8, + }, + ); +} + +#[test] +fn asset_permission_test() { + // unauthorized users cant interact with assets + let TestEnv { + env, canister_ids, .. + } = setup_new_env(); + + let asset = add_asset_with_input( + &env, + canister_ids.station, + WALLET_ADMIN_USER, + station_api::AddAssetOperationInput { + name: "asset".to_string(), + blockchain: "icp".to_string(), + standards: vec!["icp_native".to_string()], + metadata: Vec::new(), + symbol: "SYM".to_string(), + decimals: 8, + }, + ); + + let user = add_user(&env, canister_ids.station, WALLET_ADMIN_USER, vec![]); + + list_assets(&env, canister_ids.station, user.identities[0]) + .expect("Station user should be able to list assets") + .0 + .expect("Station user should be able to list assets"); + + list_assets(&env, canister_ids.station, Principal::anonymous()) + .expect_err("Unauthenticated user should not be able to list assets"); + + get_asset( + &env, + canister_ids.station, + Principal::anonymous(), + asset.id.clone(), + ) + .expect_err("Unauthenticated user should not be able to get asset"); + + list_assets(&env, canister_ids.station, Principal::from_slice(&[0; 29])) + .expect_err("Unauthorized user should not be able to list assets"); + + get_asset( + &env, + canister_ids.station, + Principal::from_slice(&[0; 29]), + asset.id, + ) + .expect_err("Unauthorized user should not be able to get asset"); +} diff --git a/tests/integration/src/cycles_monitor_tests.rs b/tests/integration/src/cycles_monitor_tests.rs index b93679b02..9bc8645fb 100644 --- a/tests/integration/src/cycles_monitor_tests.rs +++ b/tests/integration/src/cycles_monitor_tests.rs @@ -4,7 +4,8 @@ use crate::setup::{ }; use crate::utils::{ advance_time_to_burn_cycles, controller_test_id, create_icp_account, - get_core_canister_health_status, get_system_info, get_user, user_test_id, NNS_ROOT_CANISTER_ID, + get_core_canister_health_status, get_icp_account_identifier, get_system_info, get_user, + user_test_id, NNS_ROOT_CANISTER_ID, }; use crate::TestEnv; use control_panel_api::{ @@ -232,9 +233,12 @@ fn can_mint_cycles_to_top_up_self() { let user = get_user(&env, user_id, canister_ids.station); let account = create_icp_account(&env, canister_ids.station, user.id); - let account_id = AccountIdentifier::from_hex(&account.address).unwrap(); + let account_id = AccountIdentifier::from_hex( + &get_icp_account_identifier(&account.addresses).expect("no icp address found"), + ) + .unwrap(); - send_icp_to_account(&env, controller, account_id, 100 * ICP, 0, None).unwrap(); + send_icp_to_account(&env, controller, account_id, 100 * ICP, 0, None, None).unwrap(); let pre_account_balance = get_icp_account_balance(&env, account_id); let pre_cycle_balance = env.cycle_balance(canister_ids.station); assert_eq!(pre_account_balance, 100 * ICP); diff --git a/tests/integration/src/disaster_recovery_tests.rs b/tests/integration/src/disaster_recovery_tests.rs index d8db6d22c..88c7e11d7 100644 --- a/tests/integration/src/disaster_recovery_tests.rs +++ b/tests/integration/src/disaster_recovery_tests.rs @@ -4,25 +4,29 @@ use crate::setup::{ use crate::utils::{ add_user, advance_time_to_burn_cycles, await_station_healthy, execute_request, get_account_read_permission, get_account_transfer_permission, get_account_update_permission, - get_core_canister_health_status, get_system_info, get_upgrader_disaster_recovery, - get_upgrader_logs, get_user, set_disaster_recovery, upload_canister_chunks_to_asset_canister, - user_test_id, NNS_ROOT_CANISTER_ID, + get_core_canister_health_status, get_icp_asset, get_system_info, + get_upgrader_disaster_recovery, get_upgrader_logs, get_user, set_disaster_recovery, + upload_canister_chunks_to_asset_canister, user_test_id, NNS_ROOT_CANISTER_ID, }; use crate::TestEnv; -use candid::{Encode, Principal}; +use candid::{CandidType, Encode, Principal}; use orbit_essentials::api::ApiResult; use orbit_essentials::utils::sha256_hash; use pocket_ic::{query_candid_as, update_candid_as, PocketIc}; +use serde::Deserialize; use station_api::{ - AddAccountOperationInput, AllowDTO, DisasterRecoveryCommitteeDTO, HealthStatus, + AccountDTO, AddAccountOperationInput, AllowDTO, DisasterRecoveryCommitteeDTO, HealthStatus, ListAccountsResponse, RequestOperationDTO, RequestOperationInput, RequestPolicyRuleDTO, SetDisasterRecoveryOperationInput, SystemInit, SystemInstall, SystemUpgrade, }; use std::collections::BTreeMap; +use std::str::FromStr; use upgrader_api::{ - Account, AdminUser, DisasterRecoveryCommittee, GetDisasterRecoveryAccountsResponse, - GetDisasterRecoveryCommitteeResponse, SetDisasterRecoveryAccountsInput, - SetDisasterRecoveryCommitteeInput, + Account, AdminUser, Asset, DisasterRecoveryCommittee, + GetDisasterRecoveryAccountsAndAssetsResponse, GetDisasterRecoveryAccountsResponse, + GetDisasterRecoveryCommitteeResponse, MultiAssetAccount, RecoveryResult, RecoveryStatus, + SetDisasterRecoveryAccountsAndAssetsInput, SetDisasterRecoveryAccountsInput, + SetDisasterRecoveryCommitteeInput, StationRecoveryRequest, }; use uuid::Uuid; @@ -150,29 +154,34 @@ fn successful_disaster_recovery_sync() { assert_eq!(admins.users[0].name, "user_1"); assert_eq!(admins.users[1].name, "user_2"); - let args = SetDisasterRecoveryAccountsInput { + let icp_asset_id = Uuid::from_bytes([0; 16]).hyphenated().to_string(); + + let args = SetDisasterRecoveryAccountsAndAssetsInput { accounts: vec![ - Account { + MultiAssetAccount { id: Uuid::from_bytes([0; 16]).hyphenated().to_string(), - blockchain: "icp".to_owned(), - address: "abc".to_owned(), - standard: "native".to_owned(), - symbol: "ICP".to_owned(), - decimals: 8, name: "Main Account".to_owned(), metadata: vec![], + assets: vec![icp_asset_id.clone()], + seed: [0; 16], }, - Account { + MultiAssetAccount { id: Uuid::from_bytes([1; 16]).hyphenated().to_string(), - blockchain: "icp".to_owned(), - address: "def".to_owned(), - standard: "native".to_owned(), - symbol: "ICP".to_owned(), - decimals: 8, name: "Another Account".to_owned(), metadata: vec![], + assets: vec![icp_asset_id.clone()], + seed: [1; 16], }, ], + assets: vec![Asset { + blockchain: "icp".to_owned(), + id: Uuid::from_bytes([0; 16]).hyphenated().to_string(), + name: "Internet Computer".to_owned(), + symbol: "ICP".to_owned(), + decimals: 8, + metadata: vec![], + standards: vec!["icp_native".to_owned()], + }], }; // non-controller can't set disaster recovery accounts @@ -180,7 +189,7 @@ fn successful_disaster_recovery_sync() { &env, upgrader_id, Principal::from_slice(&[1]), - "set_disaster_recovery_accounts", + "set_disaster_recovery_accounts_and_assets", (args.clone(),), ) .expect("Failed update call to set disaster recovery accounts"); @@ -194,17 +203,17 @@ fn successful_disaster_recovery_sync() { &env, upgrader_id, canister_ids.station, - "set_disaster_recovery_accounts", + "set_disaster_recovery_accounts_and_assets", (args,), ) .expect("Failed update call to set disaster recovery accounts"); res.0.expect("Failed to set disaster recovery accounts"); - let res: (ApiResult,) = query_candid_as( + let res: (ApiResult,) = query_candid_as( &env, upgrader_id, canister_ids.station, - "get_disaster_recovery_accounts", + "get_disaster_recovery_accounts_and_assets", ((),), ) .expect("Failed query call to get disaster recovery accounts"); @@ -258,11 +267,12 @@ fn auto_syncs_on_account_creation() { assert!(state.accounts.is_empty()); + let icp_asset = get_icp_asset(&env, canister_ids.station, WALLET_ADMIN_USER); + // create account for admin user let add_account = RequestOperationInput::AddAccount(AddAccountOperationInput { name: "admin".to_string(), - blockchain: "icp".to_string(), - standard: "native".to_string(), + assets: vec![icp_asset.id], read_permission: AllowDTO { auth_scope: station_api::AuthScopeDTO::Restricted, user_groups: vec![], @@ -288,8 +298,8 @@ fn auto_syncs_on_account_creation() { let state = get_upgrader_disaster_recovery(&env, &upgrader_id, &canister_ids.station); - assert_eq!(state.accounts.len(), 1); - assert_eq!(state.accounts[0].name, "admin"); + assert_eq!(state.multi_asset_accounts.len(), 1); + assert_eq!(state.multi_asset_accounts[0].name, "admin"); } /* @@ -472,13 +482,14 @@ fn test_disaster_recovery_flow_recreates_same_accounts() { let upgrader_id = system_info.upgrader_id; let admin_user = get_user(&env, WALLET_ADMIN_USER, canister_ids.station); + let icp_asset = get_icp_asset(&env, canister_ids.station, WALLET_ADMIN_USER); + // 2. create 3 accounts with the same admin user and no approval required let mut initial_accounts = BTreeMap::new(); for account_nr in 0..3 { let create_account_args = AddAccountOperationInput { name: format!("account-{}", account_nr), - blockchain: "icp".to_string(), - standard: "native".to_string(), + assets: vec![icp_asset.id.clone()], read_permission: AllowDTO { auth_scope: station_api::AuthScopeDTO::Restricted, user_groups: vec![], @@ -515,25 +526,37 @@ fn test_disaster_recovery_flow_recreates_same_accounts() { .account .expect("Unexpected new account not available"); - initial_accounts.insert( - newly_added_account.id, - (newly_added_account.name, newly_added_account.address), - ); + initial_accounts.insert(newly_added_account.id.clone(), newly_added_account); } else { panic!("Unexpected request operation found"); } } + let init_assets_input = station_api::InitAssetInput { + id: icp_asset.id.clone(), + name: icp_asset.name.clone(), + symbol: icp_asset.symbol.clone(), + decimals: icp_asset.decimals, + blockchain: icp_asset.blockchain.clone(), + standards: icp_asset.standards.clone(), + metadata: vec![], + }; + // 3. perform a reinstall disaster recovery request let init_accounts_input = initial_accounts .iter() - .map(|(id, (name, _))| station_api::InitAccountInput { - id: Some(id.to_string()), - name: name.to_string(), - blockchain: "icp".to_string(), - standard: "native".to_string(), - metadata: vec![], - }) + .map( + |(id, AccountDTO { name, .. })| station_api::InitAccountInput { + id: Some(id.clone()), + name: name.to_string(), + metadata: vec![], + assets: vec![icp_asset.id.clone()], + seed: Uuid::from_str(id.as_str()) + .expect("Failed to parse uuid") + .as_bytes() + .to_owned(), + }, + ) .collect(); let (base_chunk, module_extra_chunks) = @@ -566,6 +589,7 @@ fn test_disaster_recovery_flow_recreates_same_accounts() { fallback_controller: None, upgrader: station_api::SystemUpgraderInput::Id(upgrader_id), accounts: Some(init_accounts_input), + assets: Some(vec![init_assets_input]), })) .unwrap(), install_mode: upgrader_api::InstallMode::Reinstall, @@ -636,14 +660,19 @@ fn test_disaster_recovery_flow_recreates_same_accounts() { assert_eq!(admin_user.groups.len(), 1); let admin_user_group = admin_user.groups.first().expect("No user group found"); - for (id, (name, address)) in initial_accounts { + for (id, initial_account) in initial_accounts { let account = existing_accounts .iter() .find(|a| a.id == id) .expect("Unexpected account not found"); - assert_eq!(account.name, name); - assert_eq!(account.address, address); + assert_eq!(account.name, initial_account.name); + + for account_address in initial_account.addresses.iter() { + assert!(account.addresses.iter().any(|recovered_account_address| { + recovered_account_address.address == account_address.address + })); + } account.metadata.iter().for_each(|m| { assert_eq!(m.key, "key"); @@ -734,6 +763,7 @@ fn test_disaster_recovery_flow_reuses_same_upgrader() { fallback_controller: Some(fallback_controller), upgrader: station_api::SystemUpgraderInput::Id(upgrader_id), accounts: None, + assets: None, })) .unwrap(), install_mode: upgrader_api::InstallMode::Reinstall, @@ -972,6 +1002,7 @@ fn test_disaster_recovery_failing() { name: "Station".to_string(), admins: vec![], accounts: None, + assets: None, }); // install with intentionally bad arg to fail @@ -997,3 +1028,90 @@ fn test_disaster_recovery_failing() { await_disaster_recovery_failure(&env, canister_ids.station, upgrader_id); } + +#[test] +fn test_disaster_recovery_supports_legacy_format() { + let TestEnv { + env, canister_ids, .. + } = setup_new_env(); + + let system_info = get_system_info(&env, WALLET_ADMIN_USER, canister_ids.station); + let upgrader_id = system_info.upgrader_id; + + let args = SetDisasterRecoveryAccountsInput { + accounts: vec![ + Account { + id: Uuid::from_bytes([0; 16]).hyphenated().to_string(), + name: "Main Account".to_owned(), + metadata: vec![], + blockchain: "icp".to_owned(), + address: "1".to_owned(), + standard: "icp_native".to_owned(), + symbol: "ICP1".to_owned(), + decimals: 8, + }, + Account { + id: Uuid::from_bytes([1; 16]).hyphenated().to_string(), + name: "Another Account".to_owned(), + metadata: vec![], + blockchain: "icp".to_owned(), + address: "2".to_owned(), + standard: "icp_native".to_owned(), + symbol: "ICP2".to_owned(), + decimals: 8, + }, + ], + }; + + let res: (ApiResult,) = update_candid_as( + &env, + upgrader_id, + canister_ids.station, + "set_disaster_recovery_accounts", + (args,), + ) + .expect("Failed update call to set disaster recovery accounts"); + res.0.expect("Failed to set disaster recovery accounts"); + + let res: (ApiResult,) = query_candid_as( + &env, + upgrader_id, + canister_ids.station, + "get_disaster_recovery_accounts", + ((),), + ) + .expect("Failed query call to get disaster recovery accounts"); + + let res = res.0.expect("Failed to get disaster recovery accounts"); + + assert!(res.accounts.len() == 2); + assert_eq!(res.accounts[0].name, "Main Account"); + assert_eq!(res.accounts[0].address, "1"); + + assert_eq!(res.accounts[1].name, "Another Account"); + assert_eq!(res.accounts[1].address, "2"); + + // old response format should deserialize correctly + #[derive(Clone, Debug, CandidType, Deserialize)] + pub struct GetDisasterRecoveryStateResponse { + pub committee: Option, + pub accounts: Vec, + + pub recovery_requests: Vec, + pub recovery_status: RecoveryStatus, + pub last_recovery_result: Option, + } + + let res: (ApiResult,) = query_candid_as( + &env, + upgrader_id, + canister_ids.station, + "get_disaster_recovery_state", + ((),), + ) + .expect("Failed query call to get disaster recovery accounts"); + + let res = res.0.expect("Failed to get disaster recovery accounts"); + + assert!(res.accounts.len() == 2); +} diff --git a/tests/integration/src/interfaces.rs b/tests/integration/src/interfaces.rs index 3c2aea9a9..a2b5bd2e2 100644 --- a/tests/integration/src/interfaces.rs +++ b/tests/integration/src/interfaces.rs @@ -1,11 +1,13 @@ -use candid::{CandidType, Principal}; +use candid::{CandidType, Encode, Principal}; use ic_ledger_types::{ AccountBalanceArgs, AccountIdentifier, Memo, Subaccount, Tokens, TransferArgs, TransferError, DEFAULT_SUBACCOUNT, }; -use pocket_ic::{update_candid_as, PocketIc}; +use pocket_ic::{query_candid_as, update_candid_as, PocketIc}; use std::collections::{HashMap, HashSet}; +use crate::setup::{create_canister_with_cycles, get_canister_wasm}; + #[derive(CandidType)] pub enum NnsLedgerCanisterPayload { Init(NnsLedgerCanisterInitPayload), @@ -71,12 +73,13 @@ pub fn send_icp_to_account( e8s: u64, memo: u64, from_subaccount: Option, + fee: Option, ) -> Result { let ledger_canister_id = Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai").unwrap(); let transfer_args = TransferArgs { memo: Memo(memo), amount: Tokens::from_e8s(e8s), - fee: Tokens::from_e8s(10_000), + fee: Tokens::from_e8s(fee.unwrap_or(10_000)), from_subaccount, to: beneficiary_account, created_at_time: None, @@ -100,5 +103,126 @@ pub fn send_icp( memo: u64, ) -> Result { let to = AccountIdentifier::new(&beneficiary_id, &DEFAULT_SUBACCOUNT); - send_icp_to_account(env, sender_id, to, e8s, memo, None) + send_icp_to_account(env, sender_id, to, e8s, memo, None, None) +} + +pub fn mint_icp( + env: &PocketIc, + minter_id: Principal, + to: &AccountIdentifier, + e8s: u64, +) -> Result { + send_icp_to_account(env, minter_id, *to, e8s, 0, None, Some(0)) +} + +#[derive(CandidType)] +pub struct Icrc1LedgerInitArgs { + pub minting_account: icrc_ledger_types::icrc1::account::Account, + pub fee_collector_account: Option, + pub initial_balances: Vec<(icrc_ledger_types::icrc1::account::Account, candid::Nat)>, + pub transfer_fee: candid::Nat, + pub decimals: Option, + pub token_name: String, + pub token_symbol: String, + pub metadata: Vec<( + String, + icrc_ledger_types::icrc::generic_metadata_value::MetadataValue, + )>, + pub archive_options: ArchiveOptions, + pub max_memo_length: Option, + pub feature_flags: Option, + pub maximum_number_of_accounts: Option, + pub accounts_overflow_trim_quantity: Option, +} + +#[derive(CandidType)] +pub struct ArchiveOptions { + pub trigger_threshold: usize, + pub num_blocks_to_archive: usize, + pub node_max_memory_size_bytes: Option, + pub max_message_size_bytes: Option, + pub controller_id: Principal, + pub more_controller_ids: Option>, + pub cycles_for_archive_creation: Option, + pub max_transactions_per_response: Option, +} + +#[derive(CandidType)] +pub struct FeatureFlags { + pub icrc2: bool, +} + +#[derive(CandidType)] +pub enum Icrc1LedgerArgument { + Init(Icrc1LedgerInitArgs), +} + +pub fn deploy_icrc1_token( + env: &mut PocketIc, + controller: Principal, + init: Icrc1LedgerInitArgs, +) -> Principal { + let wasm_module = get_canister_wasm("icrc1_ledger").to_vec(); + + let canister_id = create_canister_with_cycles(env, controller, 1_000_000_000_000); + + env.install_canister( + canister_id, + wasm_module, + Encode!(&Icrc1LedgerArgument::Init(init)).unwrap(), + Some(controller), + ); + + canister_id +} + +pub fn mint_icrc1_tokens( + env: &PocketIc, + ledger_id: Principal, + minter: Principal, + to: icrc_ledger_types::icrc1::account::Account, + amount: u64, +) -> Result< + icrc_ledger_types::icrc1::transfer::BlockIndex, + icrc_ledger_types::icrc1::transfer::TransferError, +> { + let res: ( + Result< + icrc_ledger_types::icrc1::transfer::BlockIndex, + icrc_ledger_types::icrc1::transfer::TransferError, + >, + ) = update_candid_as( + env, + ledger_id, + minter, + "icrc1_transfer", + (icrc_ledger_types::icrc1::transfer::TransferArg { + from_subaccount: None, + to, + fee: None, + created_at_time: None, + memo: None, + amount: amount.into(), + },), + ) + .expect("Failed to make update call"); + + res.0 +} + +pub fn get_icrc1_balance_of( + env: &PocketIc, + ledger_id: Principal, + account: icrc_ledger_types::icrc1::account::Account, +) -> candid::Nat { + let res: (candid::Nat,) = query_candid_as( + env, + ledger_id, + Principal::anonymous(), + "icrc1_balance_of", + (account,), + ) + .expect("Failed to make query call"); + + res.0 } diff --git a/tests/integration/src/lib.rs b/tests/integration/src/lib.rs index b91099213..3f25d735a 100644 --- a/tests/integration/src/lib.rs +++ b/tests/integration/src/lib.rs @@ -3,7 +3,9 @@ use candid::Principal; use pocket_ic::PocketIc; +mod account_tests; mod address_book_tests; +mod asset_tests; mod control_panel_tests; mod cycles_monitor_tests; mod dfx_orbit; diff --git a/tests/integration/src/migration_tests.rs b/tests/integration/src/migration_tests.rs index d8e28054f..e51fa12e0 100644 --- a/tests/integration/src/migration_tests.rs +++ b/tests/integration/src/migration_tests.rs @@ -1,4 +1,5 @@ use crate::setup::{get_canister_wasm, setup_new_env, WALLET_ADMIN_USER}; +use crate::test_data::asset::list_assets; use crate::test_data::{set_test_data_id, StationDataGenerator}; use crate::utils::{compress_to_gzip, create_file, read_file, NNS_ROOT_CANISTER_ID}; use crate::TestEnv; @@ -6,11 +7,14 @@ use candid::{Encode, Principal}; use orbit_essentials::api::ApiResult; use pocket_ic::{update_candid_as, PocketIc}; -const CURRENT_BASELINE_NR_OF_REQUEST_POLICIES: usize = 18; // can be found in the station core/init.rs -const CURRENT_BASELINE_NR_PERMISSIONS: usize = 35; // can be found in the station core/init.rs +const CURRENT_BASELINE_NR_OF_REQUEST_POLICIES: usize = 21; // can be found in the station core/init.rs +const CURRENT_BASELINE_NR_PERMISSIONS: usize = 40; // can be found in the station core/init.rs const PREVIOUS_BASELINE_NR_OF_REQUEST_POLICIES: usize = 18; // baseline in the previous memory version core/init.rs -const PREVIOUS_BASELINE_NR_PERMISSIONS: usize = 34; // baseline in the previous memory version core/init.rs +const PREVIOUS_BASELINE_NR_PERMISSIONS: usize = 35; // baseline in the previous memory version core/init.rs + +const POLICIES_ADDED_AT_MIGRATION: usize = 3; +const PERMISSIONS_ADDED_AT_MIGRATION: usize = 5; const USER_GROUPS_NR: usize = 10; const USER_NR: usize = 10; @@ -122,7 +126,7 @@ fn test_canister_migration_path_with_previous_wasm_memory_version() { let station_wasm = get_canister_wasm("station").to_vec(); let wasm_memory = - read_file("station-memory-v0.bin").expect("Unexpected missing older wasm memory"); + read_file("station-memory-v1.bin").expect("Unexpected missing older wasm memory"); env.stop_canister(canister_ids.station, Some(NNS_ROOT_CANISTER_ID)) .expect("unexpected failure stopping canister"); @@ -130,6 +134,7 @@ fn test_canister_migration_path_with_previous_wasm_memory_version() { // This is needed to avoid `install_code` rate limit error env.tick(); env.tick(); + env.tick(); // Set the stable memory of the canister to the previous version of the canister env.set_stable_memory( @@ -181,15 +186,21 @@ fn test_canister_migration_path_with_previous_wasm_memory_version() { &env, canister_ids.station, WALLET_ADMIN_USER, - EXPECTED_ADDITIONAL_REQUEST_POLICIES_NR + PREVIOUS_BASELINE_NR_OF_REQUEST_POLICIES, + EXPECTED_ADDITIONAL_REQUEST_POLICIES_NR + + PREVIOUS_BASELINE_NR_OF_REQUEST_POLICIES + + POLICIES_ADDED_AT_MIGRATION, ); assert_can_list_permissions( &env, canister_ids.station, WALLET_ADMIN_USER, - EXPECTED_ADDITIONAL_PERMISSIONS_NR + PREVIOUS_BASELINE_NR_PERMISSIONS, + EXPECTED_ADDITIONAL_PERMISSIONS_NR + + PREVIOUS_BASELINE_NR_PERMISSIONS + + PERMISSIONS_ADDED_AT_MIGRATION, ); + assert_has_icp_asset(&env, canister_ids.station, WALLET_ADMIN_USER); + // Makes sure that the next test data id number is pointing at a value that was // not already used in the previous version set_test_data_id(9_999); @@ -204,6 +215,7 @@ fn test_canister_migration_path_with_previous_wasm_memory_version() { .with_user_groups(new_records) .with_accounts(new_records) .with_address_book_entries(new_records) + .with_assets(new_records) .with_request_policy_updates(new_records) .with_station_updates(0) .with_upgrader_updates(0) @@ -250,6 +262,7 @@ fn test_canister_migration_path_with_previous_wasm_memory_version() { // for accounts there are transfer policies and configuration policies EXPECTED_ADDITIONAL_REQUEST_POLICIES_NR + PREVIOUS_BASELINE_NR_OF_REQUEST_POLICIES + + POLICIES_ADDED_AT_MIGRATION + new_records + (new_records * 2), ); @@ -258,7 +271,18 @@ fn test_canister_migration_path_with_previous_wasm_memory_version() { canister_ids.station, WALLET_ADMIN_USER, // for accounts there are view, transfer and configuration permissions - EXPECTED_ADDITIONAL_PERMISSIONS_NR + PREVIOUS_BASELINE_NR_PERMISSIONS + (new_records * 3), + EXPECTED_ADDITIONAL_PERMISSIONS_NR + + PREVIOUS_BASELINE_NR_PERMISSIONS + + PERMISSIONS_ADDED_AT_MIGRATION + + (new_records * 3), + ); + + assert_can_list_assets( + &env, + canister_ids.station, + WALLET_ADMIN_USER, + // there should be one asset here already: ICP + new_records + 1, ); } @@ -364,6 +388,7 @@ fn assert_can_list_address_book_entries( blockchain: None, labels: None, addresses: None, + address_formats: None, ids: None, paginate: Some(station_api::PaginationInput { offset: Some(0), @@ -426,7 +451,7 @@ fn assert_can_list_request_policies( requester, "list_request_policies", (station_api::ListRequestPoliciesInput { - limit: Some(25), + limit: Some(1000), offset: Some(0), },), ) @@ -462,3 +487,46 @@ fn assert_can_list_permissions( assert_eq!(res.total as usize, expected); } + +fn assert_can_list_assets( + env: &PocketIc, + station_id: Principal, + requester: Principal, + expected: usize, +) { + let res: (ApiResult,) = update_candid_as( + env, + station_id, + requester, + "list_assets", + (station_api::ListAssetsInput { + paginate: Some(station_api::PaginationInput { + offset: Some(0), + limit: Some(25), + }), + },), + ) + .unwrap(); + + let res = res.0.unwrap(); + + assert_eq!(res.total as usize, expected); +} + +fn assert_has_icp_asset(env: &PocketIc, station_id: Principal, requester: Principal) { + let assets = list_assets(env, station_id, requester) + .expect("Failed to query list assets") + .0 + .expect("Failed to list assets"); + + assert!(assets.assets.len() == 1); + assert_eq!(assets.assets[0].symbol, "ICP"); + assert_eq!(assets.assets[0].name, "Internet Computer"); + assert_eq!(&assets.assets[0].blockchain, "icp"); + assert!( + assets.assets[0] + .standards + .contains(&"icp_native".to_string()) + && assets.assets[0].standards.contains(&"icrc1".to_string()) + ); +} diff --git a/tests/integration/src/setup.rs b/tests/integration/src/setup.rs index 020d30679..0b01c2008 100644 --- a/tests/integration/src/setup.rs +++ b/tests/integration/src/setup.rs @@ -282,6 +282,7 @@ fn install_canisters( identity: WALLET_ADMIN_USER, name: "station-admin".to_string(), }], + assets: None, quorum: Some(1), upgrader: station_api::SystemUpgraderInput::WasmModule(upgrader_wasm), fallback_controller: config.fallback_controller, diff --git a/tests/integration/src/test_data.rs b/tests/integration/src/test_data.rs index bbb989e96..70ee9f338 100644 --- a/tests/integration/src/test_data.rs +++ b/tests/integration/src/test_data.rs @@ -9,6 +9,7 @@ use crate::utils::bump_time_to_avoid_ratelimit; pub mod account; pub mod address_book; +pub mod asset; pub mod permission; pub mod request_policy; pub mod system_upgrade; @@ -55,6 +56,7 @@ pub struct StationDataGenerator<'a> { station_updates: usize, permission_updates: usize, request_policy_updates: usize, + assets: usize, } impl<'a> StationDataGenerator<'a> { @@ -77,6 +79,7 @@ impl<'a> StationDataGenerator<'a> { max_user_groups_per_user: 5, has_generated: false, count_requests: 0, + assets: Self::DEFAULT_ENTRIES, } } @@ -124,6 +127,11 @@ impl<'a> StationDataGenerator<'a> { self } + pub fn with_assets(mut self, assets: usize) -> Self { + self.assets = assets; + self + } + pub fn with_edit_operations(mut self) -> Self { self.perform_edit_operations = true; self @@ -211,6 +219,15 @@ impl<'a> StationDataGenerator<'a> { format!("{}_edited", account.name), ); self.increment_request_count(); + + account::edit_account_assets( + self.env, + self.station_canister_id, + self.requester, + account.id.clone(), + station_api::ChangeAssets::ReplaceWith { assets: vec![] }, + ); + self.increment_request_count(); } } @@ -235,6 +252,23 @@ impl<'a> StationDataGenerator<'a> { } } + // Add the assets + for _ in 0..self.assets { + let asset = asset::add_asset(self.env, self.station_canister_id, self.requester); + self.increment_request_count(); + + if self.perform_edit_operations { + asset::edit_asset_name( + self.env, + self.station_canister_id, + self.requester, + asset.id.clone(), + format!("{}_edited", asset.name), + ); + self.increment_request_count(); + } + } + // Edit the permissions for _ in 0..self.permission_updates { permission::edit_permission( diff --git a/tests/integration/src/test_data/account.rs b/tests/integration/src/test_data/account.rs index a6f7b9f19..02c01c17d 100644 --- a/tests/integration/src/test_data/account.rs +++ b/tests/integration/src/test_data/account.rs @@ -1,13 +1,16 @@ use super::next_unique_id; -use crate::utils::{submit_request, wait_for_request}; +use crate::utils::{get_icp_asset, submit_request, wait_for_request}; use candid::Principal; use pocket_ic::PocketIc; +use station_api::ChangeAssets; pub fn add_account( env: &PocketIc, station_canister_id: Principal, requester: Principal, ) -> station_api::AccountDTO { + let icp_asset = get_icp_asset(env, station_canister_id, requester); + let next_id = next_unique_id(); let add_account_request = submit_request( env, @@ -15,8 +18,7 @@ pub fn add_account( station_canister_id, station_api::RequestOperationInput::AddAccount(station_api::AddAccountOperationInput { name: format!("account-{}", next_id), - blockchain: "icp".to_string(), - standard: "native".to_string(), + assets: vec![icp_asset.id], metadata: Vec::new(), configs_permission: station_api::AllowDTO { auth_scope: station_api::AuthScopeDTO::Authenticated, @@ -66,6 +68,34 @@ pub fn edit_account_name( station_api::RequestOperationInput::EditAccount(station_api::EditAccountOperationInput { account_id, name: Some(name), + change_assets: None, + configs_permission: None, + read_permission: None, + transfer_permission: None, + configs_request_policy: None, + transfer_request_policy: None, + }), + ); + + wait_for_request(env, requester, station_canister_id, edit_account_request) + .expect("Failed to edit account"); +} + +pub fn edit_account_assets( + env: &PocketIc, + station_canister_id: Principal, + requester: Principal, + account_id: station_api::UuidDTO, + change_assets: ChangeAssets, +) { + let edit_account_request = submit_request( + env, + requester, + station_canister_id, + station_api::RequestOperationInput::EditAccount(station_api::EditAccountOperationInput { + account_id, + name: None, + change_assets: Some(change_assets), configs_permission: None, read_permission: None, transfer_permission: None, diff --git a/tests/integration/src/test_data/address_book.rs b/tests/integration/src/test_data/address_book.rs index 951ae716d..c19d41307 100644 --- a/tests/integration/src/test_data/address_book.rs +++ b/tests/integration/src/test_data/address_book.rs @@ -17,7 +17,8 @@ pub fn add_address_book_entry( station_api::RequestOperationInput::AddAddressBookEntry( station_api::AddAddressBookEntryOperationInput { blockchain: "icp".to_string(), - labels: vec!["native".to_string()], + address_format: "icp_account_identifier".to_string(), + labels: vec!["icp_native".to_string()], address_owner: format!("user-{}", next_id), metadata: Vec::new(), address: format!("{}{}", "0x", sha256_hex(&next_id.to_le_bytes())), diff --git a/tests/integration/src/test_data/asset.rs b/tests/integration/src/test_data/asset.rs new file mode 100644 index 000000000..66f74d997 --- /dev/null +++ b/tests/integration/src/test_data/asset.rs @@ -0,0 +1,123 @@ +use super::next_unique_id; +use crate::utils::{submit_request, wait_for_request}; +use candid::Principal; +use orbit_essentials::api::ApiResult; +use pocket_ic::{query_candid_as, CallError, PocketIc}; +use station_api::{GetAssetInput, GetAssetResponse, ListAssetsInput, ListAssetsResponse}; + +pub fn add_asset_with_input( + env: &PocketIc, + station_canister_id: Principal, + requester: Principal, + input: station_api::AddAssetOperationInput, +) -> station_api::AssetDTO { + let add_asset_request = submit_request( + env, + requester, + station_canister_id, + station_api::RequestOperationInput::AddAsset(input), + ); + + let request = wait_for_request(env, requester, station_canister_id, add_asset_request) + .expect("Failed to add asset"); + + match request.operation { + station_api::RequestOperationDTO::AddAsset(add_asset) => add_asset.asset.unwrap(), + _ => panic!("invalid request operation"), + } +} + +pub fn add_asset( + env: &PocketIc, + station_canister_id: Principal, + requester: Principal, +) -> station_api::AssetDTO { + let next_id = next_unique_id(); + + add_asset_with_input( + env, + station_canister_id, + requester, + station_api::AddAssetOperationInput { + name: format!("asset-{}", next_id), + blockchain: "icp".to_string(), + standards: vec!["icp_native".to_string()], + metadata: Vec::new(), + symbol: format!("SYM{}", next_id), + decimals: 8, + }, + ) +} + +pub fn edit_asset_name( + env: &PocketIc, + station_canister_id: Principal, + requester: Principal, + asset_id: station_api::UuidDTO, + name: String, +) { + let edit_asset_request = submit_request( + env, + requester, + station_canister_id, + station_api::RequestOperationInput::EditAsset(station_api::EditAssetOperationInput { + asset_id, + name: Some(name), + blockchain: None, + standards: None, + symbol: None, + change_metadata: None, + }), + ); + + wait_for_request(env, requester, station_canister_id, edit_asset_request) + .expect("Failed to edit asset name"); +} + +pub fn remove_asset( + env: &PocketIc, + station_canister_id: Principal, + requester: Principal, + asset_id: station_api::UuidDTO, +) { + let remove_asset_request = submit_request( + env, + requester, + station_canister_id, + station_api::RequestOperationInput::RemoveAsset(station_api::RemoveAssetOperationInput { + asset_id, + }), + ); + + wait_for_request(env, requester, station_canister_id, remove_asset_request) + .expect("Failed to remove asset"); +} + +pub fn list_assets( + env: &PocketIc, + station_canister_id: Principal, + requester: Principal, +) -> Result<(ApiResult,), CallError> { + query_candid_as::<(ListAssetsInput,), (ApiResult,)>( + env, + station_canister_id, + requester, + "list_assets", + (ListAssetsInput { paginate: None },), + ) +} + +pub fn get_asset( + env: &PocketIc, + station_canister_id: Principal, + requester: Principal, + asset_id: station_api::UuidDTO, +) -> Result<(ApiResult,), CallError> { + query_candid_as::<(GetAssetInput,), (ApiResult,)>( + env, + station_canister_id, + requester, + "get_asset", + (GetAssetInput { asset_id },), + ) +} diff --git a/tests/integration/src/transfer_tests.rs b/tests/integration/src/transfer_tests.rs index ede83a832..cf8e349fb 100644 --- a/tests/integration/src/transfer_tests.rs +++ b/tests/integration/src/transfer_tests.rs @@ -1,19 +1,28 @@ use crate::interfaces::{ - default_account, get_icp_balance, send_icp, send_icp_to_account, ICP, ICP_FEE, + default_account, deploy_icrc1_token, get_icp_balance, get_icrc1_balance_of, mint_icp, + mint_icrc1_tokens, send_icp, send_icp_to_account, ArchiveOptions, Icrc1LedgerInitArgs, ICP, + ICP_FEE, }; use crate::setup::{setup_new_env, WALLET_ADMIN_USER}; -use crate::utils::user_test_id; +use crate::test_data::asset::add_asset_with_input; +use crate::utils::{ + create_account, create_transfer, fetch_account_balances, get_icp_account_identifier, + get_icp_asset, user_test_id, +}; use crate::TestEnv; +use candid::Principal; use ic_ledger_types::AccountIdentifier; use orbit_essentials::api::ApiResult; use pocket_ic::{query_candid_as, update_candid_as}; use station_api::{ - AddAccountOperationInput, AllowDTO, ApiErrorDTO, CreateRequestInput, CreateRequestResponse, - GetRequestInput, GetRequestResponse, GetTransfersInput, GetTransfersResponse, - ListAccountTransfersInput, ListAccountTransfersResponse, MeResponse, QuorumPercentageDTO, - RequestExecutionScheduleDTO, RequestOperationDTO, RequestOperationInput, RequestPolicyRuleDTO, - RequestStatusDTO, TransferOperationInput, UserSpecifierDTO, + AddAccountOperationInput, AddAssetOperationInput, AllowDTO, ApiErrorDTO, CreateRequestInput, + CreateRequestResponse, GetRequestInput, GetRequestResponse, GetTransfersInput, + GetTransfersResponse, ListAccountTransfersInput, ListAccountTransfersResponse, MeResponse, + MetadataDTO, QuorumPercentageDTO, RequestExecutionScheduleDTO, RequestOperationDTO, + RequestOperationInput, RequestPolicyRuleDTO, RequestStatusDTO, TransferOperationInput, + UserSpecifierDTO, }; +use std::str::FromStr; use std::time::Duration; #[test] @@ -32,11 +41,12 @@ fn make_transfer_successful() { update_candid_as(&env, canister_ids.station, WALLET_ADMIN_USER, "me", ()).unwrap(); let user_dto = res.0.unwrap().me; + let icp_asset = get_icp_asset(&env, canister_ids.station, WALLET_ADMIN_USER); + // create account let create_account_args = AddAccountOperationInput { name: "test".to_string(), - blockchain: "icp".to_string(), - standard: "native".to_string(), + assets: vec![icp_asset.id.clone()], read_permission: AllowDTO { auth_scope: station_api::AuthScopeDTO::Restricted, user_groups: vec![], @@ -135,7 +145,10 @@ fn make_transfer_successful() { assert_eq!(user_balance, ICP + 2 * ICP_FEE); // send ICP to orbit station account - let account_address = AccountIdentifier::from_hex(&account_dto.address).unwrap(); + let account_address = AccountIdentifier::from_hex( + &get_icp_account_identifier(&account_dto.addresses).expect("no icp address found"), + ) + .unwrap(); send_icp_to_account( &env, WALLET_ADMIN_USER, @@ -143,6 +156,7 @@ fn make_transfer_successful() { ICP + ICP_FEE, 0, None, + None, ) .unwrap(); @@ -157,6 +171,8 @@ fn make_transfer_successful() { // make transfer request to beneficiary let transfer = TransferOperationInput { from_account_id: account_dto.id.clone(), + from_asset_id: icp_asset.id.clone(), + with_standard: "icp_native".to_string(), to: default_account(beneficiary_id), amount: ICP.into(), fee: None, @@ -188,6 +204,10 @@ fn make_transfer_successful() { env.tick(); env.tick(); env.tick(); + env.advance_time(Duration::from_secs(5)); + env.tick(); + env.tick(); + env.tick(); // check transfer request status let get_request_args = GetRequestInput { @@ -275,3 +295,310 @@ fn make_transfer_successful() { assert!(all_have_transaction_hash); } + +#[test] +fn make_icrc1_transfer() { + let TestEnv { + mut env, + canister_ids, + // controller, + .. + } = setup_new_env(); + + let beneficiary_id = user_test_id(1); + + // register user + let res: (ApiResult,) = + update_candid_as(&env, canister_ids.station, WALLET_ADMIN_USER, "me", ()).unwrap(); + let user_dto = res.0.unwrap().me; + + let ledger_controller = Principal::from_slice(&[99; 29]); + + let token_ledger_canister_id = deploy_icrc1_token( + &mut env, + ledger_controller, + Icrc1LedgerInitArgs { + minting_account: icrc_ledger_types::icrc1::account::Account { + owner: ledger_controller, + subaccount: None, + }, + fee_collector_account: None, + initial_balances: vec![], + transfer_fee: 50u64.into(), + decimals: Some(12), + token_name: "TEST_ICRC1".to_owned(), + token_symbol: "TST".to_owned(), + metadata: vec![], + archive_options: ArchiveOptions { + trigger_threshold: 1000, + num_blocks_to_archive: 1000, + node_max_memory_size_bytes: None, + max_message_size_bytes: None, + controller_id: ledger_controller, + more_controller_ids: None, + cycles_for_archive_creation: None, + max_transactions_per_response: None, + }, + max_memo_length: None, + feature_flags: None, + maximum_number_of_accounts: None, + accounts_overflow_trim_quantity: None, + }, + ); + + let asset = add_asset_with_input( + &env, + canister_ids.station, + user_dto.identities[0], + AddAssetOperationInput { + name: "Test ICRC1 Token".to_owned(), + blockchain: "icp".to_owned(), + standards: vec!["icrc1".to_owned()], + symbol: "TEST".to_owned(), + decimals: 4, + metadata: vec![ + MetadataDTO { + key: "ledger_canister_id".to_owned(), + value: Principal::to_text(&token_ledger_canister_id), + }, + MetadataDTO { + key: "index_canister_id".to_owned(), + value: Principal::to_text(&token_ledger_canister_id), + }, + ], + }, + ); + + let permission = AllowDTO { + auth_scope: station_api::AuthScopeDTO::Restricted, + user_groups: vec![], + users: vec![user_dto.id.clone()], + }; + + let account = create_account( + &env, + canister_ids.station, + user_dto.identities[0], + AddAccountOperationInput { + name: "test account".to_owned(), + assets: vec![asset.id.clone()], + metadata: vec![], + read_permission: permission.clone(), + configs_permission: permission.clone(), + transfer_permission: permission.clone(), + configs_request_policy: Some(RequestPolicyRuleDTO::AutoApproved), + transfer_request_policy: Some(RequestPolicyRuleDTO::AutoApproved), + }, + ); + + let station_account_icrc1_account = + icrc_ledger_types::icrc1::account::Account::from_str(&account.addresses[0].address) + .expect("invalid account address"); + + mint_icrc1_tokens( + &env, + token_ledger_canister_id, + ledger_controller, + station_account_icrc1_account, + 1_000_000, + ) + .expect("failed to mint icrc1 tokens"); + + let to_address = icrc_ledger_types::icrc1::account::Account { + owner: beneficiary_id, + subaccount: None, + } + .to_string(); + + create_transfer( + &env, + canister_ids.station, + user_dto.identities[0], + station_api::TransferOperationInput { + from_account_id: account.id.clone(), + from_asset_id: asset.id.clone(), + with_standard: "icrc1".to_owned(), + to: to_address.clone(), + amount: candid::Nat::from(100u128), + fee: Some(50u64.into()), + metadata: vec![], + network: None, + }, + ); + + let balance = get_icrc1_balance_of( + &env, + token_ledger_canister_id, + icrc_ledger_types::icrc1::account::Account { + owner: beneficiary_id, + subaccount: None, + }, + ); + + assert_eq!(balance, candid::Nat::from(100u128)); + + let account_balances = fetch_account_balances( + &env, + canister_ids.station, + user_dto.identities[0], + station_api::FetchAccountBalancesInput { + account_ids: vec![account.id.clone()], + }, + ); + + assert_eq!( + account_balances.balances[0] + .as_ref() + .expect("should have balance") + .balance, + candid::Nat::from(999_850u128) + ); + + // test transfering without specifying fee + let transfer_without_fee = create_transfer( + &env, + canister_ids.station, + user_dto.identities[0], + station_api::TransferOperationInput { + from_account_id: account.id.clone(), + from_asset_id: asset.id.clone(), + with_standard: "icrc1".to_owned(), + to: to_address, + amount: candid::Nat::from(500u128), + fee: None, + metadata: vec![], + network: None, + }, + ); + + // the station queries the ledger canister to get the fee + assert_eq!(transfer_without_fee.fee, candid::Nat::from(50u64)); +} + +#[test] +fn make_icrc1_icp_transfer() { + let TestEnv { + env, + canister_ids, + // controller, + minter, + .. + } = setup_new_env(); + + // register user + let res: (ApiResult,) = + update_candid_as(&env, canister_ids.station, WALLET_ADMIN_USER, "me", ()).unwrap(); + let user_dto = res.0.unwrap().me; + + let icp_asset = get_icp_asset(&env, canister_ids.station, WALLET_ADMIN_USER); + + let permission = AllowDTO { + auth_scope: station_api::AuthScopeDTO::Restricted, + user_groups: vec![], + users: vec![user_dto.id.clone()], + }; + + let account = create_account( + &env, + canister_ids.station, + user_dto.identities[0], + AddAccountOperationInput { + name: "test account".to_owned(), + assets: vec![icp_asset.id.clone()], + metadata: vec![], + read_permission: permission.clone(), + configs_permission: permission.clone(), + transfer_permission: permission.clone(), + configs_request_policy: Some(RequestPolicyRuleDTO::AutoApproved), + transfer_request_policy: Some(RequestPolicyRuleDTO::AutoApproved), + }, + ); + + assert_eq!(account.addresses.len(), 2); + + let icp_account_identifier = AccountIdentifier::from_hex( + &account + .addresses + .iter() + .find(|a| a.format == "icp_account_identifier") + .expect("cannot get ICP account identifier") + .address, + ) + .expect("cannot parse ICP account identifier"); + + let icp_icrc1_account = icrc_ledger_types::icrc1::account::Account::from_str( + &account + .addresses + .iter() + .find(|a| a.format == "icrc1_account") + .expect("cannot get ICRC1 account") + .address, + ) + .expect("invalid account address"); + + mint_icp(&env, minter, &icp_account_identifier, 10 * 100_000_000) + .expect("failed to mint ICP to account"); + + mint_icrc1_tokens( + &env, + Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai").unwrap(), + minter, + icp_icrc1_account, + 20 * 100_000_000, + ) + .expect("failed to mint ICP to ICRC1 account"); + + let account_balances = fetch_account_balances( + &env, + canister_ids.station, + user_dto.identities[0], + station_api::FetchAccountBalancesInput { + account_ids: vec![account.id.clone()], + }, + ); + assert_eq!(account_balances.balances.len(), 1); + assert_eq!( + account_balances.balances[0] + .as_ref() + .expect("should have balance") + .balance, + candid::Nat::from(30 * 100_000_000u64) + ); + + create_transfer( + &env, + canister_ids.station, + user_dto.identities[0], + station_api::TransferOperationInput { + from_account_id: account.id.clone(), + from_asset_id: icp_asset.id.clone(), + with_standard: "icrc1".to_owned(), + to: icrc_ledger_types::icrc1::account::Account { + owner: user_dto.identities[0], + subaccount: None, + } + .to_string(), + amount: candid::Nat::from(25 * 100_000_000u64), + fee: None, + metadata: vec![], + network: None, + }, + ); + + let account_balances = fetch_account_balances( + &env, + canister_ids.station, + user_dto.identities[0], + station_api::FetchAccountBalancesInput { + account_ids: vec![account.id.clone()], + }, + ); + assert_eq!(account_balances.balances.len(), 1); + assert_eq!( + account_balances.balances[0] + .as_ref() + .expect("should have balance") + .balance, + candid::Nat::from(5 * 100_000_000u64 - 10_000) + ); +} diff --git a/tests/integration/src/utils.rs b/tests/integration/src/utils.rs index 4ca8ef1c9..6029a06e0 100644 --- a/tests/integration/src/utils.rs +++ b/tests/integration/src/utils.rs @@ -1,5 +1,8 @@ use crate::setup::{create_canister, get_canister_wasm, WALLET_ADMIN_USER}; -use candid::{CandidType, Encode, Principal}; +use crate::test_data::asset::list_assets; +use candid::utils::ArgumentDecoder; +use candid::Principal; +use candid::{decode_args, CandidType, Encode}; use control_panel_api::UploadCanisterModulesInput; use flate2::{write::GzEncoder, Compression}; use ic_certified_assets::types::{ @@ -15,12 +18,14 @@ use sha2::Digest; use sha2::Sha256; use station_api::{ AccountDTO, AddAccountOperationInput, AddUserOperationInput, AllowDTO, ApiErrorDTO, - CreateRequestInput, CreateRequestResponse, GetPermissionResponse, GetRequestInput, - GetRequestResponse, HealthStatus, MeResponse, QuorumPercentageDTO, RequestApprovalStatusDTO, - RequestDTO, RequestExecutionScheduleDTO, RequestOperationDTO, RequestOperationInput, - RequestPolicyRuleDTO, RequestStatusDTO, ResourceIdDTO, SetDisasterRecoveryOperationDTO, - SetDisasterRecoveryOperationInput, SubmitRequestApprovalInput, SubmitRequestApprovalResponse, - SystemInfoDTO, SystemInfoResponse, UserDTO, UserSpecifierDTO, UserStatusDTO, UuidDTO, + CreateRequestInput, CreateRequestResponse, FetchAccountBalancesInput, + FetchAccountBalancesResponse, GetPermissionResponse, GetRequestInput, GetRequestResponse, + GetTransfersInput, GetTransfersResponse, HealthStatus, MeResponse, QuorumPercentageDTO, + RequestApprovalStatusDTO, RequestDTO, RequestExecutionScheduleDTO, RequestOperationDTO, + RequestOperationInput, RequestPolicyRuleDTO, RequestStatusDTO, ResourceIdDTO, + SetDisasterRecoveryOperationDTO, SetDisasterRecoveryOperationInput, SubmitRequestApprovalInput, + SubmitRequestApprovalResponse, SystemInfoDTO, SystemInfoResponse, UserDTO, UserSpecifierDTO, + UserStatusDTO, UuidDTO, }; use std::io::Write; use std::path::PathBuf; @@ -227,6 +232,16 @@ pub fn wait_for_request_with_extra_ticks( request: RequestDTO, extra_ticks: u64, ) -> Result> { + // wait for the request to be approved + env.advance_time(Duration::from_secs(2)); + env.tick(); + // wait for the request to be processing + env.advance_time(Duration::from_secs(2)); + env.tick(); + // wait in case the request calls out to other canisters + env.advance_time(Duration::from_secs(2)); + env.tick(); + for _ in 0..extra_ticks { // timer's period for processing requests is 5 seconds env.advance_time(Duration::from_secs(5)); @@ -577,11 +592,12 @@ pub fn get_account_transfer_permission( } pub fn create_icp_account(env: &PocketIc, station_id: Principal, user_id: UuidDTO) -> AccountDTO { + let icp = get_icp_asset(env, station_id, WALLET_ADMIN_USER); + // create account let create_account_args = AddAccountOperationInput { name: "test".to_string(), - blockchain: "icp".to_string(), - standard: "native".to_string(), + assets: vec![icp.id.clone()], read_permission: AllowDTO { auth_scope: station_api::AuthScopeDTO::Restricted, user_groups: vec![], @@ -611,8 +627,18 @@ pub fn create_icp_account(env: &PocketIc, station_id: Principal, user_id: UuidDT )), metadata: vec![], }; + + create_account(env, station_id, WALLET_ADMIN_USER, create_account_args) +} + +pub fn create_account( + env: &PocketIc, + station_id: Principal, + requester: Principal, + input: AddAccountOperationInput, +) -> AccountDTO { let add_account_request = CreateRequestInput { - operation: RequestOperationInput::AddAccount(create_account_args), + operation: RequestOperationInput::AddAccount(input), title: None, summary: None, execution_plan: Some(RequestExecutionScheduleDTO::Immediate), @@ -621,7 +647,7 @@ pub fn create_icp_account(env: &PocketIc, station_id: Principal, user_id: UuidDT let res: (ApiResult,) = update_candid_as( env, station_id, - WALLET_ADMIN_USER, + requester, "create_request", (add_account_request,), ) @@ -651,7 +677,7 @@ pub fn create_icp_account(env: &PocketIc, station_id: Principal, user_id: UuidDT let res: (ApiResult,) = update_candid_as( env, station_id, - WALLET_ADMIN_USER, + requester, "get_request", (get_request_args,), ) @@ -677,6 +703,138 @@ pub fn create_icp_account(env: &PocketIc, station_id: Principal, user_id: UuidDT } } +pub fn create_transfer( + env: &PocketIc, + station_id: Principal, + requester: Principal, + input: station_api::TransferOperationInput, +) -> station_api::TransferDTO { + // make transfer request to beneficiary + + let transfer_request = CreateRequestInput { + operation: RequestOperationInput::Transfer(input), + title: None, + summary: None, + expiration_dt: None, + execution_plan: Some(RequestExecutionScheduleDTO::Immediate), + }; + let res: (Result,) = update_candid_as( + env, + station_id, + requester, + "create_request", + (transfer_request,), + ) + .unwrap(); + let request_dto = res.0.unwrap().request; + + // wait for the request to be approved (timer's period is 5 seconds) + env.advance_time(Duration::from_secs(5)); + env.tick(); + // wait for the request to be processing (timer's period is 5 seconds) and first is set to processing + env.advance_time(Duration::from_secs(5)); + env.tick(); + env.tick(); + env.tick(); + env.advance_time(Duration::from_secs(5)); + env.tick(); + env.tick(); + env.tick(); + + // check transfer request status + let get_request_args = GetRequestInput { + request_id: request_dto.id.clone(), + with_full_info: Some(false), + }; + let res: (Result,) = update_candid_as( + env, + station_id, + requester, + "get_request", + (get_request_args,), + ) + .unwrap(); + let new_request_dto = res.0.unwrap().request; + match new_request_dto.status { + RequestStatusDTO::Completed { .. } => {} + _ => { + panic!( + "request must be completed by now but instead is {:?}", + new_request_dto.status + ); + } + }; + + // request has the transfer id filled out + let transfer_id = match new_request_dto.operation { + RequestOperationDTO::Transfer(transfer) => transfer + .transfer_id + .expect("transfer id must be set for completed transfer"), + _ => { + panic!("request must be Transfer"); + } + }; + + // fetch the transfer and check if its request id matches the request id that created it + let res: (Result,) = query_candid_as( + env, + station_id, + requester, + "get_transfers", + (GetTransfersInput { + transfer_ids: vec![transfer_id], + },), + ) + .expect("Failed to send query call"); + + res.0 + .expect("Failed to get transfers") + .transfers + .first() + .expect("no transfer in result") + .clone() +} + +pub fn fetch_account_balances( + env: &PocketIc, + station_canister_id: Principal, + requester: Principal, + input: FetchAccountBalancesInput, +) -> station_api::FetchAccountBalancesResponse { + update_candid_as::<(FetchAccountBalancesInput,), (ApiResult,)>( + env, + station_canister_id, + requester, + "fetch_account_balances", + (input,), + ) + .expect("Failed to send query call") + .0 + .expect("Failed to get account balances") +} + +pub fn get_icp_asset( + env: &PocketIc, + station_canister_id: Principal, + requester: Principal, +) -> station_api::AssetDTO { + list_assets(env, station_canister_id, requester) + .expect("Failed to query list_assets") + .0 + .expect("Failed to list assets") + .assets + .into_iter() + .find(|asset| asset.symbol == "ICP") + .expect("Failed to find ICP asset") +} + +pub fn get_icp_account_identifier(addresses: &[station_api::AccountAddressDTO]) -> Option { + addresses + .iter() + .find(|a| a.format == "icp_account_identifier") + .map(|a| a.address.clone()) +} + /// Compresses the given data to a gzip format. pub fn compress_to_gzip(data: &[u8]) -> Vec { let mut encoder = GzEncoder::new(Vec::new(), Compression::best()); @@ -742,6 +900,7 @@ pub fn upload_canister_modules(env: &PocketIc, control_panel_id: Principal, cont res.0.unwrap(); // upload station + let station_wasm = get_canister_wasm("station"); let (base_chunk, module_extra_chunks) = upload_canister_chunks_to_asset_canister(env, station_wasm, 500_000); @@ -936,3 +1095,16 @@ pub(crate) fn add_external_canister_call_any_method_permission_and_approval( ) .expect("Failed to add approval policy to call external canister"); } + +pub fn expect_await_call_result(result: WasmResult) -> T +where + T: for<'a> ArgumentDecoder<'a>, +{ + match result { + WasmResult::Reply(vec) => { + let result: T = decode_args(&vec).expect("Failed to decode result"); + result + } + WasmResult::Reject(error) => panic!("Unexpected reject: {error}"), + } +} diff --git a/tools/dfx-orbit/src/me.rs b/tools/dfx-orbit/src/me.rs index d2dc35d85..97db0e04f 100644 --- a/tools/dfx-orbit/src/me.rs +++ b/tools/dfx-orbit/src/me.rs @@ -84,5 +84,7 @@ fn display_privilege(privilege: &UserPrivilege) -> &'static str { UserPrivilege::CreateExternalCanister => "CreateExternalCanister", UserPrivilege::ListExternalCanisters => "ListExternalCanisters", UserPrivilege::CallAnyExternalCanister => "CallAnyExternalCanister", + UserPrivilege::AddAsset => "AddAsset", + UserPrivilege::ListAssets => "ListAssets", } } diff --git a/tools/dfx-orbit/src/review/display.rs b/tools/dfx-orbit/src/review/display.rs index b0a1648d2..1c3d4a0ba 100644 --- a/tools/dfx-orbit/src/review/display.rs +++ b/tools/dfx-orbit/src/review/display.rs @@ -241,6 +241,9 @@ pub(super) fn display_request_operation(op: &RequestOperationDTO) -> &'static st RequestOperationDTO::EditRequestPolicy(_) => "EditRequestPolicy", RequestOperationDTO::RemoveRequestPolicy(_) => "RemoveRequestPolicy", RequestOperationDTO::ManageSystemInfo(_) => "ManageSystemInfo", + RequestOperationDTO::AddAsset(_) => "AddAsset", + RequestOperationDTO::EditAsset(_) => "EditAsset", + RequestOperationDTO::RemoveAsset(_) => "RemoveAsset", } }