From 25037079a7a0f8364f77c00c50f4443297f43d58 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:46:19 +0800 Subject: [PATCH 001/384] Initial commit --- merged-packages/stellar-wallet-snap/README.md | 12 +++++ .../stellar-wallet-snap/jest.config.js | 6 +++ .../stellar-wallet-snap/package.json | 52 +++++++++++++++++++ .../stellar-wallet-snap/snap.config.ts | 14 +++++ .../stellar-wallet-snap/snap.manifest.json | 28 ++++++++++ .../stellar-wallet-snap/src/index.test.tsx | 51 ++++++++++++++++++ .../stellar-wallet-snap/src/index.tsx | 43 +++++++++++++++ .../stellar-wallet-snap/tsconfig.json | 9 ++++ 8 files changed, 215 insertions(+) create mode 100644 merged-packages/stellar-wallet-snap/README.md create mode 100644 merged-packages/stellar-wallet-snap/jest.config.js create mode 100644 merged-packages/stellar-wallet-snap/package.json create mode 100644 merged-packages/stellar-wallet-snap/snap.config.ts create mode 100644 merged-packages/stellar-wallet-snap/snap.manifest.json create mode 100644 merged-packages/stellar-wallet-snap/src/index.test.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/index.tsx create mode 100644 merged-packages/stellar-wallet-snap/tsconfig.json diff --git a/merged-packages/stellar-wallet-snap/README.md b/merged-packages/stellar-wallet-snap/README.md new file mode 100644 index 00000000..c3601a1f --- /dev/null +++ b/merged-packages/stellar-wallet-snap/README.md @@ -0,0 +1,12 @@ +# TypeScript Example Snap + +This snap demonstrates how to develop a snap with TypeScript. It is a simple +snap that displays a confirmation dialog when the `hello` JSON-RPC method is +called. + +## Testing + +The snap comes with some basic tests, to demonstrate how to write tests for +snaps. To test the snap, run `yarn test` in this directory. This will use +[`@metamask/snaps-jest`](https://github.com/MetaMask/snaps/tree/main/packages/snaps-jest) +to run the tests in `src/index.test.ts`. diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js new file mode 100644 index 00000000..f0a22c3e --- /dev/null +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: '@metamask/snaps-jest', + transform: { + '^.+\\.(t|j)sx?$': 'ts-jest', + }, +}; diff --git a/merged-packages/stellar-wallet-snap/package.json b/merged-packages/stellar-wallet-snap/package.json new file mode 100644 index 00000000..2fd3714c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/package.json @@ -0,0 +1,52 @@ +{ + "name": "snap", + "version": "0.1.0", + "description": "The 'Hello, world!' of MetaMask Snaps, now written in TypeScript.", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/template-snap-monorepo.git" + }, + "license": "(MIT-0 OR Apache-2.0)", + "main": "./dist/bundle.js", + "files": [ + "dist/", + "snap.manifest.json" + ], + "scripts": { + "allow-scripts": "yarn workspace root allow-scripts", + "build": "mm-snap build", + "build:clean": "yarn clean && yarn build", + "clean": "rimraf dist", + "lint": "yarn lint:eslint && yarn lint:misc --check", + "lint:eslint": "eslint . --cache --ext js,ts", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", + "lint:misc": "prettier '**/*.json' '**/*.md' '**/*.yml' '!.yarnrc.yml' --ignore-path ../../.gitignore --no-error-on-unmatched-pattern", + "prepublishOnly": "mm-snap manifest", + "serve": "mm-snap serve", + "start": "mm-snap watch", + "test": "jest" + }, + "dependencies": { + "@metamask/snaps-sdk": "~10.3.0" + }, + "devDependencies": { + "@jest/globals": "^29.5.0", + "@metamask/snaps-cli": "^8.3.0", + "@metamask/snaps-jest": "^9.8.0", + "@types/react": "18.2.4", + "@types/react-dom": "18.2.4", + "eslint": "^9.11.0", + "jest": "^29.5.0", + "rimraf": "^3.0.2", + "ts-jest": "^29.1.0", + "typescript": "~5.7.3" + }, + "packageManager": "yarn@3.2.1", + "engines": { + "node": ">=18.6.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/merged-packages/stellar-wallet-snap/snap.config.ts b/merged-packages/stellar-wallet-snap/snap.config.ts new file mode 100644 index 00000000..b10d7b52 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/snap.config.ts @@ -0,0 +1,14 @@ +import type { SnapConfig } from '@metamask/snaps-cli'; +import { resolve } from 'path'; + +const config: SnapConfig = { + input: resolve(__dirname, 'src/index.tsx'), + server: { + port: 8080, + }, + polyfills: { + buffer: true, + }, +}; + +export default config; diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json new file mode 100644 index 00000000..b128cc7d --- /dev/null +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -0,0 +1,28 @@ +{ + "version": "0.1.0", + "description": "An example Snap written in TypeScript.", + "proposedName": "TypeScript Example", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/template-snap-monorepo.git" + }, + "source": { + "shasum": "r7Mf2iZsV0U3aSQIy9eTnY8KIe0Nv90t0q87Cfju4pQ=", + "location": { + "npm": { + "filePath": "dist/bundle.js", + "packageName": "snap", + "registry": "https://registry.npmjs.org/" + } + } + }, + "initialPermissions": { + "snap_dialog": {}, + "endowment:rpc": { + "dapps": true, + "snaps": false + } + }, + "platformVersion": "10.3.0", + "manifestVersion": "0.1" +} diff --git a/merged-packages/stellar-wallet-snap/src/index.test.tsx b/merged-packages/stellar-wallet-snap/src/index.test.tsx new file mode 100644 index 00000000..beb201cd --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/index.test.tsx @@ -0,0 +1,51 @@ +import { expect } from '@jest/globals'; +import type { SnapConfirmationInterface } from '@metamask/snaps-jest'; +import { installSnap } from '@metamask/snaps-jest'; +import { Box, Text, Bold } from '@metamask/snaps-sdk/jsx'; + +describe('onRpcRequest', () => { + describe('hello', () => { + it('shows a confirmation dialog', async () => { + const { request } = await installSnap(); + + const origin = 'Jest'; + const response = request({ + method: 'hello', + origin, + }); + + const ui = (await response.getInterface()) as SnapConfirmationInterface; + expect(ui.type).toBe('confirmation'); + expect(ui).toRender( + + + Hello, {origin}! + + This custom confirmation is just for display purposes. + + But you can edit the snap source code to make it do something, if + you want to! + + , + ); + + await ui.ok(); + + expect(await response).toRespondWith(true); + }); + }); + + it('throws an error if the requested method does not exist', async () => { + const { request } = await installSnap(); + + const response = await request({ + method: 'foo', + }); + + expect(response).toRespondWithError({ + code: -32603, + message: 'Method not found.', + stack: expect.any(String), + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/index.tsx b/merged-packages/stellar-wallet-snap/src/index.tsx new file mode 100644 index 00000000..250f258e --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/index.tsx @@ -0,0 +1,43 @@ +import type { OnRpcRequestHandler } from '@metamask/snaps-sdk'; +import { Box, Text, Bold } from '@metamask/snaps-sdk/jsx'; + +/** + * Handle incoming JSON-RPC requests, sent through `wallet_invokeSnap`. + * + * @param args - The request handler args as object. + * @param args.origin - The origin of the request, e.g., the website that + * invoked the snap. + * @param args.request - A validated JSON-RPC request object. + * @returns The result of `snap_dialog`. + * @throws If the request method is not valid for this snap. + */ +export const onRpcRequest: OnRpcRequestHandler = async ({ + origin, + request, +}) => { + switch (request.method) { + case 'hello': + return snap.request({ + method: 'snap_dialog', + params: { + type: 'confirmation', + content: ( + + + Hello, {origin}! + + + This custom confirmation is just for display purposes. + + + But you can edit the snap source code to make it do something, + if you want to! + + + ), + }, + }); + default: + throw new Error('Method not found.'); + } +}; diff --git a/merged-packages/stellar-wallet-snap/tsconfig.json b/merged-packages/stellar-wallet-snap/tsconfig.json new file mode 100644 index 00000000..ef0c834a --- /dev/null +++ b/merged-packages/stellar-wallet-snap/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "jsx": "react-jsx", + "jsxImportSource": "@metamask/snaps-sdk" + }, + "include": ["**/*.ts", "**/*.tsx"] +} From 9852db73d149c08635364ac130e2fc5686108cde Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:33:16 +0800 Subject: [PATCH 002/384] chore: update package for snap and site --- .../stellar-wallet-snap/.depcheckrc.json | 3 + .../stellar-wallet-snap/.env.example | 5 ++ .../stellar-wallet-snap/.prettierignore | 2 + .../stellar-wallet-snap/CHANGELOG.md | 10 +++ merged-packages/stellar-wallet-snap/README.md | 33 +++++--- .../stellar-wallet-snap/babel.config.js | 6 ++ .../stellar-wallet-snap/images/icon.svg | 1 + .../stellar-wallet-snap/jest.config.js | 42 +++++++++- .../jest.integration.config.js | 10 +++ .../stellar-wallet-snap/jest.setup.ts | 7 ++ .../stellar-wallet-snap/locales/en.json | 4 + .../stellar-wallet-snap/messages.json | 1 + .../stellar-wallet-snap/package.json | 77 ++++++++++++------- .../scripts/build-preinstalled-snap.js | 76 ++++++++++++++++++ .../scripts/populate-en-locale.js | 26 +++++++ .../scripts/update-manifest-local.js | 56 ++++++++++++++ .../stellar-wallet-snap/snap.config.ts | 10 ++- .../stellar-wallet-snap/snap.manifest.json | 38 ++++++--- .../stellar-wallet-snap/src/index.test.tsx | 51 ------------ .../stellar-wallet-snap/src/index.ts | 6 ++ .../stellar-wallet-snap/src/index.tsx | 43 ----------- .../stellar-wallet-snap/tsconfig.json | 10 ++- 22 files changed, 366 insertions(+), 151 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/.depcheckrc.json create mode 100644 merged-packages/stellar-wallet-snap/.env.example create mode 100644 merged-packages/stellar-wallet-snap/.prettierignore create mode 100644 merged-packages/stellar-wallet-snap/CHANGELOG.md create mode 100644 merged-packages/stellar-wallet-snap/babel.config.js create mode 100644 merged-packages/stellar-wallet-snap/images/icon.svg create mode 100644 merged-packages/stellar-wallet-snap/jest.integration.config.js create mode 100644 merged-packages/stellar-wallet-snap/jest.setup.ts create mode 100644 merged-packages/stellar-wallet-snap/locales/en.json create mode 100644 merged-packages/stellar-wallet-snap/messages.json create mode 100644 merged-packages/stellar-wallet-snap/scripts/build-preinstalled-snap.js create mode 100644 merged-packages/stellar-wallet-snap/scripts/populate-en-locale.js create mode 100644 merged-packages/stellar-wallet-snap/scripts/update-manifest-local.js delete mode 100644 merged-packages/stellar-wallet-snap/src/index.test.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/index.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/index.tsx diff --git a/merged-packages/stellar-wallet-snap/.depcheckrc.json b/merged-packages/stellar-wallet-snap/.depcheckrc.json new file mode 100644 index 00000000..c31d8118 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/.depcheckrc.json @@ -0,0 +1,3 @@ +{ + "ignores": ["jest-transform-stub", "ts-jest", "@metamask/auto-changelog"] +} diff --git a/merged-packages/stellar-wallet-snap/.env.example b/merged-packages/stellar-wallet-snap/.env.example new file mode 100644 index 00000000..40eef231 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/.env.example @@ -0,0 +1,5 @@ +# Use: +# - local for local development +# - test for running tests locally (mandatory) +# - production before submitting a PR +ENVIRONMENT=local diff --git a/merged-packages/stellar-wallet-snap/.prettierignore b/merged-packages/stellar-wallet-snap/.prettierignore new file mode 100644 index 00000000..a60030e3 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/.prettierignore @@ -0,0 +1,2 @@ +dist/ +coverage/ diff --git a/merged-packages/stellar-wallet-snap/CHANGELOG.md b/merged-packages/stellar-wallet-snap/CHANGELOG.md new file mode 100644 index 00000000..a5cdea1f --- /dev/null +++ b/merged-packages/stellar-wallet-snap/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/snap-stellar-wallet/ diff --git a/merged-packages/stellar-wallet-snap/README.md b/merged-packages/stellar-wallet-snap/README.md index c3601a1f..006e466c 100644 --- a/merged-packages/stellar-wallet-snap/README.md +++ b/merged-packages/stellar-wallet-snap/README.md @@ -1,12 +1,27 @@ -# TypeScript Example Snap +# Stellar Snap -This snap demonstrates how to develop a snap with TypeScript. It is a simple -snap that displays a confirmation dialog when the `hello` JSON-RPC method is -called. +## Configuration -## Testing +Rename `.env.example` to `.env` +Configurations are setup though `.env`, -The snap comes with some basic tests, to demonstrate how to write tests for -snaps. To test the snap, run `yarn test` in this directory. This will use -[`@metamask/snaps-jest`](https://github.com/MetaMask/snaps/tree/main/packages/snaps-jest) -to run the tests in `src/index.test.ts`. +## API: + +### `keyring_createAccount` + +example: + +```typescript +provider.request({ + method: 'wallet_invokeKeyring', + params: { + snapId, + request: { + method: 'keyring_createAccount', + params: { + scope: 'stellar:pubnet', // the CAIP-2 chain ID of the network + }, + }, + }, +}); +``` diff --git a/merged-packages/stellar-wallet-snap/babel.config.js b/merged-packages/stellar-wallet-snap/babel.config.js new file mode 100644 index 00000000..8165fe45 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/babel.config.js @@ -0,0 +1,6 @@ +module.exports = { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + '@babel/preset-typescript', + ], +}; diff --git a/merged-packages/stellar-wallet-snap/images/icon.svg b/merged-packages/stellar-wallet-snap/images/icon.svg new file mode 100644 index 00000000..02afb7b7 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/images/icon.svg @@ -0,0 +1 @@ +Asset 1 \ No newline at end of file diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index f0a22c3e..899bfb07 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -1,6 +1,46 @@ -module.exports = { +// @ts-check +/** + * @type {import('ts-jest').JestConfigWithTsJest} + */ +const config = { + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + collectCoverageFrom: ['./src/**/*.ts', './src/**/*.tsx'], + + // The directory where Jest should output its coverage files + coverageDirectory: 'coverage', + + // An array of regexp pattern strings used to skip coverage collection + coveragePathIgnorePatterns: ['.*/index\\.ts'], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: 'babel', + + // A list of reporter names that Jest uses when writing coverage reports + coverageReporters: ['text', 'html', 'json-summary', 'lcov'], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 52.58, + functions: 55.53, + lines: 67.34, + statements: 67.41, + }, + }, + preset: '@metamask/snaps-jest', transform: { '^.+\\.(t|j)sx?$': 'ts-jest', }, + moduleNameMapper: { + '\\.svg$': 'jest-transform-stub', + }, + resetMocks: true, + testMatch: ['**/src/**/?(*.)+(spec|test).[tj]s?(x)'], + setupFilesAfterEnv: ['/jest.setup.ts'], }; + +module.exports = config; diff --git a/merged-packages/stellar-wallet-snap/jest.integration.config.js b/merged-packages/stellar-wallet-snap/jest.integration.config.js new file mode 100644 index 00000000..c36c839e --- /dev/null +++ b/merged-packages/stellar-wallet-snap/jest.integration.config.js @@ -0,0 +1,10 @@ +// @ts-check +/** + * @type {import('ts-jest').JestConfigWithTsJest} + */ +const config = { + preset: '@metamask/snaps-jest', + testMatch: ['**/integration-test/**/*.test.ts'], +}; + +export default config; diff --git a/merged-packages/stellar-wallet-snap/jest.setup.ts b/merged-packages/stellar-wallet-snap/jest.setup.ts new file mode 100644 index 00000000..7adb2fb2 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/jest.setup.ts @@ -0,0 +1,7 @@ +import { config } from 'dotenv'; + +config(); + +// Set default environment for tests if not already set +// eslint-disable-next-line no-restricted-globals +process.env.ENVIRONMENT ??= 'test'; diff --git a/merged-packages/stellar-wallet-snap/locales/en.json b/merged-packages/stellar-wallet-snap/locales/en.json new file mode 100644 index 00000000..72aa3b77 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/locales/en.json @@ -0,0 +1,4 @@ +{ + "locale": "en", + "messages": {} +} diff --git a/merged-packages/stellar-wallet-snap/messages.json b/merged-packages/stellar-wallet-snap/messages.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/messages.json @@ -0,0 +1 @@ +{} diff --git a/merged-packages/stellar-wallet-snap/package.json b/merged-packages/stellar-wallet-snap/package.json index 2fd3714c..07150347 100644 --- a/merged-packages/stellar-wallet-snap/package.json +++ b/merged-packages/stellar-wallet-snap/package.json @@ -1,49 +1,68 @@ { - "name": "snap", - "version": "0.1.0", - "description": "The 'Hello, world!' of MetaMask Snaps, now written in TypeScript.", + "name": "@metamask/stellar-wallet-snap", + "version": "0.0.1", + "description": "A Stellar wallet Snap.", "repository": { "type": "git", - "url": "https://github.com/MetaMask/template-snap-monorepo.git" + "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "license": "(MIT-0 OR Apache-2.0)", "main": "./dist/bundle.js", "files": [ "dist/", - "snap.manifest.json" + "images/", + "snap.manifest.json", + "locales/" ], "scripts": { "allow-scripts": "yarn workspace root allow-scripts", - "build": "mm-snap build", - "build:clean": "yarn clean && yarn build", + "build": "mm-snap build && yarn build:locale && yarn build-preinstalled-snap", + "build:dev": "ENVIRONMENT=local node scripts/update-manifest-local.js && mm-snap build && yarn build:locale", + "build:prod": "ENVIRONMENT=production node scripts/update-manifest-local.js && mm-snap build && yarn build:locale && yarn build-preinstalled-snap", + "build-preinstalled-snap": "node scripts/build-preinstalled-snap.js", + "build:clean": "yarn clean && yarn build:locale && yarn build", + "build:locale": "node ./scripts/populate-en-locale.js && prettier 'locales/**/*.json' -w", + "build:locale:watch": "npx nodemon --watch packages/snap/messages.json --exec \"node ./scripts/populate-en-locale.js && prettier 'locales/**/*.json' -w\"", + "changelog:update": "../../scripts/update-changelog.sh @metamask/stellar-wallet-snap", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/stellar-wallet-snap", "clean": "rimraf dist", - "lint": "yarn lint:eslint && yarn lint:misc --check", - "lint:eslint": "eslint . --cache --ext js,ts", + "lint": "yarn lint:eslint && yarn lint:misc && yarn lint:deps && yarn lint:types", + "lint:deps": "depcheck && yarn dedupe --check", + "lint:deps:fix": "depcheck && yarn dedupe", + "lint:eslint": "eslint . --cache --ext js,jsx,ts,tsx", "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", - "lint:misc": "prettier '**/*.json' '**/*.md' '**/*.yml' '!.yarnrc.yml' --ignore-path ../../.gitignore --no-error-on-unmatched-pattern", + "lint:misc": "prettier '**/*.json' '**/*.md' --check", + "lint:types": "tsc --noEmit", + "format": "prettier '**/*.ts' '**/*.tsx' --write", "prepublishOnly": "mm-snap manifest", + "publish:preview": "yarn npm publish --tag preview", "serve": "mm-snap serve", - "start": "mm-snap watch", - "test": "jest" - }, - "dependencies": { - "@metamask/snaps-sdk": "~10.3.0" + "start": "node scripts/update-manifest-local.js && concurrently \"mm-snap watch\" \"yarn build:locale:watch\"", + "test": "jest --passWithNoTests && yarn jest-it-up", + "test:integration": "./integration-test/run-integration.sh" }, "devDependencies": { - "@jest/globals": "^29.5.0", - "@metamask/snaps-cli": "^8.3.0", - "@metamask/snaps-jest": "^9.8.0", - "@types/react": "18.2.4", - "@types/react-dom": "18.2.4", - "eslint": "^9.11.0", - "jest": "^29.5.0", - "rimraf": "^3.0.2", - "ts-jest": "^29.1.0", - "typescript": "~5.7.3" - }, - "packageManager": "yarn@3.2.1", - "engines": { - "node": ">=18.6.0" + "@metamask/auto-changelog": "^3.4.4", + "@metamask/key-tree": "^10.1.1", + "@metamask/keyring-api": "^21.4.0", + "@metamask/keyring-snap-sdk": "^7.2.0", + "@metamask/snaps-cli": "^8.4.0", + "@metamask/snaps-jest": "^10.1.0", + "@metamask/snaps-sdk": "^10.4.0", + "@metamask/superstruct": "^3.2.1", + "@metamask/utils": "^11.10.0", + "@types/jest": "^30.0.0", + "@types/lodash": "^4.17.20", + "async-mutex": "^0.5.0", + "bignumber.js": "^9.3.1", + "concurrently": "^9.2.0", + "dotenv": "^17.0.0", + "jest": "^30.0.3", + "jest-it-up": "^2.0.2", + "jest-transform-stub": "2.0.0", + "lodash": "^4.17.21", + "prettier": "^3.5.3", + "ts-jest": "^29.4.0" }, "publishConfig": { "access": "public", diff --git a/merged-packages/stellar-wallet-snap/scripts/build-preinstalled-snap.js b/merged-packages/stellar-wallet-snap/scripts/build-preinstalled-snap.js new file mode 100644 index 00000000..cb4517eb --- /dev/null +++ b/merged-packages/stellar-wallet-snap/scripts/build-preinstalled-snap.js @@ -0,0 +1,76 @@ +// @ts-check + +const { readFileSync, writeFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const packageFile = require('../package.json'); + +console.log('[preinstalled-snap] - attempt to build preinstalled snap'); + +/** + * Read the contents of a file and return as a string. + * @param {string} filePath - Path to file. + * @returns {string} File as utf-8 string. + */ +function readFileContents(filePath) { + try { + return readFileSync(filePath, 'utf8'); + } catch (error) { + console.error(`Error reading file from disk: ${filePath}`, error); + throw error; + } +} + +// Paths to the files +const bundlePath = require.resolve('../dist/bundle.js'); +const iconPath = require.resolve('../images/icon.svg'); +const manifestPath = require.resolve('../snap.manifest.json'); +const englishLocalePath = require.resolve('../locales/en.json'); + +// File Contents +const bundle = readFileContents(bundlePath); +const icon = readFileContents(iconPath); +const manifest = readFileContents(manifestPath); +const englishLocale = readFileContents(englishLocalePath); + +const snapId = + /** @type {import('@metamask/snaps-controllers').PreinstalledSnap['snapId']} */ ( + `npm:${packageFile.name}` + ); + +/** + * @type {import('@metamask/snaps-controllers').PreinstalledSnap} + */ +const preinstalledSnap = { + snapId, + manifest: JSON.parse(manifest), + files: [ + { + path: 'images/icon.svg', + value: icon, + }, + { + path: 'dist/bundle.js', + value: bundle, + }, + { + path: 'locales/en.json', + value: englishLocale, + }, + ], + removable: false, + hideSnapBranding: true, +}; + +// Write preinstalled-snap file +try { + const outputPath = join(__dirname, '..', 'dist/preinstalled-snap.json'); + writeFileSync(outputPath, JSON.stringify(preinstalledSnap, null, 0)); + + console.log( + `[preinstalled-snap] - successfully created preinstalled snap at ${outputPath}`, + ); +} catch (error) { + console.error('Error writing combined file to disk:', error); + throw error; +} diff --git a/merged-packages/stellar-wallet-snap/scripts/populate-en-locale.js b/merged-packages/stellar-wallet-snap/scripts/populate-en-locale.js new file mode 100644 index 00000000..e3f1ee6c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/scripts/populate-en-locale.js @@ -0,0 +1,26 @@ +const { writeFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const messages = require('../messages.json'); + +console.log('[populate-en-locale] - attempt to populate en locale'); + +const englishLocale = { + locale: 'en', + messages: Object.entries(messages).reduce((acc, [key, { message }]) => { + acc[key] = { message }; + return acc; + }, {}), +}; + +// Write en locale file +try { + writeFileSync( + join(__dirname, '../locales/en.json'), + JSON.stringify(englishLocale, null, 2), + ); + console.log('[populate-en-locale] - en locale populated'); +} catch (error) { + console.error('Error writing en locale file', error); + throw error; +} diff --git a/merged-packages/stellar-wallet-snap/scripts/update-manifest-local.js b/merged-packages/stellar-wallet-snap/scripts/update-manifest-local.js new file mode 100644 index 00000000..0bb2fbbb --- /dev/null +++ b/merged-packages/stellar-wallet-snap/scripts/update-manifest-local.js @@ -0,0 +1,56 @@ +const fs = require('fs'); +const path = require('path'); +require('dotenv').config(); + +const manifestPath = path.join(__dirname, '..', 'snap.manifest.json'); +const environment = process.env.ENVIRONMENT || 'local'; +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + +if (environment === 'local' || environment === 'test') { + manifest.initialConnections['http://localhost:3000'] = {}; + if (manifest.initialPermissions?.['endowment:keyring']?.allowedOrigins) { + if (!manifest.initialPermissions['endowment:keyring'].allowedOrigins.includes('http://localhost:3000')) { + manifest.initialPermissions['endowment:keyring'].allowedOrigins.push('http://localhost:3000'); + } + } + + // Add endowment:rpc permission for local/dev mode + manifest.initialPermissions['endowment:rpc'] = { + dapps: true, + snaps: false + }; + + console.log('Added localhost entries and endowment:rpc to snap.manifest.json for local development'); +} else { + // Production mode - remove local-only settings + let changed = false; + + // Remove localhost from initialConnections + if (manifest.initialConnections?.['http://localhost:3000']) { + delete manifest.initialConnections['http://localhost:3000']; + changed = true; + } + + // Remove localhost from keyring allowedOrigins + if (manifest.initialPermissions?.['endowment:keyring']?.allowedOrigins) { + const origins = manifest.initialPermissions['endowment:keyring'].allowedOrigins; + const index = origins.indexOf('http://localhost:3000'); + if (index > -1) { + origins.splice(index, 1); + changed = true; + } + } + + // Remove endowment:rpc permission + if (manifest.initialPermissions?.['endowment:rpc']) { + delete manifest.initialPermissions['endowment:rpc']; + changed = true; + } + + if (changed) { + console.log('Removed local-only settings from snap.manifest.json for production'); + } +} + +fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + diff --git a/merged-packages/stellar-wallet-snap/snap.config.ts b/merged-packages/stellar-wallet-snap/snap.config.ts index b10d7b52..044c5029 100644 --- a/merged-packages/stellar-wallet-snap/snap.config.ts +++ b/merged-packages/stellar-wallet-snap/snap.config.ts @@ -1,14 +1,18 @@ import type { SnapConfig } from '@metamask/snaps-cli'; +import { config as dotenv } from 'dotenv'; import { resolve } from 'path'; +dotenv(); + const config: SnapConfig = { - input: resolve(__dirname, 'src/index.tsx'), + input: resolve(__dirname, 'src/index.ts'), server: { port: 8080, }, - polyfills: { - buffer: true, + environment: { + ENVIRONMENT: process.env.ENVIRONMENT ?? '', }, + polyfills: true, }; export default config; diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index b128cc7d..a87dd682 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -1,27 +1,43 @@ { - "version": "0.1.0", - "description": "An example Snap written in TypeScript.", - "proposedName": "TypeScript Example", + "version": "0.0.1", + "description": "Manage Stellar using MetaMask", + "proposedName": "Stellar", "repository": { "type": "git", - "url": "https://github.com/MetaMask/template-snap-monorepo.git" + "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "r7Mf2iZsV0U3aSQIy9eTnY8KIe0Nv90t0q87Cfju4pQ=", + "shasum": "nP7hWeBvo8PpYOScT22irYViI3TXzn9bXkk8kdRPcL0=", "location": { "npm": { "filePath": "dist/bundle.js", - "packageName": "snap", + "iconPath": "images/icon.svg", + "packageName": "@metamask/stellar-wallet-snap", "registry": "https://registry.npmjs.org/" } - } + }, + "locales": ["locales/en.json"] + }, + "initialConnections": { + "https://portfolio.metamask.io": {} }, "initialPermissions": { + "snap_getBip32Entropy": [ + { + "path": ["m", "44'", "148'"], + "curve": "ed25519" + } + ], + "snap_getBip44Entropy": [ + { + "coinType": 148 + } + ], + "endowment:network-access": {}, + "snap_manageAccounts": {}, + "snap_manageState": {}, "snap_dialog": {}, - "endowment:rpc": { - "dapps": true, - "snaps": false - } + "snap_getPreferences": {} }, "platformVersion": "10.3.0", "manifestVersion": "0.1" diff --git a/merged-packages/stellar-wallet-snap/src/index.test.tsx b/merged-packages/stellar-wallet-snap/src/index.test.tsx deleted file mode 100644 index beb201cd..00000000 --- a/merged-packages/stellar-wallet-snap/src/index.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { expect } from '@jest/globals'; -import type { SnapConfirmationInterface } from '@metamask/snaps-jest'; -import { installSnap } from '@metamask/snaps-jest'; -import { Box, Text, Bold } from '@metamask/snaps-sdk/jsx'; - -describe('onRpcRequest', () => { - describe('hello', () => { - it('shows a confirmation dialog', async () => { - const { request } = await installSnap(); - - const origin = 'Jest'; - const response = request({ - method: 'hello', - origin, - }); - - const ui = (await response.getInterface()) as SnapConfirmationInterface; - expect(ui.type).toBe('confirmation'); - expect(ui).toRender( - - - Hello, {origin}! - - This custom confirmation is just for display purposes. - - But you can edit the snap source code to make it do something, if - you want to! - - , - ); - - await ui.ok(); - - expect(await response).toRespondWith(true); - }); - }); - - it('throws an error if the requested method does not exist', async () => { - const { request } = await installSnap(); - - const response = await request({ - method: 'foo', - }); - - expect(response).toRespondWithError({ - code: -32603, - message: 'Method not found.', - stack: expect.any(String), - }); - }); -}); diff --git a/merged-packages/stellar-wallet-snap/src/index.ts b/merged-packages/stellar-wallet-snap/src/index.ts new file mode 100644 index 00000000..ca766ac6 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/index.ts @@ -0,0 +1,6 @@ +import type { OnRpcRequestHandler } from '@metamask/snaps-sdk'; +import { MethodNotFoundError } from '@metamask/snaps-sdk'; + +export const onRpcRequest: OnRpcRequestHandler = async () => { + throw new MethodNotFoundError() as Error; +}; diff --git a/merged-packages/stellar-wallet-snap/src/index.tsx b/merged-packages/stellar-wallet-snap/src/index.tsx deleted file mode 100644 index 250f258e..00000000 --- a/merged-packages/stellar-wallet-snap/src/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { OnRpcRequestHandler } from '@metamask/snaps-sdk'; -import { Box, Text, Bold } from '@metamask/snaps-sdk/jsx'; - -/** - * Handle incoming JSON-RPC requests, sent through `wallet_invokeSnap`. - * - * @param args - The request handler args as object. - * @param args.origin - The origin of the request, e.g., the website that - * invoked the snap. - * @param args.request - A validated JSON-RPC request object. - * @returns The result of `snap_dialog`. - * @throws If the request method is not valid for this snap. - */ -export const onRpcRequest: OnRpcRequestHandler = async ({ - origin, - request, -}) => { - switch (request.method) { - case 'hello': - return snap.request({ - method: 'snap_dialog', - params: { - type: 'confirmation', - content: ( - - - Hello, {origin}! - - - This custom confirmation is just for display purposes. - - - But you can edit the snap source code to make it do something, - if you want to! - - - ), - }, - }); - default: - throw new Error('Method not found.'); - } -}; diff --git a/merged-packages/stellar-wallet-snap/tsconfig.json b/merged-packages/stellar-wallet-snap/tsconfig.json index ef0c834a..99b45e34 100644 --- a/merged-packages/stellar-wallet-snap/tsconfig.json +++ b/merged-packages/stellar-wallet-snap/tsconfig.json @@ -1,9 +1,11 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.packages.json", "compilerOptions": { - "baseUrl": "./", + "resolveJsonModule": true /* lets us import JSON modules from within TypeScript modules. */, "jsx": "react-jsx", - "jsxImportSource": "@metamask/snaps-sdk" + "jsxImportSource": "@metamask/snaps-sdk", + "exactOptionalPropertyTypes": false, + "types": ["jest"] }, - "include": ["**/*.ts", "**/*.tsx"] + "include": ["**/*.ts", "**/*.tsx", "locales/*.json"] } From 21bf4ff502693c042d06d5304b60af173b8215c0 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:43:12 +0800 Subject: [PATCH 003/384] chore: add change log --- merged-packages/stellar-wallet-snap/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/merged-packages/stellar-wallet-snap/CHANGELOG.md b/merged-packages/stellar-wallet-snap/CHANGELOG.md index a5cdea1f..7ea018e0 100644 --- a/merged-packages/stellar-wallet-snap/CHANGELOG.md +++ b/merged-packages/stellar-wallet-snap/CHANGELOG.md @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Initial config for Stellar Snap + [Unreleased]: https://github.com/MetaMask/snap-stellar-wallet/ From 76cbf283e459906815027e8a9a9b84cdfdd04c4e Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:46:56 +0800 Subject: [PATCH 004/384] chore: remove change log check --- merged-packages/stellar-wallet-snap/CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/CHANGELOG.md b/merged-packages/stellar-wallet-snap/CHANGELOG.md index 7ea018e0..a5cdea1f 100644 --- a/merged-packages/stellar-wallet-snap/CHANGELOG.md +++ b/merged-packages/stellar-wallet-snap/CHANGELOG.md @@ -7,8 +7,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -- Initial config for Stellar Snap - [Unreleased]: https://github.com/MetaMask/snap-stellar-wallet/ From b6203e0c31e592afc1627e47870fc276d2826481 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:25:54 +0800 Subject: [PATCH 005/384] chore: add common util, config and handlers --- .../stellar-wallet-snap/.env.example | 28 ++ .../stellar-wallet-snap/jest.config.js | 17 +- .../stellar-wallet-snap/package.json | 1 + .../stellar-wallet-snap/snap.config.ts | 7 + .../stellar-wallet-snap/snap.manifest.json | 2 +- .../src/constants/asset.ts | 18 ++ .../src/constants/environment.ts | 5 + .../src/constants/index.ts | 4 + .../src/constants/loglevel.ts | 9 + .../src/constants/network.ts | 10 + .../stellar-wallet-snap/src/context.ts | 10 + .../stellar-wallet-snap/src/handlers/index.ts | 2 + .../src/handlers/keyring.test.ts | 184 +++++++++++ .../src/handlers/keyring.ts | 119 ++++++++ .../src/handlers/rpc.test.ts | 32 ++ .../stellar-wallet-snap/src/handlers/rpc.ts | 26 ++ .../stellar-wallet-snap/src/index.ts | 22 +- .../stellar-wallet-snap/src/permissions.ts | 46 +++ .../services/config/ConfigProvider.test.ts | 88 ++++++ .../src/services/config/ConfigProvider.ts | 94 ++++++ .../src/services/config/index.ts | 2 + .../stellar-wallet-snap/src/structs/index.ts | 1 + .../src/structs/url.test.ts | 130 ++++++++ .../stellar-wallet-snap/src/structs/url.ts | 125 ++++++++ .../src/utils/__mocks__/logger.ts | 26 ++ .../src/utils/assertOrThrow.test.ts | 11 + .../src/utils/assertOrThrow.ts | 24 ++ .../src/utils/errors.test.ts | 285 ++++++++++++++++++ .../stellar-wallet-snap/src/utils/errors.ts | 129 ++++++++ .../stellar-wallet-snap/src/utils/index.ts | 4 + .../stellar-wallet-snap/src/utils/logger.ts | 94 ++++++ .../src/utils/requestResponse.test.ts | 92 ++++++ .../src/utils/requestResponse.ts | 66 ++++ 33 files changed, 1702 insertions(+), 11 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/constants/asset.ts create mode 100644 merged-packages/stellar-wallet-snap/src/constants/environment.ts create mode 100644 merged-packages/stellar-wallet-snap/src/constants/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/constants/loglevel.ts create mode 100644 merged-packages/stellar-wallet-snap/src/constants/network.ts create mode 100644 merged-packages/stellar-wallet-snap/src/context.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/keyring.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/keyring.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/rpc.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/rpc.ts create mode 100644 merged-packages/stellar-wallet-snap/src/permissions.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/config/ConfigProvider.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/config/ConfigProvider.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/config/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/structs/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/structs/url.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/structs/url.ts create mode 100644 merged-packages/stellar-wallet-snap/src/utils/__mocks__/logger.ts create mode 100644 merged-packages/stellar-wallet-snap/src/utils/assertOrThrow.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/utils/assertOrThrow.ts create mode 100644 merged-packages/stellar-wallet-snap/src/utils/errors.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/utils/errors.ts create mode 100644 merged-packages/stellar-wallet-snap/src/utils/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/utils/logger.ts create mode 100644 merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/utils/requestResponse.ts diff --git a/merged-packages/stellar-wallet-snap/.env.example b/merged-packages/stellar-wallet-snap/.env.example index 40eef231..061beae3 100644 --- a/merged-packages/stellar-wallet-snap/.env.example +++ b/merged-packages/stellar-wallet-snap/.env.example @@ -3,3 +3,31 @@ # - test for running tests locally (mandatory) # - production before submitting a PR ENVIRONMENT=local + +# Use: +# - all for all logs +# - error for error logs +# - warn for warn logs +# - info for info logs +# - debug for debug logs +# - trace for trace logs +# - silent for silent logs +LOG_LEVEL=all + +# Mainnet RPC URLs +RPC_URL_MAINNET=https://mainnet.sorobanrpc.com + +# Mainnet Horizon URLs +HORIZON_URL_MAINNET=https://horizon.stellar.org + +# Mainnet Explorer Base URLs +EXPLORER_MAINNET_BASE_URL=https://stellar.expert/explorer/public + +# Testnet RPC URLs +RPC_URL_TESTNET=https://soroban-testnet.stellar.org + +# Testnet Horizon URLs +HORIZON_URL_TESTNET=https://horizon-testnet.stellar.org + +# Testnet Explorer Base URLs +EXPLORER_TESTNET_BASE_URL=hthttps://stellar.expert/explorer/testnet \ No newline at end of file diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index 899bfb07..c04867dc 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -13,7 +13,14 @@ const config = { coverageDirectory: 'coverage', // An array of regexp pattern strings used to skip coverage collection - coveragePathIgnorePatterns: ['.*/index\\.ts'], + coveragePathIgnorePatterns: [ + '.*/index\\.ts$', // any index.ts + '.*/constants\\.ts$', // any file named constants.ts + '.*/constants/', // any file in a folder named constants + '.*/utils/logger\\.ts$', // skip logger.ts + '.*/permissions\\.ts$', // skip permissions.ts + '.*/context\\.ts$', // skip context.ts + ], // Indicates which provider should be used to instrument code for coverage coverageProvider: 'babel', @@ -24,10 +31,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 52.58, - functions: 55.53, - lines: 67.34, - statements: 67.41, + branches: 90, + functions: 100, + lines: 97.45, + statements: 97.52, }, }, diff --git a/merged-packages/stellar-wallet-snap/package.json b/merged-packages/stellar-wallet-snap/package.json index 07150347..e28a789b 100644 --- a/merged-packages/stellar-wallet-snap/package.json +++ b/merged-packages/stellar-wallet-snap/package.json @@ -51,6 +51,7 @@ "@metamask/snaps-sdk": "^10.4.0", "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.10.0", + "@stellar/stellar-sdk": "^14.5.0", "@types/jest": "^30.0.0", "@types/lodash": "^4.17.20", "async-mutex": "^0.5.0", diff --git a/merged-packages/stellar-wallet-snap/snap.config.ts b/merged-packages/stellar-wallet-snap/snap.config.ts index 044c5029..936ba249 100644 --- a/merged-packages/stellar-wallet-snap/snap.config.ts +++ b/merged-packages/stellar-wallet-snap/snap.config.ts @@ -11,6 +11,13 @@ const config: SnapConfig = { }, environment: { ENVIRONMENT: process.env.ENVIRONMENT ?? '', + LOG_LEVEL: process.env.LOG_LEVEL ?? '', + RPC_URL_MAINNET: process.env.RPC_URL_MAINNET ?? '', + HORIZON_URL_MAINNET: process.env.HORIZON_URL_MAINNET ?? '', + EXPLORER_MAINNET_BASE_URL: process.env.EXPLORER_MAINNET_BASE_URL ?? '', + RPC_URL_TESTNET: process.env.RPC_URL_TESTNET ?? '', + HORIZON_URL_TESTNET: process.env.HORIZON_URL_TESTNET ?? '', + EXPLORER_TESTNET_BASE_URL: process.env.EXPLORER_TESTNET_BASE_URL ?? '', }, polyfills: true, }; diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index a87dd682..3f877024 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "nP7hWeBvo8PpYOScT22irYViI3TXzn9bXkk8kdRPcL0=", + "shasum": "D5OxBujQrIo3tU5R+ZP4OzoNysGtJvNSEi+IYgygL/g=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/constants/asset.ts b/merged-packages/stellar-wallet-snap/src/constants/asset.ts new file mode 100644 index 00000000..f311d9a4 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/constants/asset.ts @@ -0,0 +1,18 @@ +import { KnownCaip19ChainId } from './network'; + +/** Stellar Asset namespace */ +/** please see https://namespaces.chainagnostic.org/stellar/caip19#asset-namespaces */ +export enum NativeAssetType { + Native = 'slip44', + Token = 'asset', +} + +/** Stellar's coin type */ +/** please see https://github.com/satoshilabs/slips/blob/master/slip-0044.md */ +export const CoinType = 148; + +/** Known CAIP-19 IDs */ +export enum KnownCaip19Id { + Slip44Mainnet = `${KnownCaip19ChainId.Mainnet}/${NativeAssetType.Native}:${CoinType}`, + Slip44Testnet = `${KnownCaip19ChainId.Testnet}/${NativeAssetType.Native}:${CoinType}`, +} diff --git a/merged-packages/stellar-wallet-snap/src/constants/environment.ts b/merged-packages/stellar-wallet-snap/src/constants/environment.ts new file mode 100644 index 00000000..1b6b0511 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/constants/environment.ts @@ -0,0 +1,5 @@ +export enum Environment { + Local = 'local', + Test = 'test', + Production = 'production', +} diff --git a/merged-packages/stellar-wallet-snap/src/constants/index.ts b/merged-packages/stellar-wallet-snap/src/constants/index.ts new file mode 100644 index 00000000..c0badf42 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/constants/index.ts @@ -0,0 +1,4 @@ +export * from './network'; +export * from './asset'; +export * from './environment'; +export * from './loglevel'; diff --git a/merged-packages/stellar-wallet-snap/src/constants/loglevel.ts b/merged-packages/stellar-wallet-snap/src/constants/loglevel.ts new file mode 100644 index 00000000..d161efef --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/constants/loglevel.ts @@ -0,0 +1,9 @@ +export enum LogLevel { + ALL = 'all', + ERROR = 'error', + WARN = 'warn', + INFO = 'info', + DEBUG = 'debug', + TRACE = 'trace', + SILENT = 'silent', +} diff --git a/merged-packages/stellar-wallet-snap/src/constants/network.ts b/merged-packages/stellar-wallet-snap/src/constants/network.ts new file mode 100644 index 00000000..fc155a8d --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/constants/network.ts @@ -0,0 +1,10 @@ +/** Stellar Chain namespace */ +/** please see https://namespaces.chainagnostic.org/stellar/caip2 */ +export const ChainNameSpace = 'stellar'; + +/** Known CAIP-2 IDs */ +/** please see https://namespaces.chainagnostic.org/stellar/caip2 */ +export enum KnownCaip19ChainId { + Mainnet = `${ChainNameSpace}:pubnet`, + Testnet = `${ChainNameSpace}:testnet`, +} diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts new file mode 100644 index 00000000..97742ae3 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -0,0 +1,10 @@ +import { KeyringHandler, RpcHandler } from './handlers'; +import { ConfigProvider } from './services/config'; +import { logger } from './utils'; + +ConfigProvider.initializeConfig(); + +const keyringHandler = new KeyringHandler({ logger }); +const rpcHandler = new RpcHandler({ logger }); + +export { keyringHandler, rpcHandler }; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/index.ts b/merged-packages/stellar-wallet-snap/src/handlers/index.ts new file mode 100644 index 00000000..fa37518c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/index.ts @@ -0,0 +1,2 @@ +export * from './keyring'; +export * from './rpc'; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring.test.ts new file mode 100644 index 00000000..c4869c88 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring.test.ts @@ -0,0 +1,184 @@ +import { KeyringRpcMethod } from '@metamask/keyring-api'; +import { handleKeyringRequest } from '@metamask/keyring-snap-sdk'; +import type { JsonRpcRequest } from '@metamask/snaps-sdk'; + +import { KeyringHandler } from './keyring'; +import { KnownCaip19ChainId, KnownCaip19Id } from '../constants'; +import { logger } from '../utils/logger'; + +jest.mock('../utils/logger'); +jest.mock('../utils/requestResponse', () => ({ + validateOrigin: jest.fn(), +})); +jest.mock('@metamask/keyring-snap-sdk', () => ({ + handleKeyringRequest: jest.fn(), +})); + +describe('KeyringHandler', () => { + let keyringHandler: KeyringHandler; + + beforeEach(() => { + jest.clearAllMocks(); + keyringHandler = new KeyringHandler({ logger }); + }); + + describe('handle', () => { + const request = { + method: KeyringRpcMethod.ListAccounts, + id: '1', + jsonrpc: '2.0', + } as JsonRpcRequest; + + it('calls handleKeyringRequest', async () => { + const handleKeyringRequestSpy = jest.mocked(handleKeyringRequest); + handleKeyringRequestSpy.mockResolvedValue([]); + + const result = await keyringHandler.handle('metamask', request); + + expect(handleKeyringRequestSpy).toHaveBeenCalledWith( + keyringHandler, + request, + ); + expect(result).toStrictEqual([]); + }); + + it('returns null if handleKeyringRequest returns null', async () => { + const handleKeyringRequestSpy = jest.mocked(handleKeyringRequest); + handleKeyringRequestSpy.mockReturnThis(); + + const result = await keyringHandler.handle('metamask', request); + + expect(handleKeyringRequestSpy).toHaveBeenCalledWith( + keyringHandler, + request, + ); + expect(result).toBeNull(); + }); + }); + + describe('listAccounts', () => { + it('throws `Method not implemented.` error', async () => { + await expect(keyringHandler.listAccounts()).rejects.toThrow( + 'Method not implemented.', + ); + }); + }); + + describe('getAccount', () => { + it('throws `Method not implemented.` error', async () => { + await expect(keyringHandler.getAccount('1')).rejects.toThrow( + 'Method not implemented.', + ); + }); + }); + + describe('createAccount', () => { + it('throws `Method not implemented.` error', async () => { + await expect(keyringHandler.createAccount()).rejects.toThrow( + 'Method not implemented.', + ); + }); + }); + + describe('listAccountAssets', () => { + it('throws `Method not implemented.` error', async () => { + await expect(keyringHandler.listAccountAssets('1')).rejects.toThrow( + 'Method not implemented.', + ); + }); + }); + + describe('listAccountTransactions', () => { + it('throws `Method not implemented.` error', async () => { + await expect( + keyringHandler.listAccountTransactions('1', { limit: 10 }), + ).rejects.toThrow('Method not implemented.'); + }); + }); + + describe('discoverAccounts', () => { + it('throws `Method not implemented.` error', async () => { + await expect( + keyringHandler.discoverAccounts?.( + [KnownCaip19ChainId.Mainnet], + 'entropy-source-1', + 0, + ), + ).rejects.toThrow('Method not implemented.'); + }); + }); + + describe('getAccountBalances', () => { + it('throws `Method not implemented.` error', async () => { + await expect( + keyringHandler.getAccountBalances('1', [KnownCaip19Id.Slip44Mainnet]), + ).rejects.toThrow('Method not implemented.'); + }); + }); + + describe('resolveAccountAddress', () => { + it('throws `Method not implemented.` error', async () => { + await expect( + keyringHandler.resolveAccountAddress(KnownCaip19ChainId.Mainnet, { + method: 'resolveAccountAddress', + params: ['1'], + id: '1', + jsonrpc: '2.0', + }), + ).rejects.toThrow('Method not implemented.'); + }); + }); + + describe('filterAccountChains', () => { + it('throws `Method not implemented.` error', async () => { + await expect( + keyringHandler.filterAccountChains('1', [KnownCaip19ChainId.Mainnet]), + ).rejects.toThrow('Method not implemented.'); + }); + }); + + describe('updateAccount', () => { + it('throws `Method not implemented.` error', async () => { + await expect( + keyringHandler.updateAccount({ + type: 'any:account', + id: '1', + address: '1', + scopes: [KnownCaip19ChainId.Mainnet], + options: {}, + methods: [], + }), + ).rejects.toThrow('Method not implemented.'); + }); + }); + + describe('deleteAccount', () => { + it('throws `Method not implemented.` error', async () => { + await expect(keyringHandler.deleteAccount('1')).rejects.toThrow( + 'Method not implemented.', + ); + }); + }); + + describe('submitRequest', () => { + it('throws `Method not implemented.` error', async () => { + await expect( + keyringHandler.submitRequest({ + id: '1', + origin: 'metamask', + request: { method: 'submitRequest', params: ['1'] }, + scope: KnownCaip19ChainId.Mainnet, + account: '1', + }), + ).rejects.toThrow('Method not implemented.'); + }); + }); + + describe('setSelectedAccounts', () => { + it('throws `Method not implemented.` error', async () => { + await expect(keyringHandler.setSelectedAccounts(['1'])).rejects.toThrow( + 'Method not implemented.', + ); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring.ts new file mode 100644 index 00000000..30c963f8 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring.ts @@ -0,0 +1,119 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + type Balance, + type DiscoveredAccount, + type EntropySourceId, + type Keyring, + type KeyringAccount, + type KeyringRequest, + type KeyringResponse, + type Pagination, + type ResolvedAccountAddress, + type Transaction, +} from '@metamask/keyring-api'; +import { handleKeyringRequest } from '@metamask/keyring-snap-sdk'; +import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; +import type { + CaipAssetType, + CaipAssetTypeOrId, + CaipChainId, +} from '@metamask/utils'; + +import { + createPrefixedLogger, + type ILogger, + validateOrigin, + withCatchAndThrowSnapError, +} from '../utils'; + +export class KeyringHandler implements Keyring { + readonly #logger: ILogger; + + constructor({ logger }: { logger: ILogger }) { + this.#logger = createPrefixedLogger(logger, '[🔑 KeyringHandler]'); + } + + async handle(origin: string, request: JsonRpcRequest): Promise { + validateOrigin(origin, request.method); + + const result = + (await withCatchAndThrowSnapError( + async () => handleKeyringRequest(this, request), + this.#logger, + )) ?? null; + + return result; + } + + async listAccounts(): Promise { + throw new Error('Method not implemented.'); + } + + async getAccount(accountId: string): Promise { + throw new Error('Method not implemented.'); + } + + async createAccount(options?: unknown): Promise { + throw new Error('Method not implemented.'); + } + + async listAccountAssets(accountId: string): Promise { + throw new Error('Method not implemented.'); + } + + async listAccountTransactions( + accountId: string, + pagination: Pagination, + ): Promise<{ + data: Transaction[]; + next: string | null; + }> { + throw new Error('Method not implemented.'); + } + + async discoverAccounts?( + scopes: CaipChainId[], + entropySource: EntropySourceId, + groupIndex: number, + ): Promise { + throw new Error('Method not implemented.'); + } + + async getAccountBalances( + accountId: string, + assets: CaipAssetType[], + ): Promise> { + throw new Error('Method not implemented.'); + } + + async resolveAccountAddress( + scope: CaipChainId, + request: JsonRpcRequest, + ): Promise { + throw new Error('Method not implemented.'); + } + + async filterAccountChains(id: string, chains: string[]): Promise { + throw new Error('Method not implemented.'); + } + + async updateAccount(account: KeyringAccount): Promise { + throw new Error('Method not implemented.'); + } + + async deleteAccount(accountId: string): Promise { + throw new Error('Method not implemented.'); + } + + async submitRequest(request: KeyringRequest): Promise { + return { pending: false, result: await this.#handleSubmitRequest(request) }; + } + + async #handleSubmitRequest(request: KeyringRequest): Promise { + throw new Error('Method not implemented.'); + } + + async setSelectedAccounts(accountIds: string[]): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/rpc.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/rpc.test.ts new file mode 100644 index 00000000..a4ccde85 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/rpc.test.ts @@ -0,0 +1,32 @@ +import type { JsonRpcRequest } from '@metamask/snaps-sdk'; +import { MethodNotFoundError } from '@metamask/snaps-sdk'; + +import { RpcHandler } from './rpc'; +import { logger } from '../utils/logger'; + +jest.mock('../utils/logger'); +jest.mock('../utils/requestResponse', () => ({ + validateOrigin: jest.fn(), +})); + +describe('RpcHandler', () => { + let rpcHandler: RpcHandler; + + beforeEach(() => { + jest.clearAllMocks(); + rpcHandler = new RpcHandler({ logger }); + }); + + it('throws MethodNotFoundError if the method is not found', async () => { + const request = { + method: 'invalid', + params: [], + id: '1', + jsonrpc: '2.0', + } as JsonRpcRequest; + + await expect(rpcHandler.handle('metamask', request)).rejects.toThrow( + MethodNotFoundError, + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/rpc.ts b/merged-packages/stellar-wallet-snap/src/handlers/rpc.ts new file mode 100644 index 00000000..b6fee254 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/rpc.ts @@ -0,0 +1,26 @@ +import { MethodNotFoundError } from '@metamask/snaps-sdk'; +import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; + +import type { ILogger } from '../utils'; +import { createPrefixedLogger, validateOrigin } from '../utils'; + +export class RpcHandler { + readonly #logger: ILogger; + + constructor({ logger }: { logger: ILogger }) { + this.#logger = createPrefixedLogger(logger, '[👋 RpcHandler]'); + } + + async handle(origin: string, request: JsonRpcRequest): Promise { + validateOrigin(origin, request.method); + + this.#logger.info('Handling RPC request', request); + + const { method } = request; + + switch (method) { + default: + throw new MethodNotFoundError() as Error; + } + } +} diff --git a/merged-packages/stellar-wallet-snap/src/index.ts b/merged-packages/stellar-wallet-snap/src/index.ts index ca766ac6..f46ea680 100644 --- a/merged-packages/stellar-wallet-snap/src/index.ts +++ b/merged-packages/stellar-wallet-snap/src/index.ts @@ -1,6 +1,18 @@ -import type { OnRpcRequestHandler } from '@metamask/snaps-sdk'; -import { MethodNotFoundError } from '@metamask/snaps-sdk'; +import type { + OnRpcRequestHandler, + OnKeyringRequestHandler, +} from '@metamask/snaps-sdk'; -export const onRpcRequest: OnRpcRequestHandler = async () => { - throw new MethodNotFoundError() as Error; -}; +import { keyringHandler, rpcHandler } from './context'; +import { withCatchAndThrowSnapError } from './utils'; + +export const onKeyringRequest: OnKeyringRequestHandler = async ({ + origin, + request, +}) => + withCatchAndThrowSnapError(async () => + keyringHandler.handle(origin, request), + ); + +export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request }) => + withCatchAndThrowSnapError(async () => rpcHandler.handle(origin, request)); diff --git a/merged-packages/stellar-wallet-snap/src/permissions.ts b/merged-packages/stellar-wallet-snap/src/permissions.ts new file mode 100644 index 00000000..5bb33732 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/permissions.ts @@ -0,0 +1,46 @@ +import { KeyringRpcMethod } from '@metamask/keyring-api'; + +// eslint-disable-next-line no-restricted-globals +const isDev = process.env.ENVIRONMENT !== 'production'; + +const prodOrigins = ['https://portfolio.metamask.io']; +const allowedOrigins = isDev ? ['http://localhost:3000'] : prodOrigins; + +const dappPermissions = isDev + ? new Set([ + // Keyring methods + KeyringRpcMethod.ListAccounts, + KeyringRpcMethod.GetAccount, + KeyringRpcMethod.CreateAccount, + KeyringRpcMethod.DeleteAccount, + KeyringRpcMethod.DiscoverAccounts, + KeyringRpcMethod.GetAccountBalances, + KeyringRpcMethod.SubmitRequest, + KeyringRpcMethod.ListAccountTransactions, + KeyringRpcMethod.ListAccountAssets, + ]) + : new Set([]); + +const metamaskPermissions = new Set([ + // Keyring methods + KeyringRpcMethod.ListAccounts, + KeyringRpcMethod.GetAccount, + KeyringRpcMethod.CreateAccount, + KeyringRpcMethod.DeleteAccount, + KeyringRpcMethod.DiscoverAccounts, + KeyringRpcMethod.GetAccountBalances, + KeyringRpcMethod.SubmitRequest, + KeyringRpcMethod.ListAccountTransactions, + KeyringRpcMethod.ListAccountAssets, + KeyringRpcMethod.ResolveAccountAddress, + KeyringRpcMethod.SetSelectedAccounts, +]); + +const metamask = 'metamask'; + +export const originPermissions = new Map>([]); + +for (const origin of allowedOrigins) { + originPermissions.set(origin, dappPermissions); +} +originPermissions.set(metamask, metamaskPermissions); diff --git a/merged-packages/stellar-wallet-snap/src/services/config/ConfigProvider.test.ts b/merged-packages/stellar-wallet-snap/src/services/config/ConfigProvider.test.ts new file mode 100644 index 00000000..49e49f7f --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/config/ConfigProvider.test.ts @@ -0,0 +1,88 @@ +/* eslint-disable no-restricted-globals */ +import { ConfigProvider } from './ConfigProvider'; + +describe('ConfigProvider', () => { + let OriginalEnvironment: string | undefined; + let OriginalRpcUrlMainnet: string | undefined; + let OriginalHorizonUrlMainnet: string | undefined; + let OriginalExplorerMainnetBaseUrl: string | undefined; + let OriginalRpcUrlTestnet: string | undefined; + let OriginalHorizonUrlTestnet: string | undefined; + let OriginalExplorerTestnetBaseUrl: string | undefined; + let OriginalLogLevel: string | undefined; + + beforeEach(() => { + OriginalEnvironment = process.env.ENVIRONMENT; + OriginalRpcUrlMainnet = process.env.RPC_URL_MAINNET; + OriginalHorizonUrlMainnet = process.env.HORIZON_URL_MAINNET; + OriginalExplorerMainnetBaseUrl = process.env.EXPLORER_MAINNET_BASE_URL; + OriginalRpcUrlTestnet = process.env.RPC_URL_TESTNET; + OriginalHorizonUrlTestnet = process.env.HORIZON_URL_TESTNET; + OriginalExplorerTestnetBaseUrl = process.env.EXPLORER_TESTNET_BASE_URL; + OriginalLogLevel = process.env.LOG_LEVEL; + + process.env.ENVIRONMENT = 'local'; + process.env.RPC_URL_MAINNET = 'https://mainnet.stellar.org'; + process.env.HORIZON_URL_MAINNET = 'https://mainnet.stellar.org'; + process.env.EXPLORER_MAINNET_BASE_URL = 'https://mainnet.stellar.org'; + process.env.RPC_URL_TESTNET = 'https://testnet.stellar.org'; + process.env.HORIZON_URL_TESTNET = 'https://testnet.stellar.org'; + process.env.EXPLORER_TESTNET_BASE_URL = 'https://testnet.stellar.org'; + process.env.LOG_LEVEL = 'info'; + }); + + afterEach(() => { + process.env.ENVIRONMENT = OriginalEnvironment; + process.env.RPC_URL_MAINNET = OriginalRpcUrlMainnet; + process.env.HORIZON_URL_MAINNET = OriginalHorizonUrlMainnet; + process.env.EXPLORER_MAINNET_BASE_URL = OriginalExplorerMainnetBaseUrl; + process.env.RPC_URL_TESTNET = OriginalRpcUrlTestnet; + process.env.HORIZON_URL_TESTNET = OriginalHorizonUrlTestnet; + process.env.EXPLORER_TESTNET_BASE_URL = OriginalExplorerTestnetBaseUrl; + process.env.LOG_LEVEL = OriginalLogLevel; + }); + + describe('get', () => { + it('return the parsed config', () => { + ConfigProvider.initializeConfig(); + const config = ConfigProvider.get(); + + expect(config.environment).toBe('local'); + expect(config.networks.mainnet.rpcUrl).toBe( + 'https://mainnet.stellar.org', + ); + expect(config.networks.mainnet.horizonUrl).toBe( + 'https://mainnet.stellar.org', + ); + expect(config.networks.mainnet.explorerBaseUrl).toBe( + 'https://mainnet.stellar.org', + ); + expect(config.networks.testnet.rpcUrl).toBe( + 'https://testnet.stellar.org', + ); + expect(config.networks.testnet.horizonUrl).toBe( + 'https://testnet.stellar.org', + ); + expect(config.networks.testnet.explorerBaseUrl).toBe( + 'https://testnet.stellar.org', + ); + expect(config.logLevel).toBe('info'); + }); + }); + + describe('initializeConfig', () => { + it('throw an error if the config is not valid', () => { + process.env.ENVIRONMENT = 'invalid'; + expect(() => ConfigProvider.initializeConfig()).toThrow( + 'Expected one of `"local","test","production"`, but received: "invalid"', + ); + }); + + it('set the default log level if the log level is not set', () => { + process.env.LOG_LEVEL = ''; + ConfigProvider.initializeConfig(); + const config = ConfigProvider.get(); + expect(config.logLevel).toBe('error'); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/config/ConfigProvider.ts b/merged-packages/stellar-wallet-snap/src/services/config/ConfigProvider.ts new file mode 100644 index 00000000..6707221b --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/config/ConfigProvider.ts @@ -0,0 +1,94 @@ +/* eslint-disable no-restricted-globals */ +import type { Infer } from '@metamask/superstruct'; +import { + create, + enums, + object, + defaulted, + coerce, + string, +} from '@metamask/superstruct'; + +import { Environment, LogLevel } from '../../constants'; +import { UrlStruct } from '../../structs'; + +/** + * A struct for validating the network config. + */ +const networkConfigStruct = object({ + rpcUrl: UrlStruct, + horizonUrl: UrlStruct, + explorerBaseUrl: UrlStruct, +}); + +/** + * A coerce function for validating log levels. + * Converts the log level to lowercase and checks if it is a valid log level. + * If the log level is empty, it returns the default log level. + * + * @returns The validated log level. + */ +const LogLevelCoerce = coerce( + defaulted(enums(Object.values(LogLevel)), LogLevel.ERROR), + string(), + (value: string) => (value === '' ? undefined : value.toLowerCase()), +); + +/** + * A struct for validating the config. + */ +const ConfigStruct = object({ + environment: enums(Object.values(Environment)), + logLevel: LogLevelCoerce, + networks: object({ + mainnet: networkConfigStruct, + testnet: networkConfigStruct, + }), +}); + +/** + * The config type. + */ +export type Config = Infer; + +export class ConfigProvider { + /** + * The config. + */ + static config: Config; + + /** + * Initializes the config. + * Reads the environment variables and validates them. + * Sets the config. + */ + static initializeConfig(): void { + const rawEnvironment = { + environment: process.env.ENVIRONMENT, + networks: { + mainnet: { + rpcUrl: process.env.RPC_URL_MAINNET, + horizonUrl: process.env.HORIZON_URL_MAINNET, + explorerBaseUrl: process.env.EXPLORER_MAINNET_BASE_URL, + }, + testnet: { + rpcUrl: process.env.RPC_URL_TESTNET, + horizonUrl: process.env.HORIZON_URL_TESTNET, + explorerBaseUrl: process.env.EXPLORER_TESTNET_BASE_URL, + }, + }, + logLevel: process.env.LOG_LEVEL, + }; + + ConfigProvider.config = create(rawEnvironment, ConfigStruct); + } + + /** + * Returns the config. + * + * @returns The config. + */ + static get(): Config { + return ConfigProvider.config; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/config/index.ts b/merged-packages/stellar-wallet-snap/src/services/config/index.ts new file mode 100644 index 00000000..144e270c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/config/index.ts @@ -0,0 +1,2 @@ +export { ConfigProvider } from './ConfigProvider'; +export type { Config } from './ConfigProvider'; diff --git a/merged-packages/stellar-wallet-snap/src/structs/index.ts b/merged-packages/stellar-wallet-snap/src/structs/index.ts new file mode 100644 index 00000000..944a376a --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/structs/index.ts @@ -0,0 +1 @@ +export * from './url'; diff --git a/merged-packages/stellar-wallet-snap/src/structs/url.test.ts b/merged-packages/stellar-wallet-snap/src/structs/url.test.ts new file mode 100644 index 00000000..cdb12adf --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/structs/url.test.ts @@ -0,0 +1,130 @@ +/* eslint-disable jest/expect-expect -- assertions are in assertValid/assertInvalid helpers */ +import { assert } from '@metamask/superstruct'; + +import { UrlStruct } from './url'; + +const assertValid = (value: string) => { + expect(() => assert(value, UrlStruct)).not.toThrow(); +}; + +const assertInvalid = (value: string, expectedMessage?: string) => { + try { + assert(value, UrlStruct); + throw new Error('Expected assertion to throw'); + } catch (thrown) { + const error = thrown as Error; + expect(error).toBeDefined(); + if (expectedMessage !== undefined) { + expect(error.message).toContain(expectedMessage); + } + } +}; + +describe('UrlStruct', () => { + it.each([ + // https URL with domain + 'https://example.com', + // https URL with path + 'https://example.com/path/to/resource', + // https URL with query string + 'https://example.com/api?foo=bar', + // http URL + 'http://example.com', + // wss URL + 'wss://example.com/socket', + // localhost without port + 'http://localhost', + // localhost with port + 'http://localhost:3000', + // https URL with domain + 'https://api.example.com', + ])('accepts %s', (url) => { + assertValid(url); + }); + + describe('protocol validation', () => { + it.each([ + // ftp protocol + 'ftp://example.com', + // file protocol + 'file:///etc/passwd', + // javascript protocol + // eslint-disable-next-line no-script-url + 'javascript:alert(1)', + // file protocol + 'file:///etc/passwd', + // data URI + 'data:text/html,', + callback: 'javascript:alert(1)', + }, + }), + ).toThrow('URL contains potentially malicious patterns'); + }); + + it('prevents path traversal attacks', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/../../../etc/passwd', + queryParams: {}, + }); + expect(result).toBe('https://api.example.com/etc/passwd'); + }); + + it('handles null and undefined query parameters', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '/users', + queryParams: { + id: null as unknown as string, + name: undefined as unknown as string, + valid: 'data', + }, + }); + expect(result).toBe('https://api.example.com/users?valid=data'); + }); + + it('prevents protocol switching in parameters', () => { + expect(() => + buildUrl({ + baseUrl: 'https://api.example.com', + path: '/redirect', + queryParams: { + url: 'javascript://alert(1)', + next: 'data:text/html,', + }, + }), + ).toThrow('URL contains potentially malicious patterns'); + }); + + it('handles empty path segments', () => { + const result = buildUrl({ + baseUrl: 'https://api.example.com', + path: '//path//to//resource//', + queryParams: {}, + }); + expect(result).toBe('https://api.example.com/path/to/resource'); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/utils/buildUrl.ts b/merged-packages/stellar-wallet-snap/src/utils/buildUrl.ts new file mode 100644 index 00000000..1698f5d6 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/utils/buildUrl.ts @@ -0,0 +1,78 @@ +import { assert } from '@metamask/superstruct'; + +import { sanitizeControlCharacters, sanitizeUri } from './sanitize'; +import { UrlStruct } from '../api'; + +export type BuildUrlParams = { + baseUrl: string; + path: string; + pathParams?: Record | undefined; + queryParams?: Record | undefined; + encodePathParams?: boolean; +}; + +/** + * Builds a URL with the given base URL and parameters: + * - The `URL` API provides proper URL parsing and encoding. + * - The `path` is sanitized to prevent path traversal attacks. + * - Path and query parameters are sanitized to remove control characters. + * + * Ensures that the built URL is safe, valid, and sanitized. + * + * @param params - The parameters to build the URL from. + * @returns The built URL. + */ +export function buildUrl(params: BuildUrlParams): string { + const { + baseUrl, + path, + pathParams, + queryParams, + encodePathParams = true, + } = params; + + // Validate and sanitize base URL + const sanitizedBaseUrl = sanitizeUri(baseUrl); + if (sanitizedBaseUrl === '') { + throw new Error('Invalid URL format'); + } + assert(sanitizedBaseUrl, UrlStruct); + + const pathWithParams = path.replace(/\{(\w+)\}/gu, (_match, key: string) => { + const value = pathParams?.[key]; + if (value === undefined) { + throw new Error(`Path parameter ${key} is undefined`); + } + // Sanitize path parameter values to remove control characters + const sanitizedValue = sanitizeControlCharacters(value); + return encodePathParams + ? encodeURIComponent(sanitizedValue) + : sanitizedValue; + }); + + const cleanPath = pathWithParams + .replace(/^\/+/u, '') // Remove leading slashes + .replace(/\/+/gu, '/') // Replace multiple slashes with single + .replace(/\/+$/u, ''); // Remove trailing slashes + + // Ensure base URL has trailing slash for proper path appending + const normalizedBaseUrl = sanitizedBaseUrl.endsWith('/') + ? sanitizedBaseUrl + : `${sanitizedBaseUrl}/`; + + const url = new URL(cleanPath, normalizedBaseUrl); + Object.entries(queryParams ?? {}) + .filter(([_key, value]) => value !== undefined) + .filter(([_key, value]) => value !== null) + .forEach(([key, value]) => { + if (value) { + // Sanitize query parameter values to remove control characters + const sanitizedValue = sanitizeControlCharacters(value); + url.searchParams.append(key, sanitizedValue); + } + }); + + const builtUrl = url.toString(); + assert(builtUrl, UrlStruct); + return builtUrl; +} diff --git a/merged-packages/stellar-wallet-snap/src/utils/caip.test.ts b/merged-packages/stellar-wallet-snap/src/utils/caip.test.ts new file mode 100644 index 00000000..8d6f25a3 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/utils/caip.test.ts @@ -0,0 +1,157 @@ +import { AssetType, KnownCaip19Slip44IdMap, KnownCaip2ChainId } from '../api'; +import { + getAssetReference, + getSlip44AssetId, + isClassicAssetId, + isSep41Id, + isSlip44Id, + parseClassicAssetCodeIssuer, + toCaip19ClassicAssetId, + toCaip19Sep41AssetId, + toCaipAssetReference, +} from './caip'; + +const CLASSIC_ASSET_ID = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + +const SEP41_ASSET_ID = + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J'; + +const SLIP44_ASSET_ID = 'stellar:pubnet/slip44:148'; + +describe('toCaip19ClassicAssetId', () => { + it('builds a CAIP-19 classic asset id from scope, code, and issuer', () => { + expect( + toCaip19ClassicAssetId( + KnownCaip2ChainId.Mainnet, + 'USDC', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ), + ).toBe(CLASSIC_ASSET_ID); + }); +}); + +describe('toCaip19Sep41AssetId', () => { + it('builds a CAIP-19 sep41 asset id from scope and contract address', () => { + expect( + toCaip19Sep41AssetId( + KnownCaip2ChainId.Mainnet, + 'CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J', + ), + ).toBe(SEP41_ASSET_ID); + }); +}); + +describe('isSlip44Id', () => { + it('returns true for known slip44 ids from the map', () => { + expect(isSlip44Id(KnownCaip19Slip44IdMap[KnownCaip2ChainId.Mainnet])).toBe( + true, + ); + expect(isSlip44Id(KnownCaip19Slip44IdMap[KnownCaip2ChainId.Testnet])).toBe( + true, + ); + }); + + it('returns false for non-slip44 asset ids', () => { + expect(isSlip44Id(CLASSIC_ASSET_ID)).toBe(false); + expect(isSlip44Id(SEP41_ASSET_ID)).toBe(false); + expect(isSlip44Id('unknown')).toBe(false); + }); +}); + +describe('isSep41Id', () => { + it('returns true for a valid sep41 CAIP-19 id', () => { + expect(isSep41Id(SEP41_ASSET_ID)).toBe(true); + }); + + it('returns false for classic and slip44 ids', () => { + expect(isSep41Id(CLASSIC_ASSET_ID)).toBe(false); + expect(isSep41Id(SLIP44_ASSET_ID)).toBe(false); + }); +}); + +describe('isClassicAssetId', () => { + it('returns true for a valid classic CAIP-19 id', () => { + expect(isClassicAssetId(CLASSIC_ASSET_ID)).toBe(true); + }); + + it('returns false for sep41 and slip44 ids', () => { + expect(isClassicAssetId(SEP41_ASSET_ID)).toBe(false); + expect(isClassicAssetId(SLIP44_ASSET_ID)).toBe(false); + }); +}); + +describe('getAssetReference', () => { + it('returns the asset reference segment of a CAIP-19 id', () => { + expect(getAssetReference(CLASSIC_ASSET_ID)).toBe( + 'USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ); + expect(getAssetReference(SEP41_ASSET_ID)).toBe( + 'CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J', + ); + expect(getAssetReference(SLIP44_ASSET_ID)).toBe('148'); + }); +}); + +describe('getSlip44AssetId', () => { + it('returns the slip44 id for the given chain scope', () => { + expect(getSlip44AssetId(KnownCaip2ChainId.Mainnet)).toBe( + `${KnownCaip2ChainId.Mainnet}/${AssetType.Native}:148`, + ); + expect(getSlip44AssetId(KnownCaip2ChainId.Testnet)).toBe( + `${KnownCaip2ChainId.Testnet}/${AssetType.Native}:148`, + ); + }); +}); + +describe('toCaipAssetReference', () => { + it('returns the input unchanged when it has no colon', () => { + expect(toCaipAssetReference('USDC-GA5Z')).toBe('USDC-GA5Z'); + }); + + it('joins code and issuer with a hyphen when given colon form', () => { + expect(toCaipAssetReference('USDC:GA5Z')).toBe('USDC-GA5Z'); + }); + + it('throws when colon form is missing code or issuer', () => { + expect(() => toCaipAssetReference(':onlyIssuer')).toThrow( + 'Invalid asset reference: :onlyIssuer', + ); + expect(() => toCaipAssetReference('onlyCode:')).toThrow( + 'Invalid asset reference: onlyCode:', + ); + }); +}); + +describe('parseClassicAssetCodeIssuer', () => { + it('parses hyphen-separated classic reference', () => { + expect( + parseClassicAssetCodeIssuer( + 'USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ), + ).toStrictEqual({ + assetCode: 'USDC', + assetIssuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }); + }); + + it('parses colon-separated classic reference', () => { + expect( + parseClassicAssetCodeIssuer( + 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ), + ).toStrictEqual({ + assetCode: 'USDC', + assetIssuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }); + }); + + it('throws when reference is missing code or issuer', () => { + expect(() => parseClassicAssetCodeIssuer('USDC-')).toThrow( + 'Invalid asset reference: USDC-', + ); + expect(() => parseClassicAssetCodeIssuer(':G123')).toThrow( + 'Invalid asset reference: :G123', + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/utils/caip.ts b/merged-packages/stellar-wallet-snap/src/utils/caip.ts new file mode 100644 index 00000000..eee7bb87 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/utils/caip.ts @@ -0,0 +1,155 @@ +import { parseCaipAssetType } from '@metamask/utils'; + +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip19ClassicAssetId, + KnownCaip19Sep41AssetId, + KnownCaip19Slip44Id, + KnownCaip2ChainId, +} from '../api'; +import { + AssetType, + KnownCaip19ClassicAssetStruct, + KnownCaip19Sep41AssetStruct, + KnownCaip19Slip44IdMap, +} from '../api'; + +/** + * Converts the given parameters to a CAIP-19 non native asset ID. + * + * @param scope - The CAIP-2 chain ID. + * @param assetCode - The asset code. + * @param assetIssuer - The asset issuer. + * @returns The CAIP-19 asset ID. + */ +export function toCaip19ClassicAssetId( + scope: KnownCaip2ChainId, + assetCode: string, + assetIssuer: string, +): KnownCaip19ClassicAssetId { + return `${scope}/${AssetType.Token}:${assetCode}-${assetIssuer}`; +} + +/** + * Converts the given parameters to a CAIP-19 Sep41 asset ID. + * + * @param scope - The CAIP-2 chain ID. + * @param contractAddress - The contract address. + * @returns The CAIP-19 Sep41 asset ID. + */ +export function toCaip19Sep41AssetId( + scope: KnownCaip2ChainId, + contractAddress: string, +): KnownCaip19Sep41AssetId { + return `${scope}/${AssetType.Sep41}:${contractAddress}`; +} + +/** + * Checks if the given asset ID is a slip44 ID. + * + * @param assetId - The CAIP-19 asset ID or slip44 ID. + * @returns True if the asset ID is a slip44 ID, false otherwise. + */ +export function isSlip44Id( + assetId: KnownCaip19AssetIdOrSlip44Id | string, +): assetId is KnownCaip19Slip44Id { + return Object.values(KnownCaip19Slip44IdMap).includes( + assetId as KnownCaip19Slip44Id, + ); +} + +/** + * Returns true if the given asset ID is a Sep41 Asset ID. + * + * @param assetId - The CAIP-19 Sep41 Asset ID. + * @returns True if the asset ID is a Sep41 Asset ID, false otherwise. + */ +export function isSep41Id( + assetId: KnownCaip19AssetIdOrSlip44Id | string, +): assetId is KnownCaip19Sep41AssetId { + const [error] = KnownCaip19Sep41AssetStruct.validate(assetId); + return error === undefined; +} + +/** + * Checks if the given asset ID is a classic asset ID. + * + * @param assetId - The CAIP-19 asset ID or slip44 ID. + * @returns True if the asset ID is a classic asset ID, false otherwise. + */ +export function isClassicAssetId( + assetId: KnownCaip19AssetIdOrSlip44Id | string, +): assetId is KnownCaip19ClassicAssetId { + const [error] = KnownCaip19ClassicAssetStruct.validate(assetId); + return error === undefined; +} + +/** + * Returns the asset reference from a CAIP-19 asset id. + * + * @param assetId - CAIP-19 asset id. + * @returns Asset reference. + */ +export function getAssetReference( + assetId: KnownCaip19AssetIdOrSlip44Id, +): string { + const { assetReference } = parseCaipAssetType(assetId); + return assetReference; +} + +/** + * Returns the slip44 asset ID for the given scope. + * + * @param scope - The CAIP-2 chain ID. + * @returns The slip44 asset ID. + */ +export function getSlip44AssetId( + scope: KnownCaip2ChainId, +): KnownCaip19Slip44Id { + return KnownCaip19Slip44IdMap[scope]; +} + +/** + * Converts the given asset reference to a CAIP-19 asset reference. + * + * @param assetRef - The asset reference. + * @returns The CAIP-19 asset reference. + */ +export function toCaipAssetReference(assetRef: string): string { + // TODO: change to sep41 asset reference detection + if (!assetRef.includes(':')) { + return assetRef; + } + // TODO: change to classic asset reference detection + const [assetCode, assetIssuer] = assetRef.split(':'); + if (!assetCode || !assetIssuer) { + throw new Error(`Invalid asset reference: ${assetRef}`); + } + return `${assetCode}-${assetIssuer}`; +} + +/** + * Parses classic asset code and issuer from CAIP-19 form (`CODE-ISSUER`) or colon form (`CODE:ISSUER`). + * + * @param assetReference - Classic asset reference segment from CAIP-19 or on-chain metadata. + * @returns Parsed asset code and issuer account id. + * @example + * ``` + * parseClassicAssetCodeIssuer('USD-G1234567890123456789012345678901234567890'); + * // { assetCode: 'USD', assetIssuer: 'G1234567890123456789012345678901234567890' } + * parseClassicAssetCodeIssuer('USD:G1234567890123456789012345678901234567890'); + * // { assetCode: 'USD', assetIssuer: 'G1234567890123456789012345678901234567890' } + * ``` + */ +export function parseClassicAssetCodeIssuer(assetReference: string): { + assetCode: string; + assetIssuer: string; +} { + // TODO: change to classic asset reference detection + const separator = assetReference.includes(':') ? ':' : '-'; + const [assetCode, assetIssuer] = assetReference.split(separator); + if (!assetCode || !assetIssuer) { + throw new Error(`Invalid asset reference: ${assetReference}`); + } + return { assetCode, assetIssuer }; +} diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts new file mode 100644 index 00000000..a343feaa --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts @@ -0,0 +1,31 @@ +import { BigNumber } from 'bignumber.js'; + +import { normalizeAmount, toSmallestUnit } from './currency'; + +describe('toSmallestUnit', () => { + it('converts human amount to stroops', () => { + expect(toSmallestUnit(new BigNumber('12.3456789')).toFixed(0)).toBe( + '123456789', + ); + }); + + it('converts integer XLM to stroops', () => { + expect(toSmallestUnit(new BigNumber(1)).toFixed(0)).toBe('10000000'); + }); +}); + +describe('normalizeAmount', () => { + it('converts stroops to human amount', () => { + expect(normalizeAmount(new BigNumber(123456789)).toString()).toBe( + '12.3456789', + ); + }); +}); + +describe('toSmallestUnit and normalizeAmount', () => { + it('roundtrips for representative values', () => { + const human = new BigNumber('12.3456789'); + const stroops = toSmallestUnit(human); + expect(normalizeAmount(stroops).toString()).toBe(human.toString()); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.ts new file mode 100644 index 00000000..c4c07013 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.ts @@ -0,0 +1,35 @@ +import { BigNumber } from 'bignumber.js'; + +import { STELLAR_DECIMAL_PLACES } from '../constants'; + +/** + * Converts an amount to the smallest unit of the asset. + * + * @example toSmallestUnit(new BigNumber('12.3456789')) // 123456789 stroops + * + * @param amount - The amount to convert. + * @param decimalPlaces - The number of decimal places to use. + * @returns The amount in the smallest unit. + */ +export function toSmallestUnit( + amount: BigNumber, + decimalPlaces: number = STELLAR_DECIMAL_PLACES, +): BigNumber { + return amount.multipliedBy(BigNumber(10).pow(decimalPlaces)); +} + +/** + * Converts an amount from the smallest unit to a human-readable amount. + * + * @example normalizeAmount(new BigNumber(123456789)) // 12.3456789 + * + * @param amount - Amount in stroops. + * @param decimalPlaces - The number of decimal places to use. + * @returns The amount in the human-readable format. + */ +export function normalizeAmount( + amount: BigNumber, + decimalPlaces: number = STELLAR_DECIMAL_PLACES, +): BigNumber { + return amount.dividedBy(BigNumber(10).pow(decimalPlaces)); +} diff --git a/merged-packages/stellar-wallet-snap/src/utils/i18n.ts b/merged-packages/stellar-wallet-snap/src/utils/i18n.ts new file mode 100644 index 00000000..79dc6d54 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/utils/i18n.ts @@ -0,0 +1,44 @@ +import en from '../../locales/en.json'; +import es from '../../locales/es.json'; + +export const locales = { + en: en.messages, + es: es.messages, +}; + +export const FALLBACK_LANGUAGE: Locale = 'en'; + +export type Locale = keyof typeof locales; +/** When locale `messages` is an empty object, `keyof` is `never`; fall back to `string` for keys. */ +type MessageKeys = keyof (typeof locales)[typeof FALLBACK_LANGUAGE]; +export type LocalizedMessage = [MessageKeys] extends [never] + ? string + : MessageKeys; + +/** + * Fetches the translations based on the user's locale preference. + * Falls back to the default language if the preferred locale is not available. + * + * @param locale - The user's preferred locale. + * @returns A function that gets the translation for a given key. + */ +export function i18n(locale: Locale) { + // Needs to be casted as EN is the main language and we can have the case where + // messages are not yet completed for the other languages (e.g. empty `es` map). + const messages = (locales[locale] ?? locales[FALLBACK_LANGUAGE]) as Partial< + Record + >; + + return (id: LocalizedMessage, replaces?: Record): string => { + let message = messages[id]?.message ?? id; + + if (replaces && message) { + Object.keys(replaces).forEach((key) => { + const regex = new RegExp(`\\{${key}\\}`, 'gu'); + message = message.replace(regex, replaces[key] ?? ''); + }); + } + + return message; + }; +} diff --git a/merged-packages/stellar-wallet-snap/src/utils/index.ts b/merged-packages/stellar-wallet-snap/src/utils/index.ts index 23a8a521..af3b9bc9 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/index.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from './currency'; export * from './logger'; export * from './requestResponse'; export * from './errors'; @@ -5,3 +6,11 @@ export * from './snap'; export * from './serialization'; export * from './safeMerge'; export * from './number'; +export * from './caip'; +export * from './buffer'; +export * from './buildUrl'; +export * from './sanitize'; +export * from './async'; +export * from './assert'; +export * from './array'; +export * from './i18n'; diff --git a/merged-packages/stellar-wallet-snap/src/utils/logger.ts b/merged-packages/stellar-wallet-snap/src/utils/logger.ts index 252f0546..870bf367 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/logger.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/logger.ts @@ -1,6 +1,4 @@ /* eslint-disable no-empty-function */ -import { ensureError } from '@metamask/utils'; - import { LogLevel } from '../api/loglevel'; import { AppConfig } from '../config'; @@ -27,7 +25,7 @@ export type ILogger = { warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; debug: (...args: unknown[]) => void; - logErrorWithDetails: (message: string, error: unknown) => void; + logErrorWithDetails: (...args: unknown[]) => void; }; /** @@ -59,8 +57,8 @@ export const logger: ILogger = { debug: withLogLevel(console.debug, LogLevel.DEBUG), error: withLogLevel(console.error, LogLevel.ERROR), logErrorWithDetails: withLogLevel((...args: unknown[]) => { - console.debug(args[0], { error: ensureError(args[1]) }); - }, LogLevel.ERROR), + console.error(...args); + }, LogLevel.DEBUG), }; /** diff --git a/merged-packages/stellar-wallet-snap/src/utils/requestResponse.ts b/merged-packages/stellar-wallet-snap/src/utils/requestResponse.ts index 011d7f37..2d96d0e9 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/requestResponse.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/requestResponse.ts @@ -5,7 +5,7 @@ import { UnauthorizedError, } from '@metamask/snaps-sdk'; import type { Struct } from '@metamask/superstruct'; -import { assert } from '@metamask/superstruct'; +import { assert, create } from '@metamask/superstruct'; import { originPermissions } from '../permissions'; @@ -28,17 +28,19 @@ export const validateOrigin = (origin: string, method: string): void => { /** * Validates that the request parameters conform to the expected structure defined by the provided struct. + * Returns the validated (and coerced) value so handlers receive the correct types. * * @param requestParams - The request parameters to validate (typically unknown at call site). * @param struct - The expected structure of the request parameters. + * @returns The validated and coerced request parameters. * @throws {InvalidParamsError} If the request parameters do not conform to the expected structure. */ export function validateRequest( requestParams: unknown, struct: Struct, -): asserts requestParams is Type { +): Type { try { - assert(requestParams, struct); + return create(requestParams, struct); } catch (validationError: unknown) { if (validationError instanceof Error) { throw new InvalidParamsError(validationError.message); diff --git a/merged-packages/stellar-wallet-snap/src/utils/sanitize.ts b/merged-packages/stellar-wallet-snap/src/utils/sanitize.ts new file mode 100644 index 00000000..dfcaa3d6 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/utils/sanitize.ts @@ -0,0 +1,44 @@ +/** + * Removes control characters from a string. + * Control characters can be used for injection attacks and should be stripped from user input. + * + * @param input - The string to sanitize. + * @returns The sanitized string with control characters removed. + */ +export function sanitizeControlCharacters(input: string): string { + if (!input || typeof input !== 'string') { + return ''; + } + + // Remove all control characters except tab + // eslint-disable-next-line no-control-regex + return input.replace(/[\u0000-\u0008\u000A-\u001F\u007F]/gu, ''); +} + +/** + * Validates and sanitizes a URI. + * + * @param uri - The URI to validate and sanitize. + * @returns The sanitized URI or empty string if invalid. + */ +export function sanitizeUri(uri: string): string { + if (!uri || typeof uri !== 'string') { + return ''; + } + + const sanitized = sanitizeControlCharacters(uri); + + try { + const url = new URL(sanitized); + const allowedProtocols = ['http:', 'https:', 'wss:']; + if (!allowedProtocols.includes(url.protocol)) { + return ''; + } + if (sanitized.length > 2048) { + return ''; + } + return sanitized; + } catch { + return ''; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/utils/snap.ts b/merged-packages/stellar-wallet-snap/src/utils/snap.ts index ce6f40f0..3477f0dd 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/snap.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/snap.ts @@ -1,6 +1,16 @@ import type { JsonSLIP10Node } from '@metamask/key-tree'; import type { EntropySourceId } from '@metamask/keyring-api'; -import type { EntropySource, Json, SnapsProvider } from '@metamask/snaps-sdk'; +import type { + ComponentOrElement, + DialogResult, + EntropySource, + GetClientStatusResult, + GetPreferencesResult, + Json, + ResolveInterfaceResult, + SnapsProvider, + UpdateInterfaceResult, +} from '@metamask/snaps-sdk'; import { type Serializable, serialize, deserialize } from './serialization'; @@ -156,3 +166,219 @@ export async function getState({ return deserialize(state); } + +/** + * Retrieves the client status (locked/unlocked) in this case from MM. + * + * @returns An object containing the status. + */ +export async function getClientStatus(): Promise { + return getSnapProvider().request({ + method: 'snap_getClientStatus', + }); +} + +/** + * Schedules a background event. + * + * @param options - The options for the background event. + * @param options.method - The method to call. + * @param options.params - The params to pass to the method. + * @param options.duration - The duration to wait before the event is scheduled. + * @returns A promise that resolves to a string. + */ +export async function scheduleBackgroundEvent({ + method, + params = {}, + duration, +}: { + method: string; + params?: Record; + duration: string; +}): Promise { + return getSnapProvider().request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration, + request: { + method, + params, + }, + }, + }); +} + +/** + * Checks if an error is an "interface not found" error. + * Detects JSON-RPC errors thrown when an interface has been dismissed by the user. + * + * @param error - The error to check. + * @returns True if the error indicates the interface was not found. + */ +function isInterfaceNotFoundError(error: unknown): boolean { + if (error instanceof Error) { + const message = error.message.toLowerCase(); + return message.includes('interface') && message.includes('not found'); + } + + return false; +} + +/** + * Create a UI interface with the provided UI component and context. + * + * @param ui - The UI component to render. + * @param context - The initial context object to associate with the interface. + * @returns The created interface id. + */ +export async function createInterface( + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ui: any, + context: TContext & Record, +): Promise { + return getSnapProvider().request({ + method: 'snap_createInterface', + params: { + ui, + context, + }, + }); +} + +/** + * Update an existing UI interface with a new UI component and context. + * Returns null if the interface has been dismissed by the user. + * + * @param id - The interface id returned from createInterface. + * @param ui - The new UI component to render. + * @param context - The updated context object to associate with the interface. + * @returns True if the interface was updated, or null if it was not found. + */ +export async function updateInterfaceIfExists( + id: string, + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ui: any, + context: TContext & Record, +): Promise { + try { + await getSnapProvider().request({ + method: 'snap_updateInterface', + params: { + id, + ui, + context, + }, + }); + return true; + } catch (error) { + if (isInterfaceNotFoundError(error)) { + return null; + } + throw error; + } +} + +/** + * Gets the context of an interface by its ID. + * Returns null if the interface has been dismissed by the user. + * + * @param id - The ID for the interface. + * @returns The context object associated with the interface, or null if not found. + */ +export async function getInterfaceContextIfExists( + id: string, +): Promise { + try { + const rawContext = await getSnapProvider().request({ + method: 'snap_getInterfaceContext', + params: { + id, + }, + }); + + if (!rawContext) { + return null; + } + + return rawContext as TContext; + } catch (error) { + if (isInterfaceNotFoundError(error)) { + return null; + } + throw error; + } +} + +/** + * Updates the context of an interface by its ID without changing the UI. + * Note: This is a helper that re-uses the existing UI. + * + * @param id - The ID for the interface. + * @param ui - The UI component. + * @param context - The updated context object. + * @returns The update interface result. + */ +export async function updateInterfaceWithContext< + TContext extends Record, +>( + id: string, + ui: ComponentOrElement, + context: TContext, +): Promise { + return getSnapProvider().request({ + method: 'snap_updateInterface', + params: { + id, + ui, + context, + }, + }); +} + +/** + * Shows a dialog using the provided ID. + * + * @param id - The ID for the dialog. + * @returns A promise that resolves to a string. + */ +export async function showDialog(id: string): Promise { + return getSnapProvider().request({ + method: 'snap_dialog', + params: { + id, + }, + }); +} + +/** + * Resolve a dialog using the provided ID. + * + * @param id - The ID for the interface to update. + * @param value - The result to resolve the interface with. + * @returns An object containing the state of the interface. + */ +export async function resolveInterface( + id: string, + value: Json, +): Promise { + return getSnapProvider().request({ + method: 'snap_resolveInterface', + params: { + id, + value, + }, + }); +} + +/** + * Get preferences from snap. + * + * @returns A promise that resolves to snap preferences. + */ +export async function getPreferences(): Promise { + return getSnapProvider().request({ + method: 'snap_getPreferences', + }); +} diff --git a/merged-packages/stellar-wallet-snap/src/utils/string.ts b/merged-packages/stellar-wallet-snap/src/utils/string.ts new file mode 100644 index 00000000..cb031458 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/utils/string.ts @@ -0,0 +1,13 @@ +import { string } from '@metamask/superstruct'; +import { base64 } from '@metamask/utils'; + +/** + * Checks if a string is a valid base64 encoded string. + * + * @param message - The string to check. + * @returns True if the string is a valid base64 encoded string, false otherwise. + */ +export function isBase64(message: string): boolean { + const [error] = base64(string()).validate(message); + return error === undefined; +} From c2e93d739dd630dd5230dc72e3deea42fdd4efb3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:34:38 +0800 Subject: [PATCH 026/384] chore: remove unuse handler --- .../clientRequest/signAndSendTransaction.ts | 104 ------------------ 1 file changed, 104 deletions(-) delete mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts deleted file mode 100644 index f071ea66..00000000 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { - SignAndSendTransactionJsonRpcRequest, - SignAndSendTransactionJsonRpcResponse, -} from './api'; -import { - SignAndSendTransactionJsonRpcRequestStruct, - SignAndSendTransactionJsonRpcResponseStruct, -} from './api'; -import type { ResolvedActivatedAccount } from '../base'; -import { WithClientRequestActiveAccountResolve } from './base'; -import type { AccountService } from '../../services/account'; -import type { OnChainAccountService } from '../../services/on-chain-account'; -import type { TransactionService } from '../../services/transaction/TransactionService'; -import type { WalletService } from '../../services/wallet'; -import { createPrefixedLogger } from '../../utils/logger'; -import type { ILogger } from '../../utils/logger'; - -export class SignAndSendTransactionHandler extends WithClientRequestActiveAccountResolve< - SignAndSendTransactionJsonRpcRequest, - SignAndSendTransactionJsonRpcResponse -> { - readonly #transactionService: TransactionService; - - constructor({ - logger, - accountService, - onChainAccountService, - walletService, - transactionService, - }: { - logger: ILogger; - accountService: AccountService; - onChainAccountService: OnChainAccountService; - walletService: WalletService; - transactionService: TransactionService; - }) { - const prefixedLogger = createPrefixedLogger( - logger, - '[👋 SignAndSendTransactionHandler]', - ); - super({ - accountService, - onChainAccountService, - walletService, - logger: prefixedLogger, - requestStruct: SignAndSendTransactionJsonRpcRequestStruct, - responseStruct: SignAndSendTransactionJsonRpcResponseStruct, - }); - this.#transactionService = transactionService; - } - - /** - * Signs and submits the Soroban envelope after {@link ComputeFeeHandler} (same `params.transaction` XDR - * and `params.scope` the client used for the fee quote). The API-built tx is expected to use the user - * as source; validation matches {@link TransactionService.createValidatedDeserializeTransaction}. - * - * CRITICAL SECURITY REQUIREMENT: - * This method does NOT request user confirmation. The caller is responsible - * for obtaining explicit user consent before invoking this method. - * - * The caller MUST: - * - Display transaction details (recipient, amount, fees) to the user - * - Obtain explicit user approval before calling this method - * - Validate transaction authenticity and integrity - * - * Failure to implement caller-side consent will result in transactions being - * signed and broadcast without user knowledge, creating a critical security - * vulnerability. - * - * @param resolved - The resolved and activated account and wallet ({@link ResolvedActivatedAccount}). - * @param request - The JSON-RPC request containing transaction details. - * @param request.params.transaction - The Base64 encoded XDR of the transaction. - * @param request.params.scope - The CAIP-2 chain ID. - * @returns A promise that resolves to the JSON-RPC response ({@link SignAndSendTransactionJsonRpcResponse}). - */ - async _handle( - resolved: ResolvedActivatedAccount, - request: SignAndSendTransactionJsonRpcRequest, - ): Promise { - const { wallet, onChainAccount } = resolved; - const { transaction: transactionBase64Xdr, scope } = request.params; - - const transaction = - await this.#transactionService.createValidatedDeserializeTransaction({ - xdr: transactionBase64Xdr, - scope, - onChainAccount, - }); - - wallet.signTransaction(transaction); - - const transactionHash = await this.#transactionService.sendTransaction({ - wallet, - onChainAccount, - scope, - transaction, - pollTransaction: false, - }); - - return { - transactionId: transactionHash, - }; - } -} From cfb3d8840b69768b6fb2eb978c6a5f7c372480a4 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:43:00 +0800 Subject: [PATCH 027/384] chore: add api and constants --- .../stellar-wallet-snap/src/api/address.ts | 22 +++++-- .../src/api/integer.test.ts | 31 ++++++++++ .../stellar-wallet-snap/src/api/integer.ts | 27 ++++++++ .../stellar-wallet-snap/src/api/network.ts | 8 +-- .../stellar-wallet-snap/src/api/string.ts | 18 ++++++ .../stellar-wallet-snap/src/api/xdr.ts | 21 +++++++ .../stellar-wallet-snap/src/constants.ts | 61 +++++++++++++++++++ 7 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/api/integer.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/api/integer.ts create mode 100644 merged-packages/stellar-wallet-snap/src/api/string.ts create mode 100644 merged-packages/stellar-wallet-snap/src/api/xdr.ts create mode 100644 merged-packages/stellar-wallet-snap/src/constants.ts diff --git a/merged-packages/stellar-wallet-snap/src/api/address.ts b/merged-packages/stellar-wallet-snap/src/api/address.ts index 0ebc09bf..5a2bf5f3 100644 --- a/merged-packages/stellar-wallet-snap/src/api/address.ts +++ b/merged-packages/stellar-wallet-snap/src/api/address.ts @@ -1,13 +1,23 @@ -import { type Infer } from '@metamask/superstruct'; -import { definePattern } from '@metamask/utils'; +import { refine, string, nonempty, type Infer } from '@metamask/superstruct'; +import { StrKey } from '@stellar/stellar-sdk'; /** - * Validation struct for Stellar address: must be a string matching the Stellar address format. + * Validation struct for Stellar address: must be a string matching the Stellar address format and checksum. * We only support non-muxed addresses. */ -export const StellarAddressStruct = definePattern( - 'StellarAddress', - /^G[A-Z2-7]{55}$/u, +export const StellarAddressStruct = refine( + nonempty(string()), + 'stellar_address', + (value: string) => { + try { + if (!StrKey.isValidEd25519PublicKey(value)) { + return 'Invalid Stellar address'; + } + return true; + } catch { + return 'Invalid Stellar address'; + } + }, ); /** diff --git a/merged-packages/stellar-wallet-snap/src/api/integer.test.ts b/merged-packages/stellar-wallet-snap/src/api/integer.test.ts new file mode 100644 index 00000000..bfb49f67 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/api/integer.test.ts @@ -0,0 +1,31 @@ +import { assert, StructError } from '@metamask/superstruct'; + +import { PositiveNumberStringStruct } from './integer'; + +describe('PositiveNumberStringStruct', () => { + it('accepts a valid positive integer string', () => { + expect(() => + assert('1000000000', PositiveNumberStringStruct), + ).not.toThrow(); + }); + + it('accepts a valid positive float string', () => { + expect(() => assert('1.5', PositiveNumberStringStruct)).not.toThrow(); + }); + + it('rejects JavaScript bigint', () => { + expect(() => assert(BigInt(100), PositiveNumberStringStruct)).toThrow( + StructError, + ); + }); + + it('rejects a negative numeric string', () => { + expect(() => assert('-1', PositiveNumberStringStruct)).toThrow(StructError); + }); + + it('rejects a non-numeric string', () => { + expect(() => assert('not-a-number', PositiveNumberStringStruct)).toThrow( + StructError, + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/api/integer.ts b/merged-packages/stellar-wallet-snap/src/api/integer.ts new file mode 100644 index 00000000..4e047162 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/api/integer.ts @@ -0,0 +1,27 @@ +import { nonempty, refine, string, type Infer } from '@metamask/superstruct'; +import { BigNumber } from 'bignumber.js'; + +/** + * Non-empty string that parses to a finite, non-negative {@link BigNumber} (stroops or human-readable amounts). + * Uses `refine` so `assert` / `validate` enforce this; not only `create` with coercion. + */ +export const PositiveNumberStringStruct = refine( + nonempty(string()), + 'positive_number_string', + (value: string) => { + try { + const bn = new BigNumber(value); + if (bn.isNaN() || !bn.isFinite()) { + return 'Invalid positive number'; + } + if (bn.isLessThan(0)) { + return 'Not a positive number'; + } + return true; + } catch { + return 'Invalid positive number'; + } + }, +); + +export type PositiveNumberString = Infer; diff --git a/merged-packages/stellar-wallet-snap/src/api/network.ts b/merged-packages/stellar-wallet-snap/src/api/network.ts index 52603623..c7dc201c 100644 --- a/merged-packages/stellar-wallet-snap/src/api/network.ts +++ b/merged-packages/stellar-wallet-snap/src/api/network.ts @@ -1,15 +1,13 @@ /** Stellar Chain namespace */ import { enums } from '@metamask/superstruct'; - -/** Please see https://namespaces.chainagnostic.org/stellar/caip2 */ -export const ChainNamespace = 'stellar'; +import { KnownCaipNamespace } from '@metamask/utils'; /** Known CAIP-2 IDs */ /** Please see https://namespaces.chainagnostic.org/stellar/caip2 */ export enum KnownCaip2ChainId { - Mainnet = `${ChainNamespace}:pubnet`, - Testnet = `${ChainNamespace}:testnet`, + Mainnet = `${KnownCaipNamespace.Stellar}:pubnet`, + Testnet = `${KnownCaipNamespace.Stellar}:testnet`, } export const KnownCaip2ChainIdStruct = enums(Object.values(KnownCaip2ChainId)); diff --git a/merged-packages/stellar-wallet-snap/src/api/string.ts b/merged-packages/stellar-wallet-snap/src/api/string.ts new file mode 100644 index 00000000..868ed4f2 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/api/string.ts @@ -0,0 +1,18 @@ +import type { Infer } from '@metamask/superstruct'; +import { refine, string } from '@metamask/superstruct'; + +/** + * Validation struct for a UTF-8 string. + */ +export const Utf8StringStruct = refine(string(), 'utf8', (value) => { + try { + // Attempt to encode to UTF-8 + const encoder = new TextEncoder(); + encoder.encode(value); + return true; // Valid UTF-8 + } catch { + return 'Invalid UTF-8 string'; + } +}); + +export type Utf8String = Infer; diff --git a/merged-packages/stellar-wallet-snap/src/api/xdr.ts b/merged-packages/stellar-wallet-snap/src/api/xdr.ts new file mode 100644 index 00000000..2ec38152 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/api/xdr.ts @@ -0,0 +1,21 @@ +import { nonempty, refine, string } from '@metamask/superstruct'; +import { base64 } from '@metamask/utils'; +import { xdr } from '@stellar/stellar-sdk'; + +/** + * Validation struct for XDR: must be a valid base64 encoded XDR string. + */ +export const XdrStruct = refine( + nonempty(base64(string())), + 'valid_xdr', + (value: string) => { + try { + if (!xdr.TransactionEnvelope.validateXDR(value, 'base64')) { + return 'Invalid XDR'; + } + return true; + } catch { + return 'Invalid XDR'; + } + }, +); diff --git a/merged-packages/stellar-wallet-snap/src/constants.ts b/merged-packages/stellar-wallet-snap/src/constants.ts new file mode 100644 index 00000000..493566d8 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/constants.ts @@ -0,0 +1,61 @@ +/** + * The base reserve for the Stellar network. + * + * @see https://developers.stellar.org/docs/learn/fundamentals/stellar-data-structures/accounts + */ +export const XLM_PER_BASE_RESERVE = 0.5; + +/** Stellar native amounts use 7 fractional digits (stroops per whole XLM). */ +export const STROOPS_PER_XLM = 10_000_000; + +/** + * One base reserve in stroops (`XLM_PER_BASE_RESERVE` × 10^7; Stellar uses 7 decimal places). + */ +export const BASE_RESERVE_STROOPS = XLM_PER_BASE_RESERVE * STROOPS_PER_XLM; + +/** + * Stellar's coin type + * + * @see https://github.com/satoshilabs/slips/blob/master/slip-0044.md + */ +export const STELLAR_COIN_TYPE = 148; + +/** + * The number of decimal places for the native asset of Stellar. + * All assets (except custom assets) on the Stellar network use exactly 7 decimal places of precision - this is a hard-coded limit at the protocol level + * + * @see https://developers.stellar.org/docs/learn/fundamentals/stellar-data-structures/assets + */ +export const STELLAR_DECIMAL_PLACES = 7; + +/** + * The symbol for the native asset of Stellar. + */ +export const NATIVE_ASSET_SYMBOL = 'XLM'; + +/** + * The name for the native asset of Stellar. + * + * @see https://stellar.org/learn/lumens + */ +export const NATIVE_ASSET_NAME = 'Lumen'; + +/** + * The minimum base fee in stroops for the Stellar network. + * + * @see https://developers.stellar.org/docs/learn/fundamentals/fees-resource-limits-metering + */ +export const BASE_FEE = 100; + +/** + * The maximum int64 balance for the Stellar network. + * + * @see https://stellar.org/learn/lumens + */ +export const MAX_INT64_BALANCE = '9223372036854775807'; + +/** + * The type for the keyring account. + * TODO: Replace with the actual account type. + */ +export const KEYRING_ACCOUNT_TYPE = 'any:account'; From 9958c910fe608358ea74fae628656a258c83da07 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:43:55 +0800 Subject: [PATCH 028/384] chore: update caip asset --- .../stellar-wallet-snap/src/api/asset.test.ts | 45 +++++++++++++-- .../stellar-wallet-snap/src/api/asset.ts | 56 +++++++++++++++---- .../stellar-wallet-snap/src/api/index.ts | 3 +- 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/api/asset.test.ts b/merged-packages/stellar-wallet-snap/src/api/asset.test.ts index f3cfb3dd..a52162d1 100644 --- a/merged-packages/stellar-wallet-snap/src/api/asset.test.ts +++ b/merged-packages/stellar-wallet-snap/src/api/asset.test.ts @@ -1,19 +1,56 @@ import { assert, StructError } from '@metamask/superstruct'; -import { KnownCaip19AssetStruct } from './asset'; +import { + KnownCaip19ClassicAssetStruct, + KnownCaip19Sep41AssetStruct, + KnownCaip19Slip44IdStruct, +} from './asset'; -describe('KnownCaip19AssetStruct', () => { +describe('KnownCaip19ClassicAssetStruct', () => { it('accepts a valid CAIP-19 asset', () => { expect(() => assert( 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', - KnownCaip19AssetStruct, + KnownCaip19ClassicAssetStruct, ), ).not.toThrow(); }); it('rejects an invalid CAIP-19 asset', () => { const address = 'invalid-caip19-asset'; - expect(() => assert(address, KnownCaip19AssetStruct)).toThrow(StructError); + expect(() => assert(address, KnownCaip19ClassicAssetStruct)).toThrow( + StructError, + ); + }); +}); + +describe('KnownCaip19Sep41AssetStruct', () => { + it.each([ + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J', + 'stellar:pubnet/sep41:CBIJBDNZNF4X35BJ4FFZWCDBSCKOP5NB4PLG4SNENRMLAPYG4P5FM6VN', + ])('accepts a valid CAIP-19 asset', (assetId) => { + expect(() => assert(assetId, KnownCaip19Sep41AssetStruct)).not.toThrow(); + }); + + it('rejects an invalid CAIP-19 asset', () => { + const address = 'invalid-caip19-asset'; + expect(() => assert(address, KnownCaip19Sep41AssetStruct)).toThrow( + StructError, + ); + }); +}); + +describe('KnownCaip19Slip44IdStruct', () => { + it('accepts a valid CAIP-19 asset', () => { + expect(() => + assert('stellar:pubnet/slip44:148', KnownCaip19Slip44IdStruct), + ).not.toThrow(); + }); + + it('rejects an invalid CAIP-19 asset', () => { + const address = 'invalid-caip19-asset'; + expect(() => assert(address, KnownCaip19Slip44IdStruct)).toThrow( + StructError, + ); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/api/asset.ts b/merged-packages/stellar-wallet-snap/src/api/asset.ts index 9b7e1556..bf2cf8c9 100644 --- a/merged-packages/stellar-wallet-snap/src/api/asset.ts +++ b/merged-packages/stellar-wallet-snap/src/api/asset.ts @@ -2,33 +2,65 @@ import type { Infer } from '@metamask/superstruct'; import { definePattern } from '@metamask/utils'; import { KnownCaip2ChainId } from './network'; +import { STELLAR_COIN_TYPE } from '../constants'; /** Stellar Asset namespace */ /** Please see https://namespaces.chainagnostic.org/stellar/caip19#asset-namespaces */ export enum AssetType { Native = 'slip44', Token = 'asset', + Sep41 = 'sep41', } -/** Stellar's coin type */ -/** Please see https://github.com/satoshilabs/slips/blob/master/slip-0044.md */ -export const STELLAR_COIN_TYPE = 148; - /** Known CAIP-19 IDs */ -export enum KnownCaip19Slip44Id { - Slip44Mainnet = `${KnownCaip2ChainId.Mainnet}/${AssetType.Native}:${STELLAR_COIN_TYPE}`, - Slip44Testnet = `${KnownCaip2ChainId.Testnet}/${AssetType.Native}:${STELLAR_COIN_TYPE}`, -} +export const KnownCaip19Slip44IdStruct = + definePattern<`${KnownCaip2ChainId}/${AssetType.Native}:${typeof STELLAR_COIN_TYPE}`>( + 'KnownCaip19Slip44Id', + /^stellar:(?:pubnet|testnet)\/slip44:148$/u, + ); + +export const KnownCaip19Slip44IdMap: Record< + KnownCaip2ChainId, + KnownCaip19Slip44Id +> = { + [KnownCaip2ChainId.Mainnet]: `${KnownCaip2ChainId.Mainnet}/${AssetType.Native}:${STELLAR_COIN_TYPE}`, + [KnownCaip2ChainId.Testnet]: `${KnownCaip2ChainId.Testnet}/${AssetType.Native}:${STELLAR_COIN_TYPE}`, +}; /** * CAIP-19 token asset ID: {chainId}/asset:{assetCode}-{issuerAddress} * * @see https://namespaces.chainagnostic.org/stellar/caip19#asset-namespaces */ -export const KnownCaip19AssetStruct = +export const KnownCaip19ClassicAssetStruct = definePattern<`${KnownCaip2ChainId}/${AssetType.Token}:${string}-${string}`>( - 'KnownCaip19Asset', - /^stellar:(?:pubnet|testnet)\/asset:[^-]{1,12}-G[A-Z2-7]{55}$/u, + 'KnownCaip19ClassicAsset', + /^stellar:(?:pubnet|testnet)\/asset:[A-Za-z0-9]{1,12}-G[A-Z2-7]{55}$/u, + ); + +export const KnownCaip19Sep41AssetStruct = + definePattern<`${KnownCaip2ChainId}/${AssetType.Sep41}:${string}`>( + 'KnownCaip19Sep41Asset', + /^stellar:(?:pubnet|testnet)\/sep41:C[A-Z2-7]{55}$/u, ); -export type KnownCaip19AssetId = Infer; +/** CAIP-19 Sep41 asset ID */ +export type KnownCaip19Sep41AssetId = Infer; + +/** CAIP-19 Classic asset ID */ +export type KnownCaip19ClassicAssetId = Infer< + typeof KnownCaip19ClassicAssetStruct +>; + +/** CAIP-19 slip44 ID */ +export type KnownCaip19Slip44Id = Infer; + +/** CAIP-19 asset ID */ +export type KnownCaip19AssetId = + | KnownCaip19Sep41AssetId + | KnownCaip19ClassicAssetId; + +/** CAIP-19 asset ID or slip44 ID */ +export type KnownCaip19AssetIdOrSlip44Id = + | KnownCaip19AssetId + | KnownCaip19Slip44Id; diff --git a/merged-packages/stellar-wallet-snap/src/api/index.ts b/merged-packages/stellar-wallet-snap/src/api/index.ts index ddb76c29..e3a89147 100644 --- a/merged-packages/stellar-wallet-snap/src/api/index.ts +++ b/merged-packages/stellar-wallet-snap/src/api/index.ts @@ -5,5 +5,6 @@ export * from './loglevel'; export * from './url'; export * from './uuid'; export * from './address'; -export * from './multichain'; export * from './json'; +export * from './integer'; +export * from './xdr'; From bd7c97dfbcb5dc173218b0c42e20b0d1c897366a Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:02:32 +0800 Subject: [PATCH 029/384] chore: update constants --- .../stellar-wallet-snap/src/constants.ts | 10 +++ .../services/transaction/Transaction.test.ts | 84 +++++++++++++++++++ .../src/services/wallet/utils.ts | 73 ++-------------- 3 files changed, 102 insertions(+), 65 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts diff --git a/merged-packages/stellar-wallet-snap/src/constants.ts b/merged-packages/stellar-wallet-snap/src/constants.ts index 493566d8..93cf5e5a 100644 --- a/merged-packages/stellar-wallet-snap/src/constants.ts +++ b/merged-packages/stellar-wallet-snap/src/constants.ts @@ -20,6 +20,16 @@ export const BASE_RESERVE_STROOPS = XLM_PER_BASE_RESERVE * STROOPS_PER_XLM; */ export const STELLAR_COIN_TYPE = 148; +/** + * Stellar curve type. + * + * @see https://developers.stellar.org/docs/learn/fundamentals/transactions/signatures-multisig + */ +export const STELLAR_CURVE = 'ed25519'; + +/** Stellar BIP32 derivation path prefix. */ +export const STELLAR_DERIVATION_PATH_PREFIX = `m/44'/${STELLAR_COIN_TYPE}'`; + /** * The number of decimal places for the native asset of Stellar. * All assets (except custom assets) on the Stellar network use exactly 7 decimal places of precision - this is a hard-coded limit at the protocol level diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts new file mode 100644 index 00000000..55b126ab --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts @@ -0,0 +1,84 @@ +import { + Account, + Asset, + FeeBumpTransaction, + Keypair, + Networks, + Operation, + TransactionBuilder as StellarTransactionBuilder, +} from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import { Transaction } from './Transaction'; + +describe('Transaction', () => { + it('reports operationCount equal to transactionOperations length for a classic transaction', () => { + const source = Keypair.random(); + const dest = Keypair.random().publicKey(); + const inner = new StellarTransactionBuilder( + new Account(source.publicKey(), '1'), + { fee: '100', networkPassphrase: Networks.TESTNET }, + ) + .addOperation( + Operation.payment({ + destination: dest, + asset: Asset.native(), + amount: '1', + }), + ) + .setTimeout(60) + .build(); + + const wrapped = new Transaction(inner); + + expect(wrapped.transactionOperations).toHaveLength(1); + expect(wrapped.operationCount).toBe(1); + expect(wrapped.operationCount).toBe(wrapped.transactionOperations.length); + expect(wrapped.totalFee).toStrictEqual(new BigNumber(inner.fee)); + }); + + it('counts inner operations for a fee-bump envelope', () => { + const source = Keypair.random(); + const feeSource = Keypair.random(); + const dest = Keypair.random().publicKey(); + + const inner = new StellarTransactionBuilder( + new Account(source.publicKey(), '1'), + { fee: '100', networkPassphrase: Networks.TESTNET }, + ) + .addOperation( + Operation.payment({ + destination: dest, + asset: Asset.native(), + amount: '1', + }), + ) + .addOperation( + Operation.payment({ + destination: dest, + asset: Asset.native(), + amount: '2', + }), + ) + .setTimeout(60) + .build(); + + const feeBump = StellarTransactionBuilder.buildFeeBumpTransaction( + feeSource, + String(Number(inner.fee) * 2), + inner, + Networks.TESTNET, + ); + + const wrapped = new Transaction(feeBump); + + expect(wrapped.transactionOperations).toHaveLength(2); + expect(wrapped.operationCount).toBe(2); + expect(wrapped.operationCount).toBe(wrapped.transactionOperations.length); + expect(wrapped.getRaw()).toBeInstanceOf(FeeBumpTransaction); + expect(wrapped.totalFee).toStrictEqual(new BigNumber(feeBump.fee)); + expect(wrapped.totalFee.toFixed(0)).not.toBe( + new BigNumber(inner.fee).toFixed(0), + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/utils.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/utils.ts index a3d0f01a..5dabf8b1 100644 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/services/wallet/utils.ts @@ -1,70 +1,13 @@ -import type { CaipAssetId } from '@metamask/utils'; -import { parseCaipAssetType } from '@metamask/utils'; -import { Asset, Networks } from '@stellar/stellar-sdk'; - -import type { KnownCaip19AssetId } from '../../api'; -import { KnownCaip2ChainId, KnownCaip19Slip44Id } from '../../api'; - -const StellarNetwork: Record = { - [KnownCaip2ChainId.Mainnet]: Networks.PUBLIC, - [KnownCaip2ChainId.Testnet]: Networks.TESTNET, -}; - -/** - * Returns the Stellar network passphrase for the given scope (e.g. for transaction building). - * - * @param caip2ChainId - The CAIP-2 chain ID. - * @returns The Stellar Networks passphrase. - * @throws {Error} If the scope is not supported. - */ -export function getNetwork(caip2ChainId: KnownCaip2ChainId): Networks { - if (!(caip2ChainId in StellarNetwork)) { - throw new Error(`Network not found for caip2ChainId: ${caip2ChainId}`); - } - return StellarNetwork[caip2ChainId]; -} +import { STELLAR_DERIVATION_PATH_PREFIX } from "../../constants"; +import { StellarDerivationPath } from "./api"; /** - * Resolves a Stellar network passphrase to the corresponding CAIP-2 chain ID. + * Returns the Stellar BIP32 derivation path for the given index (e.g. `m/44'/148'/0'`). * - * @param network - The network name or Stellar Networks enum value. - * @returns The CAIP-2 chain ID for the network. - * @throws {Error} If the network is not recognized. + * @param index - The derivation index (account number). + * @returns The derivation path string. */ -export function getCaip2ChainId(network: string | Networks): KnownCaip2ChainId { - const networkValue = - typeof network === 'string' ? (network as Networks) : network; - const caip2ChainId = ( - Object.keys(StellarNetwork) as KnownCaip2ChainId[] - ).find((key) => StellarNetwork[key] === networkValue); - if (!caip2ChainId) { - throw new Error(`Caip2ChainId not found for network: ${network}`); - } - return caip2ChainId; -} - -/** - * Returns the Stellar asset for the given CAIP-19 asset ID. - * - * @param caip19AssetId - The CAIP-19 asset ID. - * @returns The Stellar asset. - * @throws {Error} If the asset is not recognized. - */ -export function getStellarAsset( - caip19AssetId: KnownCaip19AssetId | KnownCaip19Slip44Id, -): Asset { - if ( - caip19AssetId === KnownCaip19Slip44Id.Slip44Mainnet || - caip19AssetId === KnownCaip19Slip44Id.Slip44Testnet - ) { - return new Asset('native'); - } - - const { assetReference } = parseCaipAssetType(caip19AssetId as CaipAssetId); - - const [assetCode, assetIssuer] = assetReference.split('-'); - if (!assetCode || !assetIssuer) { - throw new Error(`Invalid asset reference: ${assetReference}`); +export function getDerivationPath(index: number): StellarDerivationPath { + return `${STELLAR_DERIVATION_PATH_PREFIX}/${index}'`; } - return new Asset(assetCode, assetIssuer); -} + \ No newline at end of file From 38723cd44d49628a9397921819954e775c6a53ea Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:39:48 +0800 Subject: [PATCH 030/384] chore: remove unuse test --- .../services/transaction/Transaction.test.ts | 84 ------------------- 1 file changed, 84 deletions(-) delete mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts deleted file mode 100644 index 55b126ab..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - Account, - Asset, - FeeBumpTransaction, - Keypair, - Networks, - Operation, - TransactionBuilder as StellarTransactionBuilder, -} from '@stellar/stellar-sdk'; -import { BigNumber } from 'bignumber.js'; - -import { Transaction } from './Transaction'; - -describe('Transaction', () => { - it('reports operationCount equal to transactionOperations length for a classic transaction', () => { - const source = Keypair.random(); - const dest = Keypair.random().publicKey(); - const inner = new StellarTransactionBuilder( - new Account(source.publicKey(), '1'), - { fee: '100', networkPassphrase: Networks.TESTNET }, - ) - .addOperation( - Operation.payment({ - destination: dest, - asset: Asset.native(), - amount: '1', - }), - ) - .setTimeout(60) - .build(); - - const wrapped = new Transaction(inner); - - expect(wrapped.transactionOperations).toHaveLength(1); - expect(wrapped.operationCount).toBe(1); - expect(wrapped.operationCount).toBe(wrapped.transactionOperations.length); - expect(wrapped.totalFee).toStrictEqual(new BigNumber(inner.fee)); - }); - - it('counts inner operations for a fee-bump envelope', () => { - const source = Keypair.random(); - const feeSource = Keypair.random(); - const dest = Keypair.random().publicKey(); - - const inner = new StellarTransactionBuilder( - new Account(source.publicKey(), '1'), - { fee: '100', networkPassphrase: Networks.TESTNET }, - ) - .addOperation( - Operation.payment({ - destination: dest, - asset: Asset.native(), - amount: '1', - }), - ) - .addOperation( - Operation.payment({ - destination: dest, - asset: Asset.native(), - amount: '2', - }), - ) - .setTimeout(60) - .build(); - - const feeBump = StellarTransactionBuilder.buildFeeBumpTransaction( - feeSource, - String(Number(inner.fee) * 2), - inner, - Networks.TESTNET, - ); - - const wrapped = new Transaction(feeBump); - - expect(wrapped.transactionOperations).toHaveLength(2); - expect(wrapped.operationCount).toBe(2); - expect(wrapped.operationCount).toBe(wrapped.transactionOperations.length); - expect(wrapped.getRaw()).toBeInstanceOf(FeeBumpTransaction); - expect(wrapped.totalFee).toStrictEqual(new BigNumber(feeBump.fee)); - expect(wrapped.totalFee.toFixed(0)).not.toBe( - new BigNumber(inner.fee).toFixed(0), - ); - }); -}); From b615e773fcef0afc6ff7b0a404826940a079a0d3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:58:03 +0800 Subject: [PATCH 031/384] fix: test --- .../stellar-wallet-snap/jest.config.js | 8 +- .../stellar-wallet-snap/src/api/index.ts | 1 + .../stellar-wallet-snap/src/constants.ts | 2 +- .../src/handlers/clientRequest/api.ts | 45 ------- .../clientRequest/changeTrustHandler.ts | 125 ------------------ .../handlers/clientRequest/clientRequest.ts | 71 ---------- .../src/handlers/keyring/keyring.test.ts | 4 +- .../src/services/account/derivation.ts | 2 +- .../src/services/wallet/utils.ts | 71 ++++++++-- 9 files changed, 72 insertions(+), 257 deletions(-) delete mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustHandler.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index 1bfdb437..72d82eac 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 80.26, - functions: 88.54, - lines: 87.48, - statements: 87.42, + branches: 80.51, + functions: 90.44, + lines: 91.71, + statements: 91.6, }, }, diff --git a/merged-packages/stellar-wallet-snap/src/api/index.ts b/merged-packages/stellar-wallet-snap/src/api/index.ts index e3a89147..930b6404 100644 --- a/merged-packages/stellar-wallet-snap/src/api/index.ts +++ b/merged-packages/stellar-wallet-snap/src/api/index.ts @@ -8,3 +8,4 @@ export * from './address'; export * from './json'; export * from './integer'; export * from './xdr'; +export * from './multichain'; diff --git a/merged-packages/stellar-wallet-snap/src/constants.ts b/merged-packages/stellar-wallet-snap/src/constants.ts index 93cf5e5a..1bcf5991 100644 --- a/merged-packages/stellar-wallet-snap/src/constants.ts +++ b/merged-packages/stellar-wallet-snap/src/constants.ts @@ -20,7 +20,7 @@ export const BASE_RESERVE_STROOPS = XLM_PER_BASE_RESERVE * STROOPS_PER_XLM; */ export const STELLAR_COIN_TYPE = 148; -/** +/** * Stellar curve type. * * @see https://developers.stellar.org/docs/learn/fundamentals/transactions/signatures-multisig diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts deleted file mode 100644 index f64f6d08..00000000 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Infer } from '@metamask/superstruct'; -import { enums, object, assign } from '@metamask/superstruct'; - -import { - JsonRpcRequestStruct, - KnownCaip2ChainIdStruct, - KnownCaip19AssetStruct, - UuidStruct, -} from '../../api'; - -/** - * Enum for the client request method. - */ -export enum ClientRequestMethod { - SignChangeTrustline = 'signChangeTrustline', -} - -/** - * Validation struct for the client request method. - */ -export const ClientRequestMethodStruct = enums( - Object.values(ClientRequestMethod), -); - -/** - * Validation struct for the signChangeTrustline JSON-RPC request. - */ -export const SignChangeTrustlineJsonRpcRequestStruct = assign( - JsonRpcRequestStruct, - object({ - method: ClientRequestMethodStruct, - params: object({ - accountId: UuidStruct, - asset: KnownCaip19AssetStruct, - scope: KnownCaip2ChainIdStruct, - }), - }), -); - -/** - * Type for the signChangeTrustline JSON-RPC request. - */ -export type SignChangeTrustlineJsonRpcRequest = Infer< - typeof SignChangeTrustlineJsonRpcRequestStruct ->; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustHandler.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustHandler.ts deleted file mode 100644 index 183d4a4f..00000000 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustHandler.ts +++ /dev/null @@ -1,125 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { UserRejectedRequestError } from '@metamask/snaps-sdk'; -import type { Json, JsonRpcRequest } from '@metamask/utils'; -import { ensureError } from '@metamask/utils'; - -import { SignChangeTrustlineJsonRpcRequestStruct } from './api'; -import type { KnownCaip2ChainId, StellarAddress } from '../../api'; -import type { AccountService } from '../../services/account'; -import type { WalletService } from '../../services/wallet'; -import { AccountNotActivatedException } from '../../services/wallet'; -import { validateRequest } from '../../utils'; -import { createPrefixedLogger, type ILogger } from '../../utils/logger'; - -export class ChangeTrustHandler { - readonly #logger: ILogger; - - readonly #accountService: AccountService; - - readonly #walletService: WalletService; - - constructor({ - logger, - accountService, - walletService, - }: { - logger: ILogger; - accountService: AccountService; - walletService: WalletService; - }) { - this.#logger = createPrefixedLogger(logger, '[💼 ChangeTrustHandler]'); - this.#accountService = accountService; - this.#walletService = walletService; - } - - /** - * Handles a change trustline transaction request. - * - * @param request - The JSON-RPC request containing the change trustline transaction. - * @returns A promise that resolves to the JSON-RPC response. - */ - async handle(request: JsonRpcRequest): Promise { - validateRequest(request, SignChangeTrustlineJsonRpcRequestStruct); - - const { scope, accountId, asset } = request.params; - - try { - const { - wallet, - account: { address }, - } = await this.#accountService.resolveAccount({ - scope, - accountIdOrAddress: accountId, - resolveOptions: { - activated: true, - }, - }); - - const baseFee = await this.#walletService.network.getBaseFee(scope); - - // build a transaction for change trustline without assigning the actual sequence number yet - const transaction = this.#walletService.builder.changeTrust({ - account: wallet, - asset, - scope, - baseFee: baseFee.toString(), - }); - - const confirmed = await this.#confirmSignChangeTrustline({ - scope, - address, - asset, - fee: transaction.getTotalFee().toString(), - }); - - if (!confirmed) { - throw ensureError(new UserRejectedRequestError()); - } - - await this.#walletService.signTransaction({ - account: wallet, - scope, - transaction, - baseFee, - }); - - return await this.#walletService.network.send({ - transaction, - scope, - pollTransaction: true, - }); - } catch (error: unknown) { - if (error instanceof AccountNotActivatedException) { - await this.#showAccountNotActivatedAlert(); - return null; - } - - this.#logger.error('Failed to handle change trustline transaction', { - error, - }); - - throw ensureError( - new Error('Failed to handle change trustline transaction'), - ); - } - } - - async #showAccountNotActivatedAlert(): Promise { - throw new Error('Account not implemented'); - } - - async #confirmSignChangeTrustline({ - scope, - address, - asset, - fee, - }: { - scope: KnownCaip2ChainId; - address: StellarAddress; - asset: string; - fee: string; - }): Promise { - return true; - } -} -/* eslint-enable @typescript-eslint/no-unused-vars */ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts deleted file mode 100644 index 4c3cdace..00000000 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; -import { MethodNotFoundError } from '@metamask/snaps-sdk'; - -import { ClientRequestMethod } from './api'; -import { ChangeTrustHandler } from './changeTrustHandler'; -import type { AccountService } from '../../services/account'; -import type { WalletService } from '../../services/wallet/WalletService'; -import { withCatchAndThrowSnapError } from '../../utils'; -import { createPrefixedLogger } from '../../utils/logger'; -import type { ILogger } from '../../utils/logger'; - -export class ClientRequestHandler { - readonly #logger: ILogger; - - readonly #changeTrustHandler: ChangeTrustHandler; - - constructor({ - logger, - accountService, - walletService, - }: { - logger: ILogger; - accountService: AccountService; - walletService: WalletService; - }) { - this.#logger = createPrefixedLogger(logger, '[👋 ClientRequestHandler]'); - this.#changeTrustHandler = new ChangeTrustHandler({ - logger, - accountService, - walletService, - }); - } - - /** - * Handles JSON-RPC requests originating exclusively from the client - as defined in [SIP-31](https://github.com/MetaMask/SIPs/blob/main/SIPS/sip-31.md) - - * by routing them to the appropriate use case, based on the method. Some methods need to be implemented - * as part of the [Unified Non-EVM Send](https://www.notion.so/metamask-consensys/Unified-Non-EVM-Send-248f86d67d6880278445f9ad75478471) specification. - * - * @param request - The JSON-RPC request containing the method and parameters. - * @returns The response to the JSON-RPC request. - * @throws {MethodNotFoundError} If the method is not found. - * @throws {InvalidParamsError} If the params are invalid. - */ - async handle(request: JsonRpcRequest): Promise { - this.#logger.log('Handling client request', request); - - const result = - (await withCatchAndThrowSnapError(async () => { - return this.#handleClientRequest(request); - }, this.#logger)) ?? null; - - return result; - } - - /** - * Handles a client request by routing it to the appropriate use case, based on the method. - * - * @param request - The JSON-RPC request containing the method and parameters. - * @returns The response to the JSON-RPC request. - */ - async #handleClientRequest(request: JsonRpcRequest): Promise { - const { method } = request; - - switch (method as ClientRequestMethod) { - case ClientRequestMethod.SignChangeTrustline: - return this.#changeTrustHandler.handle(request); - default: - throw new MethodNotFoundError() as Error; - } - } -} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 72a4f70f..5e162123 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -13,7 +13,7 @@ import { InvalidParamsError, type JsonRpcRequest } from '@metamask/snaps-sdk'; import { KeyringHandler } from './keyring'; import { KnownCaip2ChainId, - KnownCaip19Slip44Id, + KnownCaip19Slip44IdMap, MultichainMethod, } from '../../api'; import { @@ -323,7 +323,7 @@ describe('KeyringHandler', () => { it('throws `Method not implemented.` error', async () => { await expect( keyringHandler.getAccountBalances('1', [ - KnownCaip19Slip44Id.Slip44Mainnet, + KnownCaip19Slip44IdMap[KnownCaip2ChainId.Mainnet], ]), ).rejects.toThrow('Method not implemented.'); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/account/derivation.ts b/merged-packages/stellar-wallet-snap/src/services/account/derivation.ts index 11974247..9ee1bff5 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/derivation.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/derivation.ts @@ -1,7 +1,7 @@ import { hexToBytes } from '@metamask/utils'; import { type StellarDerivationPath } from './api'; -import { STELLAR_COIN_TYPE } from '../../api'; +import { STELLAR_COIN_TYPE } from '../../constants'; import { createPrefixedLogger, getBip32Entropy, diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/utils.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/utils.ts index 5dabf8b1..93bc8819 100644 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/services/wallet/utils.ts @@ -1,13 +1,68 @@ -import { STELLAR_DERIVATION_PATH_PREFIX } from "../../constants"; -import { StellarDerivationPath } from "./api"; +import type { CaipAssetId } from '@metamask/utils'; +import { parseCaipAssetType } from '@metamask/utils'; +import { Asset, Networks } from '@stellar/stellar-sdk'; + +import type { KnownCaip19AssetId, KnownCaip19Slip44Id } from '../../api'; +import { KnownCaip2ChainId } from '../../api'; +import { isSlip44Id } from '../../utils'; + +const StellarNetwork: Record = { + [KnownCaip2ChainId.Mainnet]: Networks.PUBLIC, + [KnownCaip2ChainId.Testnet]: Networks.TESTNET, +}; + +/** + * Returns the Stellar network passphrase for the given scope (e.g. for transaction building). + * + * @param caip2ChainId - The CAIP-2 chain ID. + * @returns The Stellar Networks passphrase. + * @throws {Error} If the scope is not supported. + */ +export function getNetwork(caip2ChainId: KnownCaip2ChainId): Networks { + if (!(caip2ChainId in StellarNetwork)) { + throw new Error(`Network not found for caip2ChainId: ${caip2ChainId}`); + } + return StellarNetwork[caip2ChainId]; +} /** - * Returns the Stellar BIP32 derivation path for the given index (e.g. `m/44'/148'/0'`). + * Resolves a Stellar network passphrase to the corresponding CAIP-2 chain ID. * - * @param index - The derivation index (account number). - * @returns The derivation path string. + * @param network - The network name or Stellar Networks enum value. + * @returns The CAIP-2 chain ID for the network. + * @throws {Error} If the network is not recognized. */ -export function getDerivationPath(index: number): StellarDerivationPath { - return `${STELLAR_DERIVATION_PATH_PREFIX}/${index}'`; +export function getCaip2ChainId(network: string | Networks): KnownCaip2ChainId { + const networkValue = + typeof network === 'string' ? (network as Networks) : network; + const caip2ChainId = ( + Object.keys(StellarNetwork) as KnownCaip2ChainId[] + ).find((key) => StellarNetwork[key] === networkValue); + if (!caip2ChainId) { + throw new Error(`Caip2ChainId not found for network: ${network}`); + } + return caip2ChainId; +} + +/** + * Returns the Stellar asset for the given CAIP-19 asset ID. + * + * @param caip19AssetId - The CAIP-19 asset ID. + * @returns The Stellar asset. + * @throws {Error} If the asset is not recognized. + */ +export function getStellarAsset( + caip19AssetId: KnownCaip19AssetId | KnownCaip19Slip44Id, +): Asset { + if (isSlip44Id(caip19AssetId)) { + return new Asset('native'); + } + + const { assetReference } = parseCaipAssetType(caip19AssetId as CaipAssetId); + + const [assetCode, assetIssuer] = assetReference.split('-'); + if (!assetCode || !assetIssuer) { + throw new Error(`Invalid asset reference: ${assetReference}`); } - \ No newline at end of file + return new Asset(assetCode, assetIssuer); +} From ab63c3987ed021704a89fe9c73768620584703f9 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:07:54 +0800 Subject: [PATCH 032/384] chore: fix comment --- .../stellar-wallet-snap/src/context.ts | 10 ++-------- .../stellar-wallet-snap/src/handlers/index.ts | 1 - .../stellar-wallet-snap/src/index.ts | 10 ++-------- .../src/services/account/utils.ts | 19 +++++++++++++++++++ .../src/utils/assert.test.ts | 14 +++++++------- .../stellar-wallet-snap/src/utils/assert.ts | 2 +- 6 files changed, 31 insertions(+), 25 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/services/account/utils.ts diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index cf97a787..c0e23aba 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -1,4 +1,4 @@ -import { KeyringHandler, ClientRequestHandler } from './handlers'; +import { KeyringHandler } from './handlers'; import { AccountService } from './services/account/AccountService'; import { AccountsRepository } from './services/account/AccountsRepository'; import { createAccountDeriver } from './services/account/derivation'; @@ -42,10 +42,4 @@ const keyringHandler = new KeyringHandler({ accountService, }); -const clientRequestHandler = new ClientRequestHandler({ - logger, - accountService, - walletService, -}); - -export { keyringHandler, clientRequestHandler }; +export { keyringHandler }; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/index.ts b/merged-packages/stellar-wallet-snap/src/handlers/index.ts index 2334bd06..2e3cb47a 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/index.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/index.ts @@ -1,2 +1 @@ export * from './keyring/keyring'; -export * from './clientRequest/clientRequest'; diff --git a/merged-packages/stellar-wallet-snap/src/index.ts b/merged-packages/stellar-wallet-snap/src/index.ts index 8f9c4151..45ee9703 100644 --- a/merged-packages/stellar-wallet-snap/src/index.ts +++ b/merged-packages/stellar-wallet-snap/src/index.ts @@ -1,14 +1,8 @@ -import type { - OnClientRequestHandler, - OnKeyringRequestHandler, -} from '@metamask/snaps-sdk'; +import type { OnKeyringRequestHandler } from '@metamask/snaps-sdk'; -import { keyringHandler, clientRequestHandler } from './context'; +import { keyringHandler } from './context'; export const onKeyringRequest: OnKeyringRequestHandler = async ({ origin, request, }) => keyringHandler.handle(origin, request); - -export const onClientRequest: OnClientRequestHandler = async ({ request }) => - clientRequestHandler.handle(request); diff --git a/merged-packages/stellar-wallet-snap/src/services/account/utils.ts b/merged-packages/stellar-wallet-snap/src/services/account/utils.ts new file mode 100644 index 00000000..aea0aa3c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/account/utils.ts @@ -0,0 +1,19 @@ +import { DerivedAccountAddressMismatchException } from './exceptions'; +import type { StellarAddress } from '../../api'; +import { isSameStr } from '../../utils'; + +/** + * Asserts two Stellar strkeys refer to the same account (case-insensitive). + * + * @param expectedAddress - Address treated as canonical for {@link DerivedAccountAddressMismatchException}. + * @param actualAddress - Address to compare (e.g. derived or loaded from the network). + * @throws {DerivedAccountAddressMismatchException} When the addresses differ. + */ +export function assertSameAddress( + expectedAddress: StellarAddress, + actualAddress: StellarAddress, +): void { + if (!isSameStr(expectedAddress, actualAddress)) { + throw new DerivedAccountAddressMismatchException(expectedAddress); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/utils/assert.test.ts b/merged-packages/stellar-wallet-snap/src/utils/assert.test.ts index e5d3ad14..839130a2 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/assert.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/assert.test.ts @@ -1,17 +1,17 @@ -import { assertIsSameStr } from './assert'; +import { isSameStr } from './assert'; -describe('assertIsSameStr', () => { +describe('isSameStr', () => { it('returns true when strings differ only by case', () => { - expect(assertIsSameStr('Hello', 'hello')).toBe(true); - expect(assertIsSameStr('ABC', 'abc')).toBe(true); + expect(isSameStr('Hello', 'hello')).toBe(true); + expect(isSameStr('ABC', 'abc')).toBe(true); }); it('returns true when strings are identical', () => { - expect(assertIsSameStr('same', 'same')).toBe(true); + expect(isSameStr('same', 'same')).toBe(true); }); it('returns false when strings differ beyond case', () => { - expect(assertIsSameStr('hello', 'world')).toBe(false); - expect(assertIsSameStr('a', 'b')).toBe(false); + expect(isSameStr('hello', 'world')).toBe(false); + expect(isSameStr('a', 'b')).toBe(false); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/utils/assert.ts b/merged-packages/stellar-wallet-snap/src/utils/assert.ts index 1fccd8e4..093ec795 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/assert.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/assert.ts @@ -5,6 +5,6 @@ * @param string2 - The second string to compare. * @returns True if the strings are the same ignoring case, false otherwise. */ -export function assertIsSameStr(string1: string, string2: string): boolean { +export function isSameStr(string1: string, string2: string): boolean { return string1.toLowerCase() === string2.toLowerCase(); } From d8e319d05be32fdb5ebf168d60f1b48bfe13d628 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:12:28 +0800 Subject: [PATCH 033/384] chore: remove test collection --- merged-packages/stellar-wallet-snap/jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index 72d82eac..998fc0a5 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -4,7 +4,7 @@ */ const config = { // Indicates whether the coverage information should be collected while executing the test - collectCoverage: true, + collectCoverage: false, // An array of glob patterns indicating a set of files for which coverage information should be collected collectCoverageFrom: ['./src/**/*.ts', './src/**/*.tsx'], From a0a835a4ecc9d786d983fa7460b7435ce09f9c51 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:20:09 +0800 Subject: [PATCH 034/384] chore: update test config --- merged-packages/stellar-wallet-snap/jest.config.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index 998fc0a5..7f6792d2 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -4,7 +4,7 @@ */ const config = { // Indicates whether the coverage information should be collected while executing the test - collectCoverage: false, + collectCoverage: true, // An array of glob patterns indicating a set of files for which coverage information should be collected collectCoverageFrom: ['./src/**/*.ts', './src/**/*.tsx'], @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 80.51, - functions: 90.44, - lines: 91.71, - statements: 91.6, + branches: 79.92, + functions: 89.87, + lines: 91.1, + statements: 91, }, }, From d68fab234fe19152b85222822880cc70c29e7820 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:30:14 +0800 Subject: [PATCH 035/384] refactor: onchain acc and account and network --- .../stellar-wallet-snap/.env.example | 8 +- .../stellar-wallet-snap/snap.config.ts | 3 + .../stellar-wallet-snap/src/config.ts | 33 +- .../stellar-wallet-snap/src/handlers/base.ts | 210 ++++++ .../src/handlers/keyring/api.test.ts | 189 +++++- .../src/handlers/keyring/api.ts | 118 +++- .../src/handlers/keyring/base.ts | 36 ++ .../src/handlers/keyring/index.ts | 3 +- .../src/handlers/keyring/keyring.test.ts | 82 +-- .../src/handlers/keyring/keyring.ts | 41 +- .../AccountBalanceRepository.ts | 53 ++ .../account-balance/AccountBalanceService.ts | 211 ++++++ .../src/services/account-balance/api.ts | 34 + .../src/services/account-balance/index.ts | 3 + .../services/account/AccountService.test.ts | 137 +--- .../src/services/account/AccountService.ts | 171 ++--- .../services/account/AccountsRepository.ts | 31 +- .../{fixtures.ts => account.fixtures.ts} | 46 +- .../src/services/account/api.ts | 6 +- .../src/services/account/derivation.ts | 79 --- .../src/services/account/exceptions.ts | 19 +- .../src/services/account/index.ts | 3 +- .../src/services/account/utils.test.ts | 18 + .../services/network/NetworkService.test.ts | 612 ++++++++++++++++++ .../src/services/network/NetworkService.ts | 518 +++++++++++++++ .../src/services/network/api.ts | 34 + .../src/services/network/exceptions.ts | 77 +++ .../src/services/network/index.ts | 4 + .../src/services/network/utils.ts | 170 +++++ .../on-chain-account/OnChainAccount.test.ts | 332 ++++++++++ .../on-chain-account/OnChainAccount.ts | 401 ++++++++++++ .../OnChainAccountService.test.ts | 169 +++++ .../on-chain-account/OnChainAccountService.ts | 106 +++ .../__mocks__/onChainAccount.fixtures.ts | 135 ++++ .../src/services/on-chain-account/api.ts | 29 + .../services/on-chain-account/exceptions.ts | 21 + .../src/services/on-chain-account/index.ts | 3 + .../src/services/on-chain-account/utils.ts | 37 ++ .../services/transaction/Transaction.test.ts | 84 +++ .../src/services/transaction/Transaction.ts | 193 ++++++ .../__mocks__/transaction.fixtures.ts | 294 +++++++++ .../src/services/transaction/exceptions.ts | 180 ++++++ .../services/wallet/NetworkService.test.ts | 275 -------- .../src/services/wallet/NetworkService.ts | 182 ------ .../src/services/wallet/Transaction.ts | 51 -- .../wallet/TransactionBuilder.test.ts | 137 ---- .../src/services/wallet/TransactionBuilder.ts | 156 ----- .../src/services/wallet/Wallet.test.ts | 171 +++++ .../src/services/wallet/Wallet.ts | 115 +++- .../src/services/wallet/WalletService.test.ts | 258 ++------ .../src/services/wallet/WalletService.ts | 191 ++---- .../src/services/wallet/__mocks__/fixtures.ts | 3 - .../wallet/__mocks__/wallet.fixtures.ts | 24 + .../src/services/wallet/api.ts | 36 +- .../src/services/wallet/exceptions.ts | 77 +-- .../src/services/wallet/index.ts | 4 +- .../src/services/wallet/utils.ts | 70 +- 57 files changed, 4923 insertions(+), 1760 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/base.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceRepository.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceService.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/account-balance/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/account-balance/index.ts rename merged-packages/stellar-wallet-snap/src/services/account/__mocks__/{fixtures.ts => account.fixtures.ts} (58%) delete mode 100644 merged-packages/stellar-wallet-snap/src/services/account/derivation.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/account/utils.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/network/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/network/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/network/utils.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/exceptions.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/services/wallet/NetworkService.test.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/services/wallet/NetworkService.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/services/wallet/Transaction.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/services/wallet/TransactionBuilder.test.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/services/wallet/TransactionBuilder.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/services/wallet/__mocks__/fixtures.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/wallet/__mocks__/wallet.fixtures.ts diff --git a/merged-packages/stellar-wallet-snap/.env.example b/merged-packages/stellar-wallet-snap/.env.example index d93717eb..95c1daf4 100644 --- a/merged-packages/stellar-wallet-snap/.env.example +++ b/merged-packages/stellar-wallet-snap/.env.example @@ -29,4 +29,10 @@ RPC_URL_TESTNET=https://soroban-testnet.stellar.org HORIZON_URL_TESTNET=https://horizon-testnet.stellar.org # Testnet Explorer Base URLs -EXPLORER_TESTNET_BASE_URL=https://stellar.expert/explorer/testnet \ No newline at end of file +EXPLORER_TESTNET_BASE_URL=https://stellar.expert/explorer/testnet + +# Token API Base URL +TOKEN_API_BASE_URL=http://tokens.api.cx.metamask.io + +# Static API Base URL +STATIC_API_BASE_URL=https://static.api.cx.metamask.io diff --git a/merged-packages/stellar-wallet-snap/snap.config.ts b/merged-packages/stellar-wallet-snap/snap.config.ts index 77c871d8..2b20afa4 100644 --- a/merged-packages/stellar-wallet-snap/snap.config.ts +++ b/merged-packages/stellar-wallet-snap/snap.config.ts @@ -21,6 +21,9 @@ const config: SnapConfig = { TRANSACTION_TIMEOUT: process.env.TRANSACTION_TIMEOUT ?? '', TRANSACTION_POLLING_ATTEMPTS: process.env.TRANSACTION_POLLING_ATTEMPTS ?? '', + TOKEN_API_BASE_URL: process.env.TOKEN_API_BASE_URL ?? '', + TOKEN_API_CHUNK_SIZE: process.env.TOKEN_API_CHUNK_SIZE ?? '', + STATIC_API_BASE_URL: process.env.STATIC_API_BASE_URL ?? '', }, polyfills: true, }; diff --git a/merged-packages/stellar-wallet-snap/src/config.ts b/merged-packages/stellar-wallet-snap/src/config.ts index 2a810092..3c93151c 100644 --- a/merged-packages/stellar-wallet-snap/src/config.ts +++ b/merged-packages/stellar-wallet-snap/src/config.ts @@ -57,18 +57,35 @@ const selectedNetworkStruct = coerce( (value: string) => (value === '' ? undefined : value.toLowerCase()), ); +/** + * A struct for validating the network config map. + */ +const networkConfigMapStruct = record( + KnownCaip2ChainIdStruct, + networkConfigStruct, +); + /** * A struct for validating the config. */ const ConfigStruct = object({ environment: enums(Object.values(Environment)), logLevel: LogLevelStruct, - networks: record(KnownCaip2ChainIdStruct, networkConfigStruct), + networks: networkConfigMapStruct, selectedNetwork: selectedNetworkStruct, transaction: object({ timeout: parseIntegerStruct(100, 180), pollingAttempts: parseIntegerStruct(0, 10), }), + api: object({ + tokenApi: object({ + baseUrl: UrlStruct, + chunkSize: parseIntegerStruct(1, 100), + }), + staticApi: object({ + baseUrl: UrlStruct, + }), + }), }); /** @@ -76,6 +93,11 @@ const ConfigStruct = object({ */ export type Config = Infer; +/** + * The network config type. + */ +export type NetworkConfig = Infer; + /** * The app config. * Built at module load from env vars injected at build time (see snap.config.ts). @@ -102,6 +124,15 @@ export const AppConfig = create( timeout: process.env.TRANSACTION_TIMEOUT, pollingAttempts: process.env.TRANSACTION_POLLING_ATTEMPTS, }, + api: { + tokenApi: { + baseUrl: process.env.TOKEN_API_BASE_URL, + chunkSize: process.env.TOKEN_API_CHUNK_SIZE, + }, + staticApi: { + baseUrl: process.env.STATIC_API_BASE_URL, + }, + }, }, ConfigStruct, ); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/base.ts new file mode 100644 index 00000000..e4cef5f5 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/base.ts @@ -0,0 +1,210 @@ +import type { Struct } from '@metamask/superstruct'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; +import { ensureError } from '@metamask/utils'; + +import { AppConfig } from '../config'; +import type { + AccountService, + StellarKeyringAccount, +} from '../services/account'; +import { AccountNotActivatedException } from '../services/network'; +import type { OnChainAccountService } from '../services/on-chain-account'; +import { OnChainAccount } from '../services/on-chain-account'; +import type { WalletService } from '../services/wallet'; +import { Wallet } from '../services/wallet'; +import type { ILogger } from '../utils'; +import { validateRequest, validateResponse } from '../utils'; + +export const DEFAULT_RESOLVE_ACCOUNT_OPTIONS = { + onChainAccount: true, + wallet: true, +} as const; + +export type DefaultResolveAccountOptions = + typeof DEFAULT_RESOLVE_ACCOUNT_OPTIONS; + +export type ResolveAccountOptions = { + /** Whether to load the activated on-chain account. */ + onChainAccount: boolean; + /** Whether to load the wallet. */ + wallet: boolean; +}; + +export type ResolvedActivatedAccountFor = { + account: StellarKeyringAccount; +} & (Opts['onChainAccount'] extends true + ? { onChainAccount: OnChainAccount } + : unknown) & + (Opts['wallet'] extends true ? { wallet: Wallet } : unknown); + +/** Full resolution using {@link DEFAULT_RESOLVE_ACCOUNT_OPTIONS}. */ +export type ResolvedActivatedAccount = + ResolvedActivatedAccountFor; + +/** + * A base class for client request handlers that require an activated account. + */ +export abstract class WithActiveAccountResolve< + RequestType extends Json, + ResponseType extends Json, + Opts extends ResolveAccountOptions = DefaultResolveAccountOptions, +> { + protected readonly logger: ILogger; + + protected readonly accountService: AccountService; + + protected readonly onChainAccountService: OnChainAccountService; + + protected readonly walletService: WalletService; + + protected readonly requestStruct: Struct; + + protected readonly responseStruct: Struct; + + readonly #resolveAccountOptions: ResolveAccountOptions; + + constructor({ + logger, + accountService, + onChainAccountService, + walletService, + requestStruct, + responseStruct, + resolveAccountOptions, + }: { + logger: ILogger; + accountService: AccountService; + onChainAccountService: OnChainAccountService; + walletService: WalletService; + requestStruct: Struct; + responseStruct: Struct; + /** Partial override; omitted flags default to {@link DEFAULT_RESOLVE_ACCOUNT_OPTIONS}. */ + resolveAccountOptions?: Partial; + }) { + this.logger = logger; + this.accountService = accountService; + this.onChainAccountService = onChainAccountService; + this.walletService = walletService; + this.requestStruct = requestStruct; + this.responseStruct = responseStruct; + this.#resolveAccountOptions = { + onChainAccount: + resolveAccountOptions?.onChainAccount ?? + DEFAULT_RESOLVE_ACCOUNT_OPTIONS.onChainAccount, + wallet: + resolveAccountOptions?.wallet ?? DEFAULT_RESOLVE_ACCOUNT_OPTIONS.wallet, + }; + } + + protected abstract _handle( + resolved: ResolvedActivatedAccountFor, + request: RequestType, + ): Promise; + + /** + * Handles a JSON-RPC request by resolving an activated account and calling the _handle method. + * + * @param request - The JSON-RPC request to handle. + * @returns The result of the _handle method. + */ + async handle( + request: RequestType | JsonRpcRequest | Json, + ): Promise { + this.logger.debug('Handling request', { request }); + + const validatedRequest = validateRequest(request, this.requestStruct); + + let resolvedAccount: ResolvedActivatedAccountFor; + try { + resolvedAccount = await this.resolveAccount(validatedRequest); + } catch (error: unknown) { + if (error instanceof AccountNotActivatedException) { + return await this.handleAccountNotActivatedError(error); + } + throw ensureError(error); + } + + let result: ResponseType | Json; + try { + result = await this._handle(resolvedAccount, validatedRequest); + } catch (error: unknown) { + this.logger.logErrorWithDetails('Error handling request', error); + throw error; + } + + this.logger.debug('Handled request', { + result: JSON.stringify(result, null, 2), + }); + + validateResponse(result, this.responseStruct); + + return result; + } + + protected async resolveAccount( + request: RequestType, + ): Promise> { + const { onChainAccount: loadOnChain, wallet: loadWallet } = + this.#resolveAccountOptions; + + const { account } = await this.accountService.resolveAccount({ + accountId: this.getAccountId(request), + }); + + const promises: Promise[] = []; + + if (loadOnChain) { + promises.push( + this.onChainAccountService.resolveOnChainAccount( + account, + AppConfig.selectedNetwork, + ), + ); + } + if (loadWallet) { + promises.push(this.walletService.resolveWallet(account)); + } + + const entries = await Promise.all(promises); + + const onChainAccount = entries.find( + (entry): entry is OnChainAccount => entry instanceof OnChainAccount, + ); + const wallet = entries.find( + (entry): entry is Wallet => entry instanceof Wallet, + ); + + return { + account, + ...(onChainAccount === undefined ? {} : { onChainAccount }), + ...(wallet === undefined ? {} : { wallet }), + } as ResolvedActivatedAccountFor; + } + + /** + * Abstract method to get the account id from the request. + * + * @param request - The request to get the account id from. + * @returns The account id. + */ + protected abstract getAccountId(request: RequestType): string; + + async #showAccountNotActivatedAlert(): Promise { + // TODO: Implement account not activated alert + throw new Error('Account not activated: user alert not implemented'); + } + + /** + * Handles the account not activated error by showing an alert. + * Rethrows the error to be handled by the caller. + * + * @param error - The account not activated error. + * @returns A promise that resolves to the account not activated error. + */ + protected async handleAccountNotActivatedError( + error: AccountNotActivatedException, + ): Promise { + await this.#showAccountNotActivatedAlert(); + throw error; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts index 18febd79..d64db251 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts @@ -4,13 +4,37 @@ import { CreateAccountOptionsStruct, ResolveAccountAddressRequestStruct, DiscoverAccountsStruct, + ListAccountTransactionsRequestStruct, + MultichainMethod, + MultichainMethodStruct, + SignMessageRequestStruct, + SignMessageResponseStruct, + SignTransactionRequestStruct, + SignTransactionResponseStruct, } from './api'; -import { KnownCaip2ChainId, MultichainMethod } from '../../api'; +import { KnownCaip2ChainId } from '../../api'; import type { StellarKeyringAccount } from '../../services/account'; -import { generateMockStellarKeyringAccounts } from '../../services/account/__mocks__/fixtures'; +import { generateMockStellarKeyringAccounts } from '../../services/account/__mocks__/account.fixtures'; const mockAccounts = generateMockStellarKeyringAccounts(1, 'entropy-source-1'); const account = mockAccounts[0] as StellarKeyringAccount; +const keyringRequestId = '11111111-1111-4111-8111-111111111111'; +const xdr = `AAAAAgAAAADjngeX0YTNoQ15A0xC83aMm/sDnXrmLF+apmXvdmkUugAAAGQAC3gAAAAAQQAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAOZfkjSFZ31vI/Nx28cC6iAFWLWcPIvJhM2NVoxmfgVTAAAAAAAAAAAAmJaAAAAAAAAAAAA=`; + +describe('MultichainMethodStruct', () => { + it.each([MultichainMethod.SignMessage, MultichainMethod.SignTransaction])( + 'accepts a supported multichain method', + (method) => { + expect(() => assert(method, MultichainMethodStruct)).not.toThrow(); + }, + ); + + it('rejects an unsupported method string', () => { + expect(() => assert('eth_sendTransaction', MultichainMethodStruct)).toThrow( + StructError, + ); + }); +}); describe('CreateAccountOptionsStruct', () => { it.each([ @@ -152,3 +176,164 @@ describe('DiscoverAccountsStruct', () => { expect(() => assert(request, DiscoverAccountsStruct)).toThrow(StructError); }); }); + +describe('SignMessageRequestStruct', () => { + const validSignMessageRequest = { + id: keyringRequestId, + origin: 'https://example.com', + scope: KnownCaip2ChainId.Mainnet, + account: account.id, + request: { + method: MultichainMethod.SignMessage, + params: { message: 'Hello, world!' }, + }, + }; + + it('accepts a valid signMessage keyring request', () => { + expect(() => + assert(validSignMessageRequest, SignMessageRequestStruct), + ).not.toThrow(); + }); + + it.each([ + { + ...validSignMessageRequest, + request: { + method: MultichainMethod.SignTransaction, + params: { message: 'Hello' }, + }, + }, + { + ...validSignMessageRequest, + request: { + method: MultichainMethod.SignMessage, + params: { message: '' }, + }, + }, + { + ...validSignMessageRequest, + account: 'not-a-uuid', + }, + { + ...validSignMessageRequest, + scope: 'invalid:scope' as KnownCaip2ChainId, + }, + { + ...validSignMessageRequest, + id: 'not-a-uuid', + }, + ])('rejects an invalid signMessage request', (request) => { + expect(() => assert(request, SignMessageRequestStruct)).toThrow( + StructError, + ); + }); +}); + +describe('SignMessageResponseStruct', () => { + it('accepts a nonempty base64 signature', () => { + expect(() => + assert({ signature: btoa('signed') }, SignMessageResponseStruct), + ).not.toThrow(); + }); + + it.each([{ signature: '' }, { signature: 'not!!!valid-base64' }])( + 'rejects an invalid signMessage response', + (response) => { + expect(() => assert(response, SignMessageResponseStruct)).toThrow( + StructError, + ); + }, + ); +}); + +describe('SignTransactionRequestStruct', () => { + const validSignTransactionRequest = { + id: keyringRequestId, + origin: 'https://example.com', + scope: KnownCaip2ChainId.Mainnet, + account: account.id, + request: { + method: MultichainMethod.SignTransaction, + params: { transaction: xdr }, + }, + }; + + it('accepts a valid signTransaction keyring request', () => { + expect(() => + assert(validSignTransactionRequest, SignTransactionRequestStruct), + ).not.toThrow(); + }); + + it.each([ + { + ...validSignTransactionRequest, + request: { + method: MultichainMethod.SignMessage, + params: { transaction: xdr }, + }, + }, + { + ...validSignTransactionRequest, + request: { + method: MultichainMethod.SignTransaction, + params: { transaction: 'not-valid-xdr' }, + }, + }, + { + ...validSignTransactionRequest, + account: 'not-a-uuid', + }, + ])('rejects an invalid signTransaction request', (request) => { + expect(() => assert(request, SignTransactionRequestStruct)).toThrow( + StructError, + ); + }); +}); + +describe('SignTransactionResponseStruct', () => { + it('accepts a signature that is valid transaction envelope XDR', () => { + expect(() => + assert({ signature: xdr }, SignTransactionResponseStruct), + ).not.toThrow(); + }); + + it.each([{ signature: '' }, { signature: 'AAA=' }])( + 'rejects an invalid signTransaction response', + (response) => { + expect(() => assert(response, SignTransactionResponseStruct)).toThrow( + StructError, + ); + }, + ); +}); + +describe('ListAccountTransactionsRequestStruct', () => { + it('accepts a valid listAccountTransactions request', () => { + const request = { + accountId: account.id, + pagination: { limit: 10, next: null }, + }; + expect(() => + assert(request, ListAccountTransactionsRequestStruct), + ).not.toThrow(); + }); + + it.each([ + { + accountId: 'invalid-account-id', + pagination: { limit: 10, next: null }, + }, + { + accountId: account.id, + pagination: { limit: 0, next: null }, + }, + { + accountId: account.id, + pagination: { limit: 10, next: 'invalid-transaction-id' }, + }, + ])('rejects an invalid listAccountTransactions request', (request) => { + expect(() => assert(request, ListAccountTransactionsRequestStruct)).toThrow( + StructError, + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts index 4d2029c6..ef4bc218 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts @@ -1,3 +1,4 @@ +import { KeyringRequestStruct } from '@metamask/keyring-api'; import { object, min, @@ -11,15 +12,30 @@ import { union, size, nonempty, + assign, + nullable, + enums, } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; +import { base64 } from '@metamask/utils'; -import { - StellarAddressStruct, - UuidStruct, - MultichainMethodStruct, - KnownCaip2ChainIdStruct, -} from '../../api'; +import { StellarAddressStruct } from '../../api/address'; +import { KnownCaip2ChainIdStruct } from '../../api/network'; +import { Utf8StringStruct } from '../../api/string'; +import { UuidStruct } from '../../api/uuid'; +import { XdrStruct } from '../../api/xdr'; + +/** JSON-RPC methods supported by this snap's multichain keyring. */ +export enum MultichainMethod { + SignMessage = 'signMessage', + SignTransaction = 'signTransaction', +} + +/** Superstruct validator for {@link MultichainMethod} string values. */ +export const MultichainMethodStruct = enums(Object.values(MultichainMethod)); + +/** Inferred union of supported multichain method names. */ +export type MultichainMethodType = Infer; /** * Struct for validating createAccount options. @@ -55,7 +71,7 @@ export const ResolveAccountAddressJsonRpcRequestStruct = object({ */ export const ResolveAccountAddressRequestStruct = object({ request: ResolveAccountAddressJsonRpcRequestStruct, - scope: nonempty(KnownCaip2ChainIdStruct), + scope: KnownCaip2ChainIdStruct, }); /** @@ -67,6 +83,65 @@ export const DiscoverAccountsStruct = object({ groupIndex: min(integer(), 0), }); +/** + * Validation struct for the signMessage request. + */ +export const SignMessageRequestStruct = assign( + KeyringRequestStruct, + object({ + request: object({ + method: literal(MultichainMethod.SignMessage), + params: object({ + message: nonempty(union([base64(string()), Utf8StringStruct])), + }), + }), + scope: KnownCaip2ChainIdStruct, + account: UuidStruct, + }), +); + +/** + * Validation struct for the signMessage response. + */ +export const SignMessageResponseStruct = object({ + signature: nonempty(base64(string())), +}); + +/** + * Validation struct for the signTransaction request. + */ +export const SignTransactionRequestStruct = assign( + KeyringRequestStruct, + object({ + request: object({ + method: literal(MultichainMethod.SignTransaction), + params: object({ + transaction: XdrStruct, + }), + }), + scope: KnownCaip2ChainIdStruct, + account: UuidStruct, + }), +); + +/** + * Validation struct for the listAccountTransactions request. + */ +export const ListAccountTransactionsRequestStruct = object({ + accountId: UuidStruct, + pagination: object({ + limit: min(integer(), 1), + next: optional(nullable(UuidStruct)), + }), +}); + +/** + * Validation struct for the signTransaction response. + */ +export const SignTransactionResponseStruct = object({ + signature: XdrStruct, +}); + /** * Validation struct for the getAccount request. */ @@ -103,3 +178,32 @@ export type GetAccountRequest = Infer; * Type for the deleteAccount request. */ export type DeleteAccountRequest = Infer; + +/** + * Type for the setSelectedAccounts request. + */ +export type SetSelectedAccountsRequest = Infer< + typeof SetSelectedAccountsRequestStruct +>; + +/** + * Type for the signMessage request. + */ +export type SignMessageRequest = Infer; + +/** + * Type for the signMessage response. + */ +export type SignMessageResponse = Infer; + +/** + * Type for the signTransaction request. + */ +export type SignTransactionRequest = Infer; + +/** + * Type for the signTransaction response. + */ +export type SignTransactionResponse = Infer< + typeof SignTransactionResponseStruct +>; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts new file mode 100644 index 00000000..df94a7d1 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts @@ -0,0 +1,36 @@ +import type { Json } from '@metamask/utils'; + +import type { + DefaultResolveAccountOptions, + ResolveAccountOptions, +} from '../base'; +import { WithActiveAccountResolve } from '../base'; + +/** + * Interface for the client request handler. + */ +export type IKeyringRequestHandler = { + handle: (request: Json) => Promise; +}; + +/** + * A base class for keyring request handlers that require an activated account. + */ +export abstract class WithKeyringRequestActiveAccountResolve< + RequestType extends { account: string }, + ResponseType extends Json, + Opts extends ResolveAccountOptions = DefaultResolveAccountOptions, +> + extends WithActiveAccountResolve + implements IKeyringRequestHandler +{ + /** + * Get the account ID from the JSON-RPC request. + * + * @param request - The JSON-RPC request to get the account ID from. + * @returns The account ID. + */ + protected getAccountId(request: RequestType): string { + return request.account; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/index.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/index.ts index 1f956431..31cc4e70 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/index.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/index.ts @@ -1,2 +1,3 @@ -export * from './keyring'; export * from './api'; +export * from './base'; +export * from './keyring'; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 5e162123..920ca95b 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -10,21 +10,23 @@ import { } from '@metamask/keyring-snap-sdk'; import { InvalidParamsError, type JsonRpcRequest } from '@metamask/snaps-sdk'; +import { MultichainMethod } from './api'; import { KeyringHandler } from './keyring'; -import { - KnownCaip2ChainId, - KnownCaip19Slip44IdMap, - MultichainMethod, -} from '../../api'; +import { KnownCaip2ChainId } from '../../api'; +import { KEYRING_ACCOUNT_TYPE } from '../../constants'; import { AccountService, type StellarKeyringAccount, } from '../../services/account'; +import { generateMockStellarKeyringAccounts } from '../../services/account/__mocks__/account.fixtures'; +import { AccountNotFoundException } from '../../services/account/exceptions'; +import { OnChainAccountService } from '../../services/on-chain-account'; +import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; import { - generateMockStellarKeyringAccounts, - mockAccountService, -} from '../../services/account/__mocks__/fixtures'; -import { getDefaultEntropySource, getSnapProvider } from '../../utils'; + getSlip44AssetId, + getDefaultEntropySource, + getSnapProvider, +} from '../../utils'; import { logger } from '../../utils/logger'; jest.mock('../../utils/logger'); @@ -61,7 +63,7 @@ describe('KeyringHandler', () => { findByIdSpy: jest.spyOn(AccountService.prototype, 'findById'), deleteSpy: jest.spyOn(AccountService.prototype, 'delete'), discoverOnChainAccountSpy: jest.spyOn( - AccountService.prototype, + OnChainAccountService.prototype, 'discoverOnChainAccount', ), resolveAccountSpy: jest.spyOn(AccountService.prototype, 'resolveAccount'), @@ -72,10 +74,12 @@ describe('KeyringHandler', () => { jest.clearAllMocks(); jest.mocked(getDefaultEntropySource).mockResolvedValue(entropySourceId); - const { accountService } = mockAccountService(); + const { accountService, onChainAccountService } = + mockOnChainAccountService(); keyringHandler = new KeyringHandler({ logger, accountService, + onChainAccountService, }); mockAccount = generateMockStellarKeyringAccounts( @@ -251,18 +255,10 @@ describe('KeyringHandler', () => { }); }); - describe('listAccountTransactions', () => { - it('throws `Method not implemented.` error', async () => { - await expect( - keyringHandler.listAccountTransactions('1', { limit: 10 }), - ).rejects.toThrow('Method not implemented.'); - }); - }); - describe('discoverAccounts', () => { it('discovers an account', async () => { jest - .spyOn(AccountService.prototype, 'discoverOnChainAccount') + .spyOn(OnChainAccountService.prototype, 'discoverOnChainAccount') .mockResolvedValue(mockAccount); const result = await keyringHandler.discoverAccounts( @@ -282,7 +278,7 @@ describe('KeyringHandler', () => { it('returns empty array if the account is not activated on the Stellar network', async () => { jest - .spyOn(AccountService.prototype, 'discoverOnChainAccount') + .spyOn(OnChainAccountService.prototype, 'discoverOnChainAccount') .mockResolvedValue(null); const result = await keyringHandler.discoverAccounts( @@ -296,7 +292,7 @@ describe('KeyringHandler', () => { it('throws an error if the account discovery fails', async () => { jest - .spyOn(AccountService.prototype, 'discoverOnChainAccount') + .spyOn(OnChainAccountService.prototype, 'discoverOnChainAccount') .mockRejectedValue(new Error('Account discovery failed')); await expect( @@ -323,7 +319,7 @@ describe('KeyringHandler', () => { it('throws `Method not implemented.` error', async () => { await expect( keyringHandler.getAccountBalances('1', [ - KnownCaip19Slip44IdMap[KnownCaip2ChainId.Mainnet], + getSlip44AssetId(KnownCaip2ChainId.Mainnet), ]), ).rejects.toThrow('Method not implemented.'); }); @@ -334,7 +330,6 @@ describe('KeyringHandler', () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount, - wallet: undefined, }); const result = await keyringHandler.resolveAccountAddress( @@ -351,8 +346,7 @@ describe('KeyringHandler', () => { expect(resolveAccountSpy).toHaveBeenCalledWith({ scope: KnownCaip2ChainId.Mainnet, - accountIdOrAddress: mockAccount.address, - resolveOptions: { activated: false }, + accountAddress: mockAccount.address, }); expect(result).toStrictEqual({ address: `${KnownCaip2ChainId.Mainnet}:${mockAccount.address}`, @@ -403,7 +397,7 @@ describe('KeyringHandler', () => { it('throws `Method not implemented.` error', async () => { await expect( keyringHandler.updateAccount({ - type: 'any:account', + type: KEYRING_ACCOUNT_TYPE, id: '1', address: '1', scopes: [KnownCaip2ChainId.Mainnet], @@ -416,15 +410,17 @@ describe('KeyringHandler', () => { describe('deleteAccount', () => { it('deletes an account', async () => { - const { deleteSpy, findByIdSpy } = getAccountServiceSpies(); - findByIdSpy.mockResolvedValue(mockAccount); + const { deleteSpy, resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); const emitSnapKeyringEventSpy = jest.mocked(emitSnapKeyringEvent); emitSnapKeyringEventSpy.mockResolvedValue(); await keyringHandler.deleteAccount(mockAccountId); expect(deleteSpy).toHaveBeenCalledWith(mockAccountId); - expect(findByIdSpy).toHaveBeenCalledWith(mockAccountId); + expect(resolveAccountSpy).toHaveBeenCalledWith({ + accountId: mockAccountId, + }); expect(emitSnapKeyringEventSpy).toHaveBeenCalledWith( getSnapProvider(), KeyringEvent.AccountDeleted, @@ -435,8 +431,8 @@ describe('KeyringHandler', () => { }); it('throws an error if the account deletion fails', async () => { - const { deleteSpy, findByIdSpy } = getAccountServiceSpies(); - findByIdSpy.mockResolvedValue(mockAccount); + const { deleteSpy, resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); deleteSpy.mockRejectedValue(new Error('Account deletion failed')); const emitSnapKeyringEventSpy = jest.mocked(emitSnapKeyringEvent); emitSnapKeyringEventSpy.mockResolvedValue(); @@ -447,13 +443,15 @@ describe('KeyringHandler', () => { }); it('throws an error if the account to delete is not found', async () => { - const { findByIdSpy } = getAccountServiceSpies(); - findByIdSpy.mockResolvedValue(undefined); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockRejectedValue( + new AccountNotFoundException(mockAccountId), + ); const emitSnapKeyringEventSpy = jest.mocked(emitSnapKeyringEvent); emitSnapKeyringEventSpy.mockResolvedValue(); await expect(keyringHandler.deleteAccount(mockAccountId)).rejects.toThrow( - `Error deleting account: Account not found: ${mockAccountId}`, + `Error deleting account: Account not found for address or id: ${mockAccountId}`, ); }); @@ -463,18 +461,4 @@ describe('KeyringHandler', () => { ); }); }); - - describe('submitRequest', () => { - it('throws `Method not implemented.` error', async () => { - await expect( - keyringHandler.submitRequest({ - id: '1', - origin: 'metamask', - request: { method: 'submitRequest', params: ['1'] }, - scope: KnownCaip2ChainId.Mainnet, - account: '1', - }), - ).rejects.toThrow('Method not implemented.'); - }); - }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 73595c48..b00d34ac 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -17,7 +17,7 @@ import { emitSnapKeyringEvent, handleKeyringRequest, } from '@metamask/keyring-snap-sdk'; -import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; +import { type Json, type JsonRpcRequest } from '@metamask/snaps-sdk'; import { ensureError, type CaipAssetType, @@ -26,15 +26,17 @@ import { import type { CreateAccountOptions, - ResolveAccountAddressJsonRpcRequest, GetAccountRequest, + ResolveAccountAddressJsonRpcRequest, + MultichainMethod, } from './api'; import { CreateAccountOptionsStruct, - ResolveAccountAddressRequestStruct, - GetAccountRequestStruct, - DiscoverAccountsStruct, DeleteAccountRequestStruct, + DiscoverAccountsStruct, + GetAccountRequestStruct, + MultichainMethodStruct, + ResolveAccountAddressRequestStruct, SetSelectedAccountsRequestStruct, } from './api'; import type { KnownCaip2ChainId } from '../../api'; @@ -42,10 +44,11 @@ import type { AccountService, StellarKeyringAccount, } from '../../services/account'; +import type { OnChainAccountService } from '../../services/on-chain-account'; +import type { ILogger } from '../../utils'; import { createPrefixedLogger, getSnapProvider, - type ILogger, validateOrigin, validateRequest, withCatchAndThrowSnapError, @@ -56,15 +59,20 @@ export class KeyringHandler implements Keyring { readonly #accountService: AccountService; + readonly #onChainAccountService: OnChainAccountService; + constructor({ logger, accountService, + onChainAccountService, }: { logger: ILogger; accountService: AccountService; + onChainAccountService: OnChainAccountService; }) { this.#logger = createPrefixedLogger(logger, '[🔑 KeyringHandler]'); this.#accountService = accountService; + this.#onChainAccountService = onChainAccountService; } async handle(origin: string, request: JsonRpcRequest): Promise { @@ -192,7 +200,7 @@ export class KeyringHandler implements Keyring { try { // Discover an account if it exists on the blockchain. - const account = await this.#accountService.discoverOnChainAccount({ + const account = await this.#onChainAccountService.discoverOnChainAccount({ entropySource, index: groupIndex, // we assume only one scope supported @@ -239,10 +247,7 @@ export class KeyringHandler implements Keyring { try { const { account } = await this.#accountService.resolveAccount({ scope, - accountIdOrAddress: request.params.address, - resolveOptions: { - activated: false, - }, + accountAddress: request.params.address, }); return { address: `${scope}:${account.address}` }; } catch (error: unknown) { @@ -264,7 +269,9 @@ export class KeyringHandler implements Keyring { validateRequest(accountId, DeleteAccountRequestStruct); try { - const account = await this.#getAccountOrThrow(accountId); + const { account } = await this.#accountService.resolveAccount({ + accountId, + }); await emitSnapKeyringEvent( getSnapProvider(), @@ -290,15 +297,7 @@ export class KeyringHandler implements Keyring { } async #handleSubmitRequest(request: KeyringRequest): Promise { - throw new Error('Method not implemented.'); - } - - async #getAccountOrThrow(accountId: string): Promise { - const account = await this.#accountService.findById(accountId); - if (!account) { - throw new Error(`Account not found: ${accountId}`); - } - return account; + throw new Error('Method not implemented. - handleSubmitRequest'); } } /* eslint-enable @typescript-eslint/no-unused-vars */ diff --git a/merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceRepository.ts b/merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceRepository.ts new file mode 100644 index 00000000..88f9af12 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceRepository.ts @@ -0,0 +1,53 @@ +import type { + AccountBalance, + AccountBalanceRecord, + AccountBalanceState, +} from './api'; +import type { State } from '../state/State'; + +export class AccountBalanceRepository { + readonly #state: State; + + readonly #stateKey = 'accountBalances'; + + constructor(state: State) { + this.#state = state; + } + + async findByAccountId( + accountId: string, + ): Promise { + const raw = await this.#state.getKey( + `${this.#stateKey}.${accountId}`, + ); + + return raw ?? null; + } + + async save(accountId: string, balances: AccountBalance): Promise { + await this.#state.setKey(`${this.#stateKey}.${accountId}`, { + balances, + persistedAt: Date.now(), + }); + } + + /** + * Writes one {@link AccountBalanceRecord} per keyring account via `snap_setState` (no full-state `update`). + * Replaces the stored `balances` map for each id with the given payload. + * + * @param accountBalances - Map of keyring account id → full per-asset balance snapshot for that account. + */ + async saveMany( + accountBalances: Record, + ): Promise { + const now = Date.now(); + await Promise.all( + Object.entries(accountBalances).map(async ([accountId, balances]) => + this.#state.setKey(`${this.#stateKey}.${accountId}`, { + balances, + persistedAt: now, + }), + ), + ); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceService.ts b/merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceService.ts new file mode 100644 index 00000000..f51075bd --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceService.ts @@ -0,0 +1,211 @@ +import type { AccountBalanceRepository } from './AccountBalanceRepository'; +import type { AccountBalance } from './api'; +import { type KnownCaip2ChainId } from '../../api'; +import { + createPrefixedLogger, + getSlip44AssetId, + isSep41Id, + batchesAllSettled, + type ILogger, +} from '../../utils'; +import type { AssetMetadata, AssetMetadataService } from '../asset-metadata'; +import type { NetworkService } from '../network'; +import type { OnChainAccount } from '../on-chain-account'; +import type { SynchronizeAccountPairs } from '../synchronize/api'; + +export class AccountBalanceService { + readonly #assetMetadataService: AssetMetadataService; + + readonly #accountBalanceRepository: AccountBalanceRepository; + + readonly #networkService: NetworkService; + + readonly #logger: ILogger; + + static readonly rpcFetchBatchSize = 10; + + constructor({ + assetMetadataService, + accountBalanceRepository, + networkService, + logger, + }: { + assetMetadataService: AssetMetadataService; + accountBalanceRepository: AccountBalanceRepository; + networkService: NetworkService; + logger: ILogger; + }) { + this.#assetMetadataService = assetMetadataService; + this.#networkService = networkService; + this.#accountBalanceRepository = accountBalanceRepository; + this.#logger = createPrefixedLogger(logger, '[💰 AccountBalanceService]'); + } + + /** + * Gets the balances for a given account id. + * + * @param accountId - The id of the account to get the balances for. + * @returns A promise that resolves to the persisted {@link AccountBalance} map, or `null` if none. + */ + async getBalancesByAccountId( + accountId: string, + ): Promise { + const balances = + await this.#accountBalanceRepository.findByAccountId(accountId); + if (!balances) { + return null; + } + return balances.balances; + } + + /** + * Persists balances using accounts already loaded via {@link NetworkService.loadOnChainAccount} + * (native, trustlines, and SEP-41 token queries only — no second account load). + * + * @param pairs - Keyring rows paired with their Horizon `OnChainAccount`. + * @param scope - CAIP-2 network the `loaded` accounts were fetched from. + */ + async synchronize( + pairs: SynchronizeAccountPairs[], + scope: KnownCaip2ChainId, + ): Promise { + try { + if (pairs.length === 0) { + return; + } + + // assume Horizon API already loaded trustlines assets for the accounts, + // so we only need to fetch SEP-41 token balances + const assets = + await this.#assetMetadataService.getAllSep41AssetsMetadata(scope); + + const results = await Promise.allSettled( + pairs.map(async (pair) => { + // 1. Horizon `loadOnChainAccount`: native + classic trustlines (no extra account fetch). + const fromOnChainAccount = + this.#synchronizeBalancesFromOnChainAccount( + scope, + pair.onChainAccount, + ); + // 2. Soroban SEP-41 balances for configured assets. + const fromNetwork = await this.#synchronizeSep41BalancesFromNetwork( + scope, + assets, + pair.onChainAccount, + ); + return { ...fromOnChainAccount, ...fromNetwork }; + }), + ); + + const accountBalances: Record = {}; + + results.forEach((result, index) => { + const pair = pairs[index]; + if (pair === undefined) { + return; + } + if (result.status === 'fulfilled') { + accountBalances[pair.account.id] = result.value; + } else { + this.#logger.logErrorWithDetails( + 'Failed to synchronize balances for account', + { + accountId: pair.account.id, + error: result.reason, + }, + ); + } + }); + + // 3. Persist merged balances per keyring account. + await this.#accountBalanceRepository.saveMany(accountBalances); + } catch (error) { + // log error but continue the synchronization process + this.#logger.logErrorWithDetails('Failed to synchronize balances', { + error, + }); + } + } + + /** + * Native XLM + classic trustline balances from a single Horizon `loadOnChainAccount` result (no network I/O). + * + * @param scope - CAIP-2 chain id for native and classic asset id mapping. + * @param onChainAccount - Account state from Horizon (or equivalent) with balances and trustlines. + * @returns Partial {@link AccountBalance} for native XLM and classic assets only. Native `amount` is **raw** (total) stroops. + */ + #synchronizeBalancesFromOnChainAccount( + scope: KnownCaip2ChainId, + onChainAccount: OnChainAccount, + ): AccountBalance { + // Collect native balance. + const nativeAssetId = getSlip44AssetId(scope); + const balances: AccountBalance = { + [nativeAssetId]: { + unit: onChainAccount.getAsset(nativeAssetId).symbol, + // Collect raw native balance. + amount: onChainAccount.nativeRawBalance.toString(), + }, + }; + + // Collect classic trustline balances. + for (const assetId of onChainAccount.classicTrustlineAssetIds) { + const row = onChainAccount.getAsset(assetId); + balances[assetId] = { + unit: row.symbol, + // we store the limit and balance in stroops + amount: row.balance.toString(), + limit: row.limit?.toString() ?? '0', + ...(typeof row.authorized === 'boolean' + ? { authorized: row.authorized } + : {}), + ...(row.sponsored ? { sponsored: true } : {}), + }; + } + + return balances; + } + + /** + * Soroban SEP-41 token balances via RPC (in parallel per configured asset). + * + * @param scope - CAIP-2 chain id for RPC endpoints. + * @param sep41AssetsMetadata - SEP-41 assets to query balances for. + * @param onChainAccount - Account whose id and sequence are passed to balance RPC calls. + * @returns Partial {@link AccountBalance} for SEP-41 tokens only. + */ + async #synchronizeSep41BalancesFromNetwork( + scope: KnownCaip2ChainId, + sep41AssetsMetadata: AssetMetadata[], + onChainAccount: OnChainAccount, + ): Promise { + const balances: AccountBalance = {}; + + await batchesAllSettled( + sep41AssetsMetadata, + AccountBalanceService.rpcFetchBatchSize, + async (metadata) => { + const { assetId } = metadata; + if (!isSep41Id(assetId)) { + return; + } + const balance = await this.#networkService.getSep41TokenBalance({ + accountAddress: onChainAccount.accountId, + assetId, + scope, + sequenceNumber: onChainAccount.sequenceNumber, + }); + // skip non-trustline assets if the balance is 0 + if (balance.isEqualTo(0)) { + return; + } + balances[assetId] = { + unit: metadata.symbol, + amount: balance.toString(), + }; + }, + ); + + return balances; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/account-balance/api.ts b/merged-packages/stellar-wallet-snap/src/services/account-balance/api.ts new file mode 100644 index 00000000..347c777b --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/account-balance/api.ts @@ -0,0 +1,34 @@ +import type { Balance } from '@metamask/keyring-api'; + +import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; + +export type BaseAssetBalance = Balance; + +export type TrustLineAssetBalance = BaseAssetBalance & { + /** The limit of the balance. */ + limit: string; + /** Horizon `is_authorized` for this trustline (optional for legacy persisted rows). */ + authorized?: boolean; + /** The sponsored balance. */ + sponsored?: boolean; +}; + +/** + * Per-account balances keyed by native slip44, classic, or SEP-41 CAIP-19 asset id. + * + * For the slip44 native entry, `amount` is the **total** balance in stroops (same as Horizon native before reserve subtraction). Spendable XLM is derived at bind time from this value plus account metadata (subentries / sponsoring). + */ +export type AccountBalance = Partial< + Record +>; + +/** Wrapper persisted under `accountBalances[accountId]` with a single write timestamp for the row. */ +export type AccountBalanceRecord = { + balances: AccountBalance; + persistedAt: number; +}; + +/** Snap state slice: `accountBalances[accountId]` → per-asset balances for that keyring account. */ +export type AccountBalanceState = { + accountBalances: Record; +}; diff --git a/merged-packages/stellar-wallet-snap/src/services/account-balance/index.ts b/merged-packages/stellar-wallet-snap/src/services/account-balance/index.ts new file mode 100644 index 00000000..ec10323c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/account-balance/index.ts @@ -0,0 +1,3 @@ +export * from './AccountBalanceService'; +export * from './AccountBalanceRepository'; +export type * from './api'; diff --git a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.test.ts b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.test.ts index 4e925661..0214b9fa 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.test.ts @@ -1,20 +1,21 @@ import type { AccountService } from './AccountService'; import { AccountsRepository } from './AccountsRepository'; import type { StellarKeyringAccount } from './api'; -import { getDerivationPath } from './derivation'; import { AccountNotFoundException, AccountRollbackException, DerivedAccountAddressMismatchException, } from './exceptions'; -import { KnownCaip2ChainId, MultichainMethod } from '../../api'; +import { KnownCaip2ChainId } from '../../api'; +import { KEYRING_ACCOUNT_TYPE } from '../../constants'; +import { MultichainMethod } from '../../handlers/keyring'; import { mockBip32Node } from '../../utils/__mocks__/fixtures'; import { getBip32Entropy, getDefaultEntropySource } from '../../utils/snap'; -import { Wallet, WalletService } from '../wallet'; +import { WalletService, getDerivationPath } from '../wallet'; import { generateMockStellarKeyringAccounts, mockAccountService, -} from './__mocks__/fixtures'; +} from './__mocks__/account.fixtures'; jest.mock('../../utils/logger'); jest.mock('../../utils/snap'); @@ -35,7 +36,7 @@ describe('AccountService', () => { const getAccountsRepositorySpies = () => { return { - createSpy: jest.spyOn(AccountsRepository.prototype, 'create'), + saveSpy: jest.spyOn(AccountsRepository.prototype, 'save'), deleteSpy: jest.spyOn(AccountsRepository.prototype, 'delete'), getAllSpy: jest.spyOn(AccountsRepository.prototype, 'getAll'), }; @@ -43,14 +44,6 @@ describe('AccountService', () => { const getWalletServiceSpies = () => ({ deriveAddressSpy: jest.spyOn(WalletService.prototype, 'deriveAddress'), - resolveActivatedAccountSpy: jest.spyOn( - WalletService.prototype, - 'resolveActivatedAccount', - ), - isAccountActivatedSpy: jest.spyOn( - WalletService.prototype, - 'isAccountActivated', - ), }); describe('create', () => { @@ -59,13 +52,13 @@ describe('AccountService', () => { const expectedIndex = 0; const expectedDerivationPath = getDerivationPath(expectedIndex); const { deriveAddressSpy } = getWalletServiceSpies(); - const { createSpy, getAllSpy } = getAccountsRepositorySpies(); + const { saveSpy, getAllSpy } = getAccountsRepositorySpies(); getAllSpy.mockResolvedValue([]); jest.mocked(getDefaultEntropySource).mockResolvedValue(entropySource); const result = await accountService.create(); - expect(createSpy).toHaveBeenCalledWith(result); + expect(saveSpy).toHaveBeenCalledWith(result); expect(deriveAddressSpy).toHaveBeenCalledWith({ entropySource, index: expectedIndex, @@ -75,7 +68,7 @@ describe('AccountService', () => { entropySource, derivationPath: expectedDerivationPath, index: expectedIndex, - type: 'any:account', + type: KEYRING_ACCOUNT_TYPE, address: expect.any(String), scopes: [KnownCaip2ChainId.Mainnet], methods: ['signMessage', 'signTransaction'], @@ -93,7 +86,7 @@ describe('AccountService', () => { }); it('creates an account with options', async () => { - const { createSpy, getAllSpy } = getAccountsRepositorySpies(); + const { saveSpy, getAllSpy } = getAccountsRepositorySpies(); getAllSpy.mockResolvedValue([]); const result = await accountService.create({ @@ -101,13 +94,13 @@ describe('AccountService', () => { index: 1, }); - expect(createSpy).toHaveBeenCalledWith(result); + expect(saveSpy).toHaveBeenCalledWith(result); expect(result).toStrictEqual({ id: expect.any(String), entropySource: 'entropy-source-2', derivationPath: "m/44'/148'/1'", index: 1, - type: 'any:account', + type: KEYRING_ACCOUNT_TYPE, address: expect.any(String), scopes: [KnownCaip2ChainId.Mainnet], methods: [ @@ -128,7 +121,7 @@ describe('AccountService', () => { }); it('creates an account with lowest unused index', async () => { - const { createSpy, getAllSpy } = getAccountsRepositorySpies(); + const { saveSpy, getAllSpy } = getAccountsRepositorySpies(); const entropySource = 'entropy-source-2'; // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_, ...restAccounts] = generateMockStellarKeyringAccounts( @@ -143,13 +136,13 @@ describe('AccountService', () => { entropySource, }); - expect(createSpy).toHaveBeenCalledWith(result); + expect(saveSpy).toHaveBeenCalledWith(result); expect(result).toStrictEqual({ id: expect.any(String), entropySource, derivationPath: expectedDerivationPath, index: expectedIndex, - type: 'any:account', + type: KEYRING_ACCOUNT_TYPE, address: expect.any(String), scopes: [KnownCaip2ChainId.Mainnet], methods: [ @@ -184,7 +177,7 @@ describe('AccountService', () => { }); it('creates an account with a callback', async () => { - const { createSpy } = getAccountsRepositorySpies(); + const { saveSpy } = getAccountsRepositorySpies(); const callback = jest.fn(); const result = await accountService.create( @@ -196,11 +189,11 @@ describe('AccountService', () => { ); expect(callback).toHaveBeenCalledWith(result); - expect(createSpy).toHaveBeenCalledWith(result); + expect(saveSpy).toHaveBeenCalledWith(result); }); it('deletes the account and throws an error if the callback fails', async () => { - const { createSpy, deleteSpy } = getAccountsRepositorySpies(); + const { saveSpy, deleteSpy } = getAccountsRepositorySpies(); const callback = jest .fn() .mockRejectedValue(new Error('Callback failed')); @@ -215,11 +208,9 @@ describe('AccountService', () => { ), ).rejects.toThrow('Callback failed'); - expect(createSpy.mock.calls[0]?.[0]?.id).toStrictEqual( - expect.any(String), - ); - expect(deleteSpy).toHaveBeenCalledWith(createSpy.mock.calls[0]?.[0]?.id); - expect(createSpy).toHaveBeenCalled(); + expect(saveSpy.mock.calls[0]?.[0]?.id).toStrictEqual(expect.any(String)); + expect(deleteSpy).toHaveBeenCalledWith(saveSpy.mock.calls[0]?.[0]?.id); + expect(saveSpy).toHaveBeenCalled(); }); it('throws AccountRollbackException if the rollback fails', async () => { @@ -302,15 +293,11 @@ describe('AccountService', () => { getAllSpy.mockResolvedValue(mockAccounts); const result = await accountService.resolveAccount({ - accountIdOrAddress: account.address, + accountAddress: account.address, scope: KnownCaip2ChainId.Mainnet, - resolveOptions: { - activated: false, - }, }); expect(result).toStrictEqual({ account, - wallet: undefined, }); }); @@ -326,50 +313,11 @@ describe('AccountService', () => { getAllSpy.mockResolvedValue(mockAccounts); const result = await accountService.resolveAccount({ - accountIdOrAddress: account.id, - scope: KnownCaip2ChainId.Mainnet, - resolveOptions: { - activated: false, - }, - }); - expect(result).toStrictEqual({ - account, - wallet: undefined, - }); - }); - - it('resolves an activated account', async () => { - const { getAllSpy } = getAccountsRepositorySpies(); - const { deriveAddressSpy, resolveActivatedAccountSpy } = - getWalletServiceSpies(); - const mockAccounts = generateMockStellarKeyringAccounts( - 5, - 'entropy-source-1', - ); - const account = mockAccounts[0] as StellarKeyringAccount; - const loadedAccount = { - accountId(): string { - return account.address; - }, - sequenceNumber(): string { - return '1'; - }, - }; - const resolvedWallet = new Wallet(loadedAccount, null); - deriveAddressSpy.mockResolvedValue(account.address); - getAllSpy.mockResolvedValue(mockAccounts); - resolveActivatedAccountSpy.mockResolvedValue(resolvedWallet); - - const result = await accountService.resolveAccount({ - accountIdOrAddress: account.address, + accountId: account.id, scope: KnownCaip2ChainId.Mainnet, - resolveOptions: { - activated: true, - }, }); expect(result).toStrictEqual({ account, - wallet: resolvedWallet, }); }); @@ -379,12 +327,9 @@ describe('AccountService', () => { await expect( accountService.resolveAccount({ - accountIdOrAddress: + accountAddress: 'GNXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', scope: KnownCaip2ChainId.Mainnet, - resolveOptions: { - activated: false, - }, }), ).rejects.toThrow(AccountNotFoundException); }); @@ -395,11 +340,8 @@ describe('AccountService', () => { await expect( accountService.resolveAccount({ - accountIdOrAddress: '00000000-0000-0000-0000-000000000000', + accountId: '00000000-0000-0000-0000-000000000000', scope: KnownCaip2ChainId.Mainnet, - resolveOptions: { - activated: false, - }, }), ).rejects.toThrow(AccountNotFoundException); }); @@ -418,27 +360,21 @@ describe('AccountService', () => { await expect( accountService.resolveAccount({ - accountIdOrAddress: account.address, + accountAddress: account.address, scope: KnownCaip2ChainId.Mainnet, - resolveOptions: { - activated: false, - }, }), ).rejects.toThrow(DerivedAccountAddressMismatchException); }); }); - describe('discoverOnChainAccount', () => { - it('discovers an activated account', async () => { - const { isAccountActivatedSpy, deriveAddressSpy } = - getWalletServiceSpies(); - isAccountActivatedSpy.mockResolvedValue(true); + describe('deriveKeyringAccount', () => { + it('returns a keyring-shaped derived account', async () => { + const { deriveAddressSpy } = getWalletServiceSpies(); deriveAddressSpy.mockResolvedValue(mockAccount.address); - const account = await accountService.discoverOnChainAccount({ + const account = await accountService.deriveKeyringAccount({ entropySource: mockAccount.entropySource, index: mockAccount.index, - scope: KnownCaip2ChainId.Mainnet, }); expect(account).toStrictEqual({ @@ -446,18 +382,5 @@ describe('AccountService', () => { id: expect.any(String), }); }); - - it('returns null if the account is not activated on the Stellar network', async () => { - const { isAccountActivatedSpy } = getWalletServiceSpies(); - isAccountActivatedSpy.mockResolvedValue(false); - - const account = await accountService.discoverOnChainAccount({ - entropySource: mockAccount.entropySource, - index: mockAccount.index, - scope: KnownCaip2ChainId.Mainnet, - }); - - expect(account).toBeNull(); - }); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts index 0230bccb..589be3d9 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts @@ -3,28 +3,26 @@ import { ensureError } from '@metamask/utils'; import type { AccountsRepository } from './AccountsRepository'; import type { StellarKeyringAccount, StellarDerivationPath } from './api'; -import { getDerivationPath } from './derivation'; import { - KnownCaip2ChainId, - MultichainMethod, - StellarAddressStruct, -} from '../../api'; -import type { StellarAddress, UUID } from '../../api'; + AccountNotFoundException, + AccountRollbackException, + AccountServiceException, +} from './exceptions'; +import { assertSameAddress } from './utils'; +import type { StellarAddress, KnownCaip2ChainId } from '../../api'; +import { AppConfig } from '../../config'; +import { KEYRING_ACCOUNT_TYPE } from '../../constants'; +import { MultichainMethod } from '../../handlers/keyring'; import type { ILogger } from '../../utils'; import { createPrefixedLogger, getDefaultEntropySource, getLowestIndex, } from '../../utils'; -import type { Wallet, WalletService } from '../wallet'; -import { - DerivedAccountAddressMismatchException, - AccountNotFoundException, - AccountRollbackException, -} from './exceptions'; +import { getDerivationPath, type WalletService } from '../wallet'; /** - * Manages Stellar keyring accounts: discovery, creation, resolution, and persistence. + * Manages Stellar keyring accounts: creation, resolution from state, derivation checks, and persistence. */ export class AccountService { readonly #logger: ILogger; @@ -48,114 +46,74 @@ export class AccountService { } /** - * Derives an account from the given entropy source and index, then checks whether that address - * is activated on Stellar, regardless of whether the account exists in keyring state. + * Builds a keyring-shaped account from entropy and index without reading or writing keyring state. * - * @param options - The parameters for the account discovery. - * @param options.entropySource - The entropy source used to derive the account. - * @param options.index - The derivation index of the account to discover. - * @param options.scope - The network scope (e.g. mainnet/testnet). - * @returns A Promise that resolves to the derived account if it is activated on Stellar, otherwise `null`. + * @param options - Derivation inputs. + * @param options.entropySource - Entropy source ID (e.g. from the keyring). + * @param options.index - BIP-44 account index. + * @returns A promise that resolves to the derived {@link StellarKeyringAccount} shape (new random id). */ - async discoverOnChainAccount({ + async deriveKeyringAccount({ entropySource, index, - scope, }: { entropySource: EntropySourceId; index: number; - scope: KnownCaip2ChainId; - }): Promise { - // Derive the account by the given entropy source and index. - const account = await this.#deriveAccount({ entropySource, index }); - - // Verify the account is activated in the Stellar network. - const isActivated = await this.#walletService.isAccountActivated({ - address: account.address, - scope, - }); - - if (!isActivated) { - return null; - } - - return account; + }): Promise { + return await this.#deriveAccount({ entropySource, index }); } /** - * Resolves an account from a given scope and account ID or address by: - * - If `activated` is true, resolving an account from state and an activated account from the network. - * - If `activated` is false, resolving an account from state only. + * Resolves a keyring account from state by ID or address and verifies the stored address matches derivation. * * @param params - The parameters for the account resolution. - * @param params.scope - The scope of the account to resolve. - * @param params.accountIdOrAddress - The ID or address of the account to resolve. - * @param params.resolveOptions - Resolution options. - * @param params.resolveOptions.activated - When true, also resolves the activated wallet from the network; return type then includes required `wallet`. - * @returns A Promise that resolves to the resolved account and optional wallet (wallet present when `activated` is true). - * @throws If the account is not found in the keyring state. - * @throws If the address is not the same as the derived account address. - * @throws If the account is not activated on the Stellar network when `activated` is true. + * @param params.scope - Required when resolving by address. + * @param params.accountId - The ID of the account to resolve. + * @param params.accountAddress - The address of the account to resolve. + * @returns A promise that resolves to the keyring account from state. + * @throws {AccountNotFoundException} When no account matches the given id or address/scope. + * @throws {AccountServiceException} When `accountAddress` is set without `scope`, or neither id nor address is set. + * @throws {DerivedAccountAddressMismatchException} When the stored address does not match re-derivation. */ - async resolveAccount({ + async resolveAccount({ scope, - accountIdOrAddress, - resolveOptions, + accountId, + accountAddress, }: { - scope: KnownCaip2ChainId; - accountIdOrAddress: UUID | StellarAddress; - resolveOptions: { - activated: ResolveActivatedAccount; - }; - }): Promise< - ResolveActivatedAccount extends true - ? { account: StellarKeyringAccount; wallet: Wallet } - : { account: StellarKeyringAccount; wallet?: Wallet } - > { - let wallet: Wallet | undefined; + scope?: KnownCaip2ChainId; + accountId?: string; + accountAddress?: StellarAddress; + }): Promise<{ account: StellarKeyringAccount }> { let account: StellarKeyringAccount; - let derivedAddress: StellarAddress | undefined; - const { activated } = resolveOptions; - // Verify the address or id is associated with an account in the state that matches the scope. - const [addressValidateErr] = - StellarAddressStruct.validate(accountIdOrAddress); - - if (addressValidateErr === undefined) { + if (accountId) { + account = await this.#resolveKeyringAccountById(accountId); + } else if (accountAddress) { + if (!scope) { + throw new AccountServiceException( + 'Scope is required when resolving by address', + ); + } account = await this.#resolveKeyringAccountByAddress({ scope, - address: accountIdOrAddress, + address: accountAddress, }); } else { - account = await this.#resolveKeyringAccountById(accountIdOrAddress); + throw new AccountServiceException( + 'Either accountId or accountAddress is required', + ); } const { entropySource, index, address } = account; - // Verify the account is activated in the Stellar network if `activated` is true. - // Otherwise, derive the address from the entropy source and index. - if (activated) { - wallet = await this.#walletService.resolveActivatedAccount({ - scope, - entropySource, - index, - }); - derivedAddress = wallet.address; - } else { - derivedAddress = await this.#walletService.deriveAddress({ - entropySource, - index, - }); - } - // Verify the address is the same as the derived account address. - this.#assertSameAddress(address, derivedAddress); + const derivedAddress = await this.#walletService.deriveAddress({ + entropySource, + index, + }); - return { - account, - wallet, - } as ResolveActivatedAccount extends true - ? { account: StellarKeyringAccount; wallet: Wallet } - : { account: StellarKeyringAccount; wallet?: Wallet }; + assertSameAddress(address, derivedAddress); + + return { account }; } /** @@ -216,11 +174,11 @@ export class AccountService { }, }; - await this.#accountsRepository.create(account); + await this.#accountsRepository.save(account); // If a callback is provided, call it with the account // If the callback fails, delete the newly created account and re-throw the error - if (callback && typeof callback === 'function') { + if (callback) { try { await callback(account); } catch (error) { @@ -283,13 +241,13 @@ export class AccountService { scope, ); if (!account) { - throw new AccountNotFoundException(address, scope); + throw new AccountNotFoundException(address); } return account; } async #resolveKeyringAccountById( - accountId: UUID, + accountId: string, ): Promise { const account = await this.#accountsRepository.findById(accountId); if (!account) { @@ -366,10 +324,10 @@ export class AccountService { entropySource, derivationPath, index, - // TODO: Replace with the actual account type - type: 'any:account', + type: KEYRING_ACCOUNT_TYPE, address, - scopes: [KnownCaip2ChainId.Mainnet], + // Only selected network is supported for now + scopes: [AppConfig.selectedNetwork], options: { entropy: { type: 'mnemonic', @@ -382,13 +340,4 @@ export class AccountService { methods: [MultichainMethod.SignMessage, MultichainMethod.SignTransaction], }; } - - #assertSameAddress( - address: StellarAddress, - derivedAddress: StellarAddress, - ): void { - if (address.toLowerCase() !== derivedAddress.toLowerCase()) { - throw new DerivedAccountAddressMismatchException(address); - } - } } diff --git a/merged-packages/stellar-wallet-snap/src/services/account/AccountsRepository.ts b/merged-packages/stellar-wallet-snap/src/services/account/AccountsRepository.ts index c3663485..50d2ac82 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/AccountsRepository.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/AccountsRepository.ts @@ -1,20 +1,17 @@ -import type { StellarKeyringAccount } from './api'; +import type { KeyringAccountState, StellarKeyringAccount } from './api'; import type { KnownCaip2ChainId } from '../../api'; +import { isSameStr } from '../../utils/assert'; import type { IStateManager } from '../state/IStateManager'; -export type UnencryptedStateValue = { - keyringAccounts: Record; -}; - /** * Persists and retrieves Stellar keyring accounts in snap state. */ export class AccountsRepository { readonly #storageKey = 'keyringAccounts'; - readonly #state: IStateManager; + readonly #state: IStateManager; - constructor(state: IStateManager) { + constructor(state: IStateManager) { this.#state = state; } @@ -25,7 +22,7 @@ export class AccountsRepository { */ async getAll(): Promise { const accounts = await this.#state.getKey< - UnencryptedStateValue['keyringAccounts'] + KeyringAccountState['keyringAccounts'] >(this.#storageKey); return Object.values(accounts ?? {}); @@ -39,11 +36,7 @@ export class AccountsRepository { */ async findById(id: string): Promise { const accounts = await this.getAll(); - return ( - accounts.find( - (account) => account.id.toLowerCase() === id.toLowerCase(), - ) ?? null - ); + return accounts.find((account) => isSameStr(account.id, id)) ?? null; } /** @@ -67,9 +60,7 @@ export class AccountsRepository { async findByAddress(address: string): Promise { const accounts = await this.getAll(); return ( - accounts.find( - (account) => account.address.toLowerCase() === address.toLowerCase(), - ) ?? null + accounts.find((account) => isSameStr(account.address, address)) ?? null ); } @@ -88,8 +79,7 @@ export class AccountsRepository { return ( accounts.find( (account) => - account.address.toLowerCase() === address.toLowerCase() && - account.scopes.includes(scope), + isSameStr(account.address, address) && account.scopes.includes(scope), ) ?? null ); } @@ -98,11 +88,10 @@ export class AccountsRepository { * Persists a new account in keyring state. * * @param account - The account to create. - * @returns A Promise that resolves to the created account. + * @returns A Promise that resolves when the account has been written. */ - async create(account: StellarKeyringAccount): Promise { + async save(account: StellarKeyringAccount): Promise { await this.#state.setKey(`${this.#storageKey}.${account.id}`, account); - return account; } /** diff --git a/merged-packages/stellar-wallet-snap/src/services/account/__mocks__/fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/account/__mocks__/account.fixtures.ts similarity index 58% rename from merged-packages/stellar-wallet-snap/src/services/account/__mocks__/fixtures.ts rename to merged-packages/stellar-wallet-snap/src/services/account/__mocks__/account.fixtures.ts index 7286cf27..7f8ead1d 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/__mocks__/fixtures.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/__mocks__/account.fixtures.ts @@ -1,14 +1,13 @@ -import { KnownCaip2ChainId, MultichainMethod } from '../../../api'; +import { KnownCaip2ChainId } from '../../../api'; +import { KEYRING_ACCOUNT_TYPE } from '../../../constants'; +import { MultichainMethod } from '../../../handlers/keyring/api'; import { logger } from '../../../utils/logger'; import { State } from '../../state/State'; -import { generateStellarAddress } from '../../wallet/__mocks__/fixtures'; -import { NetworkService } from '../../wallet/NetworkService'; -import { TransactionBuilder } from '../../wallet/TransactionBuilder'; -import { WalletService } from '../../wallet/WalletService'; +import { WalletService, getDerivationPath } from '../../wallet'; +import { generateStellarAddress } from '../../wallet/__mocks__/wallet.fixtures'; import { AccountService } from '../AccountService'; import { AccountsRepository } from '../AccountsRepository'; import type { StellarKeyringAccount } from '../api'; -import { createAccountDeriver, getDerivationPath } from '../derivation'; export const generateStellarKeyringAccount = ( id: string, @@ -18,7 +17,7 @@ export const generateStellarKeyringAccount = ( ): StellarKeyringAccount => ({ id, address, - type: 'any:account', + type: KEYRING_ACCOUNT_TYPE, options: { entropy: { type: 'mnemonic', @@ -48,29 +47,28 @@ export const generateMockStellarKeyringAccounts = ( ), ); +/** + * Account-layer stack only. + * + * @returns Account service and wallet service wired to the same state. + */ export const mockAccountService = () => { - const networkService = new NetworkService({ logger }); - const transactionBuilder = new TransactionBuilder({ logger }); - + const walletService = new WalletService({ logger }); + const state = new State({ + encrypted: false, + defaultState: { + keyringAccounts: {}, + accountMetadata: {}, + }, + }); const accountService = new AccountService({ logger, - accountsRepository: new AccountsRepository( - new State({ - encrypted: false, - defaultState: { - keyringAccounts: {}, - }, - }), - ), - walletService: new WalletService({ - logger, - deriver: createAccountDeriver(logger), - networkService, - transactionBuilder, - }), + accountsRepository: new AccountsRepository(state), + walletService, }); return { accountService, + walletService, }; }; diff --git a/merged-packages/stellar-wallet-snap/src/services/account/api.ts b/merged-packages/stellar-wallet-snap/src/services/account/api.ts index 8d4684f7..c71949fd 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/api.ts @@ -1,6 +1,10 @@ import type { KeyringAccount, EntropySourceId } from '@metamask/keyring-api'; -/** Stellar derivation path type (e.g. `m/44'/148'/0'`). */ +export type KeyringAccountState = { + keyringAccounts: Record; +}; + +/** Stellar BIP44 derivation path (e.g. `m/44'/148'/0'`). */ export type StellarDerivationPath = `m/44'/148'/${string}'`; /** Keyring account extended with Stellar-specific derivation fields. */ diff --git a/merged-packages/stellar-wallet-snap/src/services/account/derivation.ts b/merged-packages/stellar-wallet-snap/src/services/account/derivation.ts deleted file mode 100644 index 9ee1bff5..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/account/derivation.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { hexToBytes } from '@metamask/utils'; - -import { type StellarDerivationPath } from './api'; -import { STELLAR_COIN_TYPE } from '../../constants'; -import { - createPrefixedLogger, - getBip32Entropy, - type ILogger, - sanitizeSensitiveError, -} from '../../utils'; - -const STELLAR_CURVE = 'ed25519'; -/** Stellar BIP32 derivation path prefix. */ -const STELLAR_DERIVATION_PATH_PREFIX = `m/44'/${STELLAR_COIN_TYPE}'`; -/** - * Returns the Stellar BIP32 derivation path for the given index (e.g. `m/44'/148'/0'`). - * - * @param index - The derivation index (account number). - * @returns The derivation path string. - */ -export function getDerivationPath(index: number): StellarDerivationPath { - return `${STELLAR_DERIVATION_PATH_PREFIX}/${index}'`; -} - -/** - * Derives a 32-byte Ed25519 seed from the given index and entropy source using BIP32. - * Used by WalletService for keypair derivation. - * - * @param index - The derivation index. - * @param entropySource - The entropy source ID (e.g. from the keyring). - * @param logger - Optional logger for derivation logs. - * @returns A Promise that resolves to the 32-byte seed for Ed25519 keypair derivation. - * @throws If derivation fails or the keyring does not return a valid key (errors are sanitized). - */ -export async function get32ByteSeed( - index: number, - entropySource: string, - logger?: ILogger, -): Promise { - try { - const derivationPath = getDerivationPath(index); - if (logger) { - logger.log({ derivationPath }, 'Generating Stellar wallet'); - } - const path = derivationPath.split('/'); - const node = await getBip32Entropy({ - entropySource, - path, - curve: STELLAR_CURVE, - }); - if (!node.privateKey || !node.publicKey) { - throw new Error('Unable to derive private key or public key'); - } - const privateKeyBytes = hexToBytes(node.privateKey); - return privateKeyBytes; - } catch (error) { - if (logger) { - logger.debug({ error }, 'Error getting seed'); - } - throw sanitizeSensitiveError(error as Error); - } -} - -/** - * Creates an {@link IDeriver}-compatible object that derives Stellar seeds with prefixed logging. - * Use when wiring WalletService in context. - * - * @param logger - Logger to use; a derivation-prefixed logger is created from it. - * @returns An object with `get32ByteSeed(index, entropySource)` returning a Promise that resolves to the seed. - */ -export function createAccountDeriver(logger: ILogger): { - get32ByteSeed: (index: number, entropySource: string) => Promise; -} { - const prefixed = createPrefixedLogger(logger, '[🔑 AccountDeriver]'); - return { - get32ByteSeed: async (index, entropySource) => - get32ByteSeed(index, entropySource, prefixed), - }; -} diff --git a/merged-packages/stellar-wallet-snap/src/services/account/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/account/exceptions.ts index 211ad431..d87dad4f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/exceptions.ts @@ -1,15 +1,18 @@ -import type { KnownCaip2ChainId } from '../../api'; +export class AccountServiceException extends Error { + constructor(message: string) { + super(message); + this.name = 'AccountServiceException'; + } +} -export class AccountNotFoundException extends Error { - constructor(addressOrId: string, scope?: KnownCaip2ChainId) { - super( - `Account not found for address or id: ${addressOrId} and scope: ${scope}`, - ); +export class AccountNotFoundException extends AccountServiceException { + constructor(addressOrId: string) { + super(`Account not found for address or id: ${addressOrId}`); this.name = 'AccountNotFoundException'; } } -export class DerivedAccountAddressMismatchException extends Error { +export class DerivedAccountAddressMismatchException extends AccountServiceException { constructor(address: string) { super( `Derived account address does not match the provided address: ${address}`, @@ -18,7 +21,7 @@ export class DerivedAccountAddressMismatchException extends Error { } } -export class AccountRollbackException extends Error { +export class AccountRollbackException extends AccountServiceException { constructor(accountId: string, address: string) { super( `Failed to rollback account creation for account ID: ${accountId} and address: ${address}`, diff --git a/merged-packages/stellar-wallet-snap/src/services/account/index.ts b/merged-packages/stellar-wallet-snap/src/services/account/index.ts index d064704d..640fcbd8 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/index.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/index.ts @@ -1,4 +1,5 @@ export * from './AccountService'; export * from './AccountsRepository'; export type * from './api'; -export * from './derivation'; +export * from './exceptions'; +export * from './utils'; diff --git a/merged-packages/stellar-wallet-snap/src/services/account/utils.test.ts b/merged-packages/stellar-wallet-snap/src/services/account/utils.test.ts new file mode 100644 index 00000000..e9396a26 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/account/utils.test.ts @@ -0,0 +1,18 @@ +import { DerivedAccountAddressMismatchException } from './exceptions'; +import { assertSameAddress } from './utils'; + +describe('assertSameAddress', () => { + it('returns when strkeys match ignoring case', () => { + const upper = 'GDRZ4B4X2GCM3IINPEBUYQXTO2GJX6YDTV5OMLC7TKTGL33WNEKLUSKF'; + const lower = 'gdrz4b4x2gcm3iinpebuyqxto2gjx6ydtv5omlc7tktgl33wnekluskf'; + expect(() => assertSameAddress(upper, lower)).not.toThrow(); + }); + + it('throws DerivedAccountAddressMismatchException when strkeys differ', () => { + const expected = 'GDRZ4B4X2GCM3IINPEBUYQXTO2GJX6YDTV5OMLC7TKTGL33WNEKLUSKF'; + const actual = 'GDTF7ERUQVTX23ZD6NY5XRYC5IQAKWFVTQ6IXSMEZWGVNDDGPYCVHRZP'; + expect(() => assertSameAddress(expected, actual)).toThrow( + DerivedAccountAddressMismatchException, + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts new file mode 100644 index 00000000..04d19de2 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts @@ -0,0 +1,612 @@ +import { + Account, + Horizon as StellarHorizon, + rpc as StellarRpc, + NotFoundError, + xdr, +} from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import { KnownRpcError } from './api'; +import { + AccountLoadException, + AccountNotActivatedException, + AssetDataFetchException, + BaseFeeFetchException, + NetworkServiceException, + SimulationException, + TransactionPollException, + TransactionRetryableException, + TransactionSendException, +} from './exceptions'; +import { NetworkService } from './NetworkService'; +import type { KnownCaip19Sep41AssetId } from '../../api'; +import { KnownCaip2ChainId } from '../../api'; +import { AppConfig } from '../../config'; +import { logger } from '../../utils/logger'; +import { createMockAccountWithBalances } from '../on-chain-account/__mocks__/onChainAccount.fixtures'; +import { OnChainAccount } from '../on-chain-account/OnChainAccount'; +import { + buildMockClassicTransaction, + buildMockInvokeHostFunctionTransaction, +} from '../transaction/__mocks__/transaction.fixtures'; +import { generateStellarAddress } from '../wallet/__mocks__/wallet.fixtures'; + +jest.mock('../../utils/logger'); + +describe('NetworkService', () => { + let networkService: NetworkService; + + const testTransactionHash = + '58b5e4cd7319962ecbfbdaa7a3b9444c9117e130935da4f14a695dd5d1423d0a'; + let scope: KnownCaip2ChainId; + + beforeEach(() => { + jest.clearAllMocks(); + networkService = new NetworkService({ logger }); + scope = KnownCaip2ChainId.Mainnet; + }); + + const getHorizonClientSpies = () => ({ + fetchBaseFeeSpy: jest.spyOn( + StellarHorizon.Server.prototype, + 'fetchBaseFee', + ), + loadAccountSpy: jest.spyOn(StellarHorizon.Server.prototype, 'loadAccount'), + }); + + const getRpcServerSpies = () => ({ + pollTransactionSpy: jest.spyOn( + StellarRpc.Server.prototype, + 'pollTransaction', + ), + sendTransactionSpy: jest.spyOn( + StellarRpc.Server.prototype, + 'sendTransaction', + ), + getAccountSpy: jest.spyOn(StellarRpc.Server.prototype, 'getAccount'), + getLedgerEntriesSpy: jest.spyOn( + StellarRpc.Server.prototype, + 'getLedgerEntries', + ), + simulateTransactionSpy: jest.spyOn( + StellarRpc.Server.prototype, + 'simulateTransaction', + ), + }); + + const validSep41AssetId = + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as KnownCaip19Sep41AssetId; + + const createMockTransaction = (accountId?: string) => { + return buildMockClassicTransaction([ + { + type: 'payment', + params: { + destination: accountId ?? generateStellarAddress(), + asset: 'native', + amount: '1', + }, + }, + ]); + }; + + const createMockInvokeHostFunctionTransaction = (accountId?: string) => { + return buildMockInvokeHostFunctionTransaction('invokeHostFunction', [], { + contractId: 'CASUP2OPFVEHCWGP2XLBXOV7DQIQIT42AQISG4MXAZGNLVFFN63X7WRT', + source: { + accountId: accountId ?? generateStellarAddress(), + sequence: '1', + }, + }); + }; + + describe('getBaseFee', () => { + it('returns base fee as BigNumber', async () => { + const { fetchBaseFeeSpy } = getHorizonClientSpies(); + fetchBaseFeeSpy.mockResolvedValue(100); + + const result = await networkService.getBaseFee(scope); + + expect(result).toStrictEqual(new BigNumber(100)); + expect(fetchBaseFeeSpy).toHaveBeenCalled(); + }); + + it('throws BaseFeeFetchException when fetch fails', async () => { + const { fetchBaseFeeSpy } = getHorizonClientSpies(); + fetchBaseFeeSpy.mockRejectedValue(new Error('Network error')); + + await expect(networkService.getBaseFee(scope)).rejects.toThrow( + BaseFeeFetchException, + ); + }); + }); + + describe('loadOnChainAccount', () => { + const testAddress = + 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG'; + + it('returns loaded account', async () => { + const { loadAccountSpy } = getHorizonClientSpies(); + const account = createMockAccountWithBalances(testAddress, '1', { + nativeBalance: 1, + assets: [], + }); + + loadAccountSpy.mockResolvedValue( + account as unknown as StellarHorizon.AccountResponse, + ); + + const result = await networkService.loadOnChainAccount( + testAddress, + scope, + ); + + expect(result).toBeInstanceOf(OnChainAccount); + expect(result.accountId).toStrictEqual(testAddress); + expect(result.sequenceNumber).toStrictEqual(account.sequenceNumber()); + expect(loadAccountSpy).toHaveBeenCalledWith(testAddress); + }); + + it('throws AccountNotActivatedException when account is not found', async () => { + const { loadAccountSpy } = getHorizonClientSpies(); + loadAccountSpy.mockRejectedValue(new NotFoundError('not found', {})); + + await expect( + networkService.loadOnChainAccount(testAddress, scope), + ).rejects.toThrow(AccountNotActivatedException); + }); + + it('throws AccountLoadException when load fails for other reason', async () => { + const { loadAccountSpy } = getHorizonClientSpies(); + loadAccountSpy.mockRejectedValue(new Error('Network error')); + + await expect( + networkService.loadOnChainAccount(testAddress, scope), + ).rejects.toThrow(AccountLoadException); + }); + }); + + describe('loadActivatedAccountOrNull', () => { + const testAddress = + 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG'; + + it('returns null when the account is not on-chain', async () => { + const { loadAccountSpy } = getHorizonClientSpies(); + loadAccountSpy.mockRejectedValue(new NotFoundError('not found', {})); + + const result = await networkService.loadActivatedAccountOrNull( + testAddress, + scope, + ); + expect(result).toBeNull(); + }); + + it('returns OnChainAccount when the account exists', async () => { + const { loadAccountSpy } = getHorizonClientSpies(); + const account = createMockAccountWithBalances(testAddress, '1', { + nativeBalance: 1, + assets: [], + }); + loadAccountSpy.mockResolvedValue( + account as unknown as StellarHorizon.AccountResponse, + ); + + const result = await networkService.loadActivatedAccountOrNull( + testAddress, + scope, + ); + + expect(result).toBeInstanceOf(OnChainAccount); + expect(result?.accountId).toStrictEqual(testAddress); + }); + + it('rethrows AccountLoadException when Horizon fails for other reasons', async () => { + const { loadAccountSpy } = getHorizonClientSpies(); + loadAccountSpy.mockRejectedValue(new Error('Network error')); + + await expect( + networkService.loadActivatedAccountOrNull(testAddress, scope), + ).rejects.toThrow(AccountLoadException); + }); + }); + + describe('getAccount', () => { + const testAddress = + 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG'; + + it('returns OnChainAccount from RPC getAccount', async () => { + const { getAccountSpy } = getRpcServerSpies(); + const stellarAccount = new Account(testAddress, '5'); + getAccountSpy.mockResolvedValue(stellarAccount); + + const result = await networkService.getAccount(testAddress, scope); + + expect(result).toBeInstanceOf(OnChainAccount); + expect(result.accountId).toStrictEqual(testAddress); + expect(result.sequenceNumber).toBe('5'); + expect(getAccountSpy).toHaveBeenCalledWith(testAddress); + }); + + it('throws AccountNotActivatedException when RPC uses Soroban missing-account error shape', async () => { + const { getAccountSpy } = getRpcServerSpies(); + getAccountSpy.mockRejectedValue( + new Error(`Account not found: ${testAddress}`), + ); + + await expect( + networkService.getAccount(testAddress, scope), + ).rejects.toThrow(AccountNotActivatedException); + }); + + it('throws AccountLoadException when error message is not the Soroban missing-account shape', async () => { + const { getAccountSpy } = getRpcServerSpies(); + getAccountSpy.mockRejectedValue(new Error('Account not found')); + + await expect( + networkService.getAccount(testAddress, scope), + ).rejects.toThrow(AccountLoadException); + }); + + it('throws AccountLoadException for other RPC errors', async () => { + const { getAccountSpy } = getRpcServerSpies(); + getAccountSpy.mockRejectedValue(new Error('RPC unavailable')); + + await expect( + networkService.getAccount(testAddress, scope), + ).rejects.toThrow(AccountLoadException); + }); + }); + + describe('getAccountOrNull', () => { + const testAddress = + 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG'; + + it('returns null when the account is not on-chain', async () => { + const { getAccountSpy } = getRpcServerSpies(); + getAccountSpy.mockRejectedValue( + new Error(`Account not found: ${testAddress}`), + ); + + const result = await networkService.getAccountOrNull(testAddress, scope); + expect(result).toBeNull(); + }); + + it('returns OnChainAccount when RPC succeeds', async () => { + const { getAccountSpy } = getRpcServerSpies(); + getAccountSpy.mockResolvedValue(new Account(testAddress, '2')); + + const result = await networkService.getAccountOrNull(testAddress, scope); + + expect(result).toBeInstanceOf(OnChainAccount); + expect(result?.sequenceNumber).toBe('2'); + }); + + it('rethrows AccountLoadException for other RPC errors', async () => { + const { getAccountSpy } = getRpcServerSpies(); + getAccountSpy.mockRejectedValue(new Error('RPC unavailable')); + + await expect( + networkService.getAccountOrNull(testAddress, scope), + ).rejects.toThrow(AccountLoadException); + }); + }); + + describe('getAssetData', () => { + it('returns the matching row from getAssetsData', async () => { + const row = { + assetId: validSep41AssetId, + name: 'T', + symbol: 'TOK', + decimals: 7, + }; + const spy = jest + .spyOn(NetworkService.prototype, 'getAssetsData') + .mockResolvedValue([row]); + + const result = await networkService.getAssetData( + validSep41AssetId, + scope, + ); + + expect(result).toStrictEqual(row); + expect(spy).toHaveBeenCalledWith([validSep41AssetId], scope); + spy.mockRestore(); + }); + + it('throws AssetDataFetchException when the batch omits the requested id', async () => { + const spy = jest + .spyOn(NetworkService.prototype, 'getAssetsData') + .mockResolvedValue([]); + + await expect( + networkService.getAssetData(validSep41AssetId, scope), + ).rejects.toThrow(AssetDataFetchException); + + spy.mockRestore(); + }); + }); + + describe('getAssetsData', () => { + it('throws NetworkServiceException when getLedgerEntries fails', async () => { + const { getLedgerEntriesSpy } = getRpcServerSpies(); + getLedgerEntriesSpy.mockRejectedValue(new Error('RPC error')); + + await expect( + networkService.getAssetsData([validSep41AssetId], scope), + ).rejects.toThrow(NetworkServiceException); + }); + }); + + describe('getSep41TokenBalance', () => { + const accountAddress = + 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG'; + + it('throws SimulationException when simulation returns an error payload', async () => { + const { simulateTransactionSpy } = getRpcServerSpies(); + simulateTransactionSpy.mockResolvedValue({ + error: 'contract reverted', + } as never); + + await expect( + networkService.getSep41TokenBalance({ + accountAddress, + assetId: validSep41AssetId, + scope, + sequenceNumber: '1', + }), + ).rejects.toThrow(SimulationException); + }); + + it('throws NetworkServiceException when simulation has no retval', async () => { + const { simulateTransactionSpy } = getRpcServerSpies(); + simulateTransactionSpy.mockResolvedValue({ + id: 'sim-1', + result: {}, + } as never); + + await expect( + networkService.getSep41TokenBalance({ + accountAddress, + assetId: validSep41AssetId, + scope, + sequenceNumber: '1', + }), + ).rejects.toThrow(NetworkServiceException); + }); + + it('returns balance from scVal when simulation succeeds', async () => { + const { simulateTransactionSpy } = getRpcServerSpies(); + const retval = xdr.ScVal.scvU64(xdr.Uint64.fromString('12345')); + simulateTransactionSpy.mockResolvedValue({ + id: 'sim-1', + result: { retval }, + } as never); + + const result = await networkService.getSep41TokenBalance({ + accountAddress, + assetId: validSep41AssetId, + scope, + sequenceNumber: '1', + }); + + expect(result.toString()).toBe('12345'); + }); + }); + + describe('pollTransaction', () => { + it('returns transaction hash when status is SUCCESS', async () => { + const { pollTransactionSpy } = getRpcServerSpies(); + pollTransactionSpy.mockResolvedValue({ + status: StellarRpc.Api.GetTransactionStatus.SUCCESS, + txHash: testTransactionHash, + } as unknown as StellarRpc.Api.GetSuccessfulTransactionResponse); + + const result = await networkService.pollTransaction( + testTransactionHash, + scope, + ); + + expect(result).toStrictEqual(testTransactionHash); + expect(pollTransactionSpy).toHaveBeenCalledWith(testTransactionHash, { + attempts: AppConfig.transaction.pollingAttempts, + }); + }); + + it('throws TransactionPollException when status is not SUCCESS', async () => { + const { pollTransactionSpy } = getRpcServerSpies(); + pollTransactionSpy.mockResolvedValue({ + status: StellarRpc.Api.GetTransactionStatus.FAILED, + txHash: testTransactionHash, + } as unknown as StellarRpc.Api.GetFailedTransactionResponse); + + await expect( + networkService.pollTransaction(testTransactionHash, scope), + ).rejects.toThrow(TransactionPollException); + }); + + it('throws TransactionPollException when poll fails', async () => { + const { pollTransactionSpy } = getRpcServerSpies(); + pollTransactionSpy.mockRejectedValue(new Error('RPC error')); + + await expect( + networkService.pollTransaction(testTransactionHash, scope), + ).rejects.toThrow(TransactionPollException); + }); + }); + + describe('send', () => { + it('returns transaction hash when pollTransaction is false', async () => { + const { sendTransactionSpy, pollTransactionSpy } = getRpcServerSpies(); + sendTransactionSpy.mockResolvedValue({ + hash: testTransactionHash, + } as unknown as StellarRpc.Api.SendTransactionResponse); + const mockTransaction = createMockTransaction(); + + const result = await networkService.send({ + transaction: mockTransaction, + scope, + pollTransaction: false, + }); + + expect(result).toStrictEqual(testTransactionHash); + expect(sendTransactionSpy).toHaveBeenCalledWith(mockTransaction.getRaw()); + expect(pollTransactionSpy).not.toHaveBeenCalled(); + }); + + it('polls and returns hash when pollTransaction is true and status is SUCCESS', async () => { + const { sendTransactionSpy, pollTransactionSpy } = getRpcServerSpies(); + sendTransactionSpy.mockResolvedValue({ + hash: testTransactionHash, + } as unknown as StellarRpc.Api.SendTransactionResponse); + pollTransactionSpy.mockResolvedValue({ + status: StellarRpc.Api.GetTransactionStatus.SUCCESS, + txHash: testTransactionHash, + } as unknown as StellarRpc.Api.GetSuccessfulTransactionResponse); + const mockTransaction = createMockTransaction(); + + const result = await networkService.send({ + transaction: mockTransaction, + scope, + pollTransaction: true, + }); + + expect(result).toStrictEqual(testTransactionHash); + expect(pollTransactionSpy).toHaveBeenCalledWith(testTransactionHash, { + attempts: AppConfig.transaction.pollingAttempts, + }); + }); + + it('throws TransactionPollException when pollTransaction is true and poll fails', async () => { + const { sendTransactionSpy, pollTransactionSpy } = getRpcServerSpies(); + sendTransactionSpy.mockResolvedValue({ + hash: testTransactionHash, + } as unknown as StellarRpc.Api.SendTransactionResponse); + pollTransactionSpy.mockRejectedValue(new Error('RPC error')); + const mockTransaction = createMockTransaction(); + + await expect( + networkService.send({ + transaction: mockTransaction, + scope, + pollTransaction: true, + }), + ).rejects.toThrow(TransactionPollException); + }); + + it('throws TransactionRetryableException when RPC returns ERROR with txBadSeq', async () => { + const { sendTransactionSpy } = getRpcServerSpies(); + sendTransactionSpy.mockResolvedValue({ + status: 'ERROR', + errorResult: { + result: jest.fn().mockReturnValue({ + switch: () => ({ name: KnownRpcError.TxBadSeq }), + }), + }, + } as never); + const mockTransaction = createMockTransaction(); + + await expect( + networkService.send({ transaction: mockTransaction, scope }), + ).rejects.toThrow(TransactionRetryableException); + }); + + it('throws TransactionSendException when RPC returns ERROR with another code', async () => { + const { sendTransactionSpy } = getRpcServerSpies(); + sendTransactionSpy.mockResolvedValue({ + status: 'ERROR', + errorResult: { + result: jest.fn().mockReturnValue({ + switch: () => ({ name: KnownRpcError.TxBadAuth }), + }), + }, + } as never); + const mockTransaction = createMockTransaction(); + + await expect( + networkService.send({ transaction: mockTransaction, scope }), + ).rejects.toThrow(TransactionSendException); + }); + + it('throws TransactionSendException when sendTransaction throws', async () => { + const { sendTransactionSpy } = getRpcServerSpies(); + sendTransactionSpy.mockRejectedValue(new Error('connection reset')); + const mockTransaction = createMockTransaction(); + + await expect( + networkService.send({ transaction: mockTransaction, scope }), + ).rejects.toThrow(TransactionSendException); + }); + }); + + describe('simulateTransaction', () => { + it('throws NetworkServiceException when the envelope is not a single invokeHostFunction', async () => { + const { simulateTransactionSpy } = getRpcServerSpies(); + + await expect( + networkService.simulateTransaction(createMockTransaction(), scope), + ).rejects.toThrow(NetworkServiceException); + + expect(simulateTransactionSpy).not.toHaveBeenCalled(); + }); + + it('throws SimulationException when RPC returns a simulation error', async () => { + const { simulateTransactionSpy } = getRpcServerSpies(); + simulateTransactionSpy.mockResolvedValue({ + error: 'invoke failed', + } as never); + + await expect( + networkService.simulateTransaction( + createMockInvokeHostFunctionTransaction(), + scope, + ), + ).rejects.toThrow(SimulationException); + }); + }); + + describe('#getHorizonClient', () => { + it('creates and returns a new Horizon client when it is not already created', async () => { + const { fetchBaseFeeSpy } = getHorizonClientSpies(); + fetchBaseFeeSpy.mockResolvedValue(100); + + await networkService.getBaseFee(scope); + + expect(fetchBaseFeeSpy).toHaveBeenCalled(); + }); + + it('throws NetworkServiceException when corresponding Config is not found for the given scope', async () => { + const { fetchBaseFeeSpy } = getHorizonClientSpies(); + + await expect( + networkService.getBaseFee('unknown' as unknown as KnownCaip2ChainId), + ).rejects.toThrow(NetworkServiceException); + expect(fetchBaseFeeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('#getRpcClient', () => { + it('creates and returns a new RPC client when it is not already created', async () => { + const { pollTransactionSpy } = getRpcServerSpies(); + pollTransactionSpy.mockResolvedValue({ + status: StellarRpc.Api.GetTransactionStatus.SUCCESS, + txHash: testTransactionHash, + } as unknown as StellarRpc.Api.GetSuccessfulTransactionResponse); + + await networkService.pollTransaction(testTransactionHash, scope); + expect(pollTransactionSpy).toHaveBeenCalled(); + }); + + it('throws NetworkServiceException when corresponding Config is not found for the given scope', async () => { + const { pollTransactionSpy } = getRpcServerSpies(); + + await expect( + networkService.pollTransaction( + testTransactionHash, + 'unknown' as unknown as KnownCaip2ChainId, + ), + ).rejects.toThrow(NetworkServiceException); + expect(pollTransactionSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts new file mode 100644 index 00000000..e673858b --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -0,0 +1,518 @@ +import { parseCaipAssetType } from '@metamask/utils'; +import { + Account as StellarAccount, + Address, + BASE_FEE, + Contract, + Horizon as StellarHorizon, + NotFoundError, + rpc, + scValToNative, + TransactionBuilder as StellarSdkTransactionBuilder, +} from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import type { AssetDataResponse } from './api'; +import { KnownRpcError } from './api'; +import { + AccountLoadException, + AccountNotActivatedException, + AssetDataFetchException, + BaseFeeFetchException, + NetworkServiceException, + SimulationException, + TransactionPollException, + TransactionRetryableException, + TransactionSendException, +} from './exceptions'; +import { + caip2ChainIdToNetwork, + extractAssetDataFromContractData, + isAccountNotFoundError, + parseScValToNative, +} from './utils'; +import type { KnownCaip19Sep41AssetId, KnownCaip2ChainId } from '../../api'; +import type { NetworkConfig } from '../../config'; +import { AppConfig } from '../../config'; +import type { ILogger } from '../../utils'; +import { + isSameStr, + createPrefixedLogger, + parseClassicAssetCodeIssuer, + toCaip19ClassicAssetId, + toCaip19Sep41AssetId, +} from '../../utils'; +import { OnChainAccount } from '../on-chain-account/OnChainAccount'; +import { Transaction } from '../transaction/Transaction'; + +/** + * Stellar network access through **Horizon** and **Soroban RPC**: base fee, account loading (full + * Horizon account vs RPC sequence-only), contract token metadata (`getLedgerEntries`), SEP-41 + * balance simulation, Soroban simulation / fee computation, transaction submission, and optional + * post-submit polling. + */ +export class NetworkService { + readonly #logger: ILogger; + + readonly #horizonClientMap = new Map< + KnownCaip2ChainId, + StellarHorizon.Server + >(); + + readonly #rpcClientMap = new Map(); + + constructor({ logger }: { logger: ILogger }) { + this.#logger = createPrefixedLogger(logger, '[🌐 NetworkService]'); + } + + #getHorizonClient(scope: KnownCaip2ChainId): StellarHorizon.Server { + let client = this.#horizonClientMap.get(scope); + if (!client) { + client = new StellarHorizon.Server( + this.#getNetworkConfig(scope).horizonUrl, + ); + this.#horizonClientMap.set(scope, client); + } + return client; + } + + #getRpcClient(scope: KnownCaip2ChainId): rpc.Server { + let client = this.#rpcClientMap.get(scope); + if (!client) { + client = new rpc.Server(this.#getNetworkConfig(scope).rpcUrl); + this.#rpcClientMap.set(scope, client); + } + return client; + } + + #getNetworkConfig(scope: KnownCaip2ChainId): NetworkConfig { + const config = AppConfig.networks[scope]; + if (!config) { + throw new NetworkServiceException( + `Network not found for scope: ${scope}`, + ); + } + return config; + } + + /** + * Fetches the current base fee per operation from the Stellar network. + * + * @param scope - The CAIP-2 chain ID. + * @returns A Promise that resolves to the base fee as BigNumber. + * @throws {BaseFeeFetchException} If the fee cannot be fetched. + */ + async getBaseFee(scope: KnownCaip2ChainId): Promise { + try { + const client = this.#getHorizonClient(scope); + const baseFee = await client.fetchBaseFee(); + return new BigNumber(baseFee); + } catch (error: unknown) { + this.#logger.logErrorWithDetails('Failed to fetch base fee', error); + throw new BaseFeeFetchException(scope); + } + } + + /** + * Polls Soroban RPC until the transaction reaches a terminal status, then returns the hash on + * success or throws. + * + * @param transactionHash - Hash returned from `sendTransaction`. + * @param scope - The CAIP-2 chain ID. + * @returns The transaction hash when {@link rpc.Api.GetTransactionStatus.SUCCESS}. + * @throws {TransactionPollException} When the terminal status is not SUCCESS, or polling fails + * (uses {@link AppConfig.transaction.pollingAttempts} as the attempt budget). + */ + async pollTransaction( + transactionHash: string, + scope: KnownCaip2ChainId, + ): Promise { + try { + const client = this.#getRpcClient(scope); + const result = await client.pollTransaction(transactionHash, { + attempts: AppConfig.transaction.pollingAttempts, + }); + if (result.status === rpc.Api.GetTransactionStatus.SUCCESS) { + return result.txHash; + } + throw new TransactionPollException(transactionHash, result.status, scope); + } catch (error: unknown) { + this.#logger.logErrorWithDetails('Failed to poll transaction', error); + if (error instanceof TransactionPollException) { + throw error; + } + throw new TransactionPollException(transactionHash, 'unknown', scope); + } + } + + /** + * Loads the account from **Horizon** (balances, trustlines, sequence, subentries, etc.). + * + * @param accountAddress - The Stellar account address (public key). + * @param scope - The CAIP-2 chain ID. + * @returns A Promise that resolves to a {@link OnChainAccount} backed by Horizon's account response. + * @throws {AccountNotActivatedException} If the account does not exist on the network. + * @throws {AccountLoadException} If loading fails for another reason (e.g. network error). + */ + async loadOnChainAccount( + accountAddress: string, + scope: KnownCaip2ChainId, + ): Promise { + try { + const client = this.#getHorizonClient(scope); + return OnChainAccount.fromHorizon( + await client.loadAccount(accountAddress), + scope, + ); + } catch (error: unknown) { + this.#logger.logErrorWithDetails('Failed to load an account', error); + if (error instanceof NotFoundError) { + throw new AccountNotActivatedException(accountAddress, scope); + } + throw new AccountLoadException(accountAddress, scope); + } + } + + /** + * Fetches the account via Soroban RPC (`getAccountEntry`) and wraps it as {@link OnChainAccount}. + * The underlying SDK `Account` has **id and sequence only** (no Horizon `balances`); use + * {@link loadOnChainAccount} when you need full balance / trustline data. + * + * @param accountAddress - The Stellar account address (public key). + * @param scope - The CAIP-2 chain ID. + * @returns A Promise that resolves to a loaded account (sequence suitable for rebuilding txs). + * @throws {AccountNotActivatedException} If Soroban RPC reports a missing account (SDK + * `Error` message `Account not found: `). + * @throws {AccountLoadException} If loading fails for another reason (e.g. network error). + */ + async getAccount( + accountAddress: string, + scope: KnownCaip2ChainId, + ): Promise { + try { + const client = this.#getRpcClient(scope); + return new OnChainAccount(await client.getAccount(accountAddress), scope); + } catch (error: unknown) { + this.#logger.logErrorWithDetails('Failed to get an account', error); + if (isAccountNotFoundError(error, accountAddress)) { + throw new AccountNotActivatedException(accountAddress, scope); + } + + throw new AccountLoadException(accountAddress, scope); + } + } + + /** + * Fetches token metadata from Soroban via `getLedgerEntries` for a SEP-41 contract CAIP-19 id. + * + * @param assetId - SEP-41 CAIP-19 asset id (`…/sep41:C…`) for a token contract on `scope`. + * @param scope - The CAIP-2 chain ID. + * @returns Classic- or SEP-41-shaped {@link AssetDataResponse} (classic when the contract is a Stellar Asset Contract). + * @throws {AssetDataFetchException} When RPC returns no entry for this asset. + */ + async getAssetData( + assetId: KnownCaip19Sep41AssetId, + scope: KnownCaip2ChainId, + ): Promise { + const assetsData = await this.getAssetsData([assetId], scope); + const assetData = assetsData.find((asset) => asset.assetId === assetId); + if (!assetData) { + throw new AssetDataFetchException(scope, assetId); + } + return assetData; + } + + /** + * Batch counterpart to {@link getAssetData}: loads token contract ledger entries over Soroban RPC. + * + * @param assetIds - SEP-41 CAIP-19 asset ids (`…/sep41:C…`) for contracts on `scope`. + * @param scope - The CAIP-2 chain ID. + * @returns One {@link AssetDataResponse} per returned ledger entry, in RPC order. Ids with no matching + * contract entry are omitted (this is not an error). + * @throws {NetworkServiceException} When the RPC request fails. + */ + async getAssetsData( + assetIds: KnownCaip19Sep41AssetId[], + scope: KnownCaip2ChainId, + ): Promise { + try { + const client = this.#getRpcClient(scope); + + // getLedgerEntries returns only entries that exist; missing contracts are omitted. + const ledgerEntries = await client.getLedgerEntries( + ...assetIds.map((assetId) => + new Contract( + parseCaipAssetType(assetId).assetReference, + ).getFootprint(), + ), + ); + + return ledgerEntries.entries.map((ledgerEntry) => { + const contractId = ledgerEntry.val.contractData().contract(); + const contractAddress = Address.fromScAddress(contractId).toString(); + + const extractedAssetData = extractAssetDataFromContractData( + ledgerEntry.val.contractData(), + contractAddress, + ); + + if (extractedAssetData.isStellarClassicAsset) { + const { assetCode, assetIssuer } = parseClassicAssetCodeIssuer( + extractedAssetData.name, + ); + return { + // Normalize to use CAIP-19 classic asset id - ${CAIP_2_CHAIN_ID}/token:${ASSET_CODE}-${ASSET_ISSUER} + assetId: toCaip19ClassicAssetId(scope, assetCode, assetIssuer), + symbol: extractedAssetData.symbol, + decimals: extractedAssetData.decimals, + name: assetCode, + }; + } + + return { + // Normalize to use CAIP-19 SEP-41 asset id - ${CAIP_2_CHAIN_ID}/sep41:${CONTRACT_ADDRESS} + assetId: toCaip19Sep41AssetId(scope, extractedAssetData.name), + name: extractedAssetData.name, + symbol: extractedAssetData.symbol, + decimals: extractedAssetData.decimals, + }; + }); + } catch (error: unknown) { + this.#logger.logErrorWithDetails('Failed to get assets data', error); + throw new NetworkServiceException('Failed to get assets data'); + } + } + + /** + * Reads a SEP-41-style token balance via Soroban simulation of `balance(Address)`. + * + * @param params - Balance query input. + * @param params.accountAddress - Account holding the token (`G…`). + * @param params.assetId - CAIP-19 asset id for SEP-41 token. + * @param params.scope - CAIP-2 chain id. + * @param params.sequenceNumber - Current sequence number of the account (for the ephemeral tx). + * @returns Token balance in the contract's smallest units. + * @throws {SimulationException} When Soroban simulation fails. + * @throws {NetworkServiceException} When simulation returns no result or another unexpected error occurs. + */ + async getSep41TokenBalance(params: { + accountAddress: string; + assetId: KnownCaip19Sep41AssetId; + scope: KnownCaip2ChainId; + sequenceNumber: string; + }): Promise { + const { accountAddress, assetId, scope, sequenceNumber } = params; + const { assetReference: tokenAddress } = parseCaipAssetType(assetId); + // TODO: change to use https://github.com/Creit-Tech/Stellar-Router-SDK to batch collect balances + try { + const client = this.#getRpcClient(scope); + const token = new Contract(tokenAddress); + const op = token.call( + 'balance', + Address.fromString(accountAddress).toScVal(), + ); + + const account = new StellarAccount(accountAddress, sequenceNumber); + const rawTx = new StellarSdkTransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: caip2ChainIdToNetwork(scope), + }) + .addOperation(op) + .setTimeout(180) + .build(); + + // Using simulateTransaction to get the balance is more reliable than calling the balance function directly. + const sim = await client.simulateTransaction(rawTx); + + if (rpc.Api.isSimulationError(sim)) { + throw new SimulationException( + typeof sim.error === 'string' ? sim.error : JSON.stringify(sim.error), + ); + } + + const retval = sim.result?.retval; + if (!retval) { + throw new NetworkServiceException( + 'SEP-41 balance simulation returned no result', + ); + } + + const native = scValToNative(retval); + + return parseScValToNative(native); + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to load SEP-41 token balance', + error, + ); + if ( + error instanceof SimulationException || + error instanceof NetworkServiceException + ) { + throw error; + } + throw new NetworkServiceException('Failed to load SEP-41 token balance'); + } + } + + /** + * Loads account data when the account exists and is funded; returns `null` if the account is not on-chain. + * + * @param accountAddress - The Stellar account address (public key). + * @param scope - The CAIP-2 chain ID. + * @returns The loaded account, or `null` when {@link AccountNotActivatedException} would apply. + * @throws {AccountLoadException} If loading fails for a reason other than a missing account. + */ + async loadActivatedAccountOrNull( + accountAddress: string, + scope: KnownCaip2ChainId, + ): Promise { + try { + return await this.loadOnChainAccount(accountAddress, scope); + } catch (error: unknown) { + if (error instanceof AccountNotActivatedException) { + return null; + } + throw error; + } + } + + /** + * Like {@link getAccount} but returns `null` if the account is not on-chain. + * + * @param accountAddress - The Stellar account address (public key). + * @param scope - The CAIP-2 chain ID. + * @returns A Promise that resolves to a loaded account or `null` when missing. + * @throws {AccountLoadException} If the fetch fails for a reason other than a missing account. + */ + async getAccountOrNull( + accountAddress: string, + scope: KnownCaip2ChainId, + ): Promise { + try { + return await this.getAccount(accountAddress, scope); + } catch (error: unknown) { + if (error instanceof AccountNotActivatedException) { + return null; + } + throw error; + } + } + + /** + * Submits a signed transaction to the network and optionally waits for a terminal status. + * `scope` must match {@link Transaction.scope} on the envelope. + * + * @param params - The parameters for sending a transaction. + * @param params.transaction - The signed transaction to submit. + * @param params.scope - The CAIP-2 chain ID (must match the envelope). + * @param params.pollTransaction - If true, poll until terminal status and return the hash only on SUCCESS. + * @returns The transaction hash from submission, or after successful polling when `pollTransaction` is true. + * @throws {TransactionRetryableException} When RPC indicates bad sequence (`txBadSeq`); caller may refresh sequence and retry. + * @throws {TransactionSendException} When submission fails for other RPC error reasons. + * @throws {TransactionPollException} When `pollTransaction` is true and the transaction does not end in SUCCESS. + */ + async send({ + transaction, + scope, + pollTransaction = false, + }: { + transaction: Transaction; + scope: KnownCaip2ChainId; + pollTransaction?: boolean; + }): Promise { + try { + const client = this.#getRpcClient(scope); + const executedTransaction = await client.sendTransaction( + transaction.getRaw(), + ); + + if (executedTransaction.status === 'ERROR') { + const errorCode = this.#getSendRpcErrorCode(executedTransaction); + if (isSameStr(errorCode, KnownRpcError.TxBadSeq)) { + throw new TransactionRetryableException(scope, errorCode); + } + throw new TransactionSendException(scope, errorCode); + } + + if (pollTransaction) { + return await this.pollTransaction(executedTransaction.hash, scope); + } + + return executedTransaction.hash; + } catch (error: unknown) { + this.#logger.logErrorWithDetails('Failed to send transaction', error); + if (error instanceof NetworkServiceException) { + throw error; + } + throw new TransactionSendException(scope, 'unknown'); + } + } + + /** + * Simulates a Soroban transaction via RPC and returns a new {@link Transaction} with updated fee + * and footprint (assembled envelope). + * + * @param transaction - Exactly one `invokeHostFunction` operation. + * @param scope - The CAIP-2 chain ID. + * @returns Assembled transaction suitable for signing. + * @throws {NetworkServiceException} When the envelope is not a single `invokeHostFunction` operation. + * @throws {SimulationException} When the RPC reports a simulation error or an unexpected failure occurs. + */ + async simulateTransaction( + transaction: Transaction, + scope: KnownCaip2ChainId, + ): Promise { + try { + const client = this.#getRpcClient(scope); + if ( + !transaction.hasInvokeHostFunction || + transaction.operationCount !== 1 + ) { + throw new NetworkServiceException( + 'Transaction is not a valid invokeHostFunction transaction', + ); + } + const rawTransaction = transaction.getRaw(); + const simulateResponse = await client.simulateTransaction(rawTransaction); + + if (rpc.Api.isSimulationError(simulateResponse)) { + throw new SimulationException( + typeof simulateResponse.error === 'string' + ? simulateResponse.error + : JSON.stringify(simulateResponse.error), + ); + } + + const simulatedTransaction = rpc.assembleTransaction( + rawTransaction, + simulateResponse, + ); + return new Transaction(simulatedTransaction.build()); + } catch (error: unknown) { + this.#logger.logErrorWithDetails('Failed to simulate transaction', error); + if ( + error instanceof NetworkServiceException || + error instanceof SimulationException + ) { + throw error; + } + + throw new SimulationException( + error instanceof Error ? error.message : 'Unknown error', + ); + } + } + + #getSendRpcErrorCode(rpcError: rpc.Api.SendTransactionResponse): string { + try { + return rpcError.errorResult?.result().switch().name ?? 'unknown'; + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to parse send error code', + error, + ); + return 'unknown'; + } + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/network/api.ts b/merged-packages/stellar-wallet-snap/src/services/network/api.ts new file mode 100644 index 00000000..9cb281f3 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/network/api.ts @@ -0,0 +1,34 @@ +import type { KnownCaip19AssetId } from '../../api'; + +/** + * The known RPC error codes for the Stellar network. + * The error code is shared with Horizon API and Soroban RPC. + * + * @see https://developers.stellar.org/docs/data/apis/horizon/api-reference/errors/result-codes/transactions + */ +export enum KnownRpcError { + TxBadSeq = 'txBadSeq', + TxBadAuth = 'txBadAuth', + TxTooEarly = 'txTooEarly', + TxTooLate = 'txTooLate', + TxInsufficientFee = 'txInsufficientFee', + TxInsufficientBalance = 'txInsufficientBalance', + TxInsufficientReserve = 'txInsufficientReserve', + TxFailed = 'txFailed', + TxMissingOperation = 'txMissingOperation', + TxInternalError = 'txInternalError', + TxBadAuthExtra = 'txBadAuthExtra', +} + +/** + * Asset data for a Stellar classic asset. + */ +export type AssetDataResponse = { + name?: string; + // Symbol of the asset + symbol: string; + // Number of decimal places of the asset + decimals: number; + // CAIP-19 classic asset id (`…/asset:CODE-ISSUER`) from RPC / Stellar asset contract + assetId: KnownCaip19AssetId; +}; diff --git a/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts new file mode 100644 index 00000000..f9892c8f --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts @@ -0,0 +1,77 @@ +import type { KnownCaip2ChainId } from '../../api'; + +/** Base for all network-related errors (fees, account load, send, poll). */ +export class NetworkServiceException extends Error { + constructor(message: string) { + super(message); + this.name = 'NetworkServiceException'; + } +} + +/** Thrown when the base fee cannot be fetched from the network (e.g. Horizon unreachable). */ +export class BaseFeeFetchException extends NetworkServiceException { + constructor(scope: KnownCaip2ChainId) { + super(`Failed to fetch base fee for scope: ${scope}`); + } +} + +/** Thrown when transaction polling does not result in SUCCESS (e.g. failed or unknown status). */ +export class TransactionPollException extends NetworkServiceException { + constructor( + transactionHash: string, + status: string, + scope: KnownCaip2ChainId, + ) { + super( + `Failed to poll transaction: ${transactionHash} with status: ${status} for scope: ${scope}`, + ); + } +} + +/** Thrown when account data cannot be loaded (e.g. network error; not used for "account not found"). */ +export class AccountLoadException extends NetworkServiceException { + constructor(accountAddress: string, scope: KnownCaip2ChainId) { + super(`Failed to load account: ${accountAddress} for scope: ${scope}`); + } +} + +/** Thrown when the account does not exist or is not funded on the network. */ +export class AccountNotActivatedException extends NetworkServiceException { + readonly reference: string; + + constructor(address: string, scope: KnownCaip2ChainId) { + super(`Account not activated for address: ${address} for scope: ${scope}`); + this.reference = address; + } +} + +/** Thrown when transaction submission to the network fails. */ +export class TransactionSendException extends NetworkServiceException { + readonly reference?: string; + + constructor(scope: KnownCaip2ChainId, reference?: string) { + super( + `Failed to send transaction: scope: ${scope} ${reference ? ` reference: ${reference}` : ''}`, + ); + this.reference = reference; + } +} + +/** Submit failed with a code the caller may recover from by fixing sequence and retrying (e.g. `txBadSeq`). */ +export class TransactionRetryableException extends TransactionSendException {} + +/** Thrown when a transaction simulation fails. */ +export class SimulationException extends NetworkServiceException { + constructor(message: string) { + super(`Failed to simulate transaction: ${message}`); + } +} + +/** Thrown when asset data cannot be fetched from the network. */ +export class AssetDataFetchException extends NetworkServiceException { + constructor(scope: KnownCaip2ChainId, address: string) { + super( + `Failed to fetch asset data for contract ${address} for scope: ${scope}`, + ); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/network/index.ts b/merged-packages/stellar-wallet-snap/src/services/network/index.ts new file mode 100644 index 00000000..3257a8d9 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/network/index.ts @@ -0,0 +1,4 @@ +export * from './api'; +export * from './exceptions'; +export * from './NetworkService'; +export * from './utils'; diff --git a/merged-packages/stellar-wallet-snap/src/services/network/utils.ts b/merged-packages/stellar-wallet-snap/src/services/network/utils.ts new file mode 100644 index 00000000..7497a54a --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/network/utils.ts @@ -0,0 +1,170 @@ +import { ensureError } from '@metamask/utils'; +import type { xdr } from '@stellar/stellar-sdk'; +import { Networks } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import { KnownCaip2ChainId } from '../../api'; + +const StellarNetwork: Record = { + [KnownCaip2ChainId.Mainnet]: Networks.PUBLIC, + [KnownCaip2ChainId.Testnet]: Networks.TESTNET, +}; + +/** + * Returns the Stellar network passphrase for the given scope (e.g. for transaction building). + * + * @param caip2ChainId - The CAIP-2 chain ID. + * @returns The Stellar Networks passphrase. + * @throws {Error} If the scope is not supported. + */ +export function caip2ChainIdToNetwork( + caip2ChainId: KnownCaip2ChainId, +): Networks { + if (!(caip2ChainId in StellarNetwork)) { + throw new Error(`Network not found for caip2ChainId: ${caip2ChainId}`); + } + return StellarNetwork[caip2ChainId]; +} + +/** + * Resolves a Stellar network passphrase to the corresponding CAIP-2 chain ID. + * + * @param network - The network name or Stellar Networks enum value. + * @returns The CAIP-2 chain ID for the network. + * @throws {Error} If the network is not recognized. + */ +export function networkToCaip2ChainId( + network: string | Networks, +): KnownCaip2ChainId { + const networkValue = + typeof network === 'string' ? (network as Networks) : network; + const caip2ChainId = ( + Object.keys(StellarNetwork) as KnownCaip2ChainId[] + ).find((key) => StellarNetwork[key] === networkValue); + if (!caip2ChainId) { + throw new Error(`Caip2ChainId not found for network: ${network}`); + } + return caip2ChainId; +} + +/** + * Extracts asset data from a contract data entry. + * + * @param contractData - The contract data entry. + * @param contractAddress - Token contract id strkey (`C…`) for error context and wasm token `assetRef`. + * @returns The asset data. + */ +export function extractAssetDataFromContractData( + contractData: xdr.ContractDataEntry, + contractAddress: string, +): { + name: string; + symbol: string; + decimals: number; + isStellarClassicAsset: boolean; +} { + try { + const contractDataInstance = contractData.val().instance(); + + // contractDataName is either contractExecutableWasm or contractExecutableStellarAsset + // contractExecutableWasm: Wasm contract + // contractExecutableStellarAsset: Stellar asset contract + const contractDataName = contractDataInstance.executable().switch().name; + + const isStellarClassicAsset = + contractDataName === 'contractExecutableStellarAsset'; + + const assetData = { + symbol: '', + decimals: -1, + name: '', + isStellarClassicAsset, + }; + + // it is possible to have empty storage, such as when the contract is not a token contract + for (const entry of contractDataInstance?.storage() ?? []) { + const key = entry.key(); + const keyName = key.switch().name; + + if (keyName !== 'scvSymbol' || key.sym().toString() !== 'METADATA') { + continue; + } + + for (const mapEntry of entry.val().map() ?? []) { + const fieldName = mapEntry.key().sym().toString(); + const value = mapEntry.val(); + + switch (fieldName) { + case 'name': + // if it is a Stellar asset contract, the name is ${ASSET_CODE}:${ASSET_ISSUER} + // if it is a Wasm contract, the name is the token name (e.g. "USDC") + assetData.name = isStellarClassicAsset + ? value.str().toString() + : contractAddress; + break; + case 'symbol': + assetData.symbol = value.str().toString(); + break; + case 'decimal': + assetData.decimals = value.u32(); + break; + default: + break; + } + } + } + if (assetData.name === '') { + throw new Error(`Name is empty for contract ${contractAddress}`); + } + if (assetData.symbol === '') { + throw new Error(`Symbol is empty for contract ${contractAddress}`); + } + if (assetData.decimals === -1) { + throw new Error(`Decimals is empty for contract ${contractAddress}`); + } + + return assetData; + } catch { + throw new Error( + `Error extracting asset data from contract ${contractAddress}`, + ); + } +} + +/** + * Parses a XDR value from a string, bigint, or number. + * + * @param value - The value to parse. + * @returns The parsed amount in BigNumber. + * @throws {Error} If the value is not a valid native value. + */ +export function parseScValToNative(value: string | bigint | number): BigNumber { + let amountStr: string; + if (typeof value === 'bigint') { + amountStr = value.toString(); + } else if (typeof value === 'number') { + amountStr = String(Math.trunc(value)); + } else { + amountStr = String(value); + } + const amountBn = new BigNumber(amountStr); + if (!amountBn.isFinite() || amountBn.isNegative()) { + throw new Error(`Invalid native value: ${value}`); + } + return amountBn; +} + +/** + * Detects the error shape thrown by Soroban RPC `getAccount` / `getAccountEntry` when the account + * ledger entry is missing (`Error` with message `Account not found: `). + * + * @param error - Value caught from the RPC client. + * @param accountAddress - Stellar account id that was requested. + * @returns True when `error` matches the SDK missing-account message for this address. + */ +export function isAccountNotFoundError( + error: unknown, + accountAddress: string, +): boolean { + return ensureError(error).message === `Account not found: ${accountAddress}`; +} diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts new file mode 100644 index 00000000..b01cbdde --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts @@ -0,0 +1,332 @@ +import { Account } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import type { OnChainAccountSnapshot } from './api'; +import { OnChainAccountBalanceNotAvailableException } from './exceptions'; +import type { SpendableBalance } from './OnChainAccount'; +import { OnChainAccount } from './OnChainAccount'; +import { KnownCaip2ChainId } from '../../api'; +import { + getSlip44AssetId, + toCaip19ClassicAssetId, + toSmallestUnit, +} from '../../utils'; +import type { + AccountBalance, + TrustLineAssetBalance, +} from '../account-balance/api'; +import { + createMockAccountWithBalances, + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, +} from './__mocks__/onChainAccount.fixtures'; +import { getTestWallet } from '../wallet/__mocks__/wallet.fixtures'; + +/** + * Maps an on-chain trustline view to persisted {@link TrustLineAssetBalance} shape. + * + * @param row - Classic trustline row from {@link OnChainAccount.getAsset}. + * @returns Balance row as stored by account balance sync. + */ +function trustLineToPersistedBalance( + row: SpendableBalance, +): TrustLineAssetBalance { + const base: TrustLineAssetBalance = { + unit: row.symbol, + amount: row.balance.toString(), + limit: row.limit?.toString() ?? '0', + }; + return { + ...base, + ...(typeof row.authorized === 'boolean' + ? { authorized: row.authorized } + : {}), + ...(row.sponsored ? { sponsored: true } : {}), + }; +} + +describe('OnChainAccount', () => { + const testWalletSigner = getTestWallet(); + const testOnChain = new OnChainAccount( + createMockAccountWithBalances( + testWalletSigner.address, + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ), + KnownCaip2ChainId.Mainnet, + ); + const createTestWallet = () => { + const wallet = getTestWallet(); + return { + wallet, + onChainAccount: new OnChainAccount( + createMockAccountWithBalances(wallet.address, '1', { + nativeBalance: 10, + subentryCount: 0, + sponsoringCount: 0, + sponsoredCount: 0, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + balance: 10, + }, + ], + }), + KnownCaip2ChainId.Mainnet, + ), + }; + }; + + const createAccReserve = 2; + const createSubentryReserve = 1; + + describe('accountId', () => { + it('returns the account id', () => { + expect(testWalletSigner.address).toBe(testOnChain.accountId); + }); + }); + + describe('sequenceNumber', () => { + it('returns the sequence number', () => { + expect(testOnChain.sequenceNumber).toBeDefined(); + }); + }); + + describe('hasAsset', () => { + it('returns true if the account has the trustline', () => { + const { onChainAccount } = createTestWallet(); + expect( + onChainAccount.hasAsset( + toCaip19ClassicAssetId( + KnownCaip2ChainId.Mainnet, + 'USDC', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ), + ), + ).toBe(true); + }); + + it('returns false if the account does not have the trustline', () => { + const { onChainAccount } = createTestWallet(); + expect( + onChainAccount.hasAsset( + toCaip19ClassicAssetId( + KnownCaip2ChainId.Mainnet, + 'AAAA', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ), + ), + ).toBe(false); + }); + + it('returns false when account has no Horizon balance metadata', () => { + const onChainAccount = new OnChainAccount( + new Account(testOnChain.accountId, testOnChain.sequenceNumber), + KnownCaip2ChainId.Mainnet, + ); + expect( + onChainAccount.hasAsset( + toCaip19ClassicAssetId( + KnownCaip2ChainId.Mainnet, + 'AAAA', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ), + ), + ).toBe(false); + }); + }); + + describe('getScope', () => { + it('returns the scope', () => { + const { onChainAccount } = createTestWallet(); + expect(onChainAccount.scope).toStrictEqual(KnownCaip2ChainId.Mainnet); + }); + }); + + describe('getAsset', () => { + it.each([ + { + assetId: toCaip19ClassicAssetId( + KnownCaip2ChainId.Mainnet, + 'USDC', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ), + expected: { + balance: new BigNumber('100000000'), + symbol: 'USDC', + address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + limit: new BigNumber('9223372036854775807'), + authorized: true, + }, + }, + { + assetId: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + expected: { + address: undefined, + balance: new BigNumber('90000000'), + symbol: 'XLM', + }, + }, + ])( + 'returns the balance for the asset - $assetId', + ({ assetId, expected }) => { + const { onChainAccount } = createTestWallet(); + expect(onChainAccount.getAsset(assetId)).toStrictEqual(expected); + }, + ); + + it('throws OnChainAccountBalanceNotAvailableException when account has no loaded balances', () => { + const onChainAccount = new OnChainAccount( + new Account(testOnChain.accountId, testOnChain.sequenceNumber), + KnownCaip2ChainId.Mainnet, + ); + expect(() => + onChainAccount.getAsset( + toCaip19ClassicAssetId( + KnownCaip2ChainId.Mainnet, + 'AAAA', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ), + ), + ).toThrow(OnChainAccountBalanceNotAvailableException); + }); + }); + + describe('getNativeSpendableBalance', () => { + it.each([ + // Test case with no trustlines + { + subentryCount: 0, + sponsoringCount: 0, + sponsoredCount: 0, + nativeBalance: 3, + expected: toSmallestUnit(new BigNumber('2')), + }, + // Test case with 2 trustlines + { + subentryCount: createSubentryReserve * 2, + sponsoringCount: 0, + sponsoredCount: 0, + nativeBalance: 3, + expected: toSmallestUnit(new BigNumber('1')), + }, + // Test case with 2 trustlines and 1 of those is sponsored + { + subentryCount: createSubentryReserve * 2, + sponsoringCount: 0, + sponsoredCount: createSubentryReserve, + nativeBalance: 3, + expected: toSmallestUnit(new BigNumber('1.5')), + }, + // Test case with 1 trustlines and the user is sponsored by another account + { + subentryCount: createSubentryReserve, + sponsoringCount: 0, + sponsoredCount: createAccReserve, + nativeBalance: 1, + expected: toSmallestUnit(new BigNumber('0.5')), + }, + // Test case with 2 trustlines, 2 of those is sponsored, and the user is sponsored by another account + { + subentryCount: createSubentryReserve * 2, + sponsoringCount: 0, + sponsoredCount: createAccReserve + createSubentryReserve * 2, + nativeBalance: 0, + expected: toSmallestUnit(new BigNumber('0')), + }, + // Test case with 2 trustlines, 2 of those is sponsored, and the user is sponsored by another account, and it sponering another account create + { + subentryCount: createSubentryReserve * 2, + sponsoringCount: createAccReserve, + sponsoredCount: createSubentryReserve * 2 + createAccReserve, + nativeBalance: 1, + expected: toSmallestUnit(new BigNumber('0')), + }, + ])( + 'returns the native spendable balance for the account - subentryCount: $subentryCount, sponsoringCount: $sponsoringCount, sponsoredCount: $sponsoredCount, nativeBalance: $nativeBalance', + ({ + subentryCount, + sponsoringCount, + sponsoredCount, + nativeBalance, + expected, + }) => { + const wallet = getTestWallet(); + const onChainAccount = new OnChainAccount( + createMockAccountWithBalances(wallet.address, '1', { + nativeBalance, + subentryCount, + sponsoringCount, + sponsoredCount, + assets: [], + }), + KnownCaip2ChainId.Mainnet, + ); + + expect(onChainAccount.nativeSpendableBalance).toStrictEqual(expected); + }, + ); + + it('throws an error if the balance metadata is not available', () => { + const onChainAccount = new OnChainAccount( + new Account(testOnChain.accountId, testOnChain.sequenceNumber), + KnownCaip2ChainId.Mainnet, + ); + expect(() => onChainAccount.nativeSpendableBalance).toThrow(Error); + }); + }); + + describe('getRaw', () => { + it('returns the raw account', () => { + const account = new Account( + 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG', + '1', + ); + const onChainAccount = new OnChainAccount( + account, + KnownCaip2ChainId.Mainnet, + ); + expect(onChainAccount.getRaw()).toBe(account); + }); + }); + + describe('fromSnapshot', () => { + it('matches Horizon-bound balances for native and classic trustline', () => { + const { onChainAccount: ref } = createTestWallet(); + const usdcId = toCaip19ClassicAssetId( + KnownCaip2ChainId.Mainnet, + 'USDC', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ); + const nativeId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const classicRow = ref.getAsset(usdcId); + const balances: AccountBalance = { + [nativeId]: { + unit: 'XLM', + amount: ref.nativeRawBalance.toString(), + }, + [usdcId]: trustLineToPersistedBalance(classicRow), + }; + const snapshot: OnChainAccountSnapshot = { + accountId: ref.accountId, + sequenceNumber: ref.sequenceNumber, + subentryCount: ref.subentryCount, + numSponsoring: ref.numSponsoring, + numSponsored: ref.numSponsored, + }; + const restored = OnChainAccount.fromSnapshot({ + snapshot, + balances, + scope: KnownCaip2ChainId.Mainnet, + }); + expect(restored.nativeSpendableBalance).toStrictEqual( + ref.nativeSpendableBalance, + ); + expect(restored.nativeRawBalance).toStrictEqual(ref.nativeRawBalance); + expect(restored.getAsset(usdcId)).toStrictEqual(classicRow); + expect(restored.getAsset(nativeId)).toStrictEqual(ref.getAsset(nativeId)); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts new file mode 100644 index 00000000..8b71300a --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts @@ -0,0 +1,401 @@ +import type { Horizon } from '@stellar/stellar-sdk'; +import { Account as StellarAccount } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import type { OnChainAccountSnapshot } from './api'; +import { + OnChainAccountBalanceNotAvailableException, + OnChainAccountMetadataNotAvailableException, +} from './exceptions'; +import { calculateSpendableBalance } from './utils'; +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip19ClassicAssetId, + KnownCaip2ChainId, +} from '../../api'; +import { NATIVE_ASSET_SYMBOL } from '../../constants'; +import { + entries, + getAssetReference, + getSlip44AssetId, + isClassicAssetId, + isSep41Id, + isSlip44Id, + parseClassicAssetCodeIssuer, + toCaip19ClassicAssetId, + toSmallestUnit, +} from '../../utils'; +import type { + AccountBalance, + BaseAssetBalance, + TrustLineAssetBalance, +} from '../account-balance/api'; + +/** Per-asset view: native, classic trustline (limit + issuer in `address`), or SEP-41. */ +export type SpendableBalance = { + balance: BigNumber; + symbol: string; + limit?: BigNumber; + address?: string; + authorized?: boolean; + sponsored?: boolean; +}; + +/** Ledger fields used for native reserve / spendable math (Horizon or persisted snapshot). */ +export type OnChainAccountLedgerMeta = { + subentryCount: number; + numSponsoring: number; + numSponsored: number; +}; + +/** + * Where {@link OnChainAccount} builds native + trustline maps from. + * + * - **horizon** — full Horizon account response (human-readable balances). + * - **accountBalance** — persisted {@link AccountBalance} (amounts in stroops as strings). For the slip44 native key, `amount` is **total** (raw) stroops; spendable native is derived at bind time via {@link calculateSpendableBalance} and snapshot meta. + */ +export type OnChainData = + | { source: 'horizon'; response: Horizon.AccountResponse } + | { + source: 'accountBalance'; + balances: AccountBalance; + meta: OnChainAccountLedgerMeta; + }; + +export class OnChainAccount { + readonly #account: StellarAccount; + + readonly #scope: KnownCaip2ChainId; + + #subentryCount: number | undefined; + + #numSponsoring: number | undefined; + + #numSponsored: number | undefined; + + #rawNativeBalance: BigNumber | undefined; + + readonly #balances: Map = + new Map(); + + /** + * @param account - Stellar SDK account (id + sequence). Use {@link getRaw}. + * @param scope - CAIP-2 network. + * @param onChainData - When set, hydrates from Horizon or persisted {@link AccountBalance}; when omitted, uses `account.balances` when present (e.g. `loadAccount` result). + */ + constructor( + account: StellarAccount, + scope: KnownCaip2ChainId, + onChainData?: OnChainData, + ) { + this.#account = account; + this.#scope = scope; + + if (onChainData?.source === 'horizon') { + this.#bindFromHorizonResponse(onChainData.response); + } else if (onChainData?.source === 'accountBalance') { + this.#bindFromAccountBalance(onChainData.balances, onChainData.meta); + } else if (onChainData === undefined && this.#isHorizonResponse(account)) { + this.#bindFromHorizonResponse(account); + } + } + + get accountId(): string { + return this.#account.accountId(); + } + + get sequenceNumber(): string { + return this.#account.sequenceNumber(); + } + + get scope(): KnownCaip2ChainId { + return this.#scope; + } + + get subentryCount(): number { + if (this.#subentryCount !== undefined) { + return this.#subentryCount; + } + throw new OnChainAccountMetadataNotAvailableException(this.accountId); + } + + get numSponsoring(): number { + if (this.#numSponsoring !== undefined) { + return this.#numSponsoring; + } + throw new OnChainAccountMetadataNotAvailableException(this.accountId); + } + + get numSponsored(): number { + if (this.#numSponsored !== undefined) { + return this.#numSponsored; + } + throw new OnChainAccountMetadataNotAvailableException(this.accountId); + } + + /** + * Checks if the account has a balance for a given asset id. + * + * @param assetId - The asset id to check. + * @returns `true` if the account has a balance for the given asset id, `false` otherwise. + */ + hasAsset(assetId: KnownCaip19AssetIdOrSlip44Id): boolean { + return this.#balances.has(assetId); + } + + /** + * Gets the balance for a given asset id. + * + * @param assetId - The asset id to get the balance for. + * @returns The balance for the given asset id. + */ + getAsset(assetId: KnownCaip19AssetIdOrSlip44Id): SpendableBalance { + const entry = this.#balances.get(assetId); + if (entry !== undefined) { + return { + balance: entry.balance, + symbol: entry.symbol, + address: entry.address, + ...(entry.limit === undefined ? {} : { limit: entry.limit }), + ...(entry.sponsored === undefined + ? {} + : { sponsored: entry.sponsored }), + ...(entry.authorized === undefined + ? {} + : { authorized: entry.authorized }), + }; + } + throw new OnChainAccountBalanceNotAvailableException( + assetId, + this.accountId, + ); + } + + /** + * Classic Stellar trustline asset ids (CAIP-19) that have a balance row with a limit. + * + * @returns Asset ids for which {@link getAsset} includes `limit` (classic trustlines only). + */ + get classicTrustlineAssetIds(): KnownCaip19ClassicAssetId[] { + const ids: KnownCaip19ClassicAssetId[] = []; + for (const [assetId, row] of this.#balances) { + if (isClassicAssetId(assetId) && row.limit !== undefined) { + ids.push(assetId); + } + } + return ids; + } + + get nativeSpendableBalance(): BigNumber { + const nativeId = getSlip44AssetId(this.#scope); + const entry = this.#balances.get(nativeId); + if (entry === undefined) { + throw new OnChainAccountBalanceNotAvailableException( + nativeId, + this.accountId, + ); + } + return entry.balance; + } + + get nativeRawBalance(): BigNumber { + const nativeId = getSlip44AssetId(this.#scope); + if (this.#rawNativeBalance === undefined) { + throw new OnChainAccountBalanceNotAvailableException( + nativeId, + this.accountId, + ); + } + return this.#rawNativeBalance; + } + + /** + * Gets the raw Stellar account. + * + * @returns The raw Stellar account. + */ + getRaw(): StellarAccount { + return this.#account; + } + + /** + * Builds from a Horizon account record (balances and ledger meta from the response). + * + * @param response - Horizon `loadAccount` payload. + * @param scope - CAIP-2 network. + * @returns Hydrated {@link OnChainAccount} backed by a minimal SDK `Account` plus derived maps. + */ + static fromHorizon( + response: Horizon.AccountResponse, + scope: KnownCaip2ChainId, + ): OnChainAccount { + const stellarAccount = new StellarAccount( + response.accountId(), + response.sequenceNumber(), + ); + return new OnChainAccount(stellarAccount, scope, { + source: 'horizon', + response, + }); + } + + /** + * Hydrates from persisted {@link OnChainAccountSnapshot} plus {@link AccountBalance} (e.g. snap state after sync). + * + * @param params - Snapshot row, per-asset balances, and network. + * @param params.snapshot - Sequence and subentry/sponsoring fields from metadata sync. + * @param params.balances - Persisted balances; native slip44 `amount` is **raw** (total) stroops. + * @param params.scope - CAIP-2 network. + * @returns Hydrated {@link OnChainAccount} for the same id/sequence as the snapshot. + */ + static fromSnapshot(params: { + snapshot: OnChainAccountSnapshot; + balances: AccountBalance; + scope: KnownCaip2ChainId; + }): OnChainAccount { + const { snapshot, balances, scope } = params; + const stellarAccount = new StellarAccount( + snapshot.accountId, + snapshot.sequenceNumber, + ); + return new OnChainAccount(stellarAccount, scope, { + source: 'accountBalance', + balances, + meta: { + subentryCount: snapshot.subentryCount, + numSponsoring: snapshot.numSponsoring, + numSponsored: snapshot.numSponsored, + }, + }); + } + + #bindFromHorizonResponse(response: Horizon.AccountResponse): void { + const subentryCount = response.subentry_count ?? 0; + const numSponsoring = response.num_sponsoring ?? 0; + const numSponsored = response.num_sponsored ?? 0; + this.#subentryCount = subentryCount; + this.#numSponsoring = numSponsoring; + this.#numSponsored = numSponsored; + + const nativeAssetId = getSlip44AssetId(this.#scope); + + const horizonBalances = response.balances; + + for (const balance of horizonBalances) { + // Horizon API return balance as human-readable (e.g. 1.23456789), we need to convert it to stroops + const balanceStroops = toSmallestUnit(new BigNumber(balance.balance)); + // Native balance is always return for Horizon response + if (balance.asset_type === 'native') { + this.#balances.set(nativeAssetId, { + balance: calculateSpendableBalance({ + nativeBalance: balanceStroops, + subentryCount, + numSponsoring, + numSponsored, + }), + symbol: NATIVE_ASSET_SYMBOL, + }); + this.#rawNativeBalance = balanceStroops; + } else if ( + balance.asset_type === 'credit_alphanum12' || + balance.asset_type === 'credit_alphanum4' + ) { + const authorized = balance.is_authorized ?? true; + const assetId = toCaip19ClassicAssetId( + this.#scope, + balance.asset_code, + balance.asset_issuer, + ); + // Horizon API return limit as human-readable (e.g. 1.23456789), we need to convert it to stroops + const limit = toSmallestUnit(new BigNumber(balance.limit ?? 0)); + const sponsorId = + 'sponsor' in balance && + typeof (balance as { sponsor?: string }).sponsor === 'string' + ? (balance as { sponsor?: string }).sponsor + : undefined; + const sponsored = sponsorId !== undefined && sponsorId.length > 0; + this.#balances.set(assetId, { + balance: balanceStroops, + symbol: balance.asset_code, + address: balance.asset_issuer, + limit, + authorized, + ...(sponsored ? { sponsored: true } : {}), + }); + } + } + } + + #bindFromAccountBalance( + balances: AccountBalance, + meta: OnChainAccountLedgerMeta, + ): void { + this.#subentryCount = meta.subentryCount; + this.#numSponsoring = meta.numSponsoring; + this.#numSponsored = meta.numSponsored; + this.#rawNativeBalance = new BigNumber(0); + + const nativeAssetId = getSlip44AssetId(this.#scope); + + entries(balances).forEach(([assetId, entry]) => { + if (entry === undefined) { + return; + } + + if (isSlip44Id(assetId)) { + // raw native balance in stroops + const rawNative = new BigNumber(entry.amount); + this.#rawNativeBalance = rawNative; + this.#balances.set(nativeAssetId, { + balance: calculateSpendableBalance({ + nativeBalance: rawNative, + subentryCount: meta.subentryCount, + numSponsoring: meta.numSponsoring, + numSponsored: meta.numSponsored, + }), + symbol: entry.unit, + }); + } else if ( + isClassicAssetId(assetId) && + this.#isTrustLineAssetBalance(entry) + ) { + const trust = entry; + const { assetIssuer } = parseClassicAssetCodeIssuer( + getAssetReference(assetId), + ); + const balanceStroops = new BigNumber(trust.amount); + const limitStroops = new BigNumber(trust.limit); + this.#balances.set(assetId, { + balance: balanceStroops, + symbol: trust.unit, + limit: limitStroops, + address: assetIssuer, + ...(typeof trust.authorized === 'boolean' + ? { authorized: trust.authorized } + : {}), + ...(trust.sponsored === true ? { sponsored: true } : {}), + }); + } else if (isSep41Id(assetId)) { + this.#balances.set(assetId, { + balance: new BigNumber(entry.amount), + symbol: entry.unit, + }); + } + }); + } + + #isHorizonResponse( + account: StellarAccount, + ): account is Horizon.AccountResponse { + return account !== undefined && 'balances' in account; + } + + #isTrustLineAssetBalance( + value: BaseAssetBalance | TrustLineAssetBalance | undefined, + ): value is TrustLineAssetBalance { + return ( + value !== undefined && + typeof (value as TrustLineAssetBalance).limit === 'string' + ); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts new file mode 100644 index 00000000..41cc6c1a --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -0,0 +1,169 @@ +import { hexToBytes } from '@metamask/utils'; +import { Keypair } from '@stellar/stellar-sdk'; + +import { KnownCaip2ChainId } from '../../api'; +import { + createMockAccountWithBalances, + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + mockOnChainAccountService, +} from './__mocks__/onChainAccount.fixtures'; +import { OnChainAccount } from './OnChainAccount'; +import { bufferToUint8Array } from '../../utils/buffer'; +import type { StellarKeyringAccount } from '../account'; +import { generateStellarKeyringAccount } from '../account/__mocks__/account.fixtures'; +import { AccountService } from '../account/AccountService'; +import { NetworkService } from '../network'; +import { getTestWallet } from '../wallet/__mocks__/wallet.fixtures'; + +jest.mock('../../utils/logger'); +jest.mock('../../utils/snap'); + +describe('OnChainAccountService', () => { + const seed = hexToBytes( + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ); + + const getNetworkServiceSpies = () => ({ + getAccountOrNullSpy: jest.spyOn( + NetworkService.prototype, + 'getAccountOrNull', + ), + loadOnChainAccountSpy: jest.spyOn( + NetworkService.prototype, + 'loadOnChainAccount', + ), + }); + + describe('discoverOnChainAccount', () => { + it('returns derived account when activated on the network', async () => { + const mockAccount = generateStellarKeyringAccount( + globalThis.crypto.randomUUID(), + Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)).publicKey(), + 'entropy-source-default', + 0, + ); + const deriveKeyringAccountSpy = jest + .spyOn(AccountService.prototype, 'deriveKeyringAccount') + .mockResolvedValue(mockAccount); + const { getAccountOrNullSpy } = getNetworkServiceSpies(); + const wallet = getTestWallet({ seed }); + getAccountOrNullSpy.mockResolvedValue( + new OnChainAccount( + createMockAccountWithBalances( + wallet.address, + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ), + KnownCaip2ChainId.Mainnet, + ), + ); + + const { onChainAccountService } = mockOnChainAccountService(); + const account = await onChainAccountService.discoverOnChainAccount({ + entropySource: mockAccount.entropySource, + index: mockAccount.index, + scope: KnownCaip2ChainId.Mainnet, + }); + + expect(deriveKeyringAccountSpy).toHaveBeenCalledWith({ + entropySource: mockAccount.entropySource, + index: mockAccount.index, + }); + expect(account).toStrictEqual(mockAccount); + }); + + it('returns null when the account is not activated on the Stellar network', async () => { + const mockAccount = generateStellarKeyringAccount( + globalThis.crypto.randomUUID(), + Keypair.random().publicKey(), + 'entropy-source-default', + 0, + ); + jest + .spyOn(AccountService.prototype, 'deriveKeyringAccount') + .mockResolvedValue(mockAccount); + const { getAccountOrNullSpy } = getNetworkServiceSpies(); + getAccountOrNullSpy.mockResolvedValue(null); + + const { onChainAccountService } = mockOnChainAccountService(); + const account = await onChainAccountService.discoverOnChainAccount({ + entropySource: mockAccount.entropySource, + index: mockAccount.index, + scope: KnownCaip2ChainId.Mainnet, + }); + + expect(account).toBeNull(); + }); + }); + + describe('isAccountActivated', () => { + it('returns true when getAccountOrNull returns an account', async () => { + const { getAccountOrNullSpy } = getNetworkServiceSpies(); + const wallet = getTestWallet({ seed }); + const onChain = new OnChainAccount( + createMockAccountWithBalances( + wallet.address, + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ), + KnownCaip2ChainId.Mainnet, + ); + getAccountOrNullSpy.mockResolvedValue(onChain); + + const { onChainAccountService } = mockOnChainAccountService(); + const result = await onChainAccountService.isAccountActivated({ + accountAddress: onChain.accountId, + scope: KnownCaip2ChainId.Mainnet, + }); + + expect(result).toBe(true); + }); + + it('returns false when getAccountOrNull returns null', async () => { + const { getAccountOrNullSpy } = getNetworkServiceSpies(); + getAccountOrNullSpy.mockResolvedValue(null); + + const { onChainAccountService } = mockOnChainAccountService(); + const result = await onChainAccountService.isAccountActivated({ + accountAddress: Keypair.random().publicKey(), + scope: KnownCaip2ChainId.Mainnet, + }); + + expect(result).toBe(false); + }); + }); + + describe('resolveOnChainAccount', () => { + it('returns loaded account when id matches keyring address', async () => { + const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + const mockAccount: StellarKeyringAccount = generateStellarKeyringAccount( + globalThis.crypto.randomUUID(), + signer.publicKey(), + 'entropy-source-1', + 0, + ); + const loaded = new OnChainAccount( + createMockAccountWithBalances( + signer.publicKey(), + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ), + KnownCaip2ChainId.Mainnet, + ); + const { loadOnChainAccountSpy } = getNetworkServiceSpies(); + loadOnChainAccountSpy.mockResolvedValue(loaded); + + const { onChainAccountService } = mockOnChainAccountService(); + const result = await onChainAccountService.resolveOnChainAccount( + mockAccount, + KnownCaip2ChainId.Mainnet, + ); + + expect(result.accountId).toStrictEqual(signer.publicKey()); + expect(loadOnChainAccountSpy).toHaveBeenCalledWith( + mockAccount.address, + KnownCaip2ChainId.Mainnet, + ); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts new file mode 100644 index 00000000..4e56a4d5 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -0,0 +1,106 @@ +import type { EntropySourceId } from '@metamask/keyring-api'; + +import type { KnownCaip2ChainId } from '../../api'; +import type { AccountService, StellarKeyringAccount } from '../account'; +import type { OnChainAccount } from './OnChainAccount'; +import { assertSameAddress } from '../account/utils'; +import type { NetworkService } from '../network'; + +/** + * Stellar on-chain account operations: activation checks, loading {@link OnChainAccount}, + * and persisting {@link OnChainAccountSnapshot} records for sync. + * + * Signing keypairs are derived by {@link WalletService}; this service does not depend on it. + */ +export class OnChainAccountService { + readonly #networkService: NetworkService; + + readonly #accountService: AccountService; + + constructor({ + networkService, + accountService, + }: { + networkService: NetworkService; + accountService: AccountService; + }) { + this.#networkService = networkService; + this.#accountService = accountService; + } + + /** + * Derives a keyring-shaped account and returns it when that address is activated on Stellar. + * + * @param options - Discovery inputs. + * @param options.entropySource - Entropy source used to derive the address. + * @param options.index - Derivation index. + * @param options.scope - CAIP-2 network to check activation on. + * @returns The derived keyring-shaped account if funded on-chain, otherwise `null`. + */ + async discoverOnChainAccount({ + entropySource, + index, + scope, + }: { + entropySource: EntropySourceId; + index: number; + scope: KnownCaip2ChainId; + }): Promise { + const account = await this.#accountService.deriveKeyringAccount({ + entropySource, + index, + }); + + const isActivated = await this.isAccountActivated({ + accountAddress: account.address, + scope, + }); + + if (!isActivated) { + return null; + } + + return account; + } + + /** + * Returns whether the given address has a funded account on the network. + * + * @param params - Options object. + * @param params.accountAddress - The Stellar account address (public key). + * @param params.scope - The CAIP-2 chain ID. + * @returns `true` if the account exists and is funded, `false` if missing. + */ + async isAccountActivated(params: { + accountAddress: string; + scope: KnownCaip2ChainId; + }): Promise { + const { accountAddress, scope } = params; + return ( + (await this.#networkService.getAccountOrNull(accountAddress, scope)) !== + null + ); + } + + /** + * Loads activated on-chain state for a keyring row on the given network and verifies the loaded + * account id matches the keyring address. + * + * @param account - Keyring account whose address must match the Horizon account id. + * @param scope - CAIP-2 network to load the account from (Horizon `loadAccount`). + * @returns Loaded {@link OnChainAccount} for simulation, fees, and sequence. + * @throws {AccountNotActivatedException} When the account is not funded (from {@link NetworkService.loadOnChainAccount}). + * @throws {DerivedAccountAddressMismatchException} When loaded id does not match `account.address`. + */ + async resolveOnChainAccount( + account: StellarKeyringAccount, + scope: KnownCaip2ChainId, + ): Promise { + const loaded = await this.#networkService.loadOnChainAccount( + account.address, + scope, + ); + assertSameAddress(account.address, loaded.accountId); + return loaded; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts new file mode 100644 index 00000000..b849a2a4 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts @@ -0,0 +1,135 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { Account } from '@stellar/stellar-sdk'; + +import { logger } from '../../../utils/logger'; +import { AccountService } from '../../account/AccountService'; +import { AccountsRepository } from '../../account/AccountsRepository'; +import { NetworkService } from '../../network'; +import { State } from '../../state/State'; +import { WalletService } from '../../wallet'; +import { OnChainAccountService } from '../OnChainAccountService'; + +export type MockAssetLine = { + assetType: string; + assetCode: string; + assetIssuer: string; + balance: number; + /** Horizon `is_authorized`; defaults to true when omitted. */ + isAuthorized?: boolean; +}; + +export type MockAccountWithBalancesData = { + nativeBalance: number; + assets: MockAssetLine[]; + sponsoringCount?: number; + sponsoredCount?: number; + subentryCount?: number; +}; + +/** Default Horizon-shaped balance payload for tests that only need a funded mock account. */ +export const DEFAULT_MOCK_ACCOUNT_WITH_BALANCES: MockAccountWithBalancesData = { + nativeBalance: 1, + assets: [], + sponsoringCount: 0, + sponsoredCount: 0, + subentryCount: 0, +}; + +export const createMockAccountWithBalances = ( + accountId: string, + accountSequence: string, + { + subentryCount = 0, + sponsoringCount = 0, + sponsoredCount = 0, + nativeBalance = 1, + assets = [], + }: MockAccountWithBalancesData, +) => { + class MockAccount extends Account { + subentry_count: number; + + num_sponsoring: number; + + num_sponsored: number; + + balances: unknown[]; + + constructor( + id: string, + sequence: string, + inputSubentryCount: number, + inputSponsoringCount: number, + inputSponsoredCount: number, + inputNativeBalance: number, + inputAssets: MockAssetLine[], + ) { + super(id, sequence); + this.subentry_count = inputSubentryCount; + this.num_sponsoring = inputSponsoringCount; + this.num_sponsored = inputSponsoredCount; + this.balances = [ + ...inputAssets.map((asset) => ({ + balance: asset.balance.toString(), + limit: '922337203685.4775807', + buying_liabilities: '0.0000000', + selling_liabilities: '0.0000000', + asset_type: asset.assetType, + asset_code: asset.assetCode, + asset_issuer: asset.assetIssuer, + is_authorized: asset.isAuthorized !== false, + })), + { + balance: inputNativeBalance.toString(), + buying_liabilities: '0.0000000', + selling_liabilities: '0.0000000', + asset_type: 'native', + }, + ]; + } + } + return new MockAccount( + accountId, + accountSequence, + subentryCount, + sponsoringCount, + sponsoredCount, + nativeBalance, + assets, + ); +}; + +/** + * Builds {@link OnChainAccountService} with real {@link AccountService}, shared {@link State}, + * and {@link NetworkService}, for integration-style tests. + * + * @returns On-chain service plus the account and wallet services wired to the same state. + */ +export function mockOnChainAccountService() { + const walletService = new WalletService({ logger }); + const state = new State({ + encrypted: false, + defaultState: { + keyringAccounts: {}, + accountMetadata: {}, + }, + }); + const accountService = new AccountService({ + logger, + accountsRepository: new AccountsRepository(state), + walletService, + }); + const networkService = new NetworkService({ logger }); + const onChainAccountService = new OnChainAccountService({ + networkService, + accountService, + }); + + return { + onChainAccountService, + accountService, + walletService, + }; +} + +/* eslint-enable @typescript-eslint/naming-convention */ diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts new file mode 100644 index 00000000..d14cd0ed --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts @@ -0,0 +1,29 @@ +import type { KnownCaip2ChainId } from '../../api'; + +/** + * Persisted on-chain account header fields for one keyring account on one network, refreshed on sync. + * Does not include trustline balances (see `accountBalances` state). + */ +export type OnChainAccountSnapshot = { + accountId: string; + sequenceNumber: string; + subentryCount: number; + numSponsoring: number; + numSponsored: number; + /** Unix ms when this row was written to snap state. */ + persistedAt?: number; +}; + +/** `accountMetadata[keyringAccountId][scope]` → last synced {@link OnChainAccountSnapshot}. */ +export type OnChainAccountSnapshotsByKeyringId = Record< + string, + Partial> +>; + +/** + * Snap state slice for cached on-chain account snapshots. + * The root key stays `accountMetadata` for persisted snap state compatibility. + */ +export type OnChainAccountSnapshotState = { + accountMetadata: OnChainAccountSnapshotsByKeyringId; +}; diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/exceptions.ts new file mode 100644 index 00000000..864e84e4 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/exceptions.ts @@ -0,0 +1,21 @@ +import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; + +export class OnChainAccountException extends Error { + constructor(message: string) { + super(message); + this.name = 'OnChainAccountException'; + } +} + +export class OnChainAccountBalanceNotAvailableException extends OnChainAccountException { + constructor(assetId: KnownCaip19AssetIdOrSlip44Id, accountId: string) { + super(`Balance not available for asset ${assetId} on account ${accountId}`); + this.name = 'OnChainAccountBalanceNotAvailableException'; + } +} +export class OnChainAccountMetadataNotAvailableException extends OnChainAccountException { + constructor(accountId: string) { + super(`Account metadata not available for account ${accountId}`); + this.name = 'OnChainAccountMetadataNotAvailableException'; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts new file mode 100644 index 00000000..e664d1ed --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts @@ -0,0 +1,3 @@ +export type * from './api'; +export * from './OnChainAccount'; +export * from './OnChainAccountService'; diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts new file mode 100644 index 00000000..6126c349 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts @@ -0,0 +1,37 @@ +import { BigNumber } from 'bignumber.js'; + +import { BASE_RESERVE_STROOPS } from '../../constants'; + +type CalculateSpendableBalanceParams = { + nativeBalance: BigNumber; + subentryCount: number; + numSponsoring: number; + numSponsored: number; +}; + +/** + * Spendable native balance (stroops): total native minus minimum balance. + * + * Minimum balance follows Stellar protocol: + * `(2 + subentry_count + num_sponsoring − num_sponsored) × base_reserve`. + * + * @param params - Total native balance and ledger reserve fields. + * @param params.nativeBalance - Total native balance in stroops. + * @param params.subentryCount - Account subentry count (Horizon `subentry_count`). + * @param params.numSponsoring - Reserves this account sponsors for other entries. + * @param params.numSponsored - Reserves other accounts sponsor for this account. + * @returns Spendable native balance in stroops (clamped at zero). + * @see https://developers.stellar.org/docs/learn/fundamentals/stellar-data-structures/accounts#minimum-balance + */ +export function calculateSpendableBalance( + params: CalculateSpendableBalanceParams, +): BigNumber { + const { nativeBalance, subentryCount, numSponsoring, numSponsored } = params; + const minBalanceStroops = new BigNumber(2) + .plus(subentryCount) + .plus(numSponsoring) + .minus(numSponsored) + .times(BASE_RESERVE_STROOPS); + + return BigNumber.maximum(nativeBalance.minus(minBalanceStroops), 0); +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts new file mode 100644 index 00000000..55b126ab --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts @@ -0,0 +1,84 @@ +import { + Account, + Asset, + FeeBumpTransaction, + Keypair, + Networks, + Operation, + TransactionBuilder as StellarTransactionBuilder, +} from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import { Transaction } from './Transaction'; + +describe('Transaction', () => { + it('reports operationCount equal to transactionOperations length for a classic transaction', () => { + const source = Keypair.random(); + const dest = Keypair.random().publicKey(); + const inner = new StellarTransactionBuilder( + new Account(source.publicKey(), '1'), + { fee: '100', networkPassphrase: Networks.TESTNET }, + ) + .addOperation( + Operation.payment({ + destination: dest, + asset: Asset.native(), + amount: '1', + }), + ) + .setTimeout(60) + .build(); + + const wrapped = new Transaction(inner); + + expect(wrapped.transactionOperations).toHaveLength(1); + expect(wrapped.operationCount).toBe(1); + expect(wrapped.operationCount).toBe(wrapped.transactionOperations.length); + expect(wrapped.totalFee).toStrictEqual(new BigNumber(inner.fee)); + }); + + it('counts inner operations for a fee-bump envelope', () => { + const source = Keypair.random(); + const feeSource = Keypair.random(); + const dest = Keypair.random().publicKey(); + + const inner = new StellarTransactionBuilder( + new Account(source.publicKey(), '1'), + { fee: '100', networkPassphrase: Networks.TESTNET }, + ) + .addOperation( + Operation.payment({ + destination: dest, + asset: Asset.native(), + amount: '1', + }), + ) + .addOperation( + Operation.payment({ + destination: dest, + asset: Asset.native(), + amount: '2', + }), + ) + .setTimeout(60) + .build(); + + const feeBump = StellarTransactionBuilder.buildFeeBumpTransaction( + feeSource, + String(Number(inner.fee) * 2), + inner, + Networks.TESTNET, + ); + + const wrapped = new Transaction(feeBump); + + expect(wrapped.transactionOperations).toHaveLength(2); + expect(wrapped.operationCount).toBe(2); + expect(wrapped.operationCount).toBe(wrapped.transactionOperations.length); + expect(wrapped.getRaw()).toBeInstanceOf(FeeBumpTransaction); + expect(wrapped.totalFee).toStrictEqual(new BigNumber(feeBump.fee)); + expect(wrapped.totalFee.toFixed(0)).not.toBe( + new BigNumber(inner.fee).toFixed(0), + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts new file mode 100644 index 00000000..1e64cdf9 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts @@ -0,0 +1,193 @@ +import type { + Transaction as StellarTransaction, + Operation, +} from '@stellar/stellar-sdk'; +import { FeeBumpTransaction } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import type { KnownCaip2ChainId } from '../../api'; +import { bufferToUint8Array } from '../../utils'; +import { networkToCaip2ChainId } from '../network/utils'; + +/** + * Wrapper around a Stellar transaction. Exposes fee, operation count, and network passphrase + * for callers. + */ +export class Transaction { + readonly #inner: StellarTransaction | FeeBumpTransaction; + + readonly #operationTypes: Set = new Set(); + + readonly #participatingAccounts: Set = new Set(); + + constructor(inner: StellarTransaction | FeeBumpTransaction) { + this.#inner = inner; + this.#initialize(); + } + + #initialize(): void { + this.#participatingAccounts.add(this.sourceAccount); + this.#participatingAccounts.add(this.feeSourceAccount); + + for (const operation of this.transactionOperations) { + this.#participatingAccounts.add(operation.source ?? this.sourceAccount); + this.#operationTypes.add(operation.type); + } + } + + getMemo(encode: 'hex' | 'base64' | 'utf8' = 'utf8'): string | null { + const raw = this.getRaw(); + if (raw instanceof FeeBumpTransaction) { + return raw.innerTransaction.memo?.value + ? bufferToUint8Array(raw.innerTransaction.memo.value).toString(encode) + : null; + } + return raw.memo?.value + ? bufferToUint8Array(raw.memo.value).toString(encode) + : null; + } + + /** + * Total fee in stroops charged to {@link Transaction.feeSourceAccount} for this envelope. + * If it is a fee bump transaction, it will be the fee of the fee bump transaction, instead of the inner transaction. + * + * @returns The fee as BigNumber. + */ + get totalFee(): BigNumber { + const raw = this.getRaw(); + return new BigNumber(raw.fee); + } + + /** + * The number of operations on the wrapped envelope (inner transaction for fee bumps). + * Uses the same source as {@link Transaction.transactionOperations} so counts stay aligned. + * + * @returns The operation count. + */ + get operationCount(): number { + return this.transactionOperations.length; + } + + /** + * Network passphrase on the underlying transaction (matches Stellar SDK `networkPassphrase`). + * + * @returns The network passphrase string. + */ + get network(): string { + return this.#inner.networkPassphrase; + } + + /** + * Get the CAIP-2 chain ID from the network passphrase. + * + * @returns The CAIP-2 chain ID. + */ + get scope(): KnownCaip2ChainId { + return networkToCaip2ChainId(this.#inner.networkPassphrase); + } + + /** + * Checks if the transaction has a create account operation. + * + * @returns True if the transaction has a create account operation, false otherwise. + */ + get hasCreateAccount(): boolean { + return this.#operationTypes.has('createAccount'); + } + + /** + * Checks if the transaction has an `invokeHostFunction` operation. + * + * @returns True if the transaction has an invoke host function operation, false otherwise. + */ + get hasInvokeHostFunction(): boolean { + return this.#operationTypes.has('invokeHostFunction'); + } + + /** + * Get the source account from the transaction. + * + * @returns The source account. + */ + get sourceAccount(): string { + const raw = this.getRaw(); + if (raw instanceof FeeBumpTransaction) { + return raw.innerTransaction.source; + } + return raw.source; + } + + /** + * Get the fee source account from the transaction. + * + * @returns The fee source account. + */ + get feeSourceAccount(): string { + const raw = this.getRaw(); + let feeSource: string | undefined; + if (raw instanceof FeeBumpTransaction) { + feeSource = raw.feeSource; + } else { + feeSource = raw.source; + } + + if (!feeSource) { + throw new Error('Fee source account is not set'); + } + + return feeSource; + } + + /** + * Accounts that participate in the envelope: tx source, fee source, and each operation’s effective source. + * + * @returns Participating account ids (`G…`). + */ + get participatingAccounts(): string[] { + return Array.from(this.#participatingAccounts.values()); + } + + /** + * Checks if the transaction is from the given account. + * + * @param accountId - The account ID to check. + * @returns True if the transaction is from the given account, false otherwise. + */ + isSourceAccount(accountId: string): boolean { + return ( + this.sourceAccount === accountId || this.feeSourceAccount === accountId + ); + } + + /** + * Whether the account is among {@link Transaction.hasParticipatingAccount} (source, fee source, or op source). + * + * @param accountId - The account ID to check. + * @returns True if the account participates in the envelope. + */ + hasParticipatingAccount(accountId: string): boolean { + return this.#participatingAccounts.has(accountId); + } + + /** + * The raw SDK transaction. Prefer the wrapped API where possible; use this for signing and submission. + * + * @returns The raw Stellar SDK transaction. + */ + getRaw(): StellarTransaction | FeeBumpTransaction { + return this.#inner; + } + + /** + * Get the operations from the transaction. + * + * @returns The operations. + */ + get transactionOperations(): Operation[] { + const raw = this.getRaw(); + if (raw instanceof FeeBumpTransaction) { + return raw.innerTransaction.operations; + } + return raw.operations; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts new file mode 100644 index 00000000..eaeb3da5 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts @@ -0,0 +1,294 @@ +import { + Account, + Asset, + Contract, + Keypair, + nativeToScVal, + Networks, + Operation, + TransactionBuilder as StellarTransactionBuilder, + type AuthFlag, +} from '@stellar/stellar-sdk'; + +import { Transaction } from '../Transaction'; + +// --- Declarative classic (Stellar) transaction builder for tests --- + +/** Asset description without importing Stellar `Asset` at call sites. */ +export type MockClassicAssetParam = 'native' | { code: string; issuer: string }; + +export type MockClassicOperation = + | { + type: 'payment'; + params: { + destination: string; + asset: MockClassicAssetParam; + amount: string; + source?: string; + }; + } + | { + type: 'changeTrust'; + params: { + asset: MockClassicAssetParam; + limit: string; + source?: string; + }; + } + | { + type: 'createAccount'; + params: { + destination: string; + startingBalance: string; + source?: string; + }; + } + | { + type: 'setOptions'; + params: { + setFlags?: number; + clearFlags?: number; + source?: string; + }; + }; + +export type BuildMockTransactionOptions = { + /** + * Stellar network passphrase for the envelope (defaults to testnet). + */ + networkPassphrase?: string; + /** + * Transaction source account; random key at sequence `1` when omitted. + */ + source?: { accountId: string; sequence: string }; + /** + * Max fee per operation (Stellar `TransactionBuilder` `fee` field), in stroops string. + * Default: `'200'` (matches prior test helper defaults). + */ + baseFeePerOperation?: string; + /** Time bound in ledger closes. Default: `60`. */ + timeout?: number; +}; + +export type MockInvokeHostFunctionArg = + | string + | number + | bigint + | boolean + | null + | undefined + | Uint8Array + | readonly MockInvokeHostFunctionArg[] + | { readonly [key: string]: MockInvokeHostFunctionArg }; + +/** Second argument to Stellar `nativeToScVal`, aligned by index with `args`. */ +export type MockInvokeHostFunctionArgNativeToScValOptions = Exclude< + Parameters[1], + undefined +>; + +export type BuildMockInvokeHostFunctionTransactionOptions = + BuildMockTransactionOptions & { + /** Soroban contract id (`C…`). */ + contractId?: string; + /** + * Optional per-argument second parameter to `nativeToScVal` (e.g. `{ type: 'address' }` for + * a string Stellar address). Indexes align with the `args` array. + */ + argNativeToScValOptions?: readonly ( + | MockInvokeHostFunctionArgNativeToScValOptions + | undefined + )[]; + }; + +/** + * Converts a mock classic asset to a Stellar `Asset`. + * + * @param asset - The asset. + * @returns The Stellar `Asset`. + */ +function mockAssetToSdk(asset: MockClassicAssetParam): Asset { + if (asset === 'native') { + return Asset.native(); + } + return new Asset(asset.code, asset.issuer); +} + +/** + * Appends one classic operation to a Stellar transaction builder. + * + * @param builder - In-progress classic transaction builder. + * @param op - Declarative operation to translate and add. + */ +function addClassicOperationToBuilder( + builder: StellarTransactionBuilder, + op: MockClassicOperation, +): void { + switch (op.type) { + case 'payment': { + const { destination, asset, amount, source } = op.params; + builder.addOperation( + Operation.payment({ + ...(source === undefined ? {} : { source }), + destination, + asset: mockAssetToSdk(asset), + amount, + }), + ); + break; + } + case 'changeTrust': { + const { asset, limit, source } = op.params; + builder.addOperation( + Operation.changeTrust({ + ...(source === undefined ? {} : { source }), + asset: mockAssetToSdk(asset), + limit, + }), + ); + break; + } + case 'createAccount': { + const { destination, startingBalance, source } = op.params; + builder.addOperation( + Operation.createAccount({ + ...(source === undefined ? {} : { source }), + destination, + startingBalance, + }), + ); + break; + } + case 'setOptions': { + const { setFlags, clearFlags, source } = op.params; + builder.addOperation( + Operation.setOptions({ + ...(source === undefined ? {} : { source }), + ...(setFlags === undefined ? {} : { setFlags: setFlags as AuthFlag }), + ...(clearFlags === undefined + ? {} + : { clearFlags: clearFlags as AuthFlag }), + }), + ); + break; + } + default: { + const _exhaustive: never = op; + throw new Error(`Unsupported mock operation: ${String(_exhaustive)}`); + } + } +} + +/** + * Builds a mock transaction with a single classic operation. + * + * @param operations - The classic operations. + * @param options - The options for the transaction. + * @param options.networkPassphrase - The Stellar network passphrase. + * @param options.source - The source account. + * @param options.baseFeePerOperation - The base fee per operation. + * @param options.timeout - The timeout. + * @returns The mock transaction. + */ +export function buildMockClassicTransaction( + operations: MockClassicOperation[], + options: BuildMockTransactionOptions = {}, +): Transaction { + if (operations.length === 0) { + throw new Error( + 'buildMockClassicTransaction requires at least one operation', + ); + } + + const passphrase = options.networkPassphrase ?? Networks.TESTNET; + const sourceAccount = + options.source ?? + (() => { + const kp = Keypair.random(); + return { accountId: kp.publicKey(), sequence: '1' }; + })(); + + const account = new Account(sourceAccount.accountId, sourceAccount.sequence); + const builder = new StellarTransactionBuilder(account, { + fee: options.baseFeePerOperation ?? '200', + networkPassphrase: passphrase, + }); + + for (const op of operations) { + addClassicOperationToBuilder(builder, op); + } + + const built = builder.setTimeout(options.timeout ?? 60).build(); + return new Transaction(built); +} + +const DEFAULT_MOCK_SOROBAN_CONTRACT_ID = + 'CASUP2OPFVEHCWGP2XLBXOV7DQIQIT42AQISG4MXAZGNLVFFN63X7WRT'; + +/** + * + * Converts mock invoke host function arguments to Stellar `xdr.ScVal` values. + * + * @param args - The mock invoke host function arguments. + * @param argNativeToScValOptions - The options for the arguments. + * @returns The Stellar `xdr.ScVal` values. + */ +function mockInvokeHostFunctionArgsToScVals( + args: readonly MockInvokeHostFunctionArg[], + argNativeToScValOptions?: readonly ( + | MockInvokeHostFunctionArgNativeToScValOptions + | undefined + )[], +) { + return args.map((arg, index) => { + const opts = argNativeToScValOptions?.[index]; + return opts === undefined ? nativeToScVal(arg) : nativeToScVal(arg, opts); + }); +} + +/** + * + * Builds a mock transaction with a single Soroban `invokeHostFunction` operation. + * + * @param functionName - The Soroban function name. + * @param args - The Soroban arguments. + * @param options - The options for the transaction. + * @param options.networkPassphrase - The Stellar network passphrase. + * @param options.source - The source account. + * @param options.baseFeePerOperation - The base fee per operation. + * @param options.timeout - The timeout. + * @param options.contractId - The Soroban contract id (`C…`). + * @param options.argNativeToScValOptions - The options for the arguments. + * @returns The mock transaction. + */ +export function buildMockInvokeHostFunctionTransaction( + functionName: string, + args: MockInvokeHostFunctionArg[], + options: BuildMockInvokeHostFunctionTransactionOptions = {}, +): Transaction { + const passphrase = options.networkPassphrase ?? Networks.TESTNET; + const sourceAccount = + options.source ?? + (() => { + const kp = Keypair.random(); + return { accountId: kp.publicKey(), sequence: '1' }; + })(); + + const account = new Account(sourceAccount.accountId, sourceAccount.sequence); + const builder = new StellarTransactionBuilder(account, { + fee: options.baseFeePerOperation ?? '200', + networkPassphrase: passphrase, + }); + + const contract = new Contract( + options.contractId ?? DEFAULT_MOCK_SOROBAN_CONTRACT_ID, + ); + const scVals = mockInvokeHostFunctionArgsToScVals( + args, + options.argNativeToScValOptions, + ); + builder.addOperation(contract.call(functionName, ...scVals)); + + const built = builder.setTimeout(options.timeout ?? 60).build(); + return new Transaction(built); +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts new file mode 100644 index 00000000..4f016814 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts @@ -0,0 +1,180 @@ +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip2ChainId, +} from '../../api'; + +/** Thrown when building or rebuilding a transaction fails (e.g. invalid asset or SDK error). */ +export class TransactionBuilderException extends Error { + constructor(message: string) { + super(message); + this.name = 'TransactionBuilderException'; + } +} + +/** Base for all transaction validation errors (simulation, trustlines, balances). */ +export class TransactionValidationException extends Error { + constructor(message: string) { + super(message); + this.name = 'TransactionValidationException'; + } +} + +/** + * Thrown when a caller-supplied CAIP-2 scope does not match the transaction envelope's network. + */ +export class TransactionScopeNotMatchException extends TransactionValidationException { + readonly expectedScope: KnownCaip2ChainId; + + readonly transactionScope: KnownCaip2ChainId; + + constructor( + expectedScope: KnownCaip2ChainId, + transactionScope: KnownCaip2ChainId, + ) { + super( + `Transaction scope ${transactionScope} does not match expected scope ${expectedScope}`, + ); + this.name = 'TransactionScopeNotMatchException'; + this.expectedScope = expectedScope; + this.transactionScope = transactionScope; + } +} + +export class UnsupportedOperationTypeException extends TransactionValidationException { + constructor(operationType: string) { + super(`Unsupported operation type: ${operationType}`); + } +} + +/** Thrown when creating an account with a non-native asset is not supported. */ +export class InvalidAssetForCreateAccountException extends TransactionValidationException { + constructor(assetId: string) { + super(`Create account with non-native asset ${assetId} is not supported`); + } +} + +export class InvalidAssetForSep41TransferException extends TransactionValidationException { + constructor(assetId: string) { + super(`Transfer with SEP-41 asset ${assetId} is not supported`); + } +} + +/** + * Thrown when `Operation.createAccount` starting balance is below 1 XLM (no sponsorship modeled). + */ +export class InvalidAmountForCreateAccountException extends TransactionValidationException { + constructor(amount: string) { + super( + `Invalid amount for create account: ${amount} — minimum starting balance is 1 XLM`, + ); + } +} + +/** Thrown when the trustline is not found. */ +/** + * Thrown when a payment uses a trustline that exists but is not authorized (`is_authorized` false). + */ +export class TrustlineNotAuthorizedException extends TransactionValidationException { + readonly assetId: KnownCaip19AssetIdOrSlip44Id; + + readonly accountAddress: string; + + constructor(assetId: KnownCaip19AssetIdOrSlip44Id, accountAddress: string) { + super( + `Trustline for asset ${assetId} on account ${accountAddress} is not authorized`, + ); + this.assetId = assetId; + this.accountAddress = accountAddress; + } +} + +export class TrustlineNotFoundException extends TransactionValidationException { + /** CAIP-19 asset id (or slip44 id for native) for the missing trustline. */ + readonly assetId: KnownCaip19AssetIdOrSlip44Id; + + /** Stellar account address (G…) that lacks the trustline. */ + readonly accountAddress: string; + + /** + * @param assetId - CAIP-19 (or slip44) id of the asset. + * @param accountAddress - Account public key missing the trustline. + */ + constructor(assetId: KnownCaip19AssetIdOrSlip44Id, accountAddress: string) { + super( + `Trustline not found for asset ${assetId} on account ${accountAddress}`, + ); + this.assetId = assetId; + this.accountAddress = accountAddress; + } +} + +/** Thrown when the trustline already exists. */ +export class TrustlineAlreadyExistsException extends TransactionValidationException { + readonly assetId: KnownCaip19AssetIdOrSlip44Id; + + constructor(assetId: KnownCaip19AssetIdOrSlip44Id) { + super(`Trustline already exists for asset: ${assetId}`); + this.assetId = assetId; + } +} + +/** Thrown when the trustline structure is invalid. */ +export class InvalidTrustlineException extends TransactionValidationException { + constructor(message: string) { + super(`Invalid trustline: ${message}`); + } +} + +/** Thrown when the trustline removal fails. */ +export class RemoveTrustlineWithNonZeroBalanceException extends TransactionValidationException { + constructor(message: string) { + super(`Failed to remove the trustline: ${message}`); + } +} + +/** Thrown when the trustline update fails. */ +export class UpdateTrustlineException extends TransactionValidationException { + constructor(message: string) { + super(`Failed to update the trustline: ${message}`); + } +} + +/** + * Thrown when the account's spendable native (XLM) balance is below what the transaction requires + * for fees, reserves, and native outflows (all in stroops). + */ +export class InsufficientBalanceToCoverFeeException extends TransactionValidationException { + constructor(balance: string, required: string) { + super( + `Insufficient native balance for transaction: ${balance} stroops available is less than ${required} stroops required`, + ); + } +} + +export class InsufficientBalanceToCoverBaseReserveException extends TransactionValidationException { + constructor(balance: string, required: string) { + super( + `Insufficient native balance for transaction for base reserve: ${balance} stroops available is less than ${required} stroops required`, + ); + } +} +/** + * Thrown when the account's spendable balance for a non-native asset is below the amount required + * by the transaction (amounts in the asset's smallest units). + */ +export class InsufficientBalanceException extends TransactionValidationException { + constructor(balance: string, required: string) { + super( + `Insufficient asset balance for transaction: ${balance} available is less than ${required} required`, + ); + } +} + +/** + * Thrown when the invoke host function transaction has more than one operation. + */ +export class InvalidInvokeContractStructureException extends TransactionValidationException { + constructor() { + super(`Invoke host function transaction must have exactly one operation`); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/NetworkService.test.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/NetworkService.test.ts deleted file mode 100644 index 63922edb..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/NetworkService.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { hexToBytes } from '@metamask/utils'; -import { - Account, - Keypair, - Horizon as StellarHorizon, - rpc as StellarRpc, - NotFoundError, -} from '@stellar/stellar-sdk'; -import { BigNumber } from 'bignumber.js'; - -import { - AccountLoadException, - AccountNotActivatedException, - BaseFeeFetchException, - NetworkServiceException, - TransactionPollException, -} from './exceptions'; -import { NetworkService } from './NetworkService'; -import type { Transaction } from './Transaction'; -import { TransactionBuilder } from './TransactionBuilder'; -import { Wallet } from './Wallet'; -import type { KnownCaip19AssetId } from '../../api'; -import { KnownCaip2ChainId } from '../../api'; -import { logger } from '../../utils/logger'; - -jest.mock('../../utils/logger'); - -describe('NetworkService', () => { - let networkService: NetworkService; - const testAddress = - 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG'; - const testTransactionHash = - '58b5e4cd7319962ecbfbdaa7a3b9444c9117e130935da4f14a695dd5d1423d0a'; - let scope: KnownCaip2ChainId; - - beforeEach(() => { - jest.clearAllMocks(); - networkService = new NetworkService({ logger }); - scope = KnownCaip2ChainId.Mainnet; - }); - - const getHorizonClientSpies = () => ({ - fetchBaseFeeSpy: jest.spyOn( - StellarHorizon.Server.prototype, - 'fetchBaseFee', - ), - loadAccountSpy: jest.spyOn(StellarHorizon.Server.prototype, 'loadAccount'), - }); - - const getRpcServerSpies = () => ({ - pollTransactionSpy: jest.spyOn( - StellarRpc.Server.prototype, - 'pollTransaction', - ), - sendTransactionSpy: jest.spyOn( - StellarRpc.Server.prototype, - 'sendTransaction', - ), - }); - - describe('getBaseFee', () => { - it('returns base fee as BigNumber', async () => { - const { fetchBaseFeeSpy } = getHorizonClientSpies(); - fetchBaseFeeSpy.mockResolvedValue(100); - - const result = await networkService.getBaseFee(scope); - - expect(result).toStrictEqual(new BigNumber(100)); - expect(fetchBaseFeeSpy).toHaveBeenCalled(); - }); - - it('throws BaseFeeFetchException when fetch fails', async () => { - const { fetchBaseFeeSpy } = getHorizonClientSpies(); - fetchBaseFeeSpy.mockRejectedValue(new Error('Network error')); - - await expect(networkService.getBaseFee(scope)).rejects.toThrow( - BaseFeeFetchException, - ); - }); - }); - - describe('loadAccount', () => { - it('returns loaded account', async () => { - const { loadAccountSpy } = getHorizonClientSpies(); - const account = new Account(testAddress, '1'); - loadAccountSpy.mockResolvedValue( - account as unknown as StellarHorizon.AccountResponse, - ); - - const result = await networkService.loadAccount(testAddress, scope); - - expect(result).toStrictEqual(account); - expect(result.accountId()).toStrictEqual(testAddress); - expect(loadAccountSpy).toHaveBeenCalledWith(testAddress); - }); - - it('throws AccountNotActivatedException when account is not found', async () => { - const { loadAccountSpy } = getHorizonClientSpies(); - loadAccountSpy.mockRejectedValue(new NotFoundError('not found', {})); - - await expect( - networkService.loadAccount(testAddress, scope), - ).rejects.toThrow(AccountNotActivatedException); - }); - - it('throws AccountLoadException when load fails for other reason', async () => { - const { loadAccountSpy } = getHorizonClientSpies(); - loadAccountSpy.mockRejectedValue(new Error('Network error')); - - await expect( - networkService.loadAccount(testAddress, scope), - ).rejects.toThrow(AccountLoadException); - }); - }); - - describe('pollTransaction', () => { - it('returns transaction hash when status is SUCCESS', async () => { - const { pollTransactionSpy } = getRpcServerSpies(); - pollTransactionSpy.mockResolvedValue({ - status: StellarRpc.Api.GetTransactionStatus.SUCCESS, - txHash: testTransactionHash, - } as unknown as StellarRpc.Api.GetSuccessfulTransactionResponse); - - const result = await networkService.pollTransaction( - testTransactionHash, - scope, - ); - - expect(result).toStrictEqual(testTransactionHash); - expect(pollTransactionSpy).toHaveBeenCalledWith(testTransactionHash); - }); - - it('throws TransactionPollException when status is not SUCCESS', async () => { - const { pollTransactionSpy } = getRpcServerSpies(); - pollTransactionSpy.mockResolvedValue({ - status: StellarRpc.Api.GetTransactionStatus.FAILED, - txHash: testTransactionHash, - } as unknown as StellarRpc.Api.GetFailedTransactionResponse); - - await expect( - networkService.pollTransaction(testTransactionHash, scope), - ).rejects.toThrow(TransactionPollException); - }); - - it('throws TransactionPollException when poll fails', async () => { - const { pollTransactionSpy } = getRpcServerSpies(); - pollTransactionSpy.mockRejectedValue(new Error('RPC error')); - - await expect( - networkService.pollTransaction(testTransactionHash, scope), - ).rejects.toThrow(TransactionPollException); - }); - }); - - describe('send', () => { - let mockTransaction: Transaction; - let transactionBuilder: TransactionBuilder; - const testAsset: KnownCaip19AssetId = `stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN`; - - beforeEach(() => { - transactionBuilder = new TransactionBuilder({ logger }); - const seed = hexToBytes( - '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - ) as Buffer; - const testWallet = new Wallet( - new Account(Keypair.fromRawEd25519Seed(seed).publicKey(), '1'), - null, - ); - mockTransaction = transactionBuilder.changeTrust({ - baseFee: '100', - scope, - asset: testAsset, - account: testWallet, - }); - }); - - it('returns transaction hash when pollTransaction is false', async () => { - const { sendTransactionSpy, pollTransactionSpy } = getRpcServerSpies(); - sendTransactionSpy.mockResolvedValue({ - hash: testTransactionHash, - } as unknown as StellarRpc.Api.SendTransactionResponse); - - const result = await networkService.send({ - transaction: mockTransaction, - scope, - pollTransaction: false, - }); - - expect(result).toStrictEqual(testTransactionHash); - expect(sendTransactionSpy).toHaveBeenCalledWith(mockTransaction.getRaw()); - expect(pollTransactionSpy).not.toHaveBeenCalled(); - }); - - it('polls and returns hash when pollTransaction is true and status is SUCCESS', async () => { - const { sendTransactionSpy, pollTransactionSpy } = getRpcServerSpies(); - sendTransactionSpy.mockResolvedValue({ - hash: testTransactionHash, - } as unknown as StellarRpc.Api.SendTransactionResponse); - pollTransactionSpy.mockResolvedValue({ - status: StellarRpc.Api.GetTransactionStatus.SUCCESS, - txHash: testTransactionHash, - } as unknown as StellarRpc.Api.GetSuccessfulTransactionResponse); - - const result = await networkService.send({ - transaction: mockTransaction, - scope, - pollTransaction: true, - }); - - expect(result).toStrictEqual(testTransactionHash); - expect(pollTransactionSpy).toHaveBeenCalledWith(testTransactionHash); - }); - - it('throws TransactionPollException when pollTransaction is true and poll fails', async () => { - const { sendTransactionSpy, pollTransactionSpy } = getRpcServerSpies(); - sendTransactionSpy.mockResolvedValue({ - hash: testTransactionHash, - } as unknown as StellarRpc.Api.SendTransactionResponse); - pollTransactionSpy.mockRejectedValue(new Error('RPC error')); - - await expect( - networkService.send({ - transaction: mockTransaction, - scope, - pollTransaction: true, - }), - ).rejects.toThrow(TransactionPollException); - }); - }); - - describe('#getHorizonClient', () => { - it('creates and returns a new Horizon client when it is not already created', async () => { - const { fetchBaseFeeSpy } = getHorizonClientSpies(); - fetchBaseFeeSpy.mockResolvedValue(100); - - await networkService.getBaseFee(scope); - - expect(fetchBaseFeeSpy).toHaveBeenCalled(); - }); - - it('throws NetworkServiceException when corresponding Config is not found for the given scope', async () => { - const { fetchBaseFeeSpy } = getHorizonClientSpies(); - - await expect( - networkService.getBaseFee('unknown' as unknown as KnownCaip2ChainId), - ).rejects.toThrow(NetworkServiceException); - expect(fetchBaseFeeSpy).not.toHaveBeenCalled(); - }); - }); - - describe('#getRpcClient', () => { - it('creates and returns a new RPC client when it is not already created', async () => { - const { pollTransactionSpy } = getRpcServerSpies(); - pollTransactionSpy.mockResolvedValue({ - status: StellarRpc.Api.GetTransactionStatus.SUCCESS, - txHash: testTransactionHash, - } as unknown as StellarRpc.Api.GetSuccessfulTransactionResponse); - - await networkService.pollTransaction(testTransactionHash, scope); - expect(pollTransactionSpy).toHaveBeenCalled(); - }); - - it('throws NetworkServiceException when corresponding Config is not found for the given scope', async () => { - const { pollTransactionSpy } = getRpcServerSpies(); - - await expect( - networkService.pollTransaction( - testTransactionHash, - 'unknown' as unknown as KnownCaip2ChainId, - ), - ).rejects.toThrow(NetworkServiceException); - expect(pollTransactionSpy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/NetworkService.ts deleted file mode 100644 index 76ef6845..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/NetworkService.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { - Horizon as StellarHorizon, - NotFoundError, - rpc, -} from '@stellar/stellar-sdk'; -import { BigNumber } from 'bignumber.js'; - -import type { LoadedAccount } from './api'; -import { - AccountLoadException, - AccountNotActivatedException, - BaseFeeFetchException, - TransactionPollException, - TransactionSendException, - NetworkServiceException, -} from './exceptions'; -import type { Transaction } from './Transaction'; -import type { KnownCaip2ChainId } from '../../api'; -import { AppConfig } from '../../config'; -import { createPrefixedLogger } from '../../utils'; -import type { ILogger } from '../../utils'; - -/** - * Service for Stellar network reads: fees, account data, and transaction submission. - */ -export class NetworkService { - readonly #logger: ILogger; - - readonly #horizonClientMap = new Map< - KnownCaip2ChainId, - StellarHorizon.Server - >(); - - readonly #rpcClientMap = new Map(); - - constructor({ logger }: { logger: ILogger }) { - this.#logger = createPrefixedLogger(logger, '[🌐 NetworkService]'); - } - - #getHorizonClient(scope: KnownCaip2ChainId): StellarHorizon.Server { - const config = AppConfig.networks[scope]; - if (!config) { - throw new NetworkServiceException( - `Network not found for scope: ${scope}`, - ); - } - let client = this.#horizonClientMap.get(scope); - if (!client) { - client = new StellarHorizon.Server(config.horizonUrl); - this.#horizonClientMap.set(scope, client); - } - return client; - } - - #getRpcClient(scope: KnownCaip2ChainId): rpc.Server { - const config = AppConfig.networks[scope]; - if (!config) { - throw new NetworkServiceException( - `Network not found for scope: ${scope}`, - ); - } - let client = this.#rpcClientMap.get(scope); - if (!client) { - client = new rpc.Server(config.rpcUrl); - this.#rpcClientMap.set(scope, client); - } - return client; - } - - /** - * Fetches the current base fee per operation from the Stellar network. - * - * @param scope - The CAIP-2 chain ID. - * @returns A Promise that resolves to the base fee as BigNumber. - * @throws {BaseFeeFetchException} If the fee cannot be fetched. - */ - async getBaseFee(scope: KnownCaip2ChainId): Promise { - try { - const client = this.#getHorizonClient(scope); - const baseFee = await client.fetchBaseFee(); - return new BigNumber(baseFee); - } catch (error: unknown) { - this.#logger.logErrorWithDetails('Failed to fetch base fee', error); - throw new BaseFeeFetchException(scope); - } - } - - /** - * Polls the network until the transaction reaches a terminal status, then returns its hash or throws. - * - * @param transactionHash - The hash of the submitted transaction. - * @param scope - The CAIP-2 chain ID. - * @returns A Promise that resolves to the transaction hash if the status is SUCCESS. - * @throws {TransactionPollException} If status is not SUCCESS or polling fails. - */ - async pollTransaction( - transactionHash: string, - scope: KnownCaip2ChainId, - ): Promise { - try { - const client = this.#getRpcClient(scope); - const result = await client.pollTransaction(transactionHash); - - if (result.status === rpc.Api.GetTransactionStatus.SUCCESS) { - return result.txHash; - } - throw new TransactionPollException(transactionHash, result.status, scope); - } catch (error: unknown) { - this.#logger.logErrorWithDetails('Failed to poll transaction', error); - if (error instanceof TransactionPollException) { - throw error; - } - throw new TransactionPollException(transactionHash, 'unknown', scope); - } - } - - /** - * Loads account data (id and sequence) from the Stellar network. - * - * @param accountAddress - The Stellar account address (public key). - * @param scope - The CAIP-2 chain ID. - * @returns A Promise that resolves to the account object. - * @throws {AccountNotActivatedException} If the account does not exist on the network. - * @throws {AccountLoadException} If loading fails for another reason (e.g. network error). - */ - async loadAccount( - accountAddress: string, - scope: KnownCaip2ChainId, - ): Promise { - try { - const client = this.#getHorizonClient(scope); - return await client.loadAccount(accountAddress); - } catch (error: unknown) { - this.#logger.logErrorWithDetails('Failed to load account', error); - if (error instanceof NotFoundError) { - throw new AccountNotActivatedException(accountAddress, scope); - } - throw new AccountLoadException(accountAddress, scope); - } - } - - /** - * Submits a signed transaction to the network and optionally waits for a terminal status. - * - * @param params - The parameters for sending a transaction. - * @param params.transaction - The signed transaction. - * @param params.scope - The CAIP-2 chain ID. - * @param params.pollTransaction - If true, poll until terminal status and return the hash only on SUCCESS. - * @returns A Promise that resolves to the transaction hash. - * @throws {TransactionSendException} If submission fails. - * @throws {TransactionPollException} If polling is requested and the transaction does not succeed. - */ - async send({ - transaction, - pollTransaction = false, - scope, - }: { - transaction: Transaction; - pollTransaction?: boolean; - scope: KnownCaip2ChainId; - }): Promise { - try { - const client = this.#getRpcClient(scope); - - const executedTransaction = await client.sendTransaction( - transaction.getRaw(), - ); - - if (pollTransaction) { - return await this.pollTransaction(executedTransaction.hash, scope); - } - - return executedTransaction.hash; - } catch (error: unknown) { - this.#logger.logErrorWithDetails('Failed to send transaction', error); - if (error instanceof TransactionPollException) { - throw error; - } - throw new TransactionSendException(scope); - } - } -} diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/Transaction.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/Transaction.ts deleted file mode 100644 index cb8cb732..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/Transaction.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Transaction as StellarTransaction } from '@stellar/stellar-sdk'; -import { BigNumber } from 'bignumber.js'; - -/** - * Wrapper around a Stellar transaction. Exposes fee, operation count, and network passphrase - * for callers. - */ -export class Transaction { - readonly #inner: StellarTransaction; - - constructor(inner: StellarTransaction) { - this.#inner = inner; - } - - /** - * The total fee for the transaction (in stroops). - * - * @returns The total fee as BigNumber. - */ - getTotalFee(): BigNumber { - return new BigNumber(this.#inner.fee); - } - - /** - * The number of operations in the transaction. - * - * @returns The operation count. - */ - getOperationCount(): number { - return this.#inner.operations.length; - } - - /** - * The network passphrase (e.g. for mainnet/testnet). - * - * @returns The network passphrase string. - */ - getNetworkPassphrase(): string { - return this.#inner.networkPassphrase; - } - - /** - * The raw SDK transaction. For use only within the wallet module (signing, sending). - * - * @returns The raw Stellar SDK transaction. - * @internal - */ - getRaw(): StellarTransaction { - return this.#inner; - } -} diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/TransactionBuilder.test.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/TransactionBuilder.test.ts deleted file mode 100644 index 5516cb4b..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/TransactionBuilder.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { hexToBytes } from '@metamask/utils'; -import { - Account, - Keypair, - Networks, - Transaction as StellarTransaction, - TransactionBuilder as StellarSdkTransactionBuilder, -} from '@stellar/stellar-sdk'; -import { BigNumber } from 'bignumber.js'; - -import { TransactionBuilderException } from './exceptions'; -import { Transaction } from './Transaction'; -import { TransactionBuilder } from './TransactionBuilder'; -import { Wallet } from './Wallet'; -import type { KnownCaip19AssetId } from '../../api'; -import { KnownCaip2ChainId } from '../../api'; -import { logger } from '../../utils/logger'; - -jest.mock('../../utils/logger'); - -describe('TransactionBuilder', () => { - let transactionBuilder: TransactionBuilder; - let testAsset: KnownCaip19AssetId; - let testWalletWithSigner: Wallet; - let testAccount: Account; - let testKeypair: Keypair; - let testAddress: string; - - beforeEach(() => { - transactionBuilder = new TransactionBuilder({ logger }); - testAsset = `stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN`; - testKeypair = Keypair.fromRawEd25519Seed( - hexToBytes( - '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', - ) as Buffer, - ); - testAddress = testKeypair.publicKey(); - testAccount = new Account(testAddress, '100'); - testWalletWithSigner = new Wallet(testAccount, testKeypair); - }); - - const getAccountSpies = () => ({ - incrementSequenceNumberSpy: jest.spyOn( - Account.prototype, - 'incrementSequenceNumber', - ), - }); - - const getTransactionBuilderSpies = () => ({ - buildSpy: jest.spyOn(StellarSdkTransactionBuilder.prototype, 'build'), - }); - - describe('changeTrust', () => { - it('builds a change trust transaction', () => { - const transaction = transactionBuilder.changeTrust({ - baseFee: '100', - scope: KnownCaip2ChainId.Mainnet, - asset: testAsset, - account: testWalletWithSigner, - }); - - expect(transaction).toBeInstanceOf(Transaction); - expect(transaction.getTotalFee()).toStrictEqual(new BigNumber(100)); - expect(transaction.getOperationCount()).toBe(1); - expect(transaction.getNetworkPassphrase()).toStrictEqual(Networks.PUBLIC); - expect(transaction.getRaw()).toBeInstanceOf(StellarTransaction); - }); - - it('throws a TransactionBuilderException if building the transaction fails', () => { - const { incrementSequenceNumberSpy } = getAccountSpies(); - incrementSequenceNumberSpy.mockImplementation(() => { - throw new Error('Failed to increment sequence number'); - }); - - expect(() => { - transactionBuilder.changeTrust({ - baseFee: '100', - scope: KnownCaip2ChainId.Mainnet, - asset: testAsset, - account: testWalletWithSigner, - }); - }).toThrow(TransactionBuilderException); - }); - }); - - describe('rebuildTransaction', () => { - it('rebuilds a transaction', () => { - const transaction = transactionBuilder.changeTrust({ - baseFee: '100', - scope: KnownCaip2ChainId.Mainnet, - asset: testAsset, - account: new Wallet(new Account(testAddress, '1'), null), - }); - - const rebuiltTransaction = transactionBuilder.rebuildTransaction({ - transaction, - // Use the test wallet that has sequence number 100 - account: testWalletWithSigner.account, - baseFee: '100', - }); - - expect(rebuiltTransaction).toBeInstanceOf(Transaction); - expect(rebuiltTransaction.getTotalFee()).toStrictEqual( - new BigNumber(100), - ); - expect(rebuiltTransaction.getOperationCount()).toBe(1); - expect(rebuiltTransaction.getNetworkPassphrase()).toStrictEqual( - Networks.PUBLIC, - ); - expect(rebuiltTransaction.getRaw()).toBeInstanceOf(StellarTransaction); - // The sequence number should be incremented by 1 - expect(rebuiltTransaction.getRaw().sequence).toBe('101'); - }); - - it('throws a TransactionBuilderException if rebuilding the transaction fails', () => { - const transaction = transactionBuilder.changeTrust({ - baseFee: '100', - scope: KnownCaip2ChainId.Mainnet, - asset: testAsset, - account: new Wallet(new Account(testAddress, '1'), null), - }); - - const { buildSpy } = getTransactionBuilderSpies(); - buildSpy.mockImplementation(() => { - throw new Error('Failed to build transaction'); - }); - - expect(() => { - transactionBuilder.rebuildTransaction({ - transaction, - account: testWalletWithSigner.account, - baseFee: '100', - }); - }).toThrow(TransactionBuilderException); - }); - }); -}); diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/TransactionBuilder.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/TransactionBuilder.ts deleted file mode 100644 index e9b84fe2..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/TransactionBuilder.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { - xdr, - Transaction as StellarSdkTransaction, -} from '@stellar/stellar-sdk'; -import { - Account, - Operation, - TransactionBuilder as StellarSdkTransactionBuilder, - BASE_FEE, -} from '@stellar/stellar-sdk'; - -import type { LoadedAccount } from './api'; -import { TransactionBuilderException } from './exceptions'; -import { Transaction } from './Transaction'; -import { getNetwork, getStellarAsset } from './utils'; -import type { Wallet } from './Wallet'; -import type { KnownCaip2ChainId, KnownCaip19AssetId } from '../../api'; -import type { ILogger } from '../../utils'; -import { createPrefixedLogger } from '../../utils'; - -/** - * Builds Stellar transactions (e.g. change trust, create account) and rebuilds existing - * transactions with updated source/sequence/fee. All methods return a {@link Transaction} wrapper. - */ -export class TransactionBuilder { - readonly #logger: ILogger; - - constructor({ logger }: { logger: ILogger }) { - this.#logger = createPrefixedLogger(logger, '[💰 TransactionBuilder]'); - } - - /** - * Builds a change-trust operation transaction for the given asset. - * - * @param params - Options object. - * @param params.baseFee - The fee per operation. - * @param params.scope - The CAIP-2 chain ID. - * @param params.asset - The asset code or full asset string (e.g. "CODE:ISSUER"). - * @param params.account - The wallet to use as the transaction source. - * @returns An unsigned transaction ready for signing. - * @throws {TransactionBuilderException} If building fails. - */ - changeTrust(params: { - baseFee: string; - scope: KnownCaip2ChainId; - asset: KnownCaip19AssetId; - account: Wallet; - }): Transaction { - const { baseFee, scope, asset, account } = params; - - try { - return this.#buildTransaction({ - account, - operations: [ - Operation.changeTrust({ - asset: getStellarAsset(asset), - }), - ], - timeout: 180, - scope, - fee: baseFee, - }); - } catch (error: unknown) { - this.#logger.logErrorWithDetails( - 'Failed to build change trust transaction', - error, - ); - throw new TransactionBuilderException( - 'Failed to build change trust transaction', - ); - } - } - - /** - * Clones a transaction and updates the source account and sequence. - * - * @param params - Options object. - * @param params.transaction - The original transaction. - * @param params.account - The loaded account with latest sequence. - * @param params.baseFee - [optional] The base fee to use for the transaction; if omitted, the original fee is used. - * @returns A new transaction with updated source. - * @throws {TransactionBuilderException} If rebuilding fails. - */ - rebuildTransaction(params: { - transaction: Transaction; - account: LoadedAccount; - baseFee?: string; - }): Transaction { - const { transaction, account, baseFee } = params; - try { - const rawTransaction = - transaction.getRaw() as unknown as StellarSdkTransaction; - - // the initial fee passed to the builder gets scaled up based on the number - // of operations at the end, so we have to down-scale first - const unscaledFee = Math.floor( - parseInt(rawTransaction.fee, 10) / rawTransaction.operations.length, - ); - - // Minimal clone of the transaction - const builder = new StellarSdkTransactionBuilder( - new Account(account.accountId(), account.sequenceNumber()), - { - fee: (baseFee ?? unscaledFee ?? BASE_FEE).toString(), - networkPassphrase: rawTransaction.networkPassphrase, - timebounds: rawTransaction.timeBounds, - }, - ); - - // Clone the transaction operations - if ('tx' in rawTransaction) { - const tx = rawTransaction.tx as xdr.Transaction; - tx.operations().forEach((op) => builder.addOperation(op)); - } else { - throw new Error('Transaction is not a compatible transaction'); - } - - return new Transaction(builder.build()); - } catch (error: unknown) { - this.#logger.logErrorWithDetails('Failed to rebuild transaction', error); - throw new TransactionBuilderException('Failed to rebuild transaction'); - } - } - - #buildTransaction({ - account, - operations, - timeout, - scope, - fee, - }: { - account: Wallet; - operations: xdr.Operation[]; - timeout: number; - scope: KnownCaip2ChainId; - fee: string; - }): Transaction { - const accountInstance = new Account( - account.account.accountId(), - account.account.sequenceNumber(), - ); - - const networkPassphrase = getNetwork(scope); - const builder = new StellarSdkTransactionBuilder(accountInstance, { - fee, - networkPassphrase, - }); - - for (const operation of operations) { - builder.addOperation(operation); - } - - const inner = builder.setTimeout(timeout).build(); - return new Transaction(inner); - } -} diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts new file mode 100644 index 00000000..2192eb32 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts @@ -0,0 +1,171 @@ +import { hexToBytes } from '@metamask/utils'; +import { Keypair } from '@stellar/stellar-sdk'; + +import { getTestWallet } from './__mocks__/wallet.fixtures'; +import { Wallet } from './Wallet'; +import { bufferToUint8Array } from '../../utils/buffer'; +import { buildMockClassicTransaction } from '../transaction/__mocks__/transaction.fixtures'; + +jest.mock('../../utils/logger'); + +describe('Wallet', () => { + const seed = hexToBytes( + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ); + const otherSeed = hexToBytes( + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + ); + + describe('address', () => { + it('returns the signer public key for a derived wallet', () => { + const wallet = getTestWallet({ seed }); + const expected = Keypair.fromRawEd25519Seed( + bufferToUint8Array(seed), + ).publicKey(); + expect(wallet.address).toStrictEqual(expected); + }); + }); + + describe('signMessage', () => { + it('returns a base64-encoded signature for a string message', async () => { + const wallet = getTestWallet({ seed }); + const signature = await wallet.signMessage('hello stellar'); + expect(signature).toMatch(/^[A-Za-z0-9+/]+=*$/u); + expect(signature.length).toBeGreaterThan(0); + }); + + it('matches string and UTF-8 bytes for the same logical message', async () => { + const wallet = getTestWallet({ seed }); + const text = 'hello stellar'; + const asString = await wallet.signMessage(text); + const asBytes = await wallet.signMessage(new TextEncoder().encode(text)); + expect(asString).toStrictEqual(asBytes); + }); + + // https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md + describe('SEP-0053 reference vectors', () => { + const sep0053Secret = + 'SAKICEVQLYWGSOJS4WW7HZJWAHZVEEBS527LHK5V4MLJALYKICQCJXMW'; + + const sep0053Wallet = new Wallet(Keypair.fromSecret(sep0053Secret)); + + it.each([ + { + message: bufferToUint8Array('Hello, World!', 'utf8'), + signature: + 'fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA==', + }, + { + message: bufferToUint8Array('こんにちは、世界!', 'utf8'), + signature: + 'CDU265Xs8y3OWbB/56H9jPgUss5G9A0qFuTqH2zs2YDgTm+++dIfmAEceFqB7bhfN3am59lCtDXrCtwH2k1GBA==', + }, + { + message: bufferToUint8Array( + '2zZDP1sa1BVBfLP7TeeMk3sUbaxAkUhBhDiNdrksaFo=', + 'base64', + ), + signature: + 'VA1+7hefNwv2NKScH6n+Sljj15kLAge+M2wE7fzFOf+L0MMbssA1mwfJZRyyrhBORQRle10X1Dxpx+UOI4EbDQ==', + }, + { + message: 'Hello, World!', + signature: + 'fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA==', + }, + { + message: 'こんにちは、世界!', + signature: + 'CDU265Xs8y3OWbB/56H9jPgUss5G9A0qFuTqH2zs2YDgTm+++dIfmAEceFqB7bhfN3am59lCtDXrCtwH2k1GBA==', + }, + { + message: '2zZDP1sa1BVBfLP7TeeMk3sUbaxAkUhBhDiNdrksaFo=', + signature: + 'VA1+7hefNwv2NKScH6n+Sljj15kLAge+M2wE7fzFOf+L0MMbssA1mwfJZRyyrhBORQRle10X1Dxpx+UOI4EbDQ==', + }, + ])( + 'verifies each reference case with verifyMessage', + async ({ + message, + signature, + }: { + message: string | Uint8Array; + signature: string; + }) => { + // verify hex signature + const hexSignature = bufferToUint8Array(signature, 'base64').toString( + 'hex', + ); + expect(await sep0053Wallet.signMessage(message, 'hex')).toBe( + hexSignature, + ); + expect( + await sep0053Wallet.verifyMessage(message, hexSignature, 'hex'), + ).toBe(true); + // verify base64 signature + expect(await sep0053Wallet.signMessage(message)).toStrictEqual( + signature, + ); + expect(await sep0053Wallet.verifyMessage(message, signature)).toBe( + true, + ); + }, + ); + }); + }); + + describe('verifyMessage', () => { + it('returns true when signature matches signMessage for the same message', async () => { + const wallet = getTestWallet({ seed }); + const message = 'hello stellar'; + + const signature = await wallet.signMessage(message); + expect(await wallet.verifyMessage(message, signature)).toBe(true); + }); + + it('returns false for a different message with the same signature', async () => { + const wallet = getTestWallet({ seed }); + const signature = await wallet.signMessage('original'); + + expect(await wallet.verifyMessage('tampered', signature)).toBe(false); + }); + + it('returns false when the signature was produced by a different key', async () => { + const signer = getTestWallet({ seed }); + const other = getTestWallet({ seed: otherSeed }); + + const signature = await signer.signMessage('same text'); + + expect(await other.verifyMessage('same text', signature)).toBe(false); + }); + + it('returns false when the signature is truncated', async () => { + const wallet = getTestWallet({ seed }); + const full = await wallet.signMessage('hello'); + const truncated = full.slice(0, Math.max(1, full.length - 4)); + expect(await wallet.verifyMessage('hello', truncated)).toBe(false); + }); + }); + + describe('signTransaction', () => { + it('signs a transaction built for the same source account', () => { + const wallet = getTestWallet({ seed }); + + const tx = buildMockClassicTransaction([ + { + type: 'changeTrust', + params: { + asset: { + code: 'USDC', + issuer: + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, + limit: '10000', + }, + }, + ]); + + expect(() => wallet.signTransaction(tx)).not.toThrow(); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.ts index ec17342b..8e21c66f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.ts +++ b/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.ts @@ -1,49 +1,120 @@ +import { sha256 } from '@metamask/utils'; import type { Keypair } from '@stellar/stellar-sdk'; -import type { LoadedAccount } from './api'; -import type { Transaction } from './Transaction'; +import { + SignMessageException, + SignTransactionException, + VerifyMessageException, +} from './exceptions'; +import { bufferToUint8Array } from '../../utils/buffer'; +import { isBase64 } from '../../utils/string'; +import type { Transaction } from '../transaction/Transaction'; /** - * Stateful handle for a loaded Stellar account and optional signer. Created by + * Signing-only handle: Stellar SDK keypair for transaction and SEP-53 message signing. */ export class Wallet { - readonly #account: LoadedAccount; + readonly #signer: Keypair; - readonly #signer: Keypair | null; - - constructor(account: LoadedAccount, signer: Keypair | null) { - this.#account = account; + constructor(signer: Keypair) { this.#signer = signer; } /** - * The Stellar account address (public key); uses the signer if present, otherwise the account ID. + * The Stellar account address (signer's public key). * - * @returns The account address string. + * @returns Public key string (`G…`). */ get address(): string { - return this.#signer?.publicKey() ?? this.#account.accountId(); + return this.#signer.publicKey(); } /** - * The loaded account data. + * Signs the given transaction with this wallet's signer. * - * @returns The loaded account. + * @param tx - The transaction to sign. + * @throws {SignTransactionException} If Stellar SDK signing fails (details are not exposed). */ - get account(): LoadedAccount { - return this.#account; + signTransaction(tx: Transaction): void { + try { + // Allow to sign any transaction even if it is not initiated by this wallet + tx.getRaw().sign(this.#signer); + } catch { + throw new SignTransactionException(); + } } /** - * Signs the given transaction with this wallet's signer. + * Signs a given message using the Stellar Signed Message protocol. + * Please see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md for more details. * - * @param tx - The transaction to sign. - * @throws {Error} If no signer was provided when this wallet was created. + * @param message - The message to sign. + * @param encode - The encoding to use for the signature. Defaults to 'base64'. + * @returns A promise that resolves to the signature as a base64 or hex string. */ - signTransaction(tx: Transaction): void { - if (!this.#signer) { - throw new Error('No signer found when signing transaction'); + async signMessage( + message: string | Uint8Array, + encode: 'hex' | 'base64' = 'base64', + ): Promise { + try { + const messageBuffer = this.#encodeMessage(message); + + const messageHash = await sha256(messageBuffer); + + const signature = this.#signer + .sign(bufferToUint8Array(messageHash)) + .toString(encode); + + return signature; + } catch { + throw new SignMessageException(); + } + } + + /** + * Verifies a given message using the Stellar Signed Message protocol. + * Please see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md for more details. + * + * @param message - The message to verify. + * @param signature - The base64 encoded signature to verify. + * @param encode - The encoding to use for the signature. Defaults to 'base64'. + * @returns `true` if the signature is valid for this signer's public key, `false` if it is not. + * @throws {VerifyMessageException} If verification cannot be completed (details are not exposed). + */ + async verifyMessage( + message: string | Uint8Array, + signature: string, + encode: 'hex' | 'base64' = 'base64', + ): Promise { + try { + const messageBuffer = this.#encodeMessage(message); + + const messageHash = await sha256(messageBuffer); + + const verified = this.#signer.verify( + bufferToUint8Array(messageHash), + bufferToUint8Array(signature, encode), + ); + + return verified; + } catch { + throw new VerifyMessageException(); + } + } + + #encodeMessage(message: string | Uint8Array): Uint8Array { + const messagePrefix = 'Stellar Signed Message:\n'; + let messageBuffer: Uint8Array; + if (typeof message === 'string' && isBase64(message)) { + messageBuffer = bufferToUint8Array(message, 'base64'); + } else if (typeof message === 'string') { + messageBuffer = bufferToUint8Array(message, 'utf8'); + } else { + messageBuffer = message; } - tx.getRaw().sign(this.#signer); + return new Uint8Array([ + ...bufferToUint8Array(messagePrefix), + ...messageBuffer, + ]); } } diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/WalletService.test.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/WalletService.test.ts index 696e2d4e..f14cbfb3 100644 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/WalletService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/wallet/WalletService.test.ts @@ -1,100 +1,51 @@ import { hexToBytes } from '@metamask/utils'; -import { Account, Keypair } from '@stellar/stellar-sdk'; -import { BigNumber } from 'bignumber.js'; - -import { - AccountNotActivatedException, - NetworkServiceException, - WalletServiceException, -} from './exceptions'; -import { NetworkService } from './NetworkService'; -import { TransactionBuilder } from './TransactionBuilder'; -import { Wallet } from './Wallet'; +import { Keypair } from '@stellar/stellar-sdk'; + +import { getTestWallet } from './__mocks__/wallet.fixtures'; +import { WalletServiceException } from './exceptions'; import { WalletService } from './WalletService'; -import type { KnownCaip19AssetId } from '../../api'; -import { KnownCaip2ChainId } from '../../api'; +import { mockBip32Node } from '../../utils/__mocks__/fixtures'; +import { bufferToUint8Array } from '../../utils/buffer'; import { logger } from '../../utils/logger'; +import { getBip32Entropy } from '../../utils/snap'; +import { generateStellarKeyringAccount } from '../account/__mocks__/account.fixtures'; +import { DerivedAccountAddressMismatchException } from '../account/exceptions'; jest.mock('../../utils/logger'); +jest.mock('../../utils/snap'); describe('WalletService', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + let walletService: WalletService; - let networkService: NetworkService; - let transactionBuilder: TransactionBuilder; - let testKeypair: Keypair; - let testAddress: string; - let testAccount: Account; - let testWalletWithSigner: Wallet; - let testAsset: KnownCaip19AssetId; - let scope: KnownCaip2ChainId; - - const get32ByteSeedSpy: jest.Mock = jest.fn(); + const seed = hexToBytes( '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', ); beforeEach(() => { jest.clearAllMocks(); - transactionBuilder = new TransactionBuilder({ logger }); - networkService = new NetworkService({ logger }); - - walletService = new WalletService({ - logger, - deriver: { get32ByteSeed: get32ByteSeedSpy.mockResolvedValue(seed) }, - networkService, - transactionBuilder, - }); - - scope = KnownCaip2ChainId.Mainnet; - testKeypair = Keypair.fromRawEd25519Seed(seed as Buffer); - testAddress = testKeypair.publicKey(); - testAccount = new Account(testAddress, '1'); - testWalletWithSigner = new Wallet(testAccount, testKeypair); - testAsset = `stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN`; - }); - - const getNetworkServiceSpies = () => ({ - getBaseFeeSpy: jest.spyOn(NetworkService.prototype, 'getBaseFee'), - pollTransactionSpy: jest.spyOn(NetworkService.prototype, 'pollTransaction'), - loadAccountSpy: jest.spyOn(NetworkService.prototype, 'loadAccount'), - sendTransactionSpy: jest.spyOn(NetworkService.prototype, 'send'), - }); - - const getTransactionBuilderSpies = () => ({ - rebuildTransactionSpy: jest.spyOn( - TransactionBuilder.prototype, - 'rebuildTransaction', - ), - }); - - const getWalletSpies = () => ({ - signTransactionSpy: jest.spyOn(Wallet.prototype, 'signTransaction'), - }); - - describe('builder', () => { - it('returns the transaction builder', () => { - expect(walletService.builder).toStrictEqual(transactionBuilder); - }); - }); - - describe('network', () => { - it('returns the network service', () => { - expect(walletService.network).toStrictEqual(networkService); - }); + jest.mocked(getBip32Entropy).mockResolvedValue(mockBip32Node); + walletService = new WalletService({ logger }); }); describe('deriveAddress', () => { it('derives an address', async () => { + const wallet = getTestWallet({ seed }); const address = await walletService.deriveAddress({ index: 0, entropySource: 'entropy-source-1', }); - expect(address).toStrictEqual(testAddress); + expect(address).toStrictEqual(wallet.address); }); it('throws a WalletServiceException if the keypair derivation fails', async () => { - get32ByteSeedSpy.mockRejectedValue(new Error('something went wrong')); + jest + .mocked(getBip32Entropy) + .mockRejectedValue(new Error('something went wrong')); await expect( walletService.deriveAddress({ @@ -105,148 +56,49 @@ describe('WalletService', () => { }); }); - describe('resolveActivatedAccount', () => { - it('returns a wallet with loaded account', async () => { - const { loadAccountSpy } = getNetworkServiceSpies(); - loadAccountSpy.mockResolvedValue(new Account(testAddress, '1')); - - const wallet = await walletService.resolveActivatedAccount({ - scope, - entropySource: 'entropy-source-1', - index: 0, - }); - - expect(wallet.address).toStrictEqual(testAddress); - expect(wallet.account.accountId()).toStrictEqual(testAddress); - }); - }); + describe('resolveWallet', () => { + it('returns a wallet whose address matches the keyring row', async () => { + const kp = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + const account = generateStellarKeyringAccount( + globalThis.crypto.randomUUID(), + kp.publicKey(), + 'entropy-source-1', + 0, + ); - describe('isAccountActivated', () => { - it('returns true if the account is activated', async () => { - const { loadAccountSpy } = getNetworkServiceSpies(); - loadAccountSpy.mockResolvedValue(new Account(testAddress, '1')); + const wallet = await walletService.resolveWallet(account); - const result = await walletService.isAccountActivated({ - address: testAddress, - scope, - }); - expect(result).toBe(true); + expect(wallet.address).toStrictEqual(kp.publicKey()); }); - it('returns false if the account is not activated', async () => { - const { loadAccountSpy } = getNetworkServiceSpies(); - loadAccountSpy.mockRejectedValue( - new AccountNotActivatedException(testAddress, scope), + it('throws DerivedAccountAddressMismatchException when derivation does not match stored address', async () => { + const account = generateStellarKeyringAccount( + globalThis.crypto.randomUUID(), + Keypair.random().publicKey(), + 'entropy-source-1', + 0, ); - const result = await walletService.isAccountActivated({ - address: testAddress, - scope, - }); - expect(result).toBe(false); - }); - - it('throws a NetworkServiceException if loading the account fails', async () => { - const { loadAccountSpy } = getNetworkServiceSpies(); - loadAccountSpy.mockRejectedValue( - new NetworkServiceException( - 'Failed to load account from Stellar Network', - ), + await expect(walletService.resolveWallet(account)).rejects.toThrow( + DerivedAccountAddressMismatchException, ); - - await expect( - walletService.isAccountActivated({ address: testAddress, scope }), - ).rejects.toThrow(NetworkServiceException); - }); - }); - - describe('signTransaction', () => { - it('signs a transaction', async () => { - const { loadAccountSpy } = getNetworkServiceSpies(); - const { signTransactionSpy } = getWalletSpies(); - loadAccountSpy.mockResolvedValue(testAccount); - - const testTransaction = transactionBuilder.changeTrust({ - baseFee: '100', - scope, - asset: testAsset, - account: testWalletWithSigner, - }); - - const { rebuildTransactionSpy } = getTransactionBuilderSpies(); - rebuildTransactionSpy.mockReturnValue(testTransaction); - - await walletService.signTransaction({ - account: testWalletWithSigner, - scope, - transaction: testTransaction, - baseFee: new BigNumber(100), - }); - - expect(rebuildTransactionSpy).toHaveBeenCalledWith({ - transaction: testTransaction, - account: testAccount, - baseFee: '100', - }); - expect(loadAccountSpy).toHaveBeenCalledWith(testAddress, scope); - expect(signTransactionSpy).toHaveBeenCalledWith(testTransaction); - }); - - it('fetches the base fee from the network if not provided', async () => { - const { loadAccountSpy, getBaseFeeSpy } = getNetworkServiceSpies(); - loadAccountSpy.mockResolvedValue(testAccount); - getBaseFeeSpy.mockResolvedValue(new BigNumber(100)); - - const testTransaction = transactionBuilder.changeTrust({ - baseFee: '100', - scope, - asset: testAsset, - account: testWalletWithSigner, - }); - - const { rebuildTransactionSpy } = getTransactionBuilderSpies(); - rebuildTransactionSpy.mockReturnValue(testTransaction); - - await walletService.signTransaction({ - account: testWalletWithSigner, - scope, - transaction: testTransaction, - }); - - expect(rebuildTransactionSpy).toHaveBeenCalledWith({ - transaction: testTransaction, - account: testAccount, - baseFee: '100', - }); - expect(getBaseFeeSpy).toHaveBeenCalledWith(scope); }); - it('throws a WalletServiceException if signing the transaction fails', async () => { - const { loadAccountSpy } = getNetworkServiceSpies(); - const { signTransactionSpy } = getWalletSpies(); - loadAccountSpy.mockResolvedValue(testAccount); - signTransactionSpy.mockImplementation(() => { - throw new Error('Failed to sign transaction'); - }); - - const testTransaction = transactionBuilder.changeTrust({ - baseFee: '100', - scope, - asset: testAsset, - account: testWalletWithSigner, - }); - - const { rebuildTransactionSpy } = getTransactionBuilderSpies(); - rebuildTransactionSpy.mockReturnValue(testTransaction); + it('throws a WalletServiceException if the keypair derivation fails', async () => { + jest + .mocked(getBip32Entropy) + .mockRejectedValue(new Error('something went wrong')); + const kp = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + const account = generateStellarKeyringAccount( + globalThis.crypto.randomUUID(), + kp.publicKey(), + 'entropy-source-1', + 0, + ); - await expect( - walletService.signTransaction({ - account: testWalletWithSigner, - scope, - transaction: testTransaction, - baseFee: new BigNumber(100), - }), - ).rejects.toThrow(WalletServiceException); + await expect(walletService.resolveWallet(account)).rejects.toThrow( + WalletServiceException, + ); }); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/WalletService.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/WalletService.ts index 72e0aa8c..4f6df0bb 100644 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/WalletService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/wallet/WalletService.ts @@ -1,66 +1,29 @@ +import { hexToBytes } from '@metamask/utils'; import { Keypair as StellarKeypair } from '@stellar/stellar-sdk'; -import type { IDeriver } from './api'; -import { - AccountNotActivatedException, - WalletServiceException, -} from './exceptions'; -import type { NetworkService } from './NetworkService'; -import type { Transaction } from './Transaction'; -import type { TransactionBuilder } from './TransactionBuilder'; +import type { StellarKeyringAccount } from '../account'; +import { WalletServiceException } from './exceptions'; +import { getDerivationPath } from './utils'; import { Wallet } from './Wallet'; -import type { KnownCaip2ChainId } from '../../api'; -import { createPrefixedLogger } from '../../utils'; +import { STELLAR_CURVE } from '../../constants'; +import { + createPrefixedLogger, + bufferToUint8Array, + getBip32Entropy, + sanitizeSensitiveError, +} from '../../utils'; import type { ILogger } from '../../utils'; +import { assertSameAddress } from '../account/utils'; /** - * Orchestrates wallet operations: address derivation, activated account resolution, - * activation checks, and transaction signing. Delegates network I/O to {@link NetworkService} - * and transaction building to {@link TransactionBuilder}. + * Derives Stellar signing material from keyring entropy. + * Network / on-chain account access lives in {@link OnChainAccountService} and {@link NetworkService}. */ export class WalletService { readonly #logger: ILogger; - readonly #deriver: IDeriver; - - readonly #networkService: NetworkService; - - readonly #transactionBuilder: TransactionBuilder; - - constructor({ - logger, - deriver, - networkService, - transactionBuilder, - }: { - logger: ILogger; - deriver: IDeriver; - networkService: NetworkService; - transactionBuilder: TransactionBuilder; - }) { + constructor({ logger }: { logger: ILogger }) { this.#logger = createPrefixedLogger(logger, '[💼 WalletService]'); - - this.#deriver = deriver; - this.#networkService = networkService; - this.#transactionBuilder = transactionBuilder; - } - - /** - * The transaction builder for creating and rebuilding Stellar transactions. - * - * @returns The {@link TransactionBuilder} instance. - */ - get builder(): TransactionBuilder { - return this.#transactionBuilder; - } - - /** - * The network service for fees, account loading, and transaction submission. - * - * @returns The {@link NetworkService} instance. - */ - get network(): NetworkService { - return this.#networkService; } /** @@ -82,101 +45,20 @@ export class WalletService { } /** - * Loads an activated Stellar account (funded on the network) and returns a {@link Wallet} handle. + * Builds a signing {@link Wallet} for a keyring row; verifies derived public key matches stored address. * - * @param params - Options object. - * @param params.scope - The CAIP-2 chain ID. - * @param params.entropySource - The entropy source ID. - * @param params.index - The derivation index. - * @returns A Promise that resolves to a wallet with loaded account and signer. - * @throws {AccountNotActivatedException} If the account does not exist or is not funded on the network. + * @param account - Keyring account (entropy source + index + expected address). + * @returns A promise that resolves to the signing wallet. * @throws {WalletServiceException} If keypair derivation fails. + * @throws When derived public key does not match the keyring address (`DerivedAccountAddressMismatchException`). */ - async resolveActivatedAccount(params: { - scope: KnownCaip2ChainId; - entropySource: string; - index: number; - }): Promise { - const { scope, entropySource, index } = params; + async resolveWallet(account: StellarKeyringAccount): Promise { const keypair = await this.#deriveKeypair({ - index, - entropySource, + index: account.index, + entropySource: account.entropySource, }); - - const loadedAccount = await this.#networkService.loadAccount( - keypair.publicKey(), - scope, - ); - - return new Wallet(loadedAccount, keypair); - } - - /** - * Returns whether the given address has an activated account on the network. - * - * @param params - Options object. - * @param params.address - The Stellar account address (public key). - * @param params.scope - The CAIP-2 chain ID. - * @returns A Promise that resolves to `true` if the account exists and is funded, `false` if not found. Rethrows other errors (e.g. {@link AccountLoadException}). - */ - async isAccountActivated(params: { - address: string; - scope: KnownCaip2ChainId; - }): Promise { - const { address, scope } = params; - try { - await this.#networkService.loadAccount(address, scope); - return true; - } catch (error: unknown) { - if (error instanceof AccountNotActivatedException) { - return false; - } - throw error; - } - } - - /** - * Signs a transaction with the wallet's signer. Uses the current network sequence for the source account. - * - * @param params - Options object. - * @param params.account - The wallet to sign the transaction with. - * @param params.scope - The CAIP-2 chain ID. - * @param params.baseFee - [optional] The base fee to use for the transaction; if omitted, fetched from the network. - * @param params.transaction - The transaction to sign. - * @returns A Promise that resolves when the transaction has been signed. - * @throws {AccountNotActivatedException} If the account is not found on the network. - * @throws {AccountLoadException} If loading the account fails for another reason. - * @throws {WalletServiceException} If signing fails. - */ - async signTransaction(params: { - account: Wallet; - scope: KnownCaip2ChainId; - baseFee?: BigNumber; - transaction: Transaction; - }): Promise { - const { account, scope, baseFee, transaction } = params; - - let baseFeeToUse = baseFee; - baseFeeToUse ??= await this.#networkService.getBaseFee(scope); - - // Load fresh account for latest sequence number - const freshAccount = await this.#networkService.loadAccount( - account.account.accountId(), - scope, - ); - - const txToSign = this.builder.rebuildTransaction({ - transaction, - account: freshAccount, - baseFee: baseFeeToUse.toString(), - }); - - try { - account.signTransaction(txToSign); - } catch (error) { - this.#logger.logErrorWithDetails('Error signing transaction', error); - throw new WalletServiceException('Failed to sign transaction'); - } + assertSameAddress(account.address, keypair.publicKey()); + return new Wallet(keypair); } async #deriveKeypair({ @@ -187,11 +69,32 @@ export class WalletService { entropySource: string; }): Promise { try { - const seed = await this.#deriver.get32ByteSeed(index, entropySource); - return StellarKeypair.fromRawEd25519Seed(seed as Buffer); + const seed = await this.#getSeed(index, entropySource); + return StellarKeypair.fromRawEd25519Seed(bufferToUint8Array(seed)); } catch (error: unknown) { this.#logger.logErrorWithDetails('Error deriving keypair', error); throw new WalletServiceException('Failed to derive keypair'); } } + + async #getSeed(index: number, entropySource: string): Promise { + try { + const derivationPath = getDerivationPath(index); + const path = derivationPath.split('/'); + const node = await getBip32Entropy({ + entropySource, + path, + curve: STELLAR_CURVE, + }); + if (!node.privateKey || !node.publicKey) { + throw new Error('Unable to derive private key or public key'); + } + const privateKeyBytes = hexToBytes(node.privateKey); + return privateKeyBytes; + } catch (error) { + this.#logger.logErrorWithDetails('Error getting seed', error); + + throw sanitizeSensitiveError(error as Error); + } + } } diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/__mocks__/fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/__mocks__/fixtures.ts deleted file mode 100644 index 921879f1..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/__mocks__/fixtures.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Keypair } from '@stellar/stellar-sdk'; - -export const generateStellarAddress = () => Keypair.random().publicKey(); diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/__mocks__/wallet.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/__mocks__/wallet.fixtures.ts new file mode 100644 index 00000000..1d85220c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/wallet/__mocks__/wallet.fixtures.ts @@ -0,0 +1,24 @@ +import { Keypair } from '@stellar/stellar-sdk'; + +import { bufferToUint8Array } from '../../../utils/buffer'; +import { Wallet } from '../Wallet'; + +export const generateStellarAddress = () => Keypair.random().publicKey(); + +export const getTestWallet = ({ + seed, + address, +}: { + seed?: Uint8Array; + address?: string; +} = {}): Wallet => { + let keypair: Keypair; + if (address) { + keypair = Keypair.fromPublicKey(address); + } else if (seed) { + keypair = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + } else { + keypair = Keypair.random(); + } + return new Wallet(keypair); +}; diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/api.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/api.ts index 8b2f80a4..d47962eb 100644 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/wallet/api.ts @@ -1,34 +1,2 @@ -/** - * Minimal account shape used by the wallet layer for building and rebuilding transactions. - * Implementations may wrap chain-specific account types (e.g. Stellar Horizon account). - */ -export type LoadedAccount = { - /** The Stellar account address (public key). */ - accountId(): string; - /** The current sequence number (used to set transaction source sequence). */ - sequenceNumber(): string; -}; - -/** - * Interface for deriving a 32-byte seed from a derivation index and entropy source. - * Used by {@link WalletService} to obtain keypair material without depending on a specific - * derivation implementation (e.g. BIP-32). - */ -export type IDeriver = { - /** - * @param index - The derivation index for the account. - * @param entropySource - The entropy source ID (e.g. from keyring). - * @returns A Promise that resolves to the 32-byte seed for Ed25519 keypair derivation. - */ - get32ByteSeed(index: number, entropySource: string): Promise; -}; - -/** - * Interface for a Stellar asset. - */ -export type Asset = { - /** The asset code. */ - code: string; - /** The asset issuer. */ - issuer: string; -}; +/** Stellar derivation path type (e.g. `m/44'/148'/0'`). */ +export type StellarDerivationPath = `m/44'/148'/${string}'`; diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/exceptions.ts index 690b3780..55dbc416 100644 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/wallet/exceptions.ts @@ -1,66 +1,37 @@ -import type { KnownCaip2ChainId } from '../../api'; - -/** Base for all network-related errors (fees, account load, send, poll). */ -export class NetworkServiceException extends Error { +/** Base for {@link WalletService} errors (currently keypair derivation from entropy). */ +export class WalletServiceException extends Error { constructor(message: string) { super(message); - this.name = 'NetworkServiceException'; - } -} - -/** Thrown when the base fee cannot be fetched from the network (e.g. Horizon unreachable). */ -export class BaseFeeFetchException extends NetworkServiceException { - constructor(scope: KnownCaip2ChainId) { - super(`Failed to fetch base fee for scope: ${scope}`); - } -} - -/** Thrown when transaction polling does not result in SUCCESS (e.g. failed or unknown status). */ -export class TransactionPollException extends NetworkServiceException { - constructor( - transactionHash: string, - status: string, - scope: KnownCaip2ChainId, - ) { - super( - `Failed to poll transaction: ${transactionHash} with status: ${status} for scope: ${scope}`, - ); - } -} - -/** Thrown when account data cannot be loaded (e.g. network error; not used for "account not found"). */ -export class AccountLoadException extends NetworkServiceException { - constructor(accountAddress: string, scope: KnownCaip2ChainId) { - super(`Failed to load account: ${accountAddress} for scope: ${scope}`); - } -} - -/** Thrown when the account does not exist or is not funded on the network. */ -export class AccountNotActivatedException extends NetworkServiceException { - constructor(address: string, scope: KnownCaip2ChainId) { - super(`Account not activated for address: ${address} for scope: ${scope}`); + this.name = 'WalletServiceException'; } } -/** Thrown when transaction submission to the network fails. */ -export class TransactionSendException extends NetworkServiceException { - constructor(scope: KnownCaip2ChainId) { - super(`Failed to send transaction: scope: ${scope}`); +/** + * Thrown when the transaction cannot be signed. + */ +export class SignTransactionException extends Error { + constructor() { + super('Failed to sign transaction'); + this.name = 'SignTransactionException'; } } -/** Base for wallet service errors (derivation, signing). */ -export class WalletServiceException extends Error { - constructor(message: string) { - super(message); - this.name = 'WalletServiceException'; +/** + * Thrown when the message cannot be signed. + */ +export class SignMessageException extends Error { + constructor() { + super('Failed to sign message'); + this.name = 'SignMessageException'; } } -/** Thrown when building or rebuilding a transaction fails (e.g. invalid asset or SDK error). */ -export class TransactionBuilderException extends Error { - constructor(message: string) { - super(message); - this.name = 'TransactionBuilderException'; +/** + * Thrown when the message cannot be verified. + */ +export class VerifyMessageException extends Error { + constructor() { + super('Failed to verify message'); + this.name = 'VerifyMessageException'; } } diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/index.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/index.ts index 67707e84..b6a9305f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/index.ts +++ b/merged-packages/stellar-wallet-snap/src/services/wallet/index.ts @@ -1,7 +1,5 @@ -export * from './NetworkService'; -export * from './TransactionBuilder'; -export * from './Transaction'; export * from './Wallet'; export * from './WalletService'; export type * from './api'; export * from './exceptions'; +export * from './utils'; diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/utils.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/utils.ts index 93bc8819..d0c39d41 100644 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/services/wallet/utils.ts @@ -1,68 +1,12 @@ -import type { CaipAssetId } from '@metamask/utils'; -import { parseCaipAssetType } from '@metamask/utils'; -import { Asset, Networks } from '@stellar/stellar-sdk'; - -import type { KnownCaip19AssetId, KnownCaip19Slip44Id } from '../../api'; -import { KnownCaip2ChainId } from '../../api'; -import { isSlip44Id } from '../../utils'; - -const StellarNetwork: Record = { - [KnownCaip2ChainId.Mainnet]: Networks.PUBLIC, - [KnownCaip2ChainId.Testnet]: Networks.TESTNET, -}; - -/** - * Returns the Stellar network passphrase for the given scope (e.g. for transaction building). - * - * @param caip2ChainId - The CAIP-2 chain ID. - * @returns The Stellar Networks passphrase. - * @throws {Error} If the scope is not supported. - */ -export function getNetwork(caip2ChainId: KnownCaip2ChainId): Networks { - if (!(caip2ChainId in StellarNetwork)) { - throw new Error(`Network not found for caip2ChainId: ${caip2ChainId}`); - } - return StellarNetwork[caip2ChainId]; -} +import type { StellarDerivationPath } from './api'; +import { STELLAR_DERIVATION_PATH_PREFIX } from '../../constants'; /** - * Resolves a Stellar network passphrase to the corresponding CAIP-2 chain ID. + * Returns the Stellar BIP32 derivation path for the given index (e.g. `m/44'/148'/0'`). * - * @param network - The network name or Stellar Networks enum value. - * @returns The CAIP-2 chain ID for the network. - * @throws {Error} If the network is not recognized. + * @param index - The derivation index (account number). + * @returns The derivation path string. */ -export function getCaip2ChainId(network: string | Networks): KnownCaip2ChainId { - const networkValue = - typeof network === 'string' ? (network as Networks) : network; - const caip2ChainId = ( - Object.keys(StellarNetwork) as KnownCaip2ChainId[] - ).find((key) => StellarNetwork[key] === networkValue); - if (!caip2ChainId) { - throw new Error(`Caip2ChainId not found for network: ${network}`); - } - return caip2ChainId; -} - -/** - * Returns the Stellar asset for the given CAIP-19 asset ID. - * - * @param caip19AssetId - The CAIP-19 asset ID. - * @returns The Stellar asset. - * @throws {Error} If the asset is not recognized. - */ -export function getStellarAsset( - caip19AssetId: KnownCaip19AssetId | KnownCaip19Slip44Id, -): Asset { - if (isSlip44Id(caip19AssetId)) { - return new Asset('native'); - } - - const { assetReference } = parseCaipAssetType(caip19AssetId as CaipAssetId); - - const [assetCode, assetIssuer] = assetReference.split('-'); - if (!assetCode || !assetIssuer) { - throw new Error(`Invalid asset reference: ${assetReference}`); - } - return new Asset(assetCode, assetIssuer); +export function getDerivationPath(index: number): StellarDerivationPath { + return `${STELLAR_DERIVATION_PATH_PREFIX}/${index}'`; } From 9da5187fad410d6361d23507dc4ce17103886271 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:35:39 +0800 Subject: [PATCH 036/384] chore: remove unuse service --- .../stellar-wallet-snap/jest.config.js | 8 +- .../AccountBalanceRepository.ts | 53 ----- .../account-balance/AccountBalanceService.ts | 211 ------------------ .../src/services/account-balance/index.ts | 2 - 4 files changed, 4 insertions(+), 270 deletions(-) delete mode 100644 merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceRepository.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceService.ts diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index 7f6792d2..cc217fef 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 79.92, - functions: 89.87, - lines: 91.1, - statements: 91, + branches: 71.19, + functions: 77.33, + lines: 81.64, + statements: 81.71, }, }, diff --git a/merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceRepository.ts b/merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceRepository.ts deleted file mode 100644 index 88f9af12..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceRepository.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { - AccountBalance, - AccountBalanceRecord, - AccountBalanceState, -} from './api'; -import type { State } from '../state/State'; - -export class AccountBalanceRepository { - readonly #state: State; - - readonly #stateKey = 'accountBalances'; - - constructor(state: State) { - this.#state = state; - } - - async findByAccountId( - accountId: string, - ): Promise { - const raw = await this.#state.getKey( - `${this.#stateKey}.${accountId}`, - ); - - return raw ?? null; - } - - async save(accountId: string, balances: AccountBalance): Promise { - await this.#state.setKey(`${this.#stateKey}.${accountId}`, { - balances, - persistedAt: Date.now(), - }); - } - - /** - * Writes one {@link AccountBalanceRecord} per keyring account via `snap_setState` (no full-state `update`). - * Replaces the stored `balances` map for each id with the given payload. - * - * @param accountBalances - Map of keyring account id → full per-asset balance snapshot for that account. - */ - async saveMany( - accountBalances: Record, - ): Promise { - const now = Date.now(); - await Promise.all( - Object.entries(accountBalances).map(async ([accountId, balances]) => - this.#state.setKey(`${this.#stateKey}.${accountId}`, { - balances, - persistedAt: now, - }), - ), - ); - } -} diff --git a/merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceService.ts b/merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceService.ts deleted file mode 100644 index f51075bd..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/account-balance/AccountBalanceService.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { AccountBalanceRepository } from './AccountBalanceRepository'; -import type { AccountBalance } from './api'; -import { type KnownCaip2ChainId } from '../../api'; -import { - createPrefixedLogger, - getSlip44AssetId, - isSep41Id, - batchesAllSettled, - type ILogger, -} from '../../utils'; -import type { AssetMetadata, AssetMetadataService } from '../asset-metadata'; -import type { NetworkService } from '../network'; -import type { OnChainAccount } from '../on-chain-account'; -import type { SynchronizeAccountPairs } from '../synchronize/api'; - -export class AccountBalanceService { - readonly #assetMetadataService: AssetMetadataService; - - readonly #accountBalanceRepository: AccountBalanceRepository; - - readonly #networkService: NetworkService; - - readonly #logger: ILogger; - - static readonly rpcFetchBatchSize = 10; - - constructor({ - assetMetadataService, - accountBalanceRepository, - networkService, - logger, - }: { - assetMetadataService: AssetMetadataService; - accountBalanceRepository: AccountBalanceRepository; - networkService: NetworkService; - logger: ILogger; - }) { - this.#assetMetadataService = assetMetadataService; - this.#networkService = networkService; - this.#accountBalanceRepository = accountBalanceRepository; - this.#logger = createPrefixedLogger(logger, '[💰 AccountBalanceService]'); - } - - /** - * Gets the balances for a given account id. - * - * @param accountId - The id of the account to get the balances for. - * @returns A promise that resolves to the persisted {@link AccountBalance} map, or `null` if none. - */ - async getBalancesByAccountId( - accountId: string, - ): Promise { - const balances = - await this.#accountBalanceRepository.findByAccountId(accountId); - if (!balances) { - return null; - } - return balances.balances; - } - - /** - * Persists balances using accounts already loaded via {@link NetworkService.loadOnChainAccount} - * (native, trustlines, and SEP-41 token queries only — no second account load). - * - * @param pairs - Keyring rows paired with their Horizon `OnChainAccount`. - * @param scope - CAIP-2 network the `loaded` accounts were fetched from. - */ - async synchronize( - pairs: SynchronizeAccountPairs[], - scope: KnownCaip2ChainId, - ): Promise { - try { - if (pairs.length === 0) { - return; - } - - // assume Horizon API already loaded trustlines assets for the accounts, - // so we only need to fetch SEP-41 token balances - const assets = - await this.#assetMetadataService.getAllSep41AssetsMetadata(scope); - - const results = await Promise.allSettled( - pairs.map(async (pair) => { - // 1. Horizon `loadOnChainAccount`: native + classic trustlines (no extra account fetch). - const fromOnChainAccount = - this.#synchronizeBalancesFromOnChainAccount( - scope, - pair.onChainAccount, - ); - // 2. Soroban SEP-41 balances for configured assets. - const fromNetwork = await this.#synchronizeSep41BalancesFromNetwork( - scope, - assets, - pair.onChainAccount, - ); - return { ...fromOnChainAccount, ...fromNetwork }; - }), - ); - - const accountBalances: Record = {}; - - results.forEach((result, index) => { - const pair = pairs[index]; - if (pair === undefined) { - return; - } - if (result.status === 'fulfilled') { - accountBalances[pair.account.id] = result.value; - } else { - this.#logger.logErrorWithDetails( - 'Failed to synchronize balances for account', - { - accountId: pair.account.id, - error: result.reason, - }, - ); - } - }); - - // 3. Persist merged balances per keyring account. - await this.#accountBalanceRepository.saveMany(accountBalances); - } catch (error) { - // log error but continue the synchronization process - this.#logger.logErrorWithDetails('Failed to synchronize balances', { - error, - }); - } - } - - /** - * Native XLM + classic trustline balances from a single Horizon `loadOnChainAccount` result (no network I/O). - * - * @param scope - CAIP-2 chain id for native and classic asset id mapping. - * @param onChainAccount - Account state from Horizon (or equivalent) with balances and trustlines. - * @returns Partial {@link AccountBalance} for native XLM and classic assets only. Native `amount` is **raw** (total) stroops. - */ - #synchronizeBalancesFromOnChainAccount( - scope: KnownCaip2ChainId, - onChainAccount: OnChainAccount, - ): AccountBalance { - // Collect native balance. - const nativeAssetId = getSlip44AssetId(scope); - const balances: AccountBalance = { - [nativeAssetId]: { - unit: onChainAccount.getAsset(nativeAssetId).symbol, - // Collect raw native balance. - amount: onChainAccount.nativeRawBalance.toString(), - }, - }; - - // Collect classic trustline balances. - for (const assetId of onChainAccount.classicTrustlineAssetIds) { - const row = onChainAccount.getAsset(assetId); - balances[assetId] = { - unit: row.symbol, - // we store the limit and balance in stroops - amount: row.balance.toString(), - limit: row.limit?.toString() ?? '0', - ...(typeof row.authorized === 'boolean' - ? { authorized: row.authorized } - : {}), - ...(row.sponsored ? { sponsored: true } : {}), - }; - } - - return balances; - } - - /** - * Soroban SEP-41 token balances via RPC (in parallel per configured asset). - * - * @param scope - CAIP-2 chain id for RPC endpoints. - * @param sep41AssetsMetadata - SEP-41 assets to query balances for. - * @param onChainAccount - Account whose id and sequence are passed to balance RPC calls. - * @returns Partial {@link AccountBalance} for SEP-41 tokens only. - */ - async #synchronizeSep41BalancesFromNetwork( - scope: KnownCaip2ChainId, - sep41AssetsMetadata: AssetMetadata[], - onChainAccount: OnChainAccount, - ): Promise { - const balances: AccountBalance = {}; - - await batchesAllSettled( - sep41AssetsMetadata, - AccountBalanceService.rpcFetchBatchSize, - async (metadata) => { - const { assetId } = metadata; - if (!isSep41Id(assetId)) { - return; - } - const balance = await this.#networkService.getSep41TokenBalance({ - accountAddress: onChainAccount.accountId, - assetId, - scope, - sequenceNumber: onChainAccount.sequenceNumber, - }); - // skip non-trustline assets if the balance is 0 - if (balance.isEqualTo(0)) { - return; - } - balances[assetId] = { - unit: metadata.symbol, - amount: balance.toString(), - }; - }, - ); - - return balances; - } -} diff --git a/merged-packages/stellar-wallet-snap/src/services/account-balance/index.ts b/merged-packages/stellar-wallet-snap/src/services/account-balance/index.ts index ec10323c..6561443d 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account-balance/index.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account-balance/index.ts @@ -1,3 +1 @@ -export * from './AccountBalanceService'; -export * from './AccountBalanceRepository'; export type * from './api'; From 73220e30477d9a1811fed0f488d15550610cab9d Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:52:11 +0800 Subject: [PATCH 037/384] fix: context --- .../stellar-wallet-snap/src/context.ts | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index c0e23aba..28a110b5 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -1,35 +1,35 @@ +import { assert, object } from '@metamask/superstruct'; + +import { AppConfig } from './config'; import { KeyringHandler } from './handlers'; -import { AccountService } from './services/account/AccountService'; -import { AccountsRepository } from './services/account/AccountsRepository'; -import { createAccountDeriver } from './services/account/derivation'; -import { State } from './services/state/State'; -import { NetworkService } from './services/wallet/NetworkService'; -import { TransactionBuilder } from './services/wallet/TransactionBuilder'; -import { WalletService } from './services/wallet/WalletService'; +import { AccountService, AccountsRepository } from './services/account'; +import type { AccountBalanceState } from './services/account-balance'; +import { NetworkService } from './services/network'; +import type { OnChainAccountSnapshotState } from './services/on-chain-account'; +import { OnChainAccountService } from './services/on-chain-account'; +import { State } from './services/state'; +import { WalletService } from './services/wallet'; import { logger } from './utils'; +assert(AppConfig, object()); + const state = new State({ encrypted: false, defaultState: { keyringAccounts: {}, + assets: {}, + transactions: {}, + accountBalances: {} as AccountBalanceState['accountBalances'], + accountMetadata: {} as OnChainAccountSnapshotState['accountMetadata'], }, }); const accountsRepository = new AccountsRepository(state); -const accountDeriver = createAccountDeriver(logger); - +/** ------------------------------ Services ------------------------------ */ const networkService = new NetworkService({ logger }); -const transactionBuilder = new TransactionBuilder({ - logger, -}); -const walletService = new WalletService({ - logger, - deriver: accountDeriver, - networkService, - transactionBuilder, -}); +const walletService = new WalletService({ logger }); const accountService = new AccountService({ logger, @@ -37,9 +37,16 @@ const accountService = new AccountService({ walletService, }); +const onChainAccountService = new OnChainAccountService({ + networkService, + accountService, +}); + +/** ------------------------------ Keyring Handler ------------------------------ */ const keyringHandler = new KeyringHandler({ logger, accountService, + onChainAccountService, }); export { keyringHandler }; From 6959431558c6a190ee263becf71388c8f417285e Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:08:04 +0800 Subject: [PATCH 038/384] feat: add sign txn and message --- .../stellar-wallet-snap/jest.config.js | 8 +- .../stellar-wallet-snap/src/api/index.ts | 1 - .../stellar-wallet-snap/src/api/multichain.ts | 8 - .../stellar-wallet-snap/src/context.ts | 59 +- .../stellar-wallet-snap/src/handlers/index.ts | 1 + .../src/handlers/keyring/index.ts | 2 + .../src/handlers/keyring/keyring.test.ts | 170 +++++ .../src/handlers/keyring/keyring.ts | 70 ++- .../src/handlers/keyring/signMessage.test.ts | 121 ++++ .../src/handlers/keyring/signMessage.ts | 68 ++ .../src/handlers/keyring/signTransaction.ts | 110 ++++ .../src/handlers/user-input/api.ts | 11 + .../src/handlers/user-input/index.ts | 2 + .../src/handlers/user-input/userInput.ts | 61 ++ .../stellar-wallet-snap/src/index.ts | 35 +- .../on-chain-account/OnChainAccountService.ts | 3 +- .../transaction/OperationMapper.test.ts | 161 +++++ .../services/transaction/OperationMapper.ts | 592 ++++++++++++++++++ .../transaction/TransactionBuilder.test.ts | 301 +++++++++ .../transaction/TransactionBuilder.ts | 431 +++++++++++++ .../transaction/TransactionRepository.ts | 74 +++ .../transaction/TransactionService.test.ts | 80 +++ .../transaction/TransactionService.ts | 188 ++++++ .../__mocks__/transaction.fixtures.ts | 126 ++++ .../src/services/transaction/index.ts | 6 + .../src/services/transaction/utils.ts | 98 +++ .../src/ui/confirmation/utils.ts | 82 +++ .../ConfirmSignMessage/ConfirmSignMessage.tsx | 110 ++++ .../views/ConfirmSignMessage/events.tsx | 50 ++ .../views/ConfirmSignMessage/render.test.tsx | 175 ++++++ .../views/ConfirmSignMessage/render.tsx | 64 ++ .../ConfirmSignTransaction.tsx | 222 +++++++ .../views/ConfirmSignTransaction/events.tsx | 50 ++ .../views/ConfirmSignTransaction/render.tsx | 43 ++ .../src/ui/images/icon.svg | 1 + .../src/ui/images/icon.tsx | 3 + 36 files changed, 3566 insertions(+), 21 deletions(-) delete mode 100644 merged-packages/stellar-wallet-snap/src/api/multichain.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/user-input/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/user-input/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/events.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/events.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/images/icon.svg create mode 100644 merged-packages/stellar-wallet-snap/src/ui/images/icon.tsx diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index cc217fef..0b2d517d 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 71.19, - functions: 77.33, - lines: 81.64, - statements: 81.71, + branches: 59.78, + functions: 75.42, + lines: 77.34, + statements: 77.48, }, }, diff --git a/merged-packages/stellar-wallet-snap/src/api/index.ts b/merged-packages/stellar-wallet-snap/src/api/index.ts index 930b6404..e3a89147 100644 --- a/merged-packages/stellar-wallet-snap/src/api/index.ts +++ b/merged-packages/stellar-wallet-snap/src/api/index.ts @@ -8,4 +8,3 @@ export * from './address'; export * from './json'; export * from './integer'; export * from './xdr'; -export * from './multichain'; diff --git a/merged-packages/stellar-wallet-snap/src/api/multichain.ts b/merged-packages/stellar-wallet-snap/src/api/multichain.ts deleted file mode 100644 index 793528b3..00000000 --- a/merged-packages/stellar-wallet-snap/src/api/multichain.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { enums } from '@metamask/superstruct'; - -export enum MultichainMethod { - SignMessage = 'signMessage', - SignTransaction = 'signTransaction', -} - -export const MultichainMethodStruct = enums(Object.values(MultichainMethod)); diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 28a110b5..2d9ebaa7 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -2,12 +2,24 @@ import { assert, object } from '@metamask/superstruct'; import { AppConfig } from './config'; import { KeyringHandler } from './handlers'; +import type { IKeyringRequestHandler } from './handlers/keyring'; +import { + MultichainMethod, + SignMessageHandler, + SignTransactionHandler, +} from './handlers/keyring'; +import { UserInputHandler } from './handlers/user-input/userInput'; import { AccountService, AccountsRepository } from './services/account'; import type { AccountBalanceState } from './services/account-balance'; import { NetworkService } from './services/network'; import type { OnChainAccountSnapshotState } from './services/on-chain-account'; import { OnChainAccountService } from './services/on-chain-account'; import { State } from './services/state'; +import { + TransactionBuilder, + TransactionRepository, + TransactionService, +} from './services/transaction'; import { WalletService } from './services/wallet'; import { logger } from './utils'; @@ -25,10 +37,13 @@ const state = new State({ }); const accountsRepository = new AccountsRepository(state); +const transactionRepository = new TransactionRepository(state); /** ------------------------------ Services ------------------------------ */ const networkService = new NetworkService({ logger }); - +const transactionBuilder = new TransactionBuilder({ + logger, +}); const walletService = new WalletService({ logger }); const accountService = new AccountService({ @@ -42,11 +57,51 @@ const onChainAccountService = new OnChainAccountService({ accountService, }); +const transactionService = new TransactionService({ + logger, + transactionRepository, + networkService, +}); + /** ------------------------------ Keyring Handler ------------------------------ */ + +const signTransactionHandler = new SignTransactionHandler({ + logger, + accountService, + onChainAccountService, + walletService, + transactionBuilder, + transactionService, +}); + +const signMessageHandler = new SignMessageHandler({ + logger, + accountService, + onChainAccountService, + walletService, +}); + +const keyringMethodHandlers: Record = + { + [MultichainMethod.SignTransaction]: signTransactionHandler, + [MultichainMethod.SignMessage]: signMessageHandler, + }; + const keyringHandler = new KeyringHandler({ logger, accountService, onChainAccountService, + transactionService, + handlers: keyringMethodHandlers, +}); + +const userInputHandler = new UserInputHandler({ + logger, }); -export { keyringHandler }; +export { + keyringHandler, + userInputHandler, + signTransactionHandler, + signMessageHandler, +}; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/index.ts b/merged-packages/stellar-wallet-snap/src/handlers/index.ts index 2e3cb47a..a82e968d 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/index.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/index.ts @@ -1 +1,2 @@ export * from './keyring/keyring'; +export * from './user-input/userInput'; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/index.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/index.ts index 31cc4e70..68bba9c8 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/index.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/index.ts @@ -1,3 +1,5 @@ export * from './api'; export * from './base'; +export * from './signMessage'; +export * from './signTransaction'; export * from './keyring'; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 920ca95b..12b92b04 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -11,6 +11,7 @@ import { import { InvalidParamsError, type JsonRpcRequest } from '@metamask/snaps-sdk'; import { MultichainMethod } from './api'; +import type { IKeyringRequestHandler } from './base'; import { KeyringHandler } from './keyring'; import { KnownCaip2ChainId } from '../../api'; import { KEYRING_ACCOUNT_TYPE } from '../../constants'; @@ -22,11 +23,16 @@ import { generateMockStellarKeyringAccounts } from '../../services/account/__moc import { AccountNotFoundException } from '../../services/account/exceptions'; import { OnChainAccountService } from '../../services/on-chain-account'; import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; +import { + createMockTransactionService, + generateMockTransactions, +} from '../../services/transaction/__mocks__/transaction.fixtures'; import { getSlip44AssetId, getDefaultEntropySource, getSnapProvider, } from '../../utils'; +import { bufferToUint8Array } from '../../utils/buffer'; import { logger } from '../../utils/logger'; jest.mock('../../utils/logger'); @@ -45,6 +51,8 @@ describe('KeyringHandler', () => { let keyringHandler: KeyringHandler; let mockAccount: StellarKeyringAccount; let mockAccountId: string; + let mockSignMessageHandler: IKeyringRequestHandler; + let mockSignTransactionHandler: IKeyringRequestHandler; const toKeyringAccount = (account: StellarKeyringAccount): KeyringAccount => { const { id, address, type, options, methods, scopes } = account; @@ -74,12 +82,21 @@ describe('KeyringHandler', () => { jest.clearAllMocks(); jest.mocked(getDefaultEntropySource).mockResolvedValue(entropySourceId); + mockSignMessageHandler = { handle: jest.fn() }; + mockSignTransactionHandler = { handle: jest.fn() }; + const { accountService, onChainAccountService } = mockOnChainAccountService(); + const { transactionService } = createMockTransactionService(); keyringHandler = new KeyringHandler({ logger, accountService, onChainAccountService, + transactionService, + handlers: { + [MultichainMethod.SignMessage]: mockSignMessageHandler, + [MultichainMethod.SignTransaction]: mockSignTransactionHandler, + }, }); mockAccount = generateMockStellarKeyringAccounts( @@ -255,6 +272,63 @@ describe('KeyringHandler', () => { }); }); + describe('listAccountTransactions', () => { + it('lists the account transactions', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ + account: mockAccount, + }); + const { transactionServiceFindByAccountsSpy } = + createMockTransactionService(); + const mockTransactions = generateMockTransactions(10, { + account: mockAccountId, + scope: KnownCaip2ChainId.Mainnet, + fromAddress: mockAccount.address, + }); + transactionServiceFindByAccountsSpy.mockResolvedValue(mockTransactions); + + const result = await keyringHandler.listAccountTransactions( + mockAccountId, + { + limit: 10, + }, + ); + + expect(result).toStrictEqual({ + data: mockTransactions, + next: null, + }); + }); + + it('lists the account transactions with pagination', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ + account: mockAccount, + }); + const { transactionServiceFindByAccountsSpy } = + createMockTransactionService(); + const mockTransactions = generateMockTransactions(30, { + account: mockAccountId, + scope: KnownCaip2ChainId.Mainnet, + fromAddress: mockAccount.address, + }); + transactionServiceFindByAccountsSpy.mockResolvedValue(mockTransactions); + + const result = await keyringHandler.listAccountTransactions( + mockAccountId, + { + limit: 5, + next: mockTransactions[5]?.id, + }, + ); + + expect(result).toStrictEqual({ + data: mockTransactions.slice(5, 10), + next: mockTransactions[10]?.id, + }); + }); + }); + describe('discoverAccounts', () => { it('discovers an account', async () => { jest @@ -461,4 +535,100 @@ describe('KeyringHandler', () => { ); }); }); + + describe('submitRequest', () => { + const keyringRequestId = '22222222-2222-4222-8222-222222222222'; + + it('submits a sign message request', async () => { + const expectedResult = { + signature: bufferToUint8Array( + 'Stellar Signed Message: Hello, world!', + 'utf8', + ).toString('base64'), + }; + + jest + .mocked(mockSignMessageHandler.handle) + .mockResolvedValue(expectedResult); + + const signMessagePayload = { + id: keyringRequestId, + origin: 'metamask', + request: { + method: MultichainMethod.SignMessage, + params: { message: 'Hello, world!' }, + }, + scope: KnownCaip2ChainId.Mainnet, + account: mockAccountId, + }; + + const result = await keyringHandler.submitRequest(signMessagePayload); + + expect(mockSignMessageHandler.handle).toHaveBeenCalledTimes(1); + expect(mockSignMessageHandler.handle).toHaveBeenCalledWith( + signMessagePayload, + ); + expect(mockSignTransactionHandler.handle).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + pending: false, + result: expectedResult, + }); + }); + + it('submits a sign transaction request', async () => { + const xdr = `AAAAAgAAAADjngeX0YTNoQ15A0xC83aMm/sDnXrmLF+apmXvdmkUugAAAGQAC3gAAAAAQQAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAOZfkjSFZ31vI/Nx28cC6iAFWLWcPIvJhM2NVoxmfgVTAAAAAAAAAAAAmJaAAAAAAAAAAAA=`; + + const expectedResult = { + signature: bufferToUint8Array( + `Stellar Signed transaction: ${xdr}`, + 'utf8', + ).toString('base64'), + }; + + jest + .mocked(mockSignTransactionHandler.handle) + .mockResolvedValue(expectedResult); + + const signTransactionPayload = { + id: keyringRequestId, + origin: 'metamask', + request: { + method: MultichainMethod.SignTransaction, + params: { transaction: xdr }, + }, + scope: KnownCaip2ChainId.Mainnet, + account: mockAccountId, + }; + + const result = await keyringHandler.submitRequest(signTransactionPayload); + + expect(mockSignTransactionHandler.handle).toHaveBeenCalledTimes(1); + expect(mockSignTransactionHandler.handle).toHaveBeenCalledWith( + signTransactionPayload, + ); + expect(mockSignMessageHandler.handle).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + pending: false, + result: expectedResult, + }); + }); + + it('throws an error if the request is invalid', async () => { + await expect( + keyringHandler.submitRequest({ + id: keyringRequestId, + origin: 'metamask', + request: { + method: 'invalid:method' as MultichainMethod, + params: { message: 'Hello, world!' }, + }, + scope: KnownCaip2ChainId.Mainnet, + account: mockAccountId, + }), + ).rejects.toThrow(InvalidParamsError); + + expect(mockSignMessageHandler.handle).not.toHaveBeenCalled(); + expect(mockSignTransactionHandler.handle).not.toHaveBeenCalled(); + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index b00d34ac..9d26b689 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -35,16 +35,19 @@ import { DeleteAccountRequestStruct, DiscoverAccountsStruct, GetAccountRequestStruct, + ListAccountTransactionsRequestStruct, MultichainMethodStruct, ResolveAccountAddressRequestStruct, SetSelectedAccountsRequestStruct, } from './api'; +import type { IKeyringRequestHandler } from './base'; import type { KnownCaip2ChainId } from '../../api'; import type { AccountService, StellarKeyringAccount, } from '../../services/account'; import type { OnChainAccountService } from '../../services/on-chain-account'; +import type { TransactionService } from '../../services/transaction/TransactionService'; import type { ILogger } from '../../utils'; import { createPrefixedLogger, @@ -61,18 +64,28 @@ export class KeyringHandler implements Keyring { readonly #onChainAccountService: OnChainAccountService; + readonly #transactionService: TransactionService; + + readonly #handlers: Record; + constructor({ logger, accountService, onChainAccountService, + transactionService, + handlers, }: { logger: ILogger; accountService: AccountService; onChainAccountService: OnChainAccountService; + transactionService: TransactionService; + handlers: Record; }) { this.#logger = createPrefixedLogger(logger, '[🔑 KeyringHandler]'); this.#accountService = accountService; this.#onChainAccountService = onChainAccountService; + this.#transactionService = transactionService; + this.#handlers = handlers; } async handle(origin: string, request: JsonRpcRequest): Promise { @@ -180,7 +193,52 @@ export class KeyringHandler implements Keyring { data: Transaction[]; next: string | null; }> { - throw new Error('Method not implemented. - listAccountTransactions'); + try { + validateRequest( + { accountId, pagination }, + ListAccountTransactionsRequestStruct, + ); + + const { limit, next } = pagination; + + // we dont necessary to check if the account is activated + // because we are not fetching the transactions from the network. + const { account: keyringAccount } = + await this.#accountService.resolveAccount({ + accountId, + }); + + const transactions = await this.#transactionService.findByAccounts([ + keyringAccount, + ]); + + // Find the starting index based on the 'next' signature + const startIndex = next + ? transactions.findIndex((tx) => tx.id === next) + : 0; + + // Get transactions from startIndex to startIndex + limit + const accountTransactions = transactions.slice( + startIndex, + startIndex + limit, + ); + + // Determine the next signature for pagination + const hasMore = startIndex + pagination.limit < transactions.length; + const nextSignature = hasMore + ? (transactions[startIndex + pagination.limit]?.id ?? null) + : null; + + return { + data: accountTransactions, + next: nextSignature, + }; + } catch (error: unknown) { + this.#logger.logErrorWithDetails('Error listing account transactions', error); + throw new Error( + `Error listing account transactions: ${ensureError(error).message}`, + ); + } } async discoverAccounts( @@ -297,7 +355,15 @@ export class KeyringHandler implements Keyring { } async #handleSubmitRequest(request: KeyringRequest): Promise { - throw new Error('Method not implemented. - handleSubmitRequest'); + const { method } = request.request; + + this.#assertMethodIsValid(method); + + return this.#handlers[method].handle(request); + } + + #assertMethodIsValid(method: string): asserts method is MultichainMethod { + validateRequest(method, MultichainMethodStruct); } } /* eslint-enable @typescript-eslint/no-unused-vars */ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts new file mode 100644 index 00000000..c71100d4 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts @@ -0,0 +1,121 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; + +import { MultichainMethod, type SignMessageRequest } from './api'; +import { SignMessageHandler } from './signMessage'; +import { KnownCaip2ChainId } from '../../api'; +import type { StellarKeyringAccount } from '../../services/account'; +import { AccountService } from '../../services/account'; +import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; +import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; +import { WalletService } from '../../services/wallet'; +import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; +import { render as confirmSignMessageRender } from '../../ui/confirmation/views/ConfirmSignMessage/render'; +import { logger } from '../../utils/logger'; + +jest.mock('../../ui/confirmation/views/ConfirmSignMessage/render', () => ({ + render: jest.fn(), +})); + +jest.mock('../../utils/logger'); + +describe('SignMessageHandler', () => { + const keyringRequestId = '11111111-1111-4111-8111-111111111111'; + + const encodedMessage = btoa('hello stellar'); + + const buildRequest = ( + account: StellarKeyringAccount, + ): SignMessageRequest => ({ + id: keyringRequestId, + origin: 'https://example.com', + scope: KnownCaip2ChainId.Mainnet, + account: account.id, + request: { + method: MultichainMethod.SignMessage, + params: { message: encodedMessage }, + }, + }); + + /** + * Builds a {@link SignMessageHandler} with mocked account/wallet resolution. + * + * @returns Handler instance, resolved keyring account, and test wallet. + */ + function setupSignMessageHandler(): { + handler: SignMessageHandler; + mockAccount: StellarKeyringAccount; + wallet: ReturnType; + } { + const wallet = getTestWallet(); + const mockAccount = generateStellarKeyringAccount( + globalThis.crypto.randomUUID(), + wallet.address, + 'entropy-source-1', + 0, + ); + + const { accountService, onChainAccountService, walletService } = + mockOnChainAccountService(); + + jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ + account: mockAccount, + }); + + jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + + const handler = new SignMessageHandler({ + logger, + accountService, + onChainAccountService, + walletService, + }); + + return { handler, mockAccount, wallet }; + } + + it('returns signature when confirmation accepts', async () => { + const { handler, mockAccount, wallet } = setupSignMessageHandler(); + jest.mocked(confirmSignMessageRender).mockResolvedValue(true); + + const request = buildRequest(mockAccount); + const result = await handler.handle(request); + + const expectedSignature = await wallet.signMessage(encodedMessage); + + expect(confirmSignMessageRender).toHaveBeenCalledTimes(1); + expect(confirmSignMessageRender).toHaveBeenCalledWith(request, mockAccount); + expect(result).toStrictEqual({ signature: expectedSignature }); + }); + + it('throws when confirmation rejects', async () => { + const { handler, mockAccount } = setupSignMessageHandler(); + jest.mocked(confirmSignMessageRender).mockResolvedValue(false); + + const request = buildRequest(mockAccount); + + await expect(handler.handle(request)).rejects.toThrow( + UserRejectedRequestError, + ); + + expect(confirmSignMessageRender).toHaveBeenCalledWith(request, mockAccount); + }); + + it('rejects invalid requests before calling render', async () => { + const { handler, mockAccount } = setupSignMessageHandler(); + jest.mocked(confirmSignMessageRender).mockResolvedValue(true); + + await expect( + handler.handle({ + ...buildRequest(mockAccount), + request: { + method: MultichainMethod.SignMessage, + params: { message: '' }, + }, + }), + ).rejects.toThrow(/request\.params\.message/u); + + expect(confirmSignMessageRender).not.toHaveBeenCalled(); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts new file mode 100644 index 00000000..c2f1e3a7 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts @@ -0,0 +1,68 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; + +import type { + AccountService, + StellarKeyringAccount, +} from '../../services/account'; +import type { OnChainAccountService } from '../../services/on-chain-account'; +import type { WalletService } from '../../services/wallet'; +import type { ResolvedActivatedAccountFor } from '../base'; +import type { SignMessageRequest, SignMessageResponse } from './api'; +import { SignMessageRequestStruct, SignMessageResponseStruct } from './api'; +import { WithKeyringRequestActiveAccountResolve } from './base'; +import { render } from '../../ui/confirmation/views/ConfirmSignMessage/render'; +import type { ILogger } from '../../utils'; + +type SignMessageResolveOpts = { onChainAccount: false; wallet: true }; + +export class SignMessageHandler extends WithKeyringRequestActiveAccountResolve< + SignMessageRequest, + SignMessageResponse, + SignMessageResolveOpts +> { + constructor({ + logger, + accountService, + onChainAccountService, + walletService, + }: { + logger: ILogger; + accountService: AccountService; + onChainAccountService: OnChainAccountService; + walletService: WalletService; + }) { + super({ + logger, + accountService, + onChainAccountService, + walletService, + requestStruct: SignMessageRequestStruct, + responseStruct: SignMessageResponseStruct, + resolveAccountOptions: { onChainAccount: false }, + }); + } + + protected async _handle( + resolved: ResolvedActivatedAccountFor, + request: SignMessageRequest, + ): Promise { + const { wallet, account } = resolved; + + if (!(await this.#confrimation(request, account))) { + throw new UserRejectedRequestError() as unknown as Error; + } + + const { message } = request.request.params; + + const signature = await wallet.signMessage(message); + + return { signature }; + } + + async #confrimation( + request: SignMessageRequest, + account: StellarKeyringAccount, + ): Promise { + return (await render(request, account)) === true; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts new file mode 100644 index 00000000..fab095de --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts @@ -0,0 +1,110 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; +import { ensureError } from '@metamask/utils'; + +import type { + AccountService, + StellarKeyringAccount, +} from '../../services/account'; +import type { OnChainAccountService } from '../../services/on-chain-account'; +import type { ResolvedActivatedAccount } from '../base'; +import type { SignTransactionRequest, SignTransactionResponse } from './api'; +import { + SignTransactionRequestStruct, + SignTransactionResponseStruct, +} from './api'; +import { WithKeyringRequestActiveAccountResolve } from './base'; +import type { + TransactionBuilder, + Transaction, + TransactionService, +} from '../../services/transaction'; +import { + assertTransactionScope, + assertAccountInvolvesTransaction, +} from '../../services/transaction/utils'; +import type { WalletService } from '../../services/wallet'; +import { render } from '../../ui/confirmation/views/ConfirmSignTransaction/render'; +import type { ILogger } from '../../utils'; + +export class SignTransactionHandler extends WithKeyringRequestActiveAccountResolve< + SignTransactionRequest, + SignTransactionResponse +> { + readonly #transactionBuilder: TransactionBuilder; + + readonly #transactionService: TransactionService; + + constructor({ + logger, + accountService, + onChainAccountService, + walletService, + transactionBuilder, + transactionService, + }: { + logger: ILogger; + accountService: AccountService; + onChainAccountService: OnChainAccountService; + transactionService: TransactionService; + walletService: WalletService; + transactionBuilder: TransactionBuilder; + }) { + super({ + logger, + accountService, + onChainAccountService, + walletService, + requestStruct: SignTransactionRequestStruct, + responseStruct: SignTransactionResponseStruct, + resolveAccountOptions: { onChainAccount: false }, + }); + this.#transactionBuilder = transactionBuilder; + this.#transactionService = transactionService; + } + + protected async _handle( + resolved: ResolvedActivatedAccount, + request: SignTransactionRequest, + ): Promise { + const { wallet, account } = resolved; + const { scope } = request; + const { transaction: transactionBase64Xdr } = request.request.params; + + // Deserializing validates that the transaction is well-formed and scope-compatible. + // We intentionally skip balance and operation-level checks here; + // callers must validate those before requesting a signature. + const transaction = this.#transactionBuilder.deserialize({ + xdr: transactionBase64Xdr, + scope, + }); + + // verify the transaction scope matches the requested scope + assertTransactionScope(transaction, scope); + // The signer may not be the tx source of the transaction, + // but it must participate as fee source (fee bump), or op source. + // We gate signing to envelopes that involve this wallet. + assertAccountInvolvesTransaction(transaction, wallet.address); + + // Computing fee will inject the fee into the transaction + const transactionWithFee = + await this.#transactionService.computingFee(transaction); + + if (!(await this.#confirmation(request, transactionWithFee, account))) { + throw ensureError(new UserRejectedRequestError()); + } + + wallet.signTransaction(transactionWithFee); + + const signature = transactionWithFee.getRaw().toXDR(); + + return { signature }; + } + + async #confirmation( + request: SignTransactionRequest, + transaction: Transaction, + account: StellarKeyringAccount, + ): Promise { + return (await render(request, transaction, account)) === true; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/user-input/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/user-input/api.ts new file mode 100644 index 00000000..45075622 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/user-input/api.ts @@ -0,0 +1,11 @@ +import type { InterfaceContext, UserInputEvent } from '@metamask/snaps-sdk'; + +export type UserInputUiEventHandlerContext = { + id: string; + event: UserInputEvent; + context: InterfaceContext | null; +}; + +export type UserInputUiEventHandler = ( + options: UserInputUiEventHandlerContext, +) => Promise; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/user-input/index.ts b/merged-packages/stellar-wallet-snap/src/handlers/user-input/index.ts new file mode 100644 index 00000000..e9a0efd8 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/user-input/index.ts @@ -0,0 +1,2 @@ +export * from './userInput'; +export type * from './api'; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts new file mode 100644 index 00000000..2f85c365 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts @@ -0,0 +1,61 @@ +import type { InterfaceContext, UserInputEvent } from '@metamask/snaps-sdk'; + +import type { UserInputUiEventHandler } from './api'; +import { createEventHandlers as createSignMessageEvents } from '../../ui/confirmation/views/ConfirmSignMessage/events'; +import { createEventHandlers as createSignTransactionEvents } from '../../ui/confirmation/views/ConfirmSignTransaction/events'; +import { + withCatchAndThrowSnapError, + createPrefixedLogger, + type ILogger, +} from '../../utils'; + +export class UserInputHandler { + readonly #logger: ILogger; + + constructor({ logger }: { logger: ILogger }) { + this.#logger = createPrefixedLogger(logger, '[👵 LifecycleHandler]'); + } + + /** + * Handle user events requests. + * + * @param args - The request handler args as object. + * @param args.id - The interface id associated with the event. + * @param args.event - The event object. + * @param args.context - The context object. + * @returns A promise that resolves to a JSON object. + * @throws If the request method is not valid for this snap. + */ + async handle({ + id, + event, + context, + }: { + id: string; + event: UserInputEvent; + context: InterfaceContext | null; + }): Promise { + this.#logger.log('[👇 onUserInput]', id, event); + + if (!event.name) { + return; + } + const uiEventHandlers: Record = { + ...createSignMessageEvents(), + ...createSignTransactionEvents(), + }; + + /** + * Using the name of the event, route it to the correct handler + */ + const handler = uiEventHandlers[event.name]; + + if (!handler) { + return; + } + + await withCatchAndThrowSnapError(async () => + handler({ id, event, context }), + ); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/index.ts b/merged-packages/stellar-wallet-snap/src/index.ts index 45ee9703..811f49d2 100644 --- a/merged-packages/stellar-wallet-snap/src/index.ts +++ b/merged-packages/stellar-wallet-snap/src/index.ts @@ -1,8 +1,39 @@ -import type { OnKeyringRequestHandler } from '@metamask/snaps-sdk'; +import type { + OnUserInputHandler, + OnKeyringRequestHandler, + OnRpcRequestHandler, +} from '@metamask/snaps-sdk'; +import { MethodNotFoundError } from '@metamask/snaps-sdk'; +import type { JsonRpcRequest } from '@metamask/utils'; -import { keyringHandler } from './context'; +import { + keyringHandler, + signMessageHandler, + userInputHandler, + signTransactionHandler, +} from './context'; export const onKeyringRequest: OnKeyringRequestHandler = async ({ origin, request, }) => keyringHandler.handle(origin, request); + +export const onUserInput: OnUserInputHandler = async (params) => + userInputHandler.handle(params); + +export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { + const { method } = request; + + switch (method) { + case 'stellar_signMessage': + return signMessageHandler.handle( + request.params as unknown as JsonRpcRequest, + ); + case 'stellar_signTransaction': + return signTransactionHandler.handle( + request.params as unknown as JsonRpcRequest, + ); + default: + throw new MethodNotFoundError() as Error; + } +}; diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 4e56a4d5..bc313e96 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -7,8 +7,7 @@ import { assertSameAddress } from '../account/utils'; import type { NetworkService } from '../network'; /** - * Stellar on-chain account operations: activation checks, loading {@link OnChainAccount}, - * and persisting {@link OnChainAccountSnapshot} records for sync. + * Stellar on-chain account operations: activation checks, loading {@link OnChainAccount}. * * Signing keypairs are derived by {@link WalletService}; this service does not depend on it. */ diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts new file mode 100644 index 00000000..229514c4 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts @@ -0,0 +1,161 @@ +import { + AuthClawbackEnabledFlag, + AuthRequiredFlag, + AuthRevocableFlag, + Keypair, +} from '@stellar/stellar-sdk'; + +import { buildMockClassicTransaction } from './__mocks__/transaction.fixtures'; +import { OperationMapper } from './OperationMapper'; +import { KnownCaip2ChainId } from '../../api'; + +describe('OperationMapper', () => { + const mapper = new OperationMapper(); + + it('maps payment and changeTrust with JSON-serializable output', () => { + const dest = Keypair.random().publicKey(); + const issuer = Keypair.random().publicKey(); + const wrapped = buildMockClassicTransaction([ + { + type: 'payment', + params: { + destination: dest, + asset: 'native', + amount: '10', + }, + }, + { + type: 'changeTrust', + params: { + asset: { code: 'USD', issuer }, + limit: '1000', + }, + }, + ]); + + const json = mapper.mapTransaction(wrapped); + const txSource = wrapped.sourceAccount; + + expect(json.scope).toBe(KnownCaip2ChainId.Testnet); + // Builder `fee` is per operation; total is fee × operation count. + expect(json.feeStroops).toBe('400'); + expect(json.operationCount).toBe(2); + expect(() => JSON.stringify(json)).not.toThrow(); + + expect(json.operations[0]).toMatchObject({ + index: 0, + type: 'payment', + source: txSource, + explicitSource: null, + classic: true, + params: [ + { key: 'destination', value: dest, type: 'address' }, + { + key: 'asset', + type: 'assetWithAmount', + value: ['native', '10.0000000'], + }, + ], + }); + + expect(json.operations[1]).toMatchObject({ + index: 1, + type: 'changeTrust', + source: txSource, + explicitSource: null, + classic: true, + params: [ + { key: 'line', value: `USD:${issuer}`, type: 'text' }, + { key: 'limit', value: '1000.0000000', type: 'amount' }, + ], + }); + }); + + it('sets source and explicitSource when operation overrides source account', () => { + const txKp = Keypair.random(); + const opSourceKp = Keypair.random(); + const dest = Keypair.random().publicKey(); + const wrapped = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: opSourceKp.publicKey(), + destination: dest, + asset: 'native', + amount: '1', + }, + }, + ], + { + source: { accountId: txKp.publicKey(), sequence: '1' }, + baseFeePerOperation: '100', + }, + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.source).toBe(opSourceKp.publicKey()); + expect(op?.explicitSource).toBe(opSourceKp.publicKey()); + }); + + it('maps createAccount operation', () => { + const dest = Keypair.random().publicKey(); + const wrapped = buildMockClassicTransaction([ + { + type: 'createAccount', + params: { destination: dest, startingBalance: '5' }, + }, + ]); + + const [op] = mapper.mapTransaction(wrapped).operations; + expect(op).toBeDefined(); + expect(op?.source).toBe(wrapped.sourceAccount); + expect(op?.explicitSource).toBeNull(); + expect(op?.params).toStrictEqual([ + { key: 'destination', value: dest, type: 'address' }, + { key: 'startingBalance', value: '5.0000000', type: 'amount' }, + ]); + }); + + it('maps setOptions setFlags and clearFlags to readable flag labels', () => { + const wrapped = buildMockClassicTransaction([ + { + type: 'setOptions', + params: { + // eslint-disable-next-line no-bitwise -- combine disjoint AuthFlag bits + setFlags: AuthRequiredFlag | AuthClawbackEnabledFlag, + clearFlags: AuthRevocableFlag, + }, + }, + ]); + + const [op] = mapper.mapTransaction(wrapped).operations; + expect(op?.type).toBe('setOptions'); + expect(op?.params).toStrictEqual([ + { + key: 'clearFlags', + value: ['authRevocable'], + type: 'text', + }, + { + key: 'setFlags', + value: ['authRequired', 'authClawbackEnabled'], + type: 'text', + }, + ]); + }); + + it('maps unknown setOptions flag bits to unknown(0x…) suffix', () => { + const wrapped = buildMockClassicTransaction([ + { + type: 'setOptions', + params: { setFlags: 16 }, + }, + ]); + + const [op] = mapper.mapTransaction(wrapped).operations; + expect(op?.params).toStrictEqual([ + { key: 'setFlags', value: ['unknown(0x10)'], type: 'text' }, + ]); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts new file mode 100644 index 00000000..78a2e5cb --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts @@ -0,0 +1,592 @@ +import type { Json } from '@metamask/utils'; +import type { Asset, Operation } from '@stellar/stellar-sdk'; +import { LiquidityPoolAsset, LiquidityPoolId } from '@stellar/stellar-sdk'; + +import type { Transaction } from './Transaction'; +import type { KnownCaip2ChainId } from '../../api'; +import { bufferToUint8Array } from '../../utils'; + +/** + * Semantic hint for how a confirmation row should be rendered. + */ +export type ReadableFieldType = + | 'address' + | 'asset' + | 'assetWithAmount' + | 'amount' + | 'price' + | 'text' + | 'number' + | 'boolean' + | 'json'; + +/** + * One labeled value row for operation confirmation UI. + */ +export type ReadableOperationField = { + key: string; + value: Json; + type: ReadableFieldType; +}; + +/** + * One operation turned into plain data for UX or `JSON.stringify`. + */ +export type ReadableOperationJson = { + index: number; + type: string; + /** + * Resolved source account for this operation (`G…`): Stellar `operation.source` when set, + * otherwise the transaction source (same rule as the SDK). + */ + source: string; + /** + * Raw optional `source` from the Stellar operation; `null` when the op inherits the + * transaction source (not set on the XDR). + */ + explicitSource: string | null; + /** `false` for Soroban footprint / host ops (not expanded here). */ + classic: boolean; + /** Ordered rows for UX; each row has a stable `key` and semantic `type` for formatting. */ + params: ReadableOperationField[]; +}; + +/** + * Transaction-level envelope plus per-operation summaries. + */ +export type ReadableTransactionJson = { + scope: KnownCaip2ChainId; + feeStroops: string; + operationCount: number; + sourceAccount: string; + feeSourceAccount: string; + operations: ReadableOperationJson[]; +}; + +const SOROBAN_OPERATION_TYPES = new Set([ + 'invokeHostFunction', + 'extendFootprintTtl', + 'restoreFootprint', +]); + +/** Stellar account auth flags for {@link Operation.setOptions} `setFlags` / `clearFlags`. */ +const ACCOUNT_AUTH_FLAG_MASKS: readonly { mask: number; label: string }[] = [ + { mask: 1, label: 'authRequired' }, + { mask: 2, label: 'authRevocable' }, + { mask: 4, label: 'authImmutable' }, + { mask: 8, label: 'authClawbackEnabled' }, +]; + +/* eslint-disable no-bitwise -- Stellar AuthFlags are a uint32 bitmask */ +const KNOWN_ACCOUNT_AUTH_FLAGS_MASK = ACCOUNT_AUTH_FLAG_MASKS.reduce( + (acc, { mask }) => acc | mask, + 0, +); + +/** + * Turns a `setFlags` / `clearFlags` uint32 bitmask into comma-separated labels for UX. + * + * @param flags - Raw flag bits from the operation. + * @returns Labels, or `none` when the mask is zero; unknown bits become `unknown(0x…)`. + */ +function accountAuthFlagsMaskToText(flags: number): string[] { + const bits = flags >>> 0; + const parts: string[] = []; + for (const { mask, label } of ACCOUNT_AUTH_FLAG_MASKS) { + if ((bits & mask) !== 0) { + parts.push(label); + } + } + const unknown = bits & ~KNOWN_ACCOUNT_AUTH_FLAGS_MASK; + if (unknown !== 0) { + parts.push(`unknown(0x${unknown.toString(16)})`); + } + return parts; +} +/* eslint-enable no-bitwise */ + +/** + * Maps Stellar {@link Operation} values to plain JSON-friendly objects for signing UX. + */ +export class OperationMapper { + /** + * Builds a readable summary for every operation in the wrapped transaction. + * + * @param transaction - Snap {@link Transaction} wrapper. + * @returns Serializable summary. + */ + mapTransaction(transaction: Transaction): ReadableTransactionJson { + const { sourceAccount } = transaction; + const operations = transaction.transactionOperations.map( + (sdkOperation, index) => + this.mapOperation(sdkOperation, index, sourceAccount), + ); + + return { + scope: transaction.scope, + feeStroops: transaction.totalFee.toFixed(0), + operationCount: operations.length, + sourceAccount, + feeSourceAccount: transaction.feeSourceAccount, + operations, + }; + } + + /** + * Maps a single SDK operation. + * + * @param operation - Stellar SDK operation. + * @param index - Zero-based index in the transaction. + * @param transactionSource - Transaction source when `operation.source` is omitted. + * @returns Serializable operation summary. + */ + mapOperation( + operation: Operation, + index: number, + transactionSource: string, + ): ReadableOperationJson { + const { type } = operation; + const classic = !SOROBAN_OPERATION_TYPES.has(type); + const explicitSource = operation.source ?? null; + const source = operation.source ?? transactionSource; + + return { + index, + type, + source, + explicitSource, + classic, + params: classic + ? this.#mapClassicParams(operation) + : this.#mapSorobanPlaceholder(operation), + }; + } + + #mapSorobanPlaceholder(operation: Operation): ReadableOperationField[] { + if (operation.type === 'invokeHostFunction') { + const hostOp = operation; + let funcXdr: string | null = null; + try { + if (typeof hostOp.func?.toXDR === 'function') { + const raw = hostOp.func.toXDR(); + funcXdr = raw.toString('base64'); + } + } catch { + funcXdr = null; + } + const rows: ReadableOperationField[] = [ + this.#field( + 'note', + 'Soroban invokeHostFunction; review contract call on a block explorer or dedicated UI.', + 'text', + ), + ]; + if (funcXdr) { + rows.push(this.#field('hostFunctionXdrBase64', funcXdr, 'text')); + } + return rows; + } + if (operation.type === 'extendFootprintTtl') { + const extendOp = operation; + return [this.#field('extendTo', extendOp.extendTo, 'number')]; + } + if (operation.type === 'restoreFootprint') { + return [this.#field('note', 'Soroban restoreFootprint.', 'text')]; + } + return [ + this.#field( + 'note', + 'Soroban operation; expand separately if needed.', + 'text', + ), + ]; + } + + #mapClassicParams(operation: Operation): ReadableOperationField[] { + switch (operation.type) { + case 'payment': { + const payment = operation; + return [ + this.#field('destination', payment.destination, 'address'), + this.#field( + 'asset', + [payment.asset.toString(), payment.amount], + 'assetWithAmount', + ), + ]; + } + case 'createAccount': { + const createAccount = operation; + return [ + this.#field('destination', createAccount.destination, 'address'), + this.#field( + 'startingBalance', + createAccount.startingBalance, + 'amount', + ), + ]; + } + case 'changeTrust': { + const changeTrust = operation; + return [ + // we don't use assetWithAmount here because the line is not necessarily a classic asset + // and we are not sending amount here. + this.#field('line', this.#formatTrustLine(changeTrust.line), 'text'), + this.#field('limit', changeTrust.limit, 'amount'), + ]; + } + case 'accountMerge': { + const accountMerge = operation; + return [ + this.#field('destination', accountMerge.destination, 'address'), + ]; + } + case 'pathPaymentStrictReceive': { + const pathReceive = operation; + + return [ + this.#field( + 'sendAsset', + [pathReceive.sendAsset.toString(), pathReceive.sendMax], + 'assetWithAmount', + ), + this.#field('destination', pathReceive.destination, 'address'), + this.#field( + 'destAsset', + [pathReceive.destAsset.toString(), pathReceive.destAmount], + 'assetWithAmount', + ), + this.#field( + 'path', + pathReceive.path.map((asset) => asset.toString()), + 'json', + ), + ]; + } + case 'pathPaymentStrictSend': { + const pathSend = operation; + return [ + this.#field( + 'sendAsset', + [pathSend.sendAsset.toString(), pathSend.sendAmount], + 'assetWithAmount', + ), + this.#field('destination', pathSend.destination, 'address'), + this.#field( + 'destAsset', + [pathSend.destAsset.toString(), pathSend.destMin], + 'assetWithAmount', + ), + + this.#field( + 'path', + pathSend.path.map((asset) => asset.toString()), + 'json', + ), + ]; + } + case 'manageSellOffer': { + const sellOffer = operation; + return [ + this.#field( + 'selling', + [sellOffer.selling.toString(), sellOffer.amount], + 'assetWithAmount', + ), + this.#field('buying', sellOffer.buying.toString(), 'asset'), + this.#field('price', sellOffer.price, 'price'), + this.#field('offerId', sellOffer.offerId, 'text'), + ]; + } + case 'manageBuyOffer': { + const buyOffer = operation; + return [ + this.#field( + 'buying', + [buyOffer.buying.toString(), buyOffer.buyAmount], + 'assetWithAmount', + ), + this.#field('selling', buyOffer.selling.toString(), 'asset'), + this.#field('price', buyOffer.price, 'price'), + this.#field('offerId', buyOffer.offerId, 'text'), + ]; + } + case 'createPassiveSellOffer': { + const passiveOffer = operation; + return [ + this.#field( + 'selling', + [passiveOffer.selling.toString(), passiveOffer.amount], + 'assetWithAmount', + ), + this.#field('buying', passiveOffer.buying.toString(), 'asset'), + this.#field('price', passiveOffer.price, 'price'), + ]; + } + case 'setOptions': { + const setOptions = operation; + const rows: ReadableOperationField[] = []; + if (setOptions.inflationDest !== undefined) { + rows.push( + this.#field('inflationDest', setOptions.inflationDest, 'address'), + ); + } + if (setOptions.clearFlags !== undefined) { + rows.push( + this.#field( + 'clearFlags', + accountAuthFlagsMaskToText(setOptions.clearFlags), + 'text', + ), + ); + } + if (setOptions.setFlags !== undefined) { + rows.push( + this.#field( + 'setFlags', + accountAuthFlagsMaskToText(setOptions.setFlags), + 'text', + ), + ); + } + if (setOptions.masterWeight !== undefined) { + rows.push( + this.#field('masterWeight', setOptions.masterWeight, 'number'), + ); + } + if (setOptions.lowThreshold !== undefined) { + rows.push( + this.#field('lowThreshold', setOptions.lowThreshold, 'number'), + ); + } + if (setOptions.medThreshold !== undefined) { + rows.push( + this.#field('medThreshold', setOptions.medThreshold, 'number'), + ); + } + if (setOptions.highThreshold !== undefined) { + rows.push( + this.#field('highThreshold', setOptions.highThreshold, 'number'), + ); + } + if (setOptions.homeDomain !== undefined) { + rows.push(this.#field('homeDomain', setOptions.homeDomain, 'text')); + } + if ('signer' in setOptions && setOptions.signer !== undefined) { + rows.push( + this.#field('signer', JSON.stringify(setOptions.signer), 'text'), + ); + } + return rows; + } + case 'allowTrust': { + const allowTrustOp = operation; + const rows: ReadableOperationField[] = [ + this.#field('trustor', allowTrustOp.trustor, 'address'), + this.#field('assetCode', allowTrustOp.assetCode, 'text'), + ]; + if (allowTrustOp.authorize !== undefined) { + const auth = allowTrustOp.authorize; + if (typeof auth === 'boolean') { + rows.push(this.#field('authorize', auth, 'boolean')); + } else { + rows.push(this.#field('authorize', String(auth), 'text')); + } + } + return rows; + } + case 'manageData': { + const manageDataOp = operation; + return [ + this.#field('name', manageDataOp.name, 'text'), + this.#field( + 'valueBase64', + manageDataOp.value + ? bufferToUint8Array(manageDataOp.value).toString('base64') + : null, + 'text', + ), + ]; + } + case 'bumpSequence': { + const bumpSequence = operation; + return [this.#field('bumpTo', bumpSequence.bumpTo, 'text')]; + } + case 'inflation': + return []; + case 'createClaimableBalance': { + const createCb = operation; + return [ + this.#field('asset', createCb.asset.toString(), 'asset'), + this.#field('amount', createCb.amount, 'amount'), + this.#field( + 'claimants', + createCb.claimants.map((claimant) => ({ + destination: claimant.destination, + })), + 'json', + ), + ]; + } + case 'claimClaimableBalance': { + const claimCb = operation; + return [this.#field('balanceId', claimCb.balanceId, 'text')]; + } + case 'beginSponsoringFutureReserves': { + const beginSponsor = operation; + return [ + this.#field('sponsoredId', beginSponsor.sponsoredId, 'address'), + ]; + } + case 'endSponsoringFutureReserves': + return []; + case 'revokeSponsorship': + return this.#mapRevokeSponsorship(operation); + case 'clawback': { + const clawback = operation; + return [ + this.#field('asset', clawback.asset.toString(), 'asset'), + this.#field('amount', clawback.amount, 'amount'), + this.#field('from', clawback.from, 'address'), + ]; + } + case 'clawbackClaimableBalance': { + const clawbackCb = operation; + return [this.#field('balanceId', clawbackCb.balanceId, 'text')]; + } + case 'setTrustLineFlags': { + const trustFlags = operation; + return [ + this.#field('trustor', trustFlags.trustor, 'address'), + this.#field('asset', trustFlags.asset.toString(), 'asset'), + this.#field( + 'flags', + { + authorized: trustFlags.flags.authorized ?? null, + authorizedToMaintainLiabilities: + trustFlags.flags.authorizedToMaintainLiabilities ?? null, + clawbackEnabled: trustFlags.flags.clawbackEnabled ?? null, + }, + 'json', + ), + ]; + } + case 'liquidityPoolDeposit': { + const poolDeposit = operation; + return [ + this.#field('liquidityPoolId', poolDeposit.liquidityPoolId, 'text'), + this.#field('maxAmountA', poolDeposit.maxAmountA, 'amount'), + this.#field('maxAmountB', poolDeposit.maxAmountB, 'amount'), + this.#field('minPrice', poolDeposit.minPrice, 'price'), + this.#field('maxPrice', poolDeposit.maxPrice, 'price'), + ]; + } + case 'liquidityPoolWithdraw': { + const poolWithdraw = operation; + return [ + this.#field('liquidityPoolId', poolWithdraw.liquidityPoolId, 'text'), + this.#field('amount', poolWithdraw.amount, 'amount'), + this.#field('minAmountA', poolWithdraw.minAmountA, 'amount'), + this.#field('minAmountB', poolWithdraw.minAmountB, 'amount'), + ]; + } + case 'invokeHostFunction': + case 'extendFootprintTtl': + case 'restoreFootprint': + return [ + this.#field( + 'note', + 'Soroban operation; use non-classic mapping path.', + 'text', + ), + ]; + default: { + const unknownOp = operation as Operation; + return [ + this.#field( + 'note', + `Unhandled or newer operation type "${unknownOp.type}".`, + 'text', + ), + ]; + } + } + } + + #mapRevokeSponsorship(operation: Operation): ReadableOperationField[] { + if ('seller' in operation && 'offerId' in operation) { + const revokeOffer = operation as { + seller: string; + offerId: string; + }; + return [ + this.#field('seller', revokeOffer.seller, 'address'), + this.#field('offerId', revokeOffer.offerId, 'text'), + ]; + } + if ('balanceId' in operation && !('account' in operation)) { + const revokeCb = operation as { balanceId: string }; + return [this.#field('balanceId', revokeCb.balanceId, 'text')]; + } + if ('liquidityPoolId' in operation && !('account' in operation)) { + const revokePool = operation as { liquidityPoolId: string }; + return [ + this.#field('liquidityPoolId', revokePool.liquidityPoolId, 'text'), + ]; + } + if ('account' in operation && 'name' in operation) { + const revokeData = operation as { account: string; name: string }; + return [ + this.#field('account', revokeData.account, 'address'), + this.#field('name', revokeData.name, 'text'), + ]; + } + if ('account' in operation && 'signer' in operation) { + const revokeSigner = operation as { + account: string; + signer: unknown; + }; + return [ + this.#field('account', revokeSigner.account, 'address'), + this.#field('signer', JSON.stringify(revokeSigner.signer), 'text'), + ]; + } + if ('account' in operation && 'asset' in operation) { + const revokeTrust = operation as { + account: string; + asset: Asset | LiquidityPoolId; + }; + const { asset } = revokeTrust; + const assetLabel = + asset instanceof LiquidityPoolId + ? asset.getLiquidityPoolId() + : asset.toString(); + return [ + this.#field('account', revokeTrust.account, 'address'), + this.#field('asset', assetLabel, 'asset'), + ]; + } + if ('account' in operation) { + const revokeAccount = operation as { account: string }; + return [this.#field('account', revokeAccount.account, 'address')]; + } + return [ + this.#field('note', 'revokeSponsorship shape not recognized.', 'text'), + ]; + } + + #field( + key: string, + value: Json, + type: ReadableFieldType, + ): ReadableOperationField { + return { key, value, type }; + } + + #formatTrustLine(line: Asset | LiquidityPoolAsset): string { + if (line instanceof LiquidityPoolAsset) { + return `${line.assetA.toString()} / ${line.assetB.toString()} (LP fee ${line.fee})`; + } + return line.toString(); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts new file mode 100644 index 00000000..7f4bda9d --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts @@ -0,0 +1,301 @@ +import { + Account, + Keypair, + Networks, + Transaction as StellarTransaction, + TransactionBuilder as StellarSdkTransactionBuilder, +} from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import { + InvalidAssetForCreateAccountException, + TransactionBuilderException, +} from './exceptions'; +import { Transaction } from './Transaction'; +import { TransactionBuilder } from './TransactionBuilder'; +import type { KnownCaip19ClassicAssetId } from '../../api'; +import { KnownCaip2ChainId } from '../../api'; +import { getSlip44AssetId, toSmallestUnit } from '../../utils'; +import { logger } from '../../utils/logger'; +import { + createMockAccountWithBalances, + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, +} from '../on-chain-account/__mocks__/onChainAccount.fixtures'; +import { OnChainAccount } from '../on-chain-account/OnChainAccount'; +import { getTestWallet } from '../wallet/__mocks__/wallet.fixtures'; +import type { Wallet } from '../wallet/Wallet'; + +jest.mock('../../utils/logger'); + +describe('TransactionBuilder', () => { + let transactionBuilder: TransactionBuilder; + let testAsset: KnownCaip19ClassicAssetId; + let testWalletWithSigner: Wallet; + let testOnChainAccount: OnChainAccount; + + beforeEach(() => { + transactionBuilder = new TransactionBuilder({ logger }); + testAsset = `stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN`; + testWalletWithSigner = getTestWallet(); + testOnChainAccount = new OnChainAccount( + createMockAccountWithBalances( + testWalletWithSigner.address, + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ), + KnownCaip2ChainId.Mainnet, + ); + }); + + const getAccountSpies = () => ({ + incrementSequenceNumberSpy: jest.spyOn( + Account.prototype, + 'incrementSequenceNumber', + ), + }); + + const getTransactionBuilderSpies = () => ({ + buildSpy: jest.spyOn(StellarSdkTransactionBuilder.prototype, 'build'), + }); + + describe('changeTrust', () => { + it('builds a change trust transaction', () => { + const transaction = transactionBuilder.changeTrust({ + baseFee: '100', + scope: KnownCaip2ChainId.Mainnet, + assetId: testAsset, + onChainAccount: testOnChainAccount, + }); + + expect(transaction).toBeInstanceOf(Transaction); + expect(transaction.totalFee).toStrictEqual(new BigNumber(100)); + expect(transaction.operationCount).toBe(1); + expect(transaction.network).toStrictEqual(Networks.PUBLIC); + expect(transaction.getRaw()).toBeInstanceOf(StellarTransaction); + }); + + it('throws a TransactionBuilderException if building the transaction fails', () => { + const { incrementSequenceNumberSpy } = getAccountSpies(); + incrementSequenceNumberSpy.mockImplementation(() => { + throw new Error('Failed to increment sequence number'); + }); + + expect(() => { + transactionBuilder.changeTrust({ + baseFee: '100', + scope: KnownCaip2ChainId.Mainnet, + assetId: testAsset, + onChainAccount: testOnChainAccount, + }); + }).toThrow(TransactionBuilderException); + }); + }); + + describe('rebuildTxnWithNewSeq', () => { + it('rebuilds a transaction', () => { + const transaction = transactionBuilder.changeTrust({ + baseFee: '100', + scope: KnownCaip2ChainId.Mainnet, + assetId: testAsset, + onChainAccount: testOnChainAccount, + }); + + const rebuiltTransaction = transactionBuilder.rebuildTxnWithNewSeq({ + transaction, + sequenceNumber: new OnChainAccount( + createMockAccountWithBalances( + getTestWallet().address, + '100', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ), + KnownCaip2ChainId.Mainnet, + ).sequenceNumber, + }); + + expect(rebuiltTransaction).toBeInstanceOf(Transaction); + expect(rebuiltTransaction.totalFee).toStrictEqual(new BigNumber(100)); + expect(rebuiltTransaction.operationCount).toBe(1); + expect(rebuiltTransaction.network).toStrictEqual(Networks.PUBLIC); + const rebuiltRaw = rebuiltTransaction.getRaw(); + expect(rebuiltRaw).toBeInstanceOf(StellarTransaction); + expect((rebuiltRaw as StellarTransaction).sequence).toBe('101'); + }); + + it('throws a TransactionBuilderException if rebuilding the transaction fails', () => { + const transaction = transactionBuilder.changeTrust({ + baseFee: '100', + scope: KnownCaip2ChainId.Mainnet, + assetId: testAsset, + onChainAccount: testOnChainAccount, + }); + + const { buildSpy } = getTransactionBuilderSpies(); + buildSpy.mockImplementation(() => { + throw new Error('Failed to build transaction'); + }); + + expect(() => { + transactionBuilder.rebuildTxnWithNewSeq({ + transaction, + sequenceNumber: testOnChainAccount.sequenceNumber, + }); + }).toThrow(TransactionBuilderException); + }); + + it('drops prior signatures when the envelope was signed by another keypair', () => { + const transaction = transactionBuilder.changeTrust({ + baseFee: '100', + scope: KnownCaip2ChainId.Mainnet, + assetId: testAsset, + onChainAccount: testOnChainAccount, + }); + + const signedRaw = transaction.getRaw() as StellarTransaction; + signedRaw.sign(Keypair.random()); + + expect(signedRaw.signatures.length).toBeGreaterThan(0); + + const sameSourceOnChainAccount = new OnChainAccount( + createMockAccountWithBalances(testWalletWithSigner.address, '42', { + nativeBalance: 10, + subentryCount: 0, + assets: [], + }), + KnownCaip2ChainId.Mainnet, + ); + + const rebuilt = transactionBuilder.rebuildTxnWithNewSeq({ + transaction, + sequenceNumber: sameSourceOnChainAccount.sequenceNumber, + }); + + const rebuiltRaw = rebuilt.getRaw() as StellarTransaction; + expect(rebuiltRaw.signatures).toHaveLength(0); + }); + }); + + describe('transfer', () => { + it('builds a transfer transaction', () => { + const testDestination = getTestWallet(); + const transaction = transactionBuilder.transfer({ + onChainAccount: testOnChainAccount, + scope: KnownCaip2ChainId.Mainnet, + assetId: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + amount: new BigNumber(100), + destination: { + address: testDestination.address, + isActivated: true, + }, + baseFee: new BigNumber(100), + }); + + expect(transaction).toBeInstanceOf(Transaction); + expect(transaction.totalFee).toStrictEqual(new BigNumber(100)); + expect(transaction.operationCount).toBe(1); + expect(transaction.network).toStrictEqual(Networks.PUBLIC); + expect(transaction.getRaw()).toBeInstanceOf(StellarTransaction); + expect(transaction.hasCreateAccount).toBe(false); + }); + + it('builds a create account transaction', () => { + const testDestination = getTestWallet(); + const transaction = transactionBuilder.transfer({ + onChainAccount: testOnChainAccount, + scope: KnownCaip2ChainId.Mainnet, + assetId: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + amount: toSmallestUnit(BigNumber(1)), + destination: { + address: testDestination.address, + isActivated: false, + }, + baseFee: new BigNumber(100), + }); + + expect(transaction).toBeInstanceOf(Transaction); + expect(transaction.totalFee).toStrictEqual(new BigNumber(100)); + expect(transaction.operationCount).toBe(1); + expect(transaction.network).toStrictEqual(Networks.PUBLIC); + expect(transaction.getRaw()).toBeInstanceOf(StellarTransaction); + expect(transaction.hasCreateAccount).toBe(true); + }); + + it('throws a InvalidAssetForCreateAccountException if the asset is not a native asset', () => { + expect(() => { + const testDestination = getTestWallet(); + transactionBuilder.transfer({ + onChainAccount: testOnChainAccount, + scope: KnownCaip2ChainId.Mainnet, + assetId: testAsset, + amount: toSmallestUnit(BigNumber(1)), + destination: { + address: testDestination.address, + isActivated: false, + }, + baseFee: new BigNumber(100), + }); + }).toThrow(InvalidAssetForCreateAccountException); + }); + }); + + describe('deserialize', () => { + it('builds a transaction from an XDR string', () => { + const transaction = transactionBuilder.changeTrust({ + baseFee: '100', + scope: KnownCaip2ChainId.Mainnet, + assetId: testAsset, + onChainAccount: testOnChainAccount, + }); + + const fromXDRTransaction = transactionBuilder.deserialize({ + xdr: transaction.getRaw().toXDR(), + scope: KnownCaip2ChainId.Mainnet, + }); + + expect(fromXDRTransaction).toBeInstanceOf(Transaction); + expect(fromXDRTransaction.totalFee).toStrictEqual(new BigNumber(100)); + expect(fromXDRTransaction.operationCount).toBe(1); + expect(fromXDRTransaction.network).toStrictEqual(Networks.PUBLIC); + expect(fromXDRTransaction.getRaw()).toBeInstanceOf(StellarTransaction); + }); + }); + + describe('sep41Transfer', () => { + const sep41AssetId = + `stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J` as const; + + it('builds a sep41 transfer transaction', () => { + const testDestination = getTestWallet(); + const transaction = transactionBuilder.sep41Transfer({ + scope: KnownCaip2ChainId.Mainnet, + onChainAccount: testOnChainAccount, + assetId: sep41AssetId, + destination: testDestination.address, + amount: new BigNumber(100000000), + }); + + expect(transaction).toBeInstanceOf(Transaction); + expect(transaction.operationCount).toBe(1); + expect(transaction.network).toStrictEqual(Networks.PUBLIC); + expect(transaction.getRaw()).toBeInstanceOf(StellarTransaction); + expect(transaction.hasInvokeHostFunction).toBe(true); + }); + + it('throws a TransactionBuilderException if building the transaction fails', () => { + expect(() => { + const testDestination = getTestWallet(); + const { buildSpy } = getTransactionBuilderSpies(); + buildSpy.mockImplementation(() => { + throw new Error('Failed to build transaction'); + }); + + transactionBuilder.sep41Transfer({ + scope: KnownCaip2ChainId.Mainnet, + onChainAccount: testOnChainAccount, + assetId: sep41AssetId, + destination: testDestination.address, + amount: new BigNumber(100000000), + }); + }).toThrow(TransactionBuilderException); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts new file mode 100644 index 00000000..39d6d2d1 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts @@ -0,0 +1,431 @@ +import { parseCaipAssetType } from '@metamask/utils'; +import type { xdr, OperationOptions } from '@stellar/stellar-sdk'; +import { + Account, + Address, + Contract, + FeeBumpTransaction, + Operation, + ScInt, + TransactionBuilder as StellarSdkTransactionBuilder, +} from '@stellar/stellar-sdk'; + +import { + InvalidAssetForCreateAccountException, + TransactionBuilderException, +} from './exceptions'; +import { Transaction } from './Transaction'; +import { caip19ToStellarAsset } from './utils'; +import type { + KnownCaip2ChainId, + KnownCaip19AssetIdOrSlip44Id, + KnownCaip19Sep41AssetId, + KnownCaip19ClassicAssetId, + KnownCaip19Slip44Id, +} from '../../api'; +import { AppConfig } from '../../config'; +import { BASE_FEE } from '../../constants'; +import { + createPrefixedLogger, + type ILogger, + isSep41Id, + isSlip44Id, + normalizeAmount, +} from '../../utils'; +import { caip2ChainIdToNetwork } from '../network/utils'; +import type { OnChainAccount } from '../on-chain-account/OnChainAccount'; + +/** + * Builds Stellar transactions (e.g. change trust, create account) and rebuilds existing + * transactions with an updated sequence. All methods return a {@link Transaction} wrapper. + */ +export class TransactionBuilder { + readonly #logger: ILogger; + + constructor({ logger }: { logger: ILogger }) { + this.#logger = createPrefixedLogger(logger, '[💰 TransactionBuilder]'); + } + + /** + * Builds a change-trust operation transaction for the given asset. + * + * @param params - Options object. + * @param params.baseFee - The fee per operation. + * @param params.scope - The CAIP-2 chain ID. + * @param params.assetId - CAIP-19 asset id for the classic token asset. + * @param params.onChainAccount - Source account (sequence and account id for the transaction). + * @param params.deleteTrustline - [optional] Whether to delete the trustline. Defaults to false. + * @returns An unsigned transaction ready for signing. + * @throws {TransactionBuilderException} If building fails. + */ + changeTrust({ + baseFee, + scope, + assetId, + onChainAccount, + deleteTrustline = false, + }: { + baseFee: string; + scope: KnownCaip2ChainId; + assetId: KnownCaip19ClassicAssetId; + onChainAccount: OnChainAccount; + deleteTrustline?: boolean; + }): Transaction { + try { + const operationOpt: OperationOptions.ChangeTrust = { + asset: caip19ToStellarAsset(assetId), + }; + // Remove trustline by setting the limit to 0 + if (deleteTrustline) { + operationOpt.limit = '0'; + } + return this.#buildTransaction({ + onChainAccount, + operations: [Operation.changeTrust(operationOpt)], + timeout: this.#getTimeout(), + scope, + fee: baseFee, + }); + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to build change trust transaction', + error, + ); + throw new TransactionBuilderException( + 'Failed to build change trust transaction', + ); + } + } + + /** + * Builds a single `invokeHostFunction` transaction calling SEP-41 `transfer(from, to, amount)`. + * Amount must be in the token's smallest units (i128). + * + * @param params - Transfer build input. + * @param params.scope - CAIP-2 chain id. + * @param params.onChainAccount - Source account (sender is `onChainAccount.accountId`; sequence for the tx). + * @param params.assetId - SEP-41 CAIP-19 asset id (contract reference in CAIP form). + * @param params.destination - Recipient Stellar account id (`G…`). + * @param params.amount - Amount in the token's smallest units (i128). + * @returns Wrapped unsigned transaction with one `invokeHostFunction` op. + */ + sep41Transfer(params: { + scope: KnownCaip2ChainId; + onChainAccount: OnChainAccount; + assetId: KnownCaip19Sep41AssetId; + destination: string; + amount: BigNumber; + }): Transaction { + try { + const { scope, onChainAccount, assetId, destination, amount } = params; + + // If it is a SEP-41 asset, the asset reference is the token address + const { assetReference: tokenAddress } = parseCaipAssetType(assetId); + + const token = new Contract(tokenAddress); + // Contract token transfer function expects the amount in the token's smallest units (i128), + // so we don't need to convert to human readable units here + const amountScv = new ScInt(amount.toFixed(0), { + type: 'i128', + }).toScVal(); + const op = token.call( + 'transfer', + Address.fromString(onChainAccount.accountId).toScVal(), + Address.fromString(destination).toScVal(), + amountScv, + ); + + return this.#buildTransaction({ + onChainAccount, + operations: [op], + timeout: this.#getTimeout(), + scope, + // Base fee is a placeholder until RPC simulation. + fee: BASE_FEE.toString(), + }); + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to build sep41 transfer transaction', + error, + ); + throw new TransactionBuilderException( + 'Failed to build sep41 transfer transaction', + ); + } + } + + #send(params: { + baseFee: string; + scope: KnownCaip2ChainId; + asset: KnownCaip19ClassicAssetId | KnownCaip19Slip44Id; + onChainAccount: OnChainAccount; + destination: string; + amount: BigNumber; + }): Transaction { + const { amount, baseFee, scope, asset, onChainAccount, destination } = + params; + return this.#buildTransaction({ + onChainAccount, + operations: [ + Operation.payment({ + asset: caip19ToStellarAsset(asset), + amount: amount.toString(), + destination, + }), + ], + timeout: this.#getTimeout(), + scope, + fee: baseFee, + }); + } + + #createAccount(params: { + baseFee: string; + scope: KnownCaip2ChainId; + onChainAccount: OnChainAccount; + destination: string; + amount: BigNumber; + }): Transaction { + const { amount, baseFee, scope, onChainAccount, destination } = params; + + return this.#buildTransaction({ + onChainAccount, + operations: [ + Operation.createAccount({ + startingBalance: amount.toString(), + destination, + }), + ], + timeout: this.#getTimeout(), + scope, + fee: baseFee, + }); + } + + /** + * Builds a transfer operation transaction for the given asset. + * If the destination is not activated, a create account operation is added. + * If the destination is activated, a payment operation is added. + * + * @param params - Options object. + * @param params.onChainAccount - Source account (sequence and account id for the transaction). + * @param params.scope - The CAIP-2 chain ID. + * @param params.assetId - Native (slip44), classic, or SEP-41 CAIP-19 asset id. + * @param params.amount - Amount in the asset's smallest units (stroops for native/classic; token minor units for SEP-41). + * @param params.destination - Recipient address and on-chain activation flag. + * @param params.destination.address - Recipient Stellar account id (`G…`). + * @param params.destination.isActivated - Whether the destination account exists and is funded on-chain. + * @param params.baseFee - Base fee per operation in stroops. + * @returns An unsigned transaction ready for signing. + * @throws {InvalidAssetForCreateAccountException} When the destination is unfunded and the asset is not native. + * @throws {TransactionBuilderException} If building fails. + */ + transfer(params: { + onChainAccount: OnChainAccount; + scope: KnownCaip2ChainId; + assetId: KnownCaip19AssetIdOrSlip44Id; + amount: BigNumber; + destination: { + address: string; + isActivated: boolean; + }; + baseFee: BigNumber; + }): Transaction { + const { onChainAccount, scope, amount, assetId, destination, baseFee } = + params; + const { address: toAddress, isActivated } = destination; + + try { + if (isSep41Id(assetId)) { + return this.sep41Transfer({ + scope, + onChainAccount, + assetId, + destination: toAddress, + amount, + }); + } + + // Convert the amount to human readable units, + // it is required for stellar classic assets transfer + const normalizedAmount = normalizeAmount(amount); + + if (isActivated) { + return this.#send({ + baseFee: baseFee.toString(), + onChainAccount, + scope, + asset: assetId, + destination: toAddress, + amount: normalizedAmount, + }); + } + // Unfunded destination → createAccount only. + if (!isSlip44Id(assetId)) { + throw new InvalidAssetForCreateAccountException(assetId); + } + + return this.#createAccount({ + baseFee: baseFee.toString(), + onChainAccount, + scope, + amount: normalizedAmount, + destination: toAddress, + }); + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to build transfer transaction', + error, + ); + + if (error instanceof InvalidAssetForCreateAccountException) { + throw error; + } + + throw new TransactionBuilderException( + 'Failed to build transfer transaction', + ); + } + } + + /** + * Deserializes a transaction from XDR. + * + * @param params - Options object. + * @param params.xdr - The XDR string. + * @param params.scope - The CAIP-2 chain ID. + * @returns A transaction. + * @throws {TransactionBuilderException} If deserializing fails. + */ + deserialize(params: { xdr: string; scope: KnownCaip2ChainId }): Transaction { + try { + const { xdr, scope } = params; + const decodedTransaction = StellarSdkTransactionBuilder.fromXDR( + xdr, + caip2ChainIdToNetwork(scope), + ); + + const transaction = new Transaction(decodedTransaction); + + return transaction; + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to deserialize transaction', + error, + ); + throw new TransactionBuilderException( + 'Failed to deserialize transaction', + ); + } + } + + /** + * Rebuilds a transaction with a new sequence number (e.g. after `txBadSeq`). + * + * @param params - Options object. + * @param params.transaction - The original transaction. + * @param params.sequenceNumber - The new sequence number. + * @returns A new transaction with updated sequence. + * @throws {TransactionBuilderException} If rebuilding fails. + */ + rebuildTxnWithNewSeq(params: { + transaction: Transaction; + sequenceNumber: string; + }): Transaction { + const { transaction, sequenceNumber } = params; + try { + const rawTransaction = transaction.getRaw(); + + if (rawTransaction instanceof FeeBumpTransaction) { + throw new TransactionBuilderException( + 'Rebuilding fee bump transactions is not supported', + ); + } + + if (transaction.operationCount === 0) { + throw new TransactionBuilderException('No operations in transaction'); + } + + // the initial fee passed to the builder gets scaled up based on the number + // of operations at the end, so we have to down-scale first + let fee = Math.floor( + parseInt(rawTransaction.fee, 10) / rawTransaction.operations.length, + ); + + if (!Number.isFinite(fee) || fee <= 0) { + fee = BASE_FEE; + } + + // Minimal clone of the transaction + const builder = new StellarSdkTransactionBuilder( + new Account(transaction.sourceAccount, sequenceNumber), + { + fee: fee.toString(), + memo: rawTransaction.memo, + networkPassphrase: rawTransaction.networkPassphrase, + timebounds: rawTransaction.timeBounds, + ledgerbounds: rawTransaction.ledgerBounds, + minAccountSequence: rawTransaction.minAccountSequence, + minAccountSequenceAge: rawTransaction.minAccountSequenceAge, + minAccountSequenceLedgerGap: + rawTransaction.minAccountSequenceLedgerGap, + // TODO: add extraSigners when cloning the envelope + }, + ); + + // Clone the transaction operations + if ('tx' in rawTransaction) { + const tx = rawTransaction.tx as xdr.Transaction; + tx.operations().forEach((op) => builder.addOperation(op)); + } else { + throw new TransactionBuilderException( + 'Transaction is not a compatible transaction', + ); + } + + return new Transaction(builder.build()); + } catch (error: unknown) { + if (error instanceof TransactionBuilderException) { + throw error; + } + this.#logger.logErrorWithDetails('Failed to rebuild transaction', error); + throw new TransactionBuilderException('Failed to rebuild transaction'); + } + } + + #buildTransaction({ + onChainAccount, + operations, + timeout, + scope, + fee, + }: { + onChainAccount: OnChainAccount; + operations: xdr.Operation[]; + timeout: number; + scope: KnownCaip2ChainId; + fee: string; + }): Transaction { + const accountInstance = new Account( + onChainAccount.accountId, + onChainAccount.sequenceNumber, + ); + + const networkPassphrase = caip2ChainIdToNetwork(scope); + const builder = new StellarSdkTransactionBuilder(accountInstance, { + fee, + networkPassphrase, + }); + + for (const operation of operations) { + builder.addOperation(operation); + } + + const inner = builder.setTimeout(timeout).build(); + return new Transaction(inner); + } + + #getTimeout(): number { + return AppConfig.transaction.timeout; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts new file mode 100644 index 00000000..fd8598b7 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts @@ -0,0 +1,74 @@ +import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; +import sortBy from 'lodash/sortBy'; +import uniqBy from 'lodash/uniqBy'; + +import type { State } from '../state/State'; + +export type TransactionStateValue = { + transactions: Record; +}; + +export class TransactionRepository { + readonly #state: State; + + readonly #stateKey = 'transactions'; + + constructor(state: State) { + this.#state = state; + } + + async getAll(): Promise { + const transactionsByAccount = await this.#state.getKey< + TransactionStateValue['transactions'] + >(this.#stateKey); + + return Object.values(transactionsByAccount ?? {}).flat(); + } + + async findByAccountId(accountId: string): Promise { + const transactions = await this.#state.getKey( + `${this.#stateKey}.${accountId}`, + ); + + return transactions ?? []; + } + + async save(transaction: KeyringTransaction): Promise { + const transactions = await this.findByAccountId(transaction.account); + + await this.#state.setKey( + `${this.#stateKey}.${transaction.account}`, + this.#insertNewTransaction(transactions, transaction), + ); + } + + async saveMany(transactions: KeyringTransaction[]): Promise { + // Optimize the state operations by reading and writing to the state only once + await this.#state.update((state) => { + // Safe guard: persisted state may omit `transactions` until first write + if (!state[this.#stateKey]) { + state[this.#stateKey] = {}; + } + const allTransactionsByAccount = state[this.#stateKey]; + + transactions.forEach((transaction) => { + const accountId = transaction.account; + const existing = allTransactionsByAccount[accountId] ?? []; + state[this.#stateKey][accountId] = this.#insertNewTransaction( + existing, + transaction, + ); + }); + + return state; + }); + } + + #insertNewTransaction( + transactions: KeyringTransaction[], + newTransaction: KeyringTransaction, + ): KeyringTransaction[] { + const merged = [newTransaction, ...transactions]; + return sortBy(uniqBy(merged, 'id'), (item) => -(item.timestamp ?? 0)); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts new file mode 100644 index 00000000..c0f5acba --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts @@ -0,0 +1,80 @@ +import { TransactionStatus, TransactionType } from '@metamask/keyring-api'; + +import { KnownCaip2ChainId } from '../../api'; +import { getSlip44AssetId } from '../../utils'; +import { createMockTransactionService } from './__mocks__/transaction.fixtures'; +import { generateMockStellarKeyringAccounts } from '../account/__mocks__/account.fixtures'; +import type { StellarKeyringAccount } from '../account/api'; + +jest.mock('../../utils/logger'); +jest.mock('../../utils/snap'); + +describe('TransactionService', () => { + describe('createPendingSendTransaction', () => { + it('creates a pending send transaction', async () => { + const { transactionService, transactionRepositorySaveSpy } = + createMockTransactionService(); + const [fromAccount, toAccount] = generateMockStellarKeyringAccounts( + 2, + 'test-entropy', + ) as [StellarKeyringAccount, StellarKeyringAccount]; + + const transaction = await transactionService.createPendingSendTransaction( + { + txId: 'test-tx-id', + account: fromAccount, + scope: KnownCaip2ChainId.Mainnet, + toAddress: toAccount.address, + amount: '10000000', + asset: { + type: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + symbol: 'XLM', + }, + }, + ); + + const expectedTransaction = { + type: TransactionType.Send, + id: 'test-tx-id', + from: [ + { + address: fromAccount.address, + asset: { + type: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + unit: 'XLM', + amount: '10000000', + fungible: true, + }, + }, + ], + to: [ + { + address: toAccount.address, + asset: { + type: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + unit: 'XLM', + amount: '10000000', + fungible: true, + }, + }, + ], + events: [ + { + status: TransactionStatus.Unconfirmed, + timestamp: expect.any(Number), + }, + ], + chain: KnownCaip2ChainId.Mainnet, + status: TransactionStatus.Unconfirmed, + account: fromAccount.id, + timestamp: expect.any(Number), + fees: [], + }; + + expect(transaction).toStrictEqual(expectedTransaction); + expect(transactionRepositorySaveSpy).toHaveBeenCalledWith( + expectedTransaction, + ); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts new file mode 100644 index 00000000..dd21ea26 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -0,0 +1,188 @@ +import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; +import { TransactionStatus, TransactionType } from '@metamask/keyring-api'; + +import type { Transaction } from './Transaction'; +import type { TransactionRepository } from './TransactionRepository'; +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip2ChainId, +} from '../../api'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { StellarKeyringAccount } from '../account/api'; +import type { NetworkService } from '../network'; + +export class TransactionService { + readonly #logger: ILogger; + + readonly #transactionRepository: TransactionRepository; + + readonly #networkService: NetworkService; + + constructor({ + logger, + transactionRepository, + networkService, + }: { + logger: ILogger; + transactionRepository: TransactionRepository; + networkService: NetworkService; + }) { + this.#logger = createPrefixedLogger(logger, '[🧾 TransactionService]'); + this.#transactionRepository = transactionRepository; + this.#networkService = networkService; + } + + /** + * Creates a pending send transaction. + * + * @param params - The parameters for the pending send transaction. + * @param params.txId - Stable id for this activity row (e.g. client correlation id). + * @param params.account - Keyring account that initiated the send (`from`). + * @param params.scope - CAIP-2 chain for `chain` on the keyring transaction. + * @param params.toAddress - Destination Stellar address (`G…`). + * @param params.amount - Amount in the asset’s smallest units (string). + * @param params.asset - Display / CAIP metadata for `from` and `to` asset rows. + * @param params.asset.type - CAIP-19 (or slip44) asset id. + * @param params.asset.symbol - Human-readable unit label (e.g. `XLM`). + * @returns A promise that resolves to the pending send transaction. + */ + async createPendingSendTransaction({ + txId, + account, + scope, + toAddress, + amount, + asset, + }: { + txId: string; + account: StellarKeyringAccount; + scope: KnownCaip2ChainId; + toAddress: string; + amount: string; + asset: { + type: KnownCaip19AssetIdOrSlip44Id; + symbol: string; + }; + }): Promise { + const timestamp = Math.floor(Date.now() / 1000); + + const transaction: KeyringTransaction = { + type: TransactionType.Send, + id: txId, + from: [ + { + address: account.address, + asset: { + unit: asset.symbol, + type: asset.type, + amount, + fungible: true, + }, + }, + ], + to: [ + { + address: toAddress, + asset: { + unit: asset.symbol, + type: asset.type, + amount, + fungible: true, + }, + }, + ], + events: [ + { + status: TransactionStatus.Unconfirmed, + timestamp, + }, + ], + chain: scope, + status: TransactionStatus.Unconfirmed, + account: account.id, + timestamp, + fees: [], + }; + + this.#logger.debug('Creating pending send transaction', { + transaction, + }); + + await this.save(transaction); + + return transaction; + } + + /** + * Computes the fee for a transaction. + * + * @param transaction - The transaction to compute the fee for. + * @returns A promise that resolves to the transaction with the computed fee. + */ + async computingFee(transaction: Transaction): Promise { + if (transaction.hasInvokeHostFunction) { + const simulatedTransaction = + await this.#networkService.simulateTransaction( + transaction, + transaction.scope, + ); + return simulatedTransaction; + } + return transaction; + } + + /** + * Finds all transactions for the given accounts. + * + * @param accounts - The accounts to find transactions for. + * @returns A promise that resolves to an array of transactions. + */ + async findByAccounts( + accounts: StellarKeyringAccount[], + ): Promise { + const transactions = await Promise.all( + accounts.map(async (account) => + this.#transactionRepository.findByAccountId(account.id), + ), + ); + + return transactions.flat(); + } + + /** + * Saves a transaction. + * + * @param transaction - The transaction to save. + * @returns A promise that resolves when the transaction is saved. + */ + async save(transaction: KeyringTransaction): Promise { + await this.#transactionRepository.save(transaction); + } + + /** + * Saves multiple transactions. + * + * @param transactions - The transactions to save. + * @returns A promise that resolves when the transactions are saved. + */ + async saveMany(transactions: KeyringTransaction[]): Promise { + await this.#transactionRepository.saveMany(transactions); + } + + /** + * Pulls transaction history from the network and reconciles local snap state. + * Not implemented yet; safe to call from {@link SynchronizeService.synchronize}. + * + * @param _accounts - Keyring accounts whose history will be synced (unused until implemented). + * @param _scope - Network scope for fetches (unused until implemented). + */ + async synchronize( + _accounts: StellarKeyringAccount[], + _scope: KnownCaip2ChainId, + ): Promise { + this.#logger.debug( + 'TransactionService.synchronize: transaction history sync not implemented yet', + ); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts index eaeb3da5..d45da2f0 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts @@ -1,3 +1,5 @@ +import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; +import { TransactionStatus, TransactionType } from '@metamask/keyring-api'; import { Account, Asset, @@ -10,7 +12,131 @@ import { type AuthFlag, } from '@stellar/stellar-sdk'; +import type { KnownCaip19AssetIdOrSlip44Id } from '../../../api'; +import { KnownCaip2ChainId } from '../../../api'; +import { getSlip44AssetId, logger } from '../../../utils'; +import { NetworkService } from '../../network'; +import { State } from '../../state/State'; +import { generateStellarAddress } from '../../wallet/__mocks__/wallet.fixtures'; import { Transaction } from '../Transaction'; +import { TransactionBuilder } from '../TransactionBuilder'; +import { TransactionRepository } from '../TransactionRepository'; +import { TransactionService } from '../TransactionService'; + +export const createMockTransactionService = () => { + const networkService = new NetworkService({ logger }); + const transactionBuilder = new TransactionBuilder({ logger }); + const transactionService = new TransactionService({ + logger, + transactionRepository: new TransactionRepository( + new State({ + encrypted: false, + defaultState: { + transactions: {}, + }, + }), + ), + networkService, + }); + + const transactionRepositorySaveSpy = jest.spyOn( + TransactionRepository.prototype, + 'save', + ); + const transactionRepositorySaveManySpy = jest.spyOn( + TransactionRepository.prototype, + 'saveMany', + ); + const transactionServiceFindByAccountsSpy = jest.spyOn( + TransactionService.prototype, + 'findByAccounts', + ); + return { + transactionService, + networkService, + transactionBuilder, + transactionRepositorySaveSpy, + transactionRepositorySaveManySpy, + transactionServiceFindByAccountsSpy, + }; +}; + +export type GenerateMockTransactionOverrides = Partial<{ + id: string; + account: string; + fromAddress: string; + toAddress: string; + amount: string; + asset: { + type: KnownCaip19AssetIdOrSlip44Id; + symbol: string; + }; + scope: KnownCaip2ChainId; + timestamp: number; + status: TransactionStatus; + type: TransactionType; + fees: KeyringTransaction['fees']; + events: KeyringTransaction['events']; +}>; + +/** + * Generate mock transactions. + * + * @param count - The number of transactions to generate. + * @param overrides - The overrides for the transactions. + * @returns The generated transactions. + */ +export function generateMockTransactions( + count: number = 1, + overrides: GenerateMockTransactionOverrides = {}, +): KeyringTransaction[] { + return Array.from({ length: count }, () => { + const timestamp = overrides.timestamp ?? Math.floor(Date.now() / 1000); + const scope = overrides.scope ?? KnownCaip2ChainId.Mainnet; + const assetType = + overrides.asset?.type ?? getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const assetSymbol = overrides.asset?.symbol ?? 'XLM'; + const amount = overrides.amount ?? '10000000'; + + return { + type: overrides.type ?? TransactionType.Send, + id: overrides.id ?? globalThis.crypto.randomUUID(), + from: [ + { + address: overrides.fromAddress ?? generateStellarAddress(), + asset: { + unit: assetSymbol, + type: assetType, + amount, + fungible: true, + }, + }, + ], + to: [ + { + address: overrides.toAddress ?? generateStellarAddress(), + asset: { + unit: assetSymbol, + type: assetType, + amount, + fungible: true, + }, + }, + ], + events: overrides.events ?? [ + { + status: TransactionStatus.Unconfirmed, + timestamp, + }, + ], + chain: scope, + status: overrides.status ?? TransactionStatus.Unconfirmed, + account: overrides.account ?? globalThis.crypto.randomUUID(), + timestamp, + fees: overrides.fees ?? [], + }; + }); +} // --- Declarative classic (Stellar) transaction builder for tests --- diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/index.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/index.ts new file mode 100644 index 00000000..406766c0 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/index.ts @@ -0,0 +1,6 @@ +export * from './OperationMapper'; +export * from './exceptions'; +export * from './Transaction'; +export * from './TransactionBuilder'; +export * from './TransactionRepository'; +export * from './TransactionService'; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts new file mode 100644 index 00000000..f18e4d70 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts @@ -0,0 +1,98 @@ +import { parseCaipAssetType } from '@metamask/utils'; +import { Asset } from '@stellar/stellar-sdk'; + +import { + TransactionScopeNotMatchException, + TransactionValidationException, +} from './exceptions'; +import type { Transaction } from './Transaction'; +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip2ChainId, +} from '../../api'; +import { + isClassicAssetId, + isSlip44Id, + parseClassicAssetCodeIssuer, +} from '../../utils'; + +/** + * Returns the Stellar asset for the given CAIP-19 asset ID. + * SEP-41 is not supported. + * + * @param assetId - The CAIP-19 asset ID. + * @returns The Stellar asset. + * @throws If the asset is not slip44 or classic asset. + */ +export function caip19ToStellarAsset( + assetId: KnownCaip19AssetIdOrSlip44Id, +): Asset { + if (isSlip44Id(assetId)) { + return Asset.native(); + } + if (isClassicAssetId(assetId)) { + const { assetReference } = parseCaipAssetType(assetId); + const { assetCode, assetIssuer } = + parseClassicAssetCodeIssuer(assetReference); + return new Asset(assetCode, assetIssuer); + } + throw new Error(`Invalid asset id: ${assetId}`); +} + +/** + * Ensures the envelope’s network matches the expected chain before network I/O or simulation. + * + * @param transaction - Wrapped Stellar transaction. + * @param expectedScope - CAIP-2 chain ID the caller intends. + * @throws {TransactionScopeNotMatchException} When {@link Transaction.scope} differs from `expectedScope`. + */ +export function assertTransactionScope( + transaction: Transaction, + expectedScope: KnownCaip2ChainId, +): void { + const transactionScope = transaction.scope; + if (transactionScope !== expectedScope) { + throw new TransactionScopeNotMatchException( + expectedScope, + transactionScope, + ); + } +} + +/** + * Ensures the given wallet account appears on the envelope as source or fee source. + * + * @param transaction - Wrapped Stellar transaction. + * @param accountId - Wallet account id expected to be involved. + * @throws {TransactionValidationException} When the wallet account is not on the envelope. + */ +export function assertTransactionSourceAccount( + transaction: Transaction, + accountId: string, +): void { + if (transaction.isSourceAccount(accountId)) { + return; + } + throw new TransactionValidationException( + 'Transaction does not involve this wallet account as source account or fee source', + ); +} + +/** + * Ensures the given wallet account is involved by tx source, fee source, or any operation source. + * + * @param transaction - Wrapped Stellar transaction. + * @param accountId - Wallet account id expected to participate in signing. + * @throws {TransactionValidationException} When the wallet account is not involved in the transaction. + */ +export function assertAccountInvolvesTransaction( + transaction: Transaction, + accountId: string, +): void { + if (transaction.hasParticipatingAccount(accountId)) { + return; + } + throw new TransactionValidationException( + 'Transaction does not involve this wallet account', + ); +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts new file mode 100644 index 00000000..3a74b464 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts @@ -0,0 +1,82 @@ +import { KnownCaip2ChainId } from '../../api'; +import { AppConfig } from '../../config'; +import type { Locale } from '../../utils'; +import { FALLBACK_LANGUAGE, getPreferences } from '../../utils'; + +const NetworkName = { + [KnownCaip2ChainId.Mainnet]: 'Mainnet', + [KnownCaip2ChainId.Testnet]: 'Testnet', +}; + +/** + * Converts a CAIP-2 chain id to a network name string. + * + * @param scope - The CAIP-2 chain id. + * @returns The network name string. + */ +export function getNetworkName(scope: KnownCaip2ChainId): string { + return NetworkName[scope] ?? 'Unknown'; +} + +/** + * Formats an origin for display purposes. + * + * @param origin - The origin string to format (e.g., 'metamask', 'https://example.com'). + * @returns The formatted origin string (e.g., 'MetaMask', 'example.com'). + */ +export function formatOrigin(origin: string | undefined): string { + if (!origin) { + return 'Unknown'; + } + + // Special case: format 'metamask' as 'MetaMask' (case-insensitive) + if (origin.toLowerCase() === 'metamask') { + return 'MetaMask'; + } + + // Try to extract hostname from URL + try { + return new URL(origin).hostname; + } catch { + // If not a valid URL, return the original value + // This shouldn't happen if validation is working correctly + return origin; + } +} + +/** + * Gets the locale from the preferences. + * + * @returns The locale. + */ +export async function getLocale(): Promise { + return ( + ((await getPreferences() + .then((preferences) => preferences.locale) + .catch(() => FALLBACK_LANGUAGE)) as Locale) ?? FALLBACK_LANGUAGE + ); +} + +/** + * Gets the classic asset explorer url for a given asset reference. + * + * @param assetReference - The asset reference. + * @returns The classic asset explorer url. + */ +export function getClassicAssetExplorerUrl(assetReference: string): string { + return `${ + AppConfig.networks[AppConfig.selectedNetwork].explorerBaseUrl + }/asset/${assetReference}`; +} + +/** + * Gets the SEP-41 asset explorer url for a given asset reference. + * + * @param assetReference - The asset reference. + * @returns The SEP-41 asset explorer url. + */ +export function getSepAssetExplorerUrl(assetReference: string): string { + return `${ + AppConfig.networks[AppConfig.selectedNetwork].explorerBaseUrl + }/contract/${assetReference}`; +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx new file mode 100644 index 00000000..eb7a6349 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx @@ -0,0 +1,110 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { + Address, + Box, + Button, + Container, + Footer, + Heading, + Icon, + Image, + Section, + Text as SnapText, + Tooltip, +} from '@metamask/snaps-sdk/jsx'; +import type { CaipAccountId } from '@metamask/utils'; + +import { ConfirmSignMessageFormNames } from './events'; +import type { KnownCaip2ChainId } from '../../../../api'; +import type { StellarKeyringAccount } from '../../../../services/account'; +import type { Locale } from '../../../../utils'; +import { i18n } from '../../../../utils'; +import { STELLAR_IMAGE } from '../../../images/icon'; +import { getNetworkName } from '../../utils'; + +export type ConfirmSignMessageProps = { + message: string; + account: StellarKeyringAccount; + scope: KnownCaip2ChainId; + locale: Locale; + networkImage: string | null; + origin: string; +}; + +export const ConfirmSignMessage = ({ + message, + account, + scope, + locale, + networkImage, + origin, +}: ConfirmSignMessageProps): ComponentOrElement => { + const translate = i18n(locale); + const { address } = account; + const addressCaip10 = `${scope}:${address}` as `0x${string}` | CaipAccountId; + + return ( + + + + {null} + + {translate('confirmation.signMessage.title')} + + {null} + + +
+ + {translate('confirmation.signMessage.message')} + + {message} +
+ +
+ {origin ? ( + + + + {translate('confirmation.origin')} + + + + + + {origin} + + ) : null} + + + {translate('confirmation.account')} + +
+ + + + {translate('confirmation.network')} + + + + {getNetworkName(scope)} + + +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/events.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/events.tsx new file mode 100644 index 00000000..127eae45 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/events.tsx @@ -0,0 +1,50 @@ +import type { + UserInputUiEventHandler, + UserInputUiEventHandlerContext, +} from '../../../../handlers/user-input/api'; +import { resolveInterface } from '../../../../utils'; + +/** + * Handles the click event for the cancel button. + * + * @param options - The user input handler context from `onUserInput`. + * @returns A promise that resolves when the interface has been updated. + */ +async function onCancelButtonClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id } = options; + await resolveInterface(id, false); +} + +/** + * Handles the click event for the confirm button. + * + * @param options - The user input handler context from `onUserInput`. + * @returns A promise that resolves when the interface has been updated. + */ +async function onConfirmButtonClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id } = options; + await resolveInterface(id, true); +} + +export enum ConfirmSignMessageFormNames { + Cancel = 'confirm-sign-message-cancel', + Confirm = 'confirm-sign-message-confirm', +} + +/** + * Create event handlers bound to a SnapClient instance. + * + * @returns Object containing event handlers. + */ +export function createEventHandlers(): Record { + return { + [ConfirmSignMessageFormNames.Cancel]: async (options) => + onCancelButtonClick(options), + [ConfirmSignMessageFormNames.Confirm]: async (options) => + onConfirmButtonClick(options), + }; +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx new file mode 100644 index 00000000..6d300d04 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx @@ -0,0 +1,175 @@ +import type { GetPreferencesResult } from '@metamask/snaps-sdk'; +import { bytesToBase64, stringToBytes } from '@metamask/utils'; + +import { render } from './render'; +import { KnownCaip2ChainId } from '../../../../api'; +import { MultichainMethod } from '../../../../handlers/keyring'; +import type { SignMessageRequest } from '../../../../handlers/keyring'; +import type { StellarKeyringAccount } from '../../../../services/account'; +import { generateMockStellarKeyringAccounts } from '../../../../services/account/__mocks__/account.fixtures'; +import * as snapUtils from '../../../../utils/snap'; + +/** + * Helper function to convert string to base64. + * + * @param str - The string to convert. + * @returns Base64 encoded string. + */ +function toBase64(str: string): string { + return bytesToBase64(stringToBytes(str)); +} + +describe('ConfirmSignMessage render', () => { + const mockAccount = generateMockStellarKeyringAccounts( + 1, + 'entropy-source-1', + )[0] as StellarKeyringAccount; + const mockPreferences: GetPreferencesResult = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + showTestnets: false, + }; + + const createSnapSpies = () => { + const createInterfaceSpy = jest.spyOn(snapUtils, 'createInterface'); + const showDialogSpy = jest.spyOn(snapUtils, 'showDialog'); + const getPreferencesSpy = jest.spyOn(snapUtils, 'getPreferences'); + + createInterfaceSpy.mockResolvedValue('interface-id-123'); + showDialogSpy.mockResolvedValue(true); + getPreferencesSpy.mockResolvedValue(mockPreferences); + + return { + createInterfaceSpy, + showDialogSpy, + getPreferencesSpy, + }; + }; + + it('renders the confirmation dialog with correct props', async () => { + const { createInterfaceSpy, showDialogSpy, getPreferencesSpy } = + createSnapSpies(); + const testOrigin = 'https://example.com'; + const testMessage = 'Hello, Stellar!'; + + const request: SignMessageRequest = { + id: '00000000-0000-4000-8000-000000000001', + origin: testOrigin, + account: mockAccount.id, + scope: KnownCaip2ChainId.Mainnet, + request: { + method: MultichainMethod.SignMessage, + params: { + message: toBase64(testMessage), + }, + }, + }; + + await render(request, mockAccount); + + // Verify createInterface and showDialog were called correctly + expect(createInterfaceSpy).toHaveBeenCalledTimes(1); + expect(showDialogSpy).toHaveBeenCalledWith('interface-id-123'); + + // Verify the message was decoded correctly (we can't easily check the full JSX tree) + // So we verify the render function was called with correct inputs + expect(getPreferencesSpy).toHaveBeenCalled(); + }); + + it('uses fallback locale when preferences fail to load', async () => { + const { createInterfaceSpy, getPreferencesSpy } = createSnapSpies(); + getPreferencesSpy.mockRejectedValue(new Error('Failed to load')); + + const request: SignMessageRequest = { + id: '00000000-0000-4000-8000-000000000003', + origin: 'https://test.com', + account: mockAccount.id, + scope: KnownCaip2ChainId.Mainnet, + request: { + method: MultichainMethod.SignMessage, + params: { + message: toBase64('Test'), + }, + }, + }; + + await render(request, mockAccount); + + // Should still create interface even when preferences fail + expect(createInterfaceSpy).toHaveBeenCalledTimes(1); + expect(getPreferencesSpy).toHaveBeenCalled(); + }); + + it('handles missing origin gracefully', async () => { + const { createInterfaceSpy } = createSnapSpies(); + const request: SignMessageRequest = { + id: '00000000-0000-4000-8000-000000000004', + origin: undefined as any, + account: mockAccount.id, + scope: KnownCaip2ChainId.Mainnet, + request: { + method: MultichainMethod.SignMessage, + params: { + message: toBase64('Test message'), + }, + }, + }; + + await render(request, mockAccount); + + // Should create interface even with missing origin (formatOrigin handles it) + expect(createInterfaceSpy).toHaveBeenCalledTimes(1); + }); + + it('returns the dialog promise', async () => { + const expectedResult = true; + const { showDialogSpy } = createSnapSpies(); + showDialogSpy.mockResolvedValue(expectedResult); + + const request: SignMessageRequest = { + id: '00000000-0000-4000-8000-000000000006', + origin: 'https://test.com', + account: mockAccount.id, + scope: KnownCaip2ChainId.Mainnet, + request: { + method: MultichainMethod.SignMessage, + params: { + message: toBase64('Test'), + }, + }, + }; + + const result = await render(request, mockAccount); + + expect(result).toBe(expectedResult); + }); + + it('passes STELLAR_IMAGE as network image', async () => { + const { createInterfaceSpy } = createSnapSpies(); + const request: SignMessageRequest = { + id: '00000000-0000-4000-8000-000000000007', + origin: 'https://test.com', + account: mockAccount.id, + scope: KnownCaip2ChainId.Mainnet, + request: { + method: MultichainMethod.SignMessage, + params: { + message: toBase64('Test'), + }, + }, + }; + + await render(request, mockAccount); + + // Verify interface was created with TRX image + expect(createInterfaceSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.tsx new file mode 100644 index 00000000..060acb3d --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.tsx @@ -0,0 +1,64 @@ +import type { DialogResult } from '@metamask/snaps-sdk'; + +import { ConfirmSignMessage } from './ConfirmSignMessage'; +import type { SignMessageRequest } from '../../../../handlers/keyring'; +import type { StellarKeyringAccount } from '../../../../services/account'; +import { + bufferToUint8Array, + createInterface, + showDialog, +} from '../../../../utils'; +import { isBase64 } from '../../../../utils/string'; +import { STELLAR_IMAGE } from '../../../images/icon'; +import { formatOrigin, getLocale } from '../../utils'; + +/** + * Decodes a message to UTF-8. + * + * @param message - The message to decode. + * @returns The decoded message. + */ +function getUtf8Message(message: string): string { + if (isBase64(message)) { + return bufferToUint8Array(message, 'base64').toString('utf8'); + } + return message; +} + +/** + * Renders the confirmation dialog for a sign message request. + * + * @param request - The keyring request to confirm. + * @param account - The account that the request is for. + * @returns The confirmation dialog result. + */ +export async function render( + request: SignMessageRequest, + account: StellarKeyringAccount, +): Promise { + const { + request: { + params: { message }, + }, + scope, + origin, + } = request; + + const locale = await getLocale(); + + const id = await createInterface( + , + {}, + ); + + const dialogPromise = showDialog(id); + + return dialogPromise; +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx new file mode 100644 index 00000000..bb225a6c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -0,0 +1,222 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { + Address, + Box, + Button, + Container, + Footer, + Heading, + Icon, + Image, + Section, + Text as SnapText, + Tooltip, + Link, + Divider, +} from '@metamask/snaps-sdk/jsx'; +import type { Json, CaipAccountId } from '@metamask/utils'; +import { isNullOrUndefined } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { ConfirmSignTransactionFormNames } from './events'; +import type { KnownCaip2ChainId } from '../../../../api'; +import type { StellarKeyringAccount } from '../../../../services/account'; +import { + OperationMapper, + type Transaction, +} from '../../../../services/transaction'; +import type { Locale, LocalizedMessage } from '../../../../utils'; +import { i18n, parseClassicAssetCodeIssuer } from '../../../../utils'; +import { STELLAR_IMAGE } from '../../../images/icon'; +import { getClassicAssetExplorerUrl, getNetworkName } from '../../utils'; + +export type ConfirmSignTransactionProps = { + transaction: Transaction; + account: StellarKeyringAccount; + scope: KnownCaip2ChainId; + locale: Locale; + networkImage: string | null; + origin: string; +}; + +const AmountRow = ({ amount }: { amount: string }): ComponentOrElement => { + return {new BigNumber(amount).toString()}; +}; + +const AssetRow = ({ + asset, + amount, +}: { + asset: string; + amount?: string; +}): ComponentOrElement => { + let assetRow; + if (asset === 'native') { + assetRow = {'Native'}; + } else { + const { assetCode } = parseClassicAssetCodeIssuer(asset); + assetRow = ( + ${assetCode} + ); + } + + if (amount === undefined) { + return assetRow; + } + return ( + + {new BigNumber(amount).toString()} + {assetRow} + + ); +}; + +const AddressRow = ({ + address, + scope, +}: { + address: string; + scope: KnownCaip2ChainId; +}): ComponentOrElement => { + const addressCaip10 = `${scope}:${address}` as `0x${string}` | CaipAccountId; + return
; +}; + +const RenderReadableParamValue = (params: { + type: string; + value: Json; + scope: KnownCaip2ChainId; +}): ComponentOrElement | null => { + const { type, value, scope } = params; + if (isNullOrUndefined(value)) { + return null; + } + switch (type) { + case 'assetWithAmount': + if (Array.isArray(value)) { + return ( + + ); + } + return null; + case 'address': + return ; + case 'amount': + return ; + case 'asset': + return ; + default: + if (Array.isArray(value)) { + // We only have string arrays in the params, so we can safely join them. + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return {value.join(', ')}; + } else if (typeof value === 'object') { + return {JSON.stringify(value)}; + } + return {String(value)}; + } +}; + +export const ConfirmSignTransction = ({ + transaction, + account, + scope, + locale, + networkImage, + origin, +}: ConfirmSignTransactionProps): ComponentOrElement => { + const t = i18n(locale); + const { address } = account; + const addressCaip10 = `${scope}:${address}` as `0x${string}` | CaipAccountId; + + const readableTransaction = new OperationMapper().mapTransaction(transaction); + + return ( + + + + {null} + {t('confirmation.signTransaction.title')} + {null} + + +
+ {origin ? ( + + + + {t('confirmation.origin')} + + + + + + {origin} + + ) : null} + + + {t('confirmation.account')} + +
+ + + + {t('confirmation.network')} + + + + {getNetworkName(scope)} + + +
+ +
+ {[...readableTransaction.operations] + .slice(0, 2) + .map((operationJson, index) => ( + + + {t( + `confirmation.transaction.${operationJson.type.toLowerCase()}` as LocalizedMessage, + )} + + {operationJson.params.map((param) => + isNullOrUndefined(param.value) ? null : ( + + + {t( + `confirmation.transaction.param.${param.key}` as LocalizedMessage, + )} + + + + ), + )} + + {index < operationJson.params.length - 1 && } + + ))} + +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/events.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/events.tsx new file mode 100644 index 00000000..9a32db64 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/events.tsx @@ -0,0 +1,50 @@ +import type { + UserInputUiEventHandler, + UserInputUiEventHandlerContext, +} from '../../../../handlers/user-input/api'; +import { resolveInterface } from '../../../../utils'; + +/** + * Handles the click event for the cancel button. + * + * @param options - The user input handler context from `onUserInput`. + * @returns A promise that resolves when the interface has been updated. + */ +async function onCancelButtonClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id } = options; + await resolveInterface(id, false); +} + +/** + * Handles the click event for the confirm button. + * + * @param options - The user input handler context from `onUserInput`. + * @returns A promise that resolves when the interface has been updated. + */ +async function onConfirmButtonClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id } = options; + await resolveInterface(id, true); +} + +export enum ConfirmSignTransactionFormNames { + Cancel = 'confirm-sign-transaction-cancel', + Confirm = 'confirm-sign-transaction-confirm', +} + +/** + * Create event handlers bound to a SnapClient instance. + * + * @returns Object containing event handlers. + */ +export function createEventHandlers(): Record { + return { + [ConfirmSignTransactionFormNames.Cancel]: async (options) => + onCancelButtonClick(options), + [ConfirmSignTransactionFormNames.Confirm]: async (options) => + onConfirmButtonClick(options), + }; +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx new file mode 100644 index 00000000..fd00370b --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx @@ -0,0 +1,43 @@ +import type { DialogResult } from '@metamask/snaps-sdk'; + +import { ConfirmSignTransction } from './ConfirmSignTransaction'; +import type { SignTransactionRequest } from '../../../../handlers/keyring'; +import type { StellarKeyringAccount } from '../../../../services/account'; +import type { Transaction } from '../../../../services/transaction'; +import { createInterface, showDialog } from '../../../../utils'; +import { STELLAR_IMAGE } from '../../../images/icon'; +import { formatOrigin, getLocale } from '../../utils'; + +/** + * Renders the confirmation dialog for a sign transaction request. + * + * @param request - The keyring request to confirm. + * @param transaction - The transaction to show in the confirmation UI. + * @param account - The account that the request is for. + * @returns The confirmation dialog result. + */ +export async function render( + request: SignTransactionRequest, + transaction: Transaction, + account: StellarKeyringAccount, +): Promise { + const { scope, origin } = request; + + const locale = await getLocale(); + + const id = await createInterface( + , + {}, + ); + + const dialogPromise = showDialog(id); + + return dialogPromise; +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/images/icon.svg b/merged-packages/stellar-wallet-snap/src/ui/images/icon.svg new file mode 100644 index 00000000..02afb7b7 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/images/icon.svg @@ -0,0 +1 @@ +Asset 1 \ No newline at end of file diff --git a/merged-packages/stellar-wallet-snap/src/ui/images/icon.tsx b/merged-packages/stellar-wallet-snap/src/ui/images/icon.tsx new file mode 100644 index 00000000..3c9020c3 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/images/icon.tsx @@ -0,0 +1,3 @@ +import stellarIconSvg from './icon.svg'; + +export const STELLAR_IMAGE: string = stellarIconSvg; From d1e21081944fca25d03fcdb217541865844ea97f Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:57:53 +0800 Subject: [PATCH 039/384] fix: typo --- .../stellar-wallet-snap/src/handlers/keyring/keyring.ts | 5 ++++- .../stellar-wallet-snap/src/handlers/keyring/signMessage.ts | 4 ++-- .../views/ConfirmSignTransaction/ConfirmSignTransaction.tsx | 2 +- .../ui/confirmation/views/ConfirmSignTransaction/render.tsx | 4 ++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 9d26b689..5b3a3f17 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -234,7 +234,10 @@ export class KeyringHandler implements Keyring { next: nextSignature, }; } catch (error: unknown) { - this.#logger.logErrorWithDetails('Error listing account transactions', error); + this.#logger.logErrorWithDetails( + 'Error listing account transactions', + error, + ); throw new Error( `Error listing account transactions: ${ensureError(error).message}`, ); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts index c2f1e3a7..24128fa3 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts @@ -48,7 +48,7 @@ export class SignMessageHandler extends WithKeyringRequestActiveAccountResolve< ): Promise { const { wallet, account } = resolved; - if (!(await this.#confrimation(request, account))) { + if (!(await this.#confirmation(request, account))) { throw new UserRejectedRequestError() as unknown as Error; } @@ -59,7 +59,7 @@ export class SignMessageHandler extends WithKeyringRequestActiveAccountResolve< return { signature }; } - async #confrimation( + async #confirmation( request: SignMessageRequest, account: StellarKeyringAccount, ): Promise { diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx index bb225a6c..c7d10139 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -117,7 +117,7 @@ const RenderReadableParamValue = (params: { } }; -export const ConfirmSignTransction = ({ +export const ConfirmSignTransaction = ({ transaction, account, scope, diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx index fd00370b..d0174430 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx @@ -1,6 +1,6 @@ import type { DialogResult } from '@metamask/snaps-sdk'; -import { ConfirmSignTransction } from './ConfirmSignTransaction'; +import { ConfirmSignTransaction } from './ConfirmSignTransaction'; import type { SignTransactionRequest } from '../../../../handlers/keyring'; import type { StellarKeyringAccount } from '../../../../services/account'; import type { Transaction } from '../../../../services/transaction'; @@ -26,7 +26,7 @@ export async function render( const locale = await getLocale(); const id = await createInterface( - Date: Tue, 14 Apr 2026 16:41:58 +0800 Subject: [PATCH 040/384] chore: add cache service and price service --- .../src/services/cache/InMemoryCache.ts | 182 ++++ .../src/services/cache/InMemoryState.ts | 46 + .../src/services/cache/StateCache.test.ts | 807 ++++++++++++++++++ .../src/services/cache/StateCache.ts | 270 ++++++ .../cache/__mocks__/cache.fixtures.ts | 32 + .../src/services/cache/api.ts | 109 +++ .../src/services/cache/index.ts | 6 + .../src/services/cache/useCache.test.ts | 247 ++++++ .../src/services/cache/useCache.ts | 107 +++ .../src/services/cache/useCacheUntil.test.ts | 333 ++++++++ .../src/services/cache/useCacheUntil.ts | 120 +++ .../src/services/price/PriceService.test.ts | 254 ++++++ .../src/services/price/PriceService.ts | 120 +++ .../src/services/price/index.ts | 2 + .../price/price-api/PriceApiClient.test.ts | 328 +++++++ .../price/price-api/PriceApiClient.ts | 212 +++++ .../src/services/price/price-api/api.test.ts | 248 ++++++ .../src/services/price/price-api/api.ts | 248 ++++++ .../services/price/price-api/exceptions.ts | 6 + .../src/services/price/price-api/index.ts | 3 + .../stellar-wallet-snap/src/utils/async.ts | 18 + 21 files changed, 3698 insertions(+) create mode 100644 merged-packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/cache/InMemoryState.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/cache/StateCache.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/cache/__mocks__/cache.fixtures.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/cache/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/cache/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/cache/useCache.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/cache/useCache.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/cache/useCacheUntil.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/cache/useCacheUntil.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/price/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/price/price-api/api.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/price/price-api/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/price/price-api/exceptions.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/price/price-api/index.ts diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts b/merged-packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts new file mode 100644 index 00000000..311bdd4f --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts @@ -0,0 +1,182 @@ +import { assert } from '@metamask/utils'; + +import type { ICache } from './api'; +import type { CacheEntry } from './api'; +import type { ILogger } from '../../utils/logger'; +import type { Serializable } from '../../utils/serialization'; + +/** + * A simple in-memory cache implementation supporting TTL (Time To Live) functionality. + * + * WARNINGS: + * - This cache is not persistent and will be lost when the process is restarted. + */ +export class InMemoryCache implements ICache { + readonly #cache: Map = new Map(); + + public readonly logger: ILogger; + + constructor(logger: ILogger) { + this.logger = logger; + } + + #validateTtlOrThrow(ttlMilliseconds?: number): void { + if (ttlMilliseconds === undefined) { + return; + } + + if (typeof ttlMilliseconds !== 'number') { + throw new Error('TTL must be a number'); + } + + if (ttlMilliseconds < 0) { + throw new Error('TTL must be positive'); + } + + if (ttlMilliseconds > Number.MAX_SAFE_INTEGER) { + throw new Error('TTL must be less than 2^53 - 1'); + } + } + + #isExpired(cacheEntry: CacheEntry): boolean { + return cacheEntry.expiresAt < Date.now(); + } + + async #cleanupExpiredEntries(): Promise { + const expiredKeys: string[] = []; + for (const [key, entry] of this.#cache.entries()) { + if (this.#isExpired(entry)) { + expiredKeys.push(key); + } + } + await this.mdelete(expiredKeys); + } + + async get(key: string): Promise { + const result = await this.mget([key]); + return result[key]; + } + + async set( + key: string, + value: Serializable, + ttlMilliseconds = Number.MAX_SAFE_INTEGER, + ): Promise { + this.#validateTtlOrThrow(ttlMilliseconds); + + this.#cache.set(key, { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }); + } + + async delete(key: string): Promise { + const result = await this.mdelete([key]); + return result[key] ?? false; + } + + async clear(): Promise { + this.#cache.clear(); + } + + async has(key: string): Promise { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + return false; + } + + if (this.#isExpired(cacheEntry)) { + this.#cache.delete(key); + return false; + } + + return true; + } + + async keys(): Promise { + await this.#cleanupExpiredEntries(); + return Array.from(this.#cache.keys()); + } + + async size(): Promise { + await this.#cleanupExpiredEntries(); + return this.#cache.size; + } + + async peek(key: string): Promise { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + return undefined; + } + + if (this.#isExpired(cacheEntry)) { + this.#cache.delete(key); + return undefined; + } + + return cacheEntry.value; + } + + async mget( + keys: string[], + ): Promise> { + await this.#cleanupExpiredEntries(); + + const result: Record = {}; + + for (const key of keys) { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + this.logger.info(`[InMemoryCache] ❌ Cache miss for key "${key}"`); + result[key] = undefined; + continue; + } + + this.logger.info(`[InMemoryCache] 🎉 Cache hit for key "${key}"`); + result[key] = cacheEntry.value; + } + + return result; + } + + async mset( + entries: { key: string; value: Serializable; ttlMilliseconds?: number }[], + ): Promise { + if (entries.length === 0) { + return; + } + + if (entries.length === 1) { + assert(entries[0]); // Enforce type narrowing as TS cannot infer that entries[0] is defined + const { key, value, ttlMilliseconds } = entries[0]; + await this.set(key, value, ttlMilliseconds); + return; + } + + entries.forEach(({ ttlMilliseconds }) => { + this.#validateTtlOrThrow(ttlMilliseconds); + }); + + entries.forEach(({ key, value, ttlMilliseconds }) => { + if (value === undefined) { + return; + } + this.#cache.set(key, { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }); + }); + } + + async mdelete(keys: string[]): Promise> { + return Object.fromEntries( + keys.map((key) => [key, this.#cache.delete(key)]), + ); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/InMemoryState.ts b/merged-packages/stellar-wallet-snap/src/services/cache/InMemoryState.ts new file mode 100644 index 00000000..3e503ab9 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/cache/InMemoryState.ts @@ -0,0 +1,46 @@ +import { get, set, unset } from 'lodash'; + +import type { Serializable } from '../../utils/serialization'; +import type { IStateManager } from '../state/IStateManager'; + +/** + * A simple implementation of the `IStateManager` interface that relies on an in memory state that can be used for testing purposes. + */ +export class InMemoryState< + TStateValue extends Record, +> implements IStateManager { + #state: TStateValue; + + constructor(initialState: TStateValue) { + this.#state = initialState; + } + + async get(): Promise { + return this.#state; + } + + async getKey( + key: string, + ): Promise { + const value = get(this.#state, key); + + return value as TResponse | undefined; + } + + async setKey(key: string, value: Serializable): Promise { + set(this.#state, key, value); // Use lodash to set the value using a json path + } + + async update( + callback: (state: TStateValue) => TStateValue, + ): Promise { + this.#state = callback(this.#state); + + return this.#state; + } + + async deleteKey(key: string): Promise { + // Using lodash's unset to leverage the json path capabilities + unset(this.#state, key); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts b/merged-packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts new file mode 100644 index 00000000..a8db5874 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts @@ -0,0 +1,807 @@ +/* eslint-disable jest/prefer-strict-equal */ +/* eslint-disable @typescript-eslint/naming-convention */ +import { InMemoryState } from './InMemoryState'; +import type { StateValue } from './StateCache'; +import { StateCache } from './StateCache'; +import { logger } from '../../utils/logger'; +import type { IStateManager } from '../state'; + +jest.mock('../../utils/logger'); + +describe('StateCache', () => { + const createStateCache = (state: IStateManager) => + new StateCache(state, logger); + + describe('constructor', () => { + it('uses the default prefix if not specified', () => { + const cache = new StateCache(new InMemoryState({}), logger); + + expect(cache.prefix).toBe('__cache__default'); + }); + + it('uses the specified prefix if provided', () => { + const cache = new StateCache( + new InMemoryState({}), + logger, + '__cache__my-prefix', + ); + + expect(cache.prefix).toBe('__cache__my-prefix'); + }); + }); + + describe('get', () => { + it('returns undefined if the cache is not initialized', async () => { + const stateWithNoCache = new InMemoryState({ + name: 'John', // State has some data that is not related to the cache + // __cache__default: {} // State has not been initialized with cached data + }); + const cache = new StateCache(stateWithNoCache, logger); + + const value = await cache.get('someKey'); + + expect(value).toBeUndefined(); + }); + + it('returns undefined if the cache is initialized but the key is not present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const value = await cache.get('someOtherKey'); + + expect(value).toBeUndefined(); + }); + + it('returns the cached value if the cache is initialized and the key is present and not expired', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, // Expires in a long time + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const value = await cache.get('someKey'); + + expect(value).toBe('someValue'); + }); + + it('returns undefined if the cache is initialized and the key is present but expired', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const value = await cache.get('someKey'); + + expect(value).toBeUndefined(); + }); + + it('deletes expired cache entries upon retrieval', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + await cache.get('someKey'); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: {}, + }); + }); + }); + + describe('set', () => { + it('initializes the cache if it is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.set('someKey', 'someValue'); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('sets the cache entry with no expiration if no ttl is provided', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + + await cache.set('someKey', 'someValue'); + const stateValue = await stateWithCache.get(); + + const value = await cache.get('someKey'); + + expect(value).toBe('someValue'); + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('overwrites the cache entry if it is present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + await cache.set('someKey', 'someOtherValue'); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('sets the cache entry with the provided ttl', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + jest.spyOn(Date, 'now').mockReturnValueOnce(1704067200000); // January 1, 2024 + + await cache.set('someKey', 'someValue', 1000); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067201000, // January 1, 2024 + 1 second + }, + }, + }); + }); + + it('supports a ttl of 0', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValue(1704067200000); // January 1, 2024 + + await cache.set('someKey', 'someValue', 0); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 (+ 0 seconds) + }, + }, + }); + + // Change the mock to return a time after the expiration + mockDateNow.mockReturnValue(1704067200001); // January 1, 2024 + 1 millisecond + + const value = await cache.get('someKey'); // Should expire immediately + expect(value).toBeUndefined(); + + mockDateNow.mockRestore(); + }); + + it('throws an error if the ttl is not a number', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + + await expect( + cache.set('someKey', 'someValue', 'not a number' as unknown as number), + ).rejects.toThrow('TTL must be a number'); + }); + + it('throws an error if the ttl is negative', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + + await expect(cache.set('someKey', 'someValue', -1)).rejects.toThrow( + 'TTL must be positive', + ); + }); + + it('throws an error if the ttl is too large', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: {}, + }); + const cache = createStateCache(stateWithCache); + + await expect( + cache.set('someKey', 'someValue', Number.MAX_SAFE_INTEGER + 1), + ).rejects.toThrow('TTL must be less than 2^53 - 1'); + }); + }); + + describe('delete', () => { + it('deletes the cache entry and returns true if the entry was present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.delete('someKey'); + expect(result).toBe(true); + + const value = await cache.get('someKey'); + + expect(value).toBeUndefined(); + }); + + it('leaves the cache unchanged and returns false if the entry was not present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.delete('someOtherKey'); // Try to + const someKeyValue = await cache.get('someKey'); + const someOtherKeyValue = await cache.get('someOtherKey'); + + expect(result).toBe(false); + expect(someKeyValue).toBe('someValue'); + expect(someOtherKeyValue).toBeUndefined(); + }); + }); + + describe('clear', () => { + it('empties the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + createdAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + await cache.clear(); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: {}, + }); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.clear(); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: {}, + }); + }); + }); + + describe('has', () => { + it('returns true if the key is present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.has('someKey'); + + expect(result).toBe(true); + }); + + it('returns false if the key is not present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.has('someOtherKey'); + expect(result).toBe(false); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.has('someKey'); + expect(result).toBe(false); + }); + }); + + describe('keys', () => { + it('returns all keys in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.keys(); + + expect(result).toStrictEqual(['someKey', 'someOtherKey']); + }); + + it('returns an empty array if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.keys(); + + expect(result).toStrictEqual([]); + }); + }); + + describe('size', () => { + it('returns the number of items in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.size(); + + expect(result).toBe(2); + }); + + it('returns 0 if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.size(); + + expect(result).toBe(0); + }); + }); + + describe('peek', () => { + it('returns the value of an unexpired key if it is present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.peek('someKey'); + + expect(result).toBe('someValue'); + }); + + it('returns the value of an expired key if it is present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.peek('someKey'); + + expect(result).toBe('someValue'); + }); + + it('returns undefined if the key is not present in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.peek('someOtherKey'); + + expect(result).toBeUndefined(); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.peek('someKey'); + expect(result).toBeUndefined(); + }); + }); + + describe('mget', () => { + it('returns the values of the keys if they are present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: 'someValue', + someOtherKey: 'someOtherValue', + }); + }); + + it('returns undefined for keys that are not present in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toEqual({ + someKey: 'someValue', + someOtherKey: undefined, + }); + }); + + it('returns undefined for keys that are expired', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + }, + }); + const cache = createStateCache(stateWithCache); + + // Mock Date.now to return a time after the expiration + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValue(1704067200001); // January 1, 2024 + 1 millisecond + + const result = await cache.mget(['someKey']); + + expect(result).toEqual({ + someKey: undefined, + }); + + mockDateNow.mockRestore(); + }); + + it('returns an empty object if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({}); + }); + + it('deletes expired cache entries upon retrieval', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: 1704067200000, // January 1, 2024 + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + // Mock Date.now to return a time after the expiration + const mockDateNow = jest + .spyOn(Date, 'now') + .mockReturnValue(1704067200001); // January 1, 2024 + 1 millisecond + + await cache.mget(['someKey']); + const stateValue = await stateWithCache.get(); + + expect(stateValue).toStrictEqual({ + __cache__default: { + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + + mockDateNow.mockRestore(); + }); + }); + + describe('mset', () => { + it('sets the values of the keys if they are present in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.mset([ + { key: 'someKey', value: 'someValue' }, + { key: 'someOtherKey', value: 'someOtherValue' }, + ]); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: 'someValue', + someOtherKey: 'someOtherValue', + }); + }); + + it('does not store undefined values in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.mset([ + { key: 'someKey', value: 'someValue' }, + { key: 'undefinedKey', value: undefined }, + ]); + + const result = await cache.mget(['someKey', 'undefinedKey']); + + expect(result).toEqual({ + someKey: 'someValue', + undefinedKey: undefined, + }); + + // Verify the undefined value was not stored in the cache + const stateValue = await stateWithCache.get(); + expect(stateValue).toStrictEqual({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + }); + + it('stores null values in the cache', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.mset([{ key: 'someKey', value: null }]); + + const result = await cache.mget(['someKey']); + + expect(result).toStrictEqual({ + someKey: null, + }); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await cache.mset([{ key: 'someKey', value: 'someValue' }]); + + const result = await cache.mget(['someKey']); + + expect(result).toStrictEqual({ + someKey: 'someValue', + }); + }); + + it('throws an error if the ttl is invalid', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + await expect( + cache.mset([ + { + key: 'someKey', + value: 'someValue', + ttlMilliseconds: 'not a number' as unknown as number, + }, + ]), + ).rejects.toThrow('TTL must be a number'); + }); + + it('does not affect other keys in the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey0: { + value: 'someValue0', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someKey1: { + value: 'someValue1', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + await cache.mset([ + { key: 'someKey0', value: 'someValue0Overwritten' }, + { key: 'someKey2', value: 'someValue2' }, + ]); + + const result = await cache.mget(['someKey0', 'someKey1', 'someKey2']); + + expect(result).toStrictEqual({ + someKey0: 'someValue0Overwritten', + someKey1: 'someValue1', + someKey2: 'someValue2', + }); + }); + + it('no-ops if no entries are provided', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + const updateSpy = jest.spyOn(stateWithCache, 'update'); + + await cache.mset([]); + + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('defers to set if there is only one entry', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + const setSpy = jest.spyOn(cache, 'set'); + + const singleEntry = { + key: 'someKey', + value: 'someValue', + ttlMilliseconds: 1000, + }; + await cache.mset([singleEntry]); + + expect(setSpy).toHaveBeenCalledWith( + singleEntry.key, + singleEntry.value, + singleEntry.ttlMilliseconds, + ); + }); + }); + + describe('mdelete', () => { + it('deletes the keys from the cache', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + someOtherKey: { + value: 'someOtherValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + await cache.mdelete(['someKey', 'someOtherKey']); + + const result = await cache.mget(['someKey', 'someOtherKey']); + + expect(result).toEqual({ + someKey: undefined, + someOtherKey: undefined, + }); + }); + + it('returns an object where the values are true if the keys were deleted and false if they were not present', async () => { + const stateWithCache = new InMemoryState({ + __cache__default: { + someKey: { + value: 'someValue', + expiresAt: Number.MAX_SAFE_INTEGER, + }, + }, + }); + const cache = createStateCache(stateWithCache); + + const result = await cache.mdelete(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: true, + someOtherKey: false, + }); + }); + + it('does not throw an error if the cache is not initialized', async () => { + const stateWithCache = new InMemoryState({}); + const cache = createStateCache(stateWithCache); + + const result = await cache.mdelete(['someKey', 'someOtherKey']); + + expect(result).toStrictEqual({ + someKey: false, + someOtherKey: false, + }); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/StateCache.ts b/merged-packages/stellar-wallet-snap/src/services/cache/StateCache.ts new file mode 100644 index 00000000..a2d4fcb7 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/cache/StateCache.ts @@ -0,0 +1,270 @@ +import { assert } from '@metamask/utils'; + +import type { ICache, CacheEntry } from './api'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { Serializable } from '../../utils/serialization'; +import type { IStateManager } from '../state/IStateManager'; + +/** + * The whole cache store. + */ +export type CacheStore = Record | undefined; + +/** + * A prefix for the cache "location" in the state. Enforced to start with `__cache__` to avoid collisions with other state values. + */ +export type CachePrefix = `__cache__${string}`; + +/** + * Describes the shape of the whole state inside which the cache is stored. + */ +export type StateValue = { + [x: string]: Serializable; +} & { + [K in CachePrefix]?: CacheStore; +}; + +/** + * A cache that wraps any implementation of the `IStateManager` interface to store the cache. + * + * It is intended to be used with the snap's `State` class, but can be used with any other implementation of the `IStateManager` interface. For instance it can be used with the `InMemoryState` class for testing purposes. + * + * By default, it stores its data in the `__cache__default` property of the state, but you can specify any other prefix you want, provided it starts with `__cache__` to avoid collisions with other state values. + * This is useful if you want to have multiple independent caches in the same state. + * + * ``` + * { + * ..., // other state values + * __cache__default: { + * key1: value1, + * key2: value2, + * }, + * __cache__my-prefix: { + * key3: value3, + * key4: value4, + * }, + * } + * ``` + * + * @example + * ```ts + * const state = new State({}); // Here we use the real snap's state + * const cache = new StateCache(state, '__cache__my-prefix'); + * + * // state looks like this: + * // { + * // ..., // other state values + * // no __cache__my-prefix yet + * // } + * + * await cache.set('key1', 'value1'); + * + * // state looks like this: + * // { + * // ..., // other state values + * // __cache__my-prefix: { + * // key1: value1, + * // }, + * // } + * ``` + */ +export class StateCache implements ICache { + readonly #state: IStateManager; + + public readonly prefix: CachePrefix; + + public readonly logger: ILogger; + + constructor( + state: IStateManager, + logger: ILogger, + prefix: CachePrefix = '__cache__default', + ) { + this.#state = state; + this.logger = createPrefixedLogger(logger, '[💾 StateCache]'); + this.prefix = prefix; + } + + async get(key: string): Promise { + const result = await this.mget([key]); + return result[key]; + } + + async set( + key: string, + value: Serializable, + ttlMilliseconds = Number.MAX_SAFE_INTEGER, + ): Promise { + this.#validateTtlOrThrow(ttlMilliseconds); + + await this.#state.setKey(`${this.prefix}.${key}`, { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }); + } + + #validateTtlOrThrow(ttlMilliseconds?: number): void { + if (ttlMilliseconds === undefined) { + return; + } + + if (typeof ttlMilliseconds !== 'number') { + throw new Error('TTL must be a number'); + } + + if (ttlMilliseconds < 0) { + throw new Error('TTL must be positive'); + } + + if (ttlMilliseconds > Number.MAX_SAFE_INTEGER) { + throw new Error('TTL must be less than 2^53 - 1'); + } + } + + async delete(key: string): Promise { + const result = await this.mdelete([key]); + return result[key] ?? false; + } + + async clear(): Promise { + await this.#state.setKey(this.prefix, {}); + } + + async has(key: string): Promise { + const result = await this.get(key); + return result !== undefined; + } + + async keys(): Promise { + const cacheStore = await this.#state.getKey(this.prefix); + + return Object.keys(cacheStore ?? {}); + } + + async size(): Promise { + const cacheStore = await this.#state.getKey(this.prefix); + + return Object.keys(cacheStore ?? {}).length; + } + + async peek(key: string): Promise { + const cacheStore = await this.#state.getKey(this.prefix); + const cacheEntry = cacheStore?.[key]; + + return cacheEntry?.value; + } + + async mget( + keys: string[], + ): Promise> { + const cacheStore = await this.#state.getKey(this.prefix); + + // If cache is not initialized, return empty object + if (!cacheStore) { + return {}; + } + + const keysAndValues = Object.entries(cacheStore).filter(([key]) => + keys.includes(key), + ); + + const expiredKeys = keysAndValues.filter( + ([_unused, cacheEntry]) => + cacheEntry && cacheEntry.expiresAt < Date.now(), + ); + + await this.mdelete(expiredKeys.map(([key]) => key)); + + const result: Record = {}; + + // First, handle keys that exist in the cache + keysAndValues.forEach(([key, cacheEntry]) => { + if (cacheEntry === undefined) { + this.logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); + result[key] = undefined; + return; + } + + if (cacheEntry.expiresAt < Date.now()) { + this.logger.info(`[StateCache] ⌛ Cache expired for key "${key}"`); + result[key] = undefined; + } else { + this.logger.info(`[StateCache] 🎉 Cache hit for key "${key}"`); + result[key] = cacheEntry.value; + } + }); + + // Then, handle keys that don't exist in the cache + keys.forEach((key) => { + if (!(key in result)) { + this.logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); + result[key] = undefined; + } + }); + + return result; + } + + async mset( + entries: { key: string; value: Serializable; ttlMilliseconds?: number }[], + ): Promise { + if (entries.length === 0) { + return; + } + + if (entries.length === 1) { + assert(entries[0]); // Enforce type narrowing as TS cannot infer that entries[0] is defined + const { key, value, ttlMilliseconds } = entries[0]; + await this.set(key, value, ttlMilliseconds); + return; + } + + entries.forEach(({ ttlMilliseconds }) => { + this.#validateTtlOrThrow(ttlMilliseconds); + }); + + // Using `state.update` is preferred for bulk `set`s, because it's more efficient and atomic. + await this.#state.update((stateValue) => { + const cacheStore = stateValue[this.prefix] ?? {}; + entries.forEach(({ key, value, ttlMilliseconds }) => { + if (value === undefined) { + return; + } + cacheStore[key] = { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }; + }); + stateValue[this.prefix] = cacheStore; + return stateValue; + }); + } + + async mdelete(keys: string[]): Promise> { + const result: Record = {}; + + // Using `state.update` is preferred for bulk `delete`s, because it's more efficient and atomic. + await this.#state.update((stateValue) => { + const cacheStore = stateValue[this.prefix] ?? {}; + keys.forEach((key) => { + if (cacheStore[key] === undefined) { + result[key] = false; + } else { + delete cacheStore[key]; + result[key] = true; + } + }); + stateValue[this.prefix] = cacheStore; + return stateValue; + }); + + return result; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/__mocks__/cache.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/cache/__mocks__/cache.fixtures.ts new file mode 100644 index 00000000..147c74f7 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/cache/__mocks__/cache.fixtures.ts @@ -0,0 +1,32 @@ +import type { Serializable } from '../../../utils/serialization'; +import type { ICache } from '../api'; + +/** + * In-memory {@link ICache} for tests (jest.fn wrappers + backing map). + * + * @returns Cache implementation and backing map for priming reads. + */ +export function createMemoryCache(): { + cache: ICache; + store: Map; +} { + const store = new Map(); + const cache: ICache = { + get: jest.fn(async (key: string) => store.get(key)), + set: jest.fn(async (key: string, value: Serializable) => { + store.set(key, value); + }), + delete: jest.fn(async () => false), + clear: jest.fn(async () => { + store.clear(); + }), + has: jest.fn(async () => false), + keys: jest.fn(async () => [...store.keys()]), + size: jest.fn(async () => store.size), + peek: jest.fn(async (key: string) => store.get(key)), + mget: jest.fn(async () => ({})), + mset: jest.fn(async () => undefined), + mdelete: jest.fn(async () => ({})), + } as unknown as ICache; + return { cache, store }; +} diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/api.ts b/merged-packages/stellar-wallet-snap/src/services/cache/api.ts new file mode 100644 index 00000000..8d7fd886 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/cache/api.ts @@ -0,0 +1,109 @@ +import type { Serializable } from '../../utils/serialization'; + +export type TimestampMilliseconds = number; + +/** + * A single cache entry. + */ +export type CacheEntry = { + value: Serializable; + expiresAt: TimestampMilliseconds; +}; + +/** + * Interface for a generic cache implementation. + * + * @template TValue - The type of values stored in the cache + */ +export type ICache = { + /** + * Retrieves a value from the cache by key. + * + * @param key - The key to retrieve + * @returns The value if found, undefined if not found + */ + get(key: string): Promise; + + /** + * Stores a value in the cache with an optional TTL. + * - If a value is undefined, it will not be stored in the cache. + * - If a value is null, it will be stored in the cache. + * + * @param key - The key to store the value under + * @param value - The value to store + * @param ttlMilliseconds - Optional time-to-live in milliseconds. If not provided, the value will not expire. + * @throws Error if any entry's ttlMilliseconds is not a number, is negative, or is greater than 2^53 - 1 + */ + set(key: string, value: TValue, ttlMilliseconds?: number): Promise; + + /** + * Removes a value from the cache. + * + * @param key - The key to remove + * @returns true if the key was found and removed, false otherwise + */ + delete(key: string): Promise; + + /** + * Removes all values from the cache. + */ + clear(): Promise; + + /** + * Checks if a key exists in the cache. + * + * @param key - The key to check + * @returns true if the key exists, false otherwise + */ + has(key: string): Promise; + + /** + * Returns all keys currently in the cache. + * + * @returns Array of keys + */ + keys(): Promise; + + /** + * Returns the number of items in the cache. + * + * @returns The number of items + */ + size(): Promise; + + /** + * Retrieves a value from the cache without affecting its TTL or last accessed time. + * + * @param key - The key to peek at + * @returns The value if found, undefined if not found + */ + peek(key: string): Promise; + + /** + * Retrieves multiple values from the cache in a single operation. + * + * @param keys - Array of keys to retrieve + * @returns Object mapping keys to their values (or undefined if not found) + */ + mget(keys: string[]): Promise>; + + /** + * Stores multiple values in the cache in a single operation. + * - If a value is undefined, it will not be stored in the cache. + * - If a value is null, it will be stored in the cache. + * + * @param entries - Array of entries to store, each with key, value, and optional TTL (if not provided, the value will not expire) + * @throws Error if any entry's ttlMilliseconds is not a number, is negative, or is greater than 2^53 - 1 + */ + mset( + entries: { key: string; value: TValue; ttlMilliseconds?: number }[], + ): Promise; + + /** + * Removes multiple values from the cache. + * + * @param keys - Array of keys to remove + * @returns An object mapping each key to a boolean indicating whether it was found and removed + */ + mdelete(keys: string[]): Promise>; +}; diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/index.ts b/merged-packages/stellar-wallet-snap/src/services/cache/index.ts new file mode 100644 index 00000000..bc3f0ef0 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/cache/index.ts @@ -0,0 +1,6 @@ +export * from './StateCache'; +export * from './InMemoryCache'; +export type * from './ICache'; +export type * from './api'; +export * from './useCacheUntil'; +export * from './useCache'; diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/useCache.test.ts b/merged-packages/stellar-wallet-snap/src/services/cache/useCache.test.ts new file mode 100644 index 00000000..3f5c0911 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/cache/useCache.test.ts @@ -0,0 +1,247 @@ +import type { ICache } from './api'; +import { useCache, type CacheOptions } from './useCache'; +import type { Serializable } from '../../utils/serialization'; + +jest.mock('../../utils/logger'); + +describe('useCache', () => { + // Spy to check if the original function was executed or not + let actualExecutionSpy: jest.Mock; + + // Mock cache + let cache: ICache; + + // Common cache options + let cacheOptions: CacheOptions; + + // Original test functions + let testFunction: () => Promise; + let testFunctionWithArgs: (arg1: string, arg2: number) => Promise; + let testFunctionWithComplexArgs: (obj: { + name: string; + age: number; + }) => Promise; + + // Cached versions + let cachedTestFunction: () => Promise; + let cachedTestFunctionWithArgs: ( + arg1: string, + arg2: number, + ) => Promise; + let cachedTestFunctionWithComplexArgs: (obj: { + name: string; + age: number; + }) => Promise; + + beforeEach(() => { + // Reset mocks for each test + actualExecutionSpy = jest.fn().mockResolvedValue('test'); + + // Create a mock cache + cache = { + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + } as unknown as ICache; + + // Define common cache options + cacheOptions = { + ttlMilliseconds: 1000, + functionName: 'testFunction', + }; + + // Define original functions + testFunction = async () => actualExecutionSpy(); + testFunctionWithArgs = async (arg1: string, arg2: number) => + actualExecutionSpy(arg1, arg2); + testFunctionWithComplexArgs = async (obj: { name: string; age: number }) => + actualExecutionSpy(obj); + + // Create cached versions + cachedTestFunction = useCache(testFunction, cache, { + ...cacheOptions, + functionName: 'testFunction', + }); + + cachedTestFunctionWithArgs = useCache(testFunctionWithArgs, cache, { + ...cacheOptions, + functionName: 'testFunctionWithArgs', + }); + + cachedTestFunctionWithComplexArgs = useCache( + testFunctionWithComplexArgs, + cache, + { + ...cacheOptions, + functionName: 'testFunctionWithComplexArgs', + }, + ); + }); + + describe('when the data is not cached', () => { + it('should cache the result of a function', async () => { + // No cached data + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(cache.get).toHaveBeenCalledTimes(1); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 1000); + }); + }); + + describe('when the data is cached', () => { + it('should return the cached result', async () => { + // Init the cache with some data + jest.spyOn(cache, 'get').mockResolvedValue('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(cache.get).toHaveBeenCalledTimes(1); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('should propagate errors from the original function', async () => { + const error = new Error('Test error'); + actualExecutionSpy.mockRejectedValueOnce(error); + + await expect(cachedTestFunction()).rejects.toThrow('Test error'); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('should handle cache get errors gracefully', async () => { + jest.spyOn(cache, 'get').mockRejectedValueOnce(new Error('Cache error')); + actualExecutionSpy.mockResolvedValueOnce('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 1000); + }); + + it('should handle cache set errors gracefully', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + jest + .spyOn(cache, 'set') + .mockRejectedValueOnce(new Error('Cache set error')); + actualExecutionSpy.mockResolvedValueOnce('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('different argument types', () => { + it('should handle primitive arguments correctly', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + jest.spyOn(cache, 'set').mockResolvedValueOnce(undefined); + actualExecutionSpy.mockResolvedValueOnce('test with args'); + + const result = await cachedTestFunctionWithArgs('hello', 42); + + expect(result).toBe('test with args'); + expect(cache.get).toHaveBeenCalledWith('testFunctionWithArgs:"hello":42'); + expect(actualExecutionSpy).toHaveBeenCalledWith('hello', 42); + }); + + it('should handle complex object arguments correctly', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + jest.spyOn(cache, 'set').mockResolvedValueOnce(undefined); + const testObj = { name: 'John', age: 30 }; + actualExecutionSpy.mockResolvedValueOnce('test with complex args'); + + const result = await cachedTestFunctionWithComplexArgs(testObj); + + expect(result).toBe('test with complex args'); + expect(cache.get).toHaveBeenCalledWith( + 'testFunctionWithComplexArgs:{"name":"John","age":30}', + ); + expect(actualExecutionSpy).toHaveBeenCalledWith(testObj); + }); + }); + + describe('custom generateCacheKey', () => { + it('should use a custom key generator if provided', async () => { + const customKeyGenerator = jest.fn().mockReturnValue('custom-key'); + + const customCachedFunction = useCache(testFunction, cache, { + ...cacheOptions, + generateCacheKey: customKeyGenerator, + }); + + await customCachedFunction(); + + expect(customKeyGenerator).toHaveBeenCalledTimes(1); + expect(cache.get).toHaveBeenCalledWith('custom-key'); + }); + }); + + describe('anonymous functions', () => { + it('should handle anonymous functions with a default name', async () => { + // Anonymous function with no name + const anonymousFunction = async () => actualExecutionSpy(); + Object.defineProperty(anonymousFunction, 'name', { value: null }); + + const cachedAnonymousFunction = useCache(anonymousFunction, cache, { + ttlMilliseconds: 1000, + }); + + await cachedAnonymousFunction(); + + expect(cache.get).toHaveBeenCalledWith('anonymousFunction:'); + }); + }); + + describe('function name override', () => { + it('should use the provided function name if given', async () => { + const cachedWithCustomName = useCache(testFunction, cache, { + ttlMilliseconds: 1000, + functionName: 'customFunctionName', + }); + + await cachedWithCustomName(); + + expect(cache.get).toHaveBeenCalledWith('customFunctionName:'); + }); + }); + + describe('falsy but valid cache values', () => { + it('should handle falsy but valid cache values (false, 0, empty string)', async () => { + // Test with false + jest.spyOn(cache, 'get').mockResolvedValue(false); + let result = await cachedTestFunction(); + expect(result).toBe(false); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + + // Test with 0 + jest.spyOn(cache, 'get').mockResolvedValue(0); + result = await cachedTestFunction(); + expect(result).toBe(0); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + + // Test with empty string + jest.spyOn(cache, 'get').mockResolvedValue(''); + result = await cachedTestFunction(); + expect(result).toBe(''); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + }); + + it('should execute the function when cache returns undefined', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + actualExecutionSpy.mockResolvedValueOnce('test'); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/useCache.ts b/merged-packages/stellar-wallet-snap/src/services/cache/useCache.ts new file mode 100644 index 00000000..56c9bd2a --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/cache/useCache.ts @@ -0,0 +1,107 @@ +/* eslint-disable no-void */ + +import type { ICache } from './api'; +import { createPrefixedLogger, logger } from '../../utils/logger'; +import { serialize, type Serializable } from '../../utils/serialization'; + +const cacheLogger = createPrefixedLogger(logger, 'useCache'); +/** + * Options for configuring the caching behavior of a function. + */ +export type CacheOptions = { + /** + * The time to live for the cache in milliseconds. + */ + ttlMilliseconds: number; + /** + * Set this if you want to use a custom function name for the cache key. + */ + functionName?: string; + /** + * Optional function to generate the cache key for the function call. + * Defaults to a function that generates the key based on function name and JSON stringified args separated by colons. + */ + generateCacheKey?: (functionName: string, args: Serializable[]) => string; + + /** + * Whether to refresh the cache. + * Defaults to false. + */ + refreshCache?: boolean; +}; + +/** + * Default function to generate the cache key for a function call. + * + * @param functionName - The name of the function. + * @param args - The arguments of the function call. + * @returns The cache key. + */ +const defaultGenerateCacheKey = ( + functionName: string, + args: Serializable[], +): string => + `${functionName}:${args.map((arg) => JSON.stringify(serialize(arg))).join(':')}`; + +/** + * Wraps a function with caching behavior. + * + * @template TArgs - Tuple type representing the arguments of the function. + * @template TResult - The return type of the function, must be Serializable. + * @param fn - The asynchronous function to wrap. Must return a Promise. + * @param cache - The cache instance to use. + * @param options - The caching options. + * @param options.ttlMilliseconds - The time to live for the cache in milliseconds. + * @param options.refreshCache - Whether to refresh the cache. + * @param options.functionName - The name of the function. + * @param options.generateCacheKey - Optional function to generate the cache key. + * @returns A new asynchronous function with caching behavior. + */ +export const useCache = < + TArgs extends Serializable[], + TResult extends Serializable, +>( + fn: (...args: TArgs) => Promise, + cache: ICache, + { + ttlMilliseconds, + functionName, + generateCacheKey, + refreshCache = false, + }: CacheOptions, +): ((...args: TArgs) => Promise) => { + // Use provided key generator or default, adapting the default to use the function's name + const _generateCacheKey = generateCacheKey ?? defaultGenerateCacheKey; + + // Get the function name for the default key generator, handle anonymous functions + const _functionName = functionName ?? fn.name ?? 'anonymousFunction'; + + return async (...args: TArgs): Promise => { + const cacheKey = _generateCacheKey(_functionName, args); + // Check if the data is cached + if (!refreshCache) { + try { + const cached = await cache.get(cacheKey); + // Check explicitly for undefined, as null or other falsy values might be valid cache results + if (cached !== undefined) { + // Type assertion because cache stores Serializable, but we expect TResult + return cached as TResult; + } + } catch (error) { + // Log cache get errors but proceed to execute the function + cacheLogger.error(`Cache get error for key "${cacheKey}":`, error); + } + } + + // Execute the original function + const result = await fn(...args); + + // Cache the result, handle potential errors silently + // We don't await this, allowing it to happen in the background + void cache.set(cacheKey, result, ttlMilliseconds).catch((error) => { + cacheLogger.error(`Cache set error for key "${cacheKey}":`, error); + }); + + return result; + }; +}; diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/useCacheUntil.test.ts b/merged-packages/stellar-wallet-snap/src/services/cache/useCacheUntil.test.ts new file mode 100644 index 00000000..366f3b8a --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/cache/useCacheUntil.test.ts @@ -0,0 +1,333 @@ +import type { ICache } from './api'; +import { + useCacheUntil, + type CacheUntilOptions, + type ResultWithExpiry, +} from './useCacheUntil'; +import type { Serializable } from '../../utils/serialization'; + +jest.mock('../../utils/logger'); + +describe('useCacheUntil', () => { + // Spy to check if the original function was executed or not + let actualExecutionSpy: jest.Mock; + + // Mock cache + let cache: ICache; + + // Common cache options + let cacheOptions: CacheUntilOptions; + + // Original test function that returns result with expiry + let testFunction: () => Promise>; + let testFunctionWithArgs: (arg1: string) => Promise>; + + // Cached versions + let cachedTestFunction: () => Promise; + let cachedTestFunctionWithArgs: (arg1: string) => Promise; + + // Mock current time + const mockNow = 1700000000000; // Fixed timestamp for testing + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(mockNow); + + // Reset mocks for each test + actualExecutionSpy = jest.fn().mockResolvedValue({ + result: 'test', + expiresAt: mockNow + 60000, // Expires in 60 seconds + }); + + // Create a mock cache + cache = { + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + } as unknown as ICache; + + // Define common cache options + cacheOptions = { + functionName: 'testFunction', + }; + + // Define original functions + testFunction = async () => actualExecutionSpy(); + testFunctionWithArgs = async (arg1: string) => actualExecutionSpy(arg1); + + // Create cached versions + cachedTestFunction = useCacheUntil(testFunction, cache, { + ...cacheOptions, + functionName: 'testFunction', + }); + + cachedTestFunctionWithArgs = useCacheUntil(testFunctionWithArgs, cache, { + ...cacheOptions, + functionName: 'testFunctionWithArgs', + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('when the data is not cached', () => { + it('caches the result with TTL calculated from expiresAt', async () => { + // No cached data + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + // TTL should be expiresAt - now = 60000 + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 60000); + }); + + it('uses zero TTL when expiresAt is in the past', async () => { + actualExecutionSpy.mockResolvedValue({ + result: 'test', + expiresAt: mockNow - 1000, // Already expired + }); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + // TTL should be 0 when expiresAt is in the past + expect(cache.set).toHaveBeenCalledWith('testFunction:', 'test', 0); + }); + }); + + describe('when the data is cached and not expired', () => { + it('returns the cached result without calling the function', async () => { + // First call to populate the cache and expiry map + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + await cachedTestFunction(); + + // Reset mocks + actualExecutionSpy.mockClear(); + jest.spyOn(cache, 'get').mockResolvedValue('cached-test'); + + // Second call within expiry period + const result = await cachedTestFunction(); + + expect(result).toBe('cached-test'); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + expect(cache.set).toHaveBeenCalledTimes(1); // Only from first call + }); + }); + + describe('when the data is cached but expired', () => { + it('fetches fresh data after expiry time has passed', async () => { + // First call to populate the cache + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + await cachedTestFunction(); + + // Advance time past the expiry + jest.setSystemTime(mockNow + 70000); // 70 seconds later + + // Reset mocks for second call + actualExecutionSpy.mockClear(); + actualExecutionSpy.mockResolvedValue({ + result: 'fresh-test', + expiresAt: mockNow + 70000 + 60000, // New expiry + }); + + const result = await cachedTestFunction(); + + expect(result).toBe('fresh-test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('cache key generation', () => { + it('generates cache key with function name and arguments', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + actualExecutionSpy.mockResolvedValue({ + result: 'test with args', + expiresAt: mockNow + 60000, + }); + + await cachedTestFunctionWithArgs('hello'); + + expect(cache.set).toHaveBeenCalledWith( + 'testFunctionWithArgs:"hello"', + 'test with args', + 60000, + ); + }); + + it('uses a custom key generator if provided', async () => { + const customKeyGenerator = jest.fn().mockReturnValue('custom-key'); + + const customCachedFunction = useCacheUntil(testFunction, cache, { + ...cacheOptions, + generateCacheKey: customKeyGenerator, + }); + + await customCachedFunction(); + + expect(customKeyGenerator).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('custom-key', 'test', 60000); + }); + }); + + describe('error handling', () => { + it('propagates errors from the original function', async () => { + const error = new Error('Test error'); + actualExecutionSpy.mockRejectedValueOnce(error); + + await expect(cachedTestFunction()).rejects.toThrow('Test error'); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('handles cache get errors gracefully', async () => { + // First call to populate expiry map + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + await cachedTestFunction(); + + // Reset for second call + actualExecutionSpy.mockClear(); + jest.spyOn(cache, 'get').mockRejectedValueOnce(new Error('Cache error')); + actualExecutionSpy.mockResolvedValue({ + result: 'test', + expiresAt: mockNow + 60000, + }); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + + it('handles cache set errors gracefully', async () => { + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + jest + .spyOn(cache, 'set') + .mockRejectedValueOnce(new Error('Cache set error')); + + const result = await cachedTestFunction(); + + expect(result).toBe('test'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('anonymous functions', () => { + it('handles anonymous functions with a default name', async () => { + const anonymousFunction = async (): Promise> => + actualExecutionSpy(); + Object.defineProperty(anonymousFunction, 'name', { value: null }); + + const cachedAnonymousFunction = useCacheUntil(anonymousFunction, cache, { + // No functionName provided + }); + + await cachedAnonymousFunction(); + + expect(cache.set).toHaveBeenCalledWith( + 'anonymousFunction:', + 'test', + 60000, + ); + }); + }); + + describe('function name override', () => { + it('uses the provided function name if given', async () => { + const cachedWithCustomName = useCacheUntil(testFunction, cache, { + functionName: 'customFunctionName', + }); + + await cachedWithCustomName(); + + expect(cache.set).toHaveBeenCalledWith( + 'customFunctionName:', + 'test', + 60000, + ); + }); + }); + + describe('falsy but valid cache values', () => { + it('handles falsy but valid cache values (false, 0, empty string)', async () => { + // First call to populate expiry map with false result + actualExecutionSpy.mockResolvedValue({ + result: false, + expiresAt: mockNow + 60000, + }); + await cachedTestFunction(); + + // Reset and set cache to return false + actualExecutionSpy.mockClear(); + jest.spyOn(cache, 'get').mockResolvedValue(false); + + const result = await cachedTestFunction(); + + expect(result).toBe(false); + expect(actualExecutionSpy).not.toHaveBeenCalled(); + }); + + it('executes the function when cache returns undefined', async () => { + // First call to populate expiry map + await cachedTestFunction(); + + // Reset for second call with undefined cache + actualExecutionSpy.mockClear(); + jest.spyOn(cache, 'get').mockResolvedValue(undefined); + actualExecutionSpy.mockResolvedValue({ + result: 'fresh', + expiresAt: mockNow + 60000, + }); + + const result = await cachedTestFunction(); + + expect(result).toBe('fresh'); + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('maintenance-aligned caching scenario', () => { + it('caches until exact maintenance time and refetches after', async () => { + const maintenanceTime = mockNow + 6 * 60 * 60 * 1000; // 6 hours from now + + actualExecutionSpy.mockResolvedValue({ + result: { energyFee: 420, transactionFee: 1000 }, + expiresAt: maintenanceTime, + }); + + // First call - should fetch and cache + await cachedTestFunction(); + + expect(cache.set).toHaveBeenCalledWith( + 'testFunction:', + { energyFee: 420, transactionFee: 1000 }, + 6 * 60 * 60 * 1000, // 6 hours TTL + ); + + // Advance time to just before maintenance + jest.setSystemTime(maintenanceTime - 1000); + actualExecutionSpy.mockClear(); + jest + .spyOn(cache, 'get') + .mockResolvedValue({ energyFee: 420, transactionFee: 1000 }); + + await cachedTestFunction(); + expect(actualExecutionSpy).not.toHaveBeenCalled(); // Still using cache + + // Advance time past maintenance + jest.setSystemTime(maintenanceTime + 1000); + actualExecutionSpy.mockResolvedValue({ + result: { energyFee: 500, transactionFee: 1200 }, // New values + expiresAt: maintenanceTime + 6 * 60 * 60 * 1000, // Next maintenance + }); + + const freshResult = await cachedTestFunction(); + + expect(actualExecutionSpy).toHaveBeenCalledTimes(1); // Fetched fresh + expect(freshResult).toStrictEqual({ + energyFee: 500, + transactionFee: 1200, + }); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/useCacheUntil.ts b/merged-packages/stellar-wallet-snap/src/services/cache/useCacheUntil.ts new file mode 100644 index 00000000..b97db84d --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/cache/useCacheUntil.ts @@ -0,0 +1,120 @@ +import type { ICache } from './api'; +import { createPrefixedLogger, logger } from '../../utils/logger'; +import { serialize, type Serializable } from '../../utils/serialization'; + +const cacheLogger = createPrefixedLogger(logger, 'useCache'); + +/** + * Result type for functions that provide their own expiry time. + */ +export type ResultWithExpiry = { + result: TResult; + expiresAt: number; // Unix timestamp in milliseconds +}; + +/** + * Options for configuring the caching behavior of a function with dynamic expiry. + */ +export type CacheUntilOptions = { + /** + * Set this if you want to use a custom function name for the cache key. + */ + functionName?: string; + /** + * Optional function to generate the cache key for the function call. + * Defaults to a function that generates the key based on function name and JSON stringified args separated by colons. + */ + generateCacheKey?: (functionName: string, args: Serializable[]) => string; + + /** + * Whether to refresh the cache. + * Defaults to false. + */ + refreshCache?: boolean; +}; + +/** + * Default function to generate the cache key for a function call. + * + * @param functionName - The name of the function. + * @param args - The arguments of the function call. + * @returns The cache key. + */ +const defaultGenerateCacheKey = ( + functionName: string, + args: Serializable[], +): string => + `${functionName}:${args.map((arg) => JSON.stringify(serialize(arg))).join(':')}`; + +/** + * Wraps an async function with caching behavior where expiry is determined + * by the function result itself (dynamic TTL). + * + * Unlike `useCache` which uses a fixed TTL, this utility allows the wrapped + * function to specify when its result expires. This is useful for caching + * data that has known invalidation points (e.g., blockchain maintenance periods). + * + * @template TArgs - Tuple type representing the arguments of the function. + * @template TResult - The return type of the function, must be Serializable. + * @param fn - The asynchronous function to wrap. Must return a Promise>. + * @param cache - The cache instance to use. + * @param options - The caching options. + * @param options.refreshCache - Whether to refresh the cache. + * @param options.functionName - The name of the function. + * @param options.generateCacheKey - Optional function to generate the cache key. + * @returns A new asynchronous function with caching behavior. + */ +export const useCacheUntil = < + TArgs extends Serializable[], + TResult extends Serializable, +>( + fn: (...args: TArgs) => Promise>, + cache: ICache, + { functionName, generateCacheKey, refreshCache = false }: CacheUntilOptions, +): ((...args: TArgs) => Promise) => { + // Use provided key generator or default, adapting the default to use the function's name + const _generateCacheKey = generateCacheKey ?? defaultGenerateCacheKey; + + // Get the function name for the default key generator, handle anonymous functions + const _functionName = functionName ?? fn.name ?? 'anonymousFunction'; + + // Map to track expiry timestamps for each cache key + const expiryMap = new Map(); + + return async (...args: TArgs): Promise => { + const cacheKey = _generateCacheKey(_functionName, args); + const now = Date.now(); + + // Check if cached and not expired + const expiresAt = expiryMap.get(cacheKey); + if (!refreshCache && expiresAt !== undefined && now < expiresAt) { + try { + const cached = await cache.get(cacheKey); + // Check explicitly for undefined, as null or other falsy values might be valid cache results + if (cached !== undefined) { + // Type assertion because cache stores Serializable, but we expect TResult + return cached as TResult; + } + } catch (error) { + // Log cache get errors but proceed to execute the function + cacheLogger.error(`Cache get error for key "${cacheKey}":`, error); + } + } + + // Execute the original function to get result and new expiry + const { result, expiresAt: newExpiresAt } = await fn(...args); + + // Calculate TTL from expiry timestamp + const ttlMilliseconds = Math.max(0, newExpiresAt - now); + + // Store result in cache with calculated TTL + await cache.set(cacheKey, result, ttlMilliseconds).catch((error) => { + cacheLogger.error(`Cache set error for key "${cacheKey}":`, error); + }); + + // Store expiry timestamp + expiryMap.set(cacheKey, newExpiresAt); + + return result; + }; +}; diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts new file mode 100644 index 00000000..abe6e11b --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts @@ -0,0 +1,254 @@ +import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; +import { AppConfig } from '../../config'; +import { logger, serialize } from '../../utils'; +import { + GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + type FiatExchangeRatesResponse, +} from './price-api/api'; +import { PriceApiClient } from './price-api/PriceApiClient'; +import { PriceService } from './PriceService'; +import { createMemoryCache } from '../cache/__mocks__/cache.fixtures'; + +jest.mock('../../utils/logger'); + +const stellarClassicUsdc = + 'stellar:testnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as const satisfies KnownCaip19AssetIdOrSlip44Id; + +const fiatExchangeRatesBody = { + usd: { + name: 'US Dollar', + ticker: 'usd' as const, + value: 1, + currencyType: 'fiat' as const, + }, +} as FiatExchangeRatesResponse; + +const cacheKeySpotPrices = ( + assetIds: KnownCaip19AssetIdOrSlip44Id[], + vsCurrency: string, +) => + `PriceService:getSpotPrices:${JSON.stringify(serialize(assetIds))}:${JSON.stringify(serialize(vsCurrency))}`; + +const cacheKeyFiatExchangeRates = () => 'PriceService:getFiatExchangeRates:'; + +const cacheKeyHistoricalPrices = (params: { + assetType: KnownCaip19AssetIdOrSlip44Id; + timePeriod: string; + from: number; + to: number; + vsCurrency: string; +}) => `PriceService:getHistoricalPrices:${JSON.stringify(serialize(params))}`; + +describe('PriceService', () => { + let getSpotPricesSpy: jest.SpiedFunction; + let getFiatExchangeRatesSpy: jest.SpiedFunction< + PriceApiClient['getFiatExchangeRates'] + >; + let getHistoricalPricesSpy: jest.SpiedFunction< + PriceApiClient['getHistoricalPrices'] + >; + + beforeEach(() => { + getSpotPricesSpy = jest + .spyOn(PriceApiClient.prototype, 'getSpotPrices') + .mockResolvedValue({ [stellarClassicUsdc]: null }); + getFiatExchangeRatesSpy = jest + .spyOn(PriceApiClient.prototype, 'getFiatExchangeRates') + .mockResolvedValue(fiatExchangeRatesBody); + getHistoricalPricesSpy = jest + .spyOn(PriceApiClient.prototype, 'getHistoricalPrices') + .mockResolvedValue(GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('getSpotPrices', () => { + it('calls PriceApiClient and stores result in cache', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + const spotResult = { [stellarClassicUsdc]: null }; + + getSpotPricesSpy.mockResolvedValueOnce(spotResult); + + expect( + await service.getSpotPrices({ + assetIds: [stellarClassicUsdc], + vsCurrency: 'usd', + }), + ).toStrictEqual(spotResult); + + expect(getSpotPricesSpy).toHaveBeenCalledWith( + [stellarClassicUsdc], + 'usd', + ); + const key = cacheKeySpotPrices([stellarClassicUsdc], 'usd'); + expect(cache.set).toHaveBeenCalledWith( + key, + spotResult, + AppConfig.cache.ttlMilliseconds.spotPrices, + ); + }); + + it('returns cached spot prices without calling PriceApiClient', async () => { + const { cache, store } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + const cached = { [stellarClassicUsdc]: null }; + const key = cacheKeySpotPrices([stellarClassicUsdc], 'eur'); + + store.set(key, cached); + + expect( + await service.getSpotPrices({ + assetIds: [stellarClassicUsdc], + vsCurrency: 'eur', + }), + ).toStrictEqual(cached); + + expect(getSpotPricesSpy).not.toHaveBeenCalled(); + }); + + it('calls PriceApiClient when refreshCache is true', async () => { + const { cache, store } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + const key = cacheKeySpotPrices([stellarClassicUsdc], 'usd'); + + store.set(key, { [stellarClassicUsdc]: null }); + + await service.getSpotPrices( + { assetIds: [stellarClassicUsdc], vsCurrency: 'usd' }, + true, + ); + + expect(getSpotPricesSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getFiatExchangeRates', () => { + it('calls PriceApiClient and stores result in cache', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + expect(await service.getFiatExchangeRates()).toStrictEqual( + fiatExchangeRatesBody, + ); + + expect(getFiatExchangeRatesSpy).toHaveBeenCalledTimes(1); + const key = cacheKeyFiatExchangeRates(); + expect(cache.set).toHaveBeenCalledWith( + key, + fiatExchangeRatesBody, + AppConfig.cache.ttlMilliseconds.fiatExchangeRates, + ); + }); + + it('returns cached fiat rates without calling PriceApiClient', async () => { + const { cache, store } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + const key = cacheKeyFiatExchangeRates(); + + store.set(key, fiatExchangeRatesBody); + + expect(await service.getFiatExchangeRates()).toStrictEqual( + fiatExchangeRatesBody, + ); + + expect(getFiatExchangeRatesSpy).not.toHaveBeenCalled(); + }); + + it('calls PriceApiClient when refreshCache is true', async () => { + const { cache, store } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + store.set(cacheKeyFiatExchangeRates(), fiatExchangeRatesBody); + + await service.getFiatExchangeRates(true); + + expect(getFiatExchangeRatesSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('getHistoricalPrices', () => { + const historicalParams = { + assetType: stellarClassicUsdc, + timePeriod: '7d', + from: 1, + to: 2, + vsCurrency: 'usd' as const, + }; + + const historicalRequestPayload = { + assetType: stellarClassicUsdc, + timePeriod: '7d', + from: 1, + to: 2, + vsCurrency: 'usd', + }; + + it('calls PriceApiClient and stores result in cache', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + expect(await service.getHistoricalPrices(historicalParams)).toStrictEqual( + GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + ); + + expect(getHistoricalPricesSpy).toHaveBeenCalledWith( + historicalRequestPayload, + ); + const key = cacheKeyHistoricalPrices(historicalRequestPayload); + expect(cache.set).toHaveBeenCalledWith( + key, + GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + AppConfig.cache.ttlMilliseconds.historicalPrices, + ); + }); + + it('returns cached historical prices without calling PriceApiClient', async () => { + const { cache, store } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + const key = cacheKeyHistoricalPrices(historicalRequestPayload); + + store.set(key, GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT); + + expect(await service.getHistoricalPrices(historicalParams)).toStrictEqual( + GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + ); + + expect(getHistoricalPricesSpy).not.toHaveBeenCalled(); + }); + + it('calls PriceApiClient when refreshCache is true', async () => { + const { cache, store } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + const key = cacheKeyHistoricalPrices(historicalRequestPayload); + + store.set(key, GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT); + + await service.getHistoricalPrices(historicalParams, true); + + expect(getHistoricalPricesSpy).toHaveBeenCalledTimes(1); + }); + + it('defaults vsCurrency and forwards zero from and to', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + expect( + await service.getHistoricalPrices({ + assetType: stellarClassicUsdc, + from: 0, + to: 0, + }), + ).toStrictEqual(GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT); + + expect(getHistoricalPricesSpy).toHaveBeenCalledWith({ + assetType: stellarClassicUsdc, + from: 0, + to: 0, + vsCurrency: 'usd', + }); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts new file mode 100644 index 00000000..c6e98d5b --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts @@ -0,0 +1,120 @@ +import type { ILogger, Serializable } from '../../utils'; +import type { ICache } from '../cache'; +import { useCache } from '../cache'; +import type { + FiatExchangeRatesResponse, + GetHistoricalPricesParams, + GetHistoricalPricesResponse, + SpotPrices, + VsCurrencyParam, +} from './price-api/api'; +import { PriceApiClient } from './price-api/PriceApiClient'; +import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; +import { AppConfig } from '../../config'; + +export class PriceService { + readonly #priceApiClient: PriceApiClient; + + readonly #cache: ICache; + + constructor({ + cache, + logger, + }: { + cache: ICache; + logger: ILogger; + }) { + this.#priceApiClient = new PriceApiClient( + { + baseUrl: AppConfig.api.priceApi.baseUrl, + chunkSize: AppConfig.api.priceApi.chunkSize, + }, + logger, + ); + this.#cache = cache; + } + + /** + * Get the spot prices for a list of asset IDs. + * Results are cached for `AppConfig.cache.ttlMilliseconds.spotPrices`. + * + * @param params - The parameters for the request. + * @param params.assetIds - The asset IDs to get the spot prices for. + * @param params.vsCurrency - The currency to convert the prices to. + * @param refreshCache - Whether to refresh the cache. + * @returns The spot prices for the asset IDs. + */ + async getSpotPrices( + { + assetIds, + vsCurrency = 'usd', + }: { + assetIds: KnownCaip19AssetIdOrSlip44Id[]; + vsCurrency: VsCurrencyParam | string; + }, + refreshCache: boolean = false, + ): Promise> { + return useCache( + this.#priceApiClient.getSpotPrices.bind(this.#priceApiClient), + this.#cache, + { + functionName: 'PriceService:getSpotPrices', + ttlMilliseconds: AppConfig.cache.ttlMilliseconds.spotPrices, + refreshCache, + }, + )(assetIds, vsCurrency); + } + + /** + * Get the fiat exchange rates. + * Results are cached for `AppConfig.cache.ttlMilliseconds.fiatExchangeRates`. + * + * @param refreshCache - Whether to refresh the cache. + * @returns The fiat exchange rates. + */ + async getFiatExchangeRates( + refreshCache: boolean = false, + ): Promise { + return useCache( + this.#priceApiClient.getFiatExchangeRates.bind(this.#priceApiClient), + this.#cache, + { + functionName: 'PriceService:getFiatExchangeRates', + ttlMilliseconds: AppConfig.cache.ttlMilliseconds.fiatExchangeRates, + refreshCache, + }, + )(); + } + + /** + * Get the historical prices for a token. + * Results are cached for `AppConfig.cache.ttlMilliseconds.historicalPrices`. + * + * @param params - The parameters for the request. + * @param params.vsCurrency - Defaults to `usd` when omitted. + * @param refreshCache - Whether to refresh the cache. + * @returns The historical prices for the token. + */ + async getHistoricalPrices( + params: GetHistoricalPricesParams, + refreshCache: boolean = false, + ): Promise { + const { assetType, timePeriod, from, to, vsCurrency = 'usd' } = params; + + return useCache( + this.#priceApiClient.getHistoricalPrices.bind(this.#priceApiClient), + this.#cache, + { + functionName: 'PriceService:getHistoricalPrices', + ttlMilliseconds: AppConfig.cache.ttlMilliseconds.historicalPrices, + refreshCache, + }, + )({ + assetType, + ...(timePeriod !== undefined && { timePeriod }), + ...(from !== undefined && { from }), + ...(to !== undefined && { to }), + vsCurrency, + }); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/price/index.ts b/merged-packages/stellar-wallet-snap/src/services/price/index.ts new file mode 100644 index 00000000..8cec6072 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/price/index.ts @@ -0,0 +1,2 @@ +export * from './PriceService'; +export * from './price-api'; diff --git a/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.test.ts b/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.test.ts new file mode 100644 index 00000000..69839ca0 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.test.ts @@ -0,0 +1,328 @@ +import { GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT } from './api'; +import { PriceApiException } from './exceptions'; +import { PriceApiClient } from './PriceApiClient'; +import { buildUrl, logger } from '../../../utils'; + +jest.mock('../../../utils/logger'); + +const jsonResponse = ( + body: unknown, + init: { ok?: boolean; status?: number } = {}, +): Response => { + const { ok = true, status = ok ? 200 : 500 } = init; + return { + ok, + status, + json: async () => body, + } as Response; +}; + +const baseUrl = 'https://price.test'; + +/** Known-valid Stellar CAIP asset ids (see `api/asset.test.ts`). */ +const stellarClassicUsdc = + 'stellar:testnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as const; + +const stellarSep41 = + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as const; + +const minimalSpotPrice = (id: string, price: number) => ({ + id, + price, +}); + +describe('PriceApiClient', () => { + const mockFetch = jest.fn() as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const createClient = (chunkSize = 10) => + new PriceApiClient({ baseUrl, chunkSize }, logger, mockFetch); + + describe('getFiatExchangeRates', () => { + it('requests fiat exchange rates endpoint and returns parsed body', async () => { + const body = { + usd: { + name: 'US Dollar', + ticker: 'usd' as const, + value: 1, + currencyType: 'fiat' as const, + }, + }; + mockFetch.mockResolvedValueOnce(jsonResponse(body)); + + const client = createClient(); + expect(await client.getFiatExchangeRates()).toStrictEqual(body); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]?.[0]).toBe( + buildUrl({ + baseUrl, + path: '/v1/exchange-rates/fiat', + }), + ); + }); + + it('throws PriceApiException when response is not ok', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({}, { ok: false, status: 502 }), + ); + + const client = createClient(); + await expect(client.getFiatExchangeRates()).rejects.toThrow( + PriceApiException, + ); + }); + + it('throws PriceApiException when response body fails validation', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ invalid: true })); + + const client = createClient(); + await expect(client.getFiatExchangeRates()).rejects.toThrow( + PriceApiException, + ); + }); + + it('throws PriceApiException when fetch rejects', async () => { + mockFetch.mockRejectedValueOnce(new Error('network down')); + + const client = createClient(); + await expect(client.getFiatExchangeRates()).rejects.toThrow( + PriceApiException, + ); + }); + }); + + describe('getSpotPrices', () => { + it('returns empty object when assetIds is empty', async () => { + const client = createClient(); + expect(await client.getSpotPrices([])).toStrictEqual({}); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('requests spot prices with default vsCurrency and includeMarketData', async () => { + const spotBody = { + [stellarClassicUsdc]: minimalSpotPrice('usdc', 1), + }; + mockFetch.mockResolvedValueOnce(jsonResponse(spotBody)); + + const client = createClient(); + expect(await client.getSpotPrices([stellarClassicUsdc])).toStrictEqual( + spotBody, + ); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]?.[0]).toBe( + buildUrl({ + baseUrl, + path: '/v3/spot-prices', + queryParams: { + vsCurrency: 'usd', + assetIds: stellarClassicUsdc, + includeMarketData: 'true', + }, + }), + ); + }); + + it('passes custom vsCurrency in query params', async () => { + const spotBody = { + [stellarClassicUsdc]: minimalSpotPrice('usdc', 1), + }; + mockFetch.mockResolvedValueOnce(jsonResponse(spotBody)); + + const client = createClient(); + await client.getSpotPrices([stellarClassicUsdc], 'eur'); + + expect(mockFetch.mock.calls[0]?.[0]).toBe( + buildUrl({ + baseUrl, + path: '/v3/spot-prices', + queryParams: { + vsCurrency: 'eur', + assetIds: stellarClassicUsdc, + includeMarketData: 'true', + }, + }), + ); + }); + + it('deduplicates assetIds before batching', async () => { + const spotBody = { + [stellarClassicUsdc]: minimalSpotPrice('usdc', 1), + }; + mockFetch.mockResolvedValueOnce(jsonResponse(spotBody)); + + const client = createClient(); + await client.getSpotPrices([stellarClassicUsdc, stellarClassicUsdc]); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const urlArg = mockFetch.mock.calls[0]?.[0] as string; + expect(new URL(urlArg).searchParams.get('assetIds')).toBe( + stellarClassicUsdc, + ); + }); + + it('splits requests by chunkSize and merges batch results', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + [stellarClassicUsdc]: minimalSpotPrice('usdc', 1), + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + [stellarSep41]: minimalSpotPrice('sep41', 0.12), + }), + ); + + const client = createClient(1); + expect( + await client.getSpotPrices([stellarClassicUsdc, stellarSep41]), + ).toStrictEqual({ + [stellarClassicUsdc]: minimalSpotPrice('usdc', 1), + [stellarSep41]: minimalSpotPrice('sep41', 0.12), + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('returns empty spot prices and logs when response is not ok', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({}, { ok: false, status: 503 }), + ); + + const client = createClient(); + expect(await client.getSpotPrices([stellarClassicUsdc])).toStrictEqual( + {}, + ); + }); + + it('returns empty spot prices and logs when response body fails validation', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ notValid: true })); + + const client = createClient(); + expect(await client.getSpotPrices([stellarClassicUsdc])).toStrictEqual( + {}, + ); + }); + + it('returns empty spot prices and logs when fetch rejects', async () => { + const networkError = new Error('network down'); + mockFetch.mockRejectedValueOnce(networkError); + + const client = createClient(); + expect(await client.getSpotPrices([stellarClassicUsdc])).toStrictEqual( + {}, + ); + }); + + it('merges successful batches and skips failed batches', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + [stellarClassicUsdc]: minimalSpotPrice('usdc', 1), + }), + ) + .mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 500 })); + + const client = createClient(1); + expect( + await client.getSpotPrices([stellarClassicUsdc, stellarSep41]), + ).toStrictEqual({ + [stellarClassicUsdc]: minimalSpotPrice('usdc', 1), + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('getHistoricalPrices', () => { + it('requests historical prices with path and query params', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT), + ); + + const client = createClient(); + expect( + await client.getHistoricalPrices({ + assetType: stellarClassicUsdc, + timePeriod: '7d', + from: 1, + to: 2, + vsCurrency: 'usd', + }), + ).toStrictEqual(GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT); + + expect(mockFetch.mock.calls[0]?.[0]).toBe( + buildUrl({ + baseUrl, + path: '/v3/historical-prices/{assetType}', + pathParams: { assetType: stellarClassicUsdc }, + queryParams: { + timePeriod: '7d', + from: '1', + to: '2', + vsCurrency: 'usd', + }, + encodePathParams: false, + }), + ); + }); + + it('includes from and to in query when both are zero', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse(GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT), + ); + + const client = createClient(); + expect( + await client.getHistoricalPrices({ + assetType: stellarClassicUsdc, + from: 0, + to: 0, + }), + ).toStrictEqual(GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT); + + expect(mockFetch.mock.calls[0]?.[0]).toBe( + buildUrl({ + baseUrl, + path: '/v3/historical-prices/{assetType}', + pathParams: { assetType: stellarClassicUsdc }, + queryParams: { + from: '0', + to: '0', + }, + encodePathParams: false, + }), + ); + }); + + it('throws PriceApiException when response is not ok', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({}, { ok: false, status: 502 }), + ); + + const client = createClient(); + await expect( + client.getHistoricalPrices({ + assetType: stellarClassicUsdc, + timePeriod: '7d', + }), + ).rejects.toThrow(PriceApiException); + }); + + it('throws PriceApiException when response body fails validation', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ invalid: true })); + + const client = createClient(); + await expect( + client.getHistoricalPrices({ + assetType: stellarClassicUsdc, + }), + ).rejects.toThrow(PriceApiException); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts b/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts new file mode 100644 index 00000000..f7f6fc5b --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts @@ -0,0 +1,212 @@ +import { assert } from '@metamask/superstruct'; +import type { CaipAssetType } from '@metamask/utils'; +import { CaipAssetTypeStruct } from '@metamask/utils'; + +import type { + FiatExchangeRatesResponse, + GetHistoricalPricesParams, + GetHistoricalPricesResponse, + SpotPrices, + VsCurrencyParam, +} from './api'; +import { + FiatExchangeRatesResponseStruct, + GetHistoricalPricesResponseStruct, + SpotPricesStruct, +} from './api'; +import { PriceApiException } from './exceptions'; +import { UrlStruct } from '../../../api'; +import type { ILogger } from '../../../utils'; +import { + batchesAllSettled, + buildUrl, + chunks as chunkItems, + logger, +} from '../../../utils'; + +export class PriceApiClient { + readonly #fetch: typeof globalThis.fetch; + + readonly #logger: ILogger; + + readonly #baseUrl: string; + + readonly #chunkSize: number; + + static readonly #parallelBatchFetchLimit = 3; + + constructor( + { + baseUrl, + chunkSize, + }: { + baseUrl: string; + chunkSize: number; + }, + _logger: ILogger = logger, + _fetch: typeof globalThis.fetch = globalThis.fetch, + ) { + assert(baseUrl, UrlStruct); + + this.#fetch = _fetch; + this.#logger = _logger; + this.#baseUrl = baseUrl; + this.#chunkSize = chunkSize; + } + + async getFiatExchangeRates(): Promise { + try { + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: '/v1/exchange-rates/fiat', + }); + + const response = await this.#fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + assert(data, FiatExchangeRatesResponseStruct); + + return data; + } catch (error) { + this.#logger.logErrorWithDetails( + 'Error fetching fiat exchange rates', + error, + ); + throw new PriceApiException('Error fetching fiat exchange rates'); + } + } + + /** + * Get the spot prices for a list of asset IDs. + * + * @param assetIds - The asset IDs to get the spot prices for. + * @param vsCurrency - The currency to convert the prices to. + * @returns A promise that resolves to the spot prices for the asset IDs. + * @throws {PriceApiException} If the spot prices cannot be fetched. + */ + async getSpotPrices( + assetIds: CaipAssetType[], + vsCurrency: VsCurrencyParam | string = 'usd', + ): Promise> { + try { + if (assetIds.length === 0) { + return {}; + } + + const deduplicatedAssetIds = [...new Set(assetIds)]; + + // Split into chunks + const chunks = chunkItems(deduplicatedAssetIds, this.#chunkSize); + + const settled = await batchesAllSettled( + chunks, + PriceApiClient.#parallelBatchFetchLimit, + async (chunk) => this.#fetchSpotPricesBatch(chunk, vsCurrency), + ); + + const response: Partial = {}; + for (const entry of settled) { + if (entry.status === 'rejected') { + this.#logger.logErrorWithDetails( + 'Error fetching spot prices', + entry.reason, + ); + continue; + } + for (const [assetId, spotPrice] of Object.entries(entry.value)) { + assert(assetId, CaipAssetTypeStruct); + response[assetId] = spotPrice; + } + } + + return response; + } catch (error) { + this.#logger.logErrorWithDetails('Error fetching spot prices', error); + throw new PriceApiException('Error fetching spot prices'); + } + } + + async #fetchSpotPricesBatch( + assetIds: CaipAssetType[], + vsCurrency: VsCurrencyParam | string = 'usd', + ): Promise { + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: '/v3/spot-prices', + queryParams: { + vsCurrency, + assetIds: assetIds.join(','), + includeMarketData: 'true', + }, + }); + + const response = await this.#fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const spotPrices = await response.json(); + assert(spotPrices, SpotPricesStruct); + + return spotPrices; + } + + /** + * Business logic for `getHistoricalPrices`. + * + * @param params - The parameters for the request. + * @param params.assetType - The asset type of the token. + * @param params.timePeriod - The time period for the historical prices. + * @param params.from - The start date for the historical prices. + * @param params.to - The end date for the historical prices. + * @param params.vsCurrency - The currency to convert the prices to. + * @returns The historical prices for the token. + * @throws {PriceApiException} When the request fails or the response is invalid. + */ + async getHistoricalPrices( + params: GetHistoricalPricesParams, + ): Promise { + try { + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: '/v3/historical-prices/{assetType}', + pathParams: { + assetType: params.assetType, + }, + queryParams: { + ...(params.timePeriod !== undefined && { + timePeriod: params.timePeriod, + }), + ...(params.from !== undefined && { from: params.from.toString() }), + ...(params.to !== undefined && { to: params.to.toString() }), + ...(params.vsCurrency !== undefined && { + vsCurrency: params.vsCurrency, + }), + }, + encodePathParams: false, + }); + + const response = await this.#fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const historicalPrices = await response.json(); + assert(historicalPrices, GetHistoricalPricesResponseStruct); + + return historicalPrices; + } catch (error) { + this.#logger.logErrorWithDetails( + 'Error fetching historical prices', + error, + ); + throw new PriceApiException('Error fetching historical prices'); + } + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/price/price-api/api.test.ts b/merged-packages/stellar-wallet-snap/src/services/price/price-api/api.test.ts new file mode 100644 index 00000000..0327c76f --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/price/price-api/api.test.ts @@ -0,0 +1,248 @@ +import { assert, StructError } from '@metamask/superstruct'; +import { cloneDeep, set } from 'lodash'; + +import { + ExchangeRateStruct, + FiatExchangeRatesResponseStruct, + GetHistoricalPricesParamsStruct, + GetHistoricalPricesResponseStruct, + GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + SpotPriceStruct, + SpotPricesStruct, + type SpotPrices, +} from './api'; + +const stellarClassicUsdc = + 'stellar:testnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as const; + +const validSpotPrices: SpotPrices = { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + id: 'solana', + price: 150, + }, + 'eip155:1/slip44:60': { + id: 'ethereum', + price: 2000, + }, + [stellarClassicUsdc]: null, +}; + +describe('price-api structs', () => { + describe('ExchangeRateStruct', () => { + it('accepts a fiat exchange rate row', () => { + expect(() => + assert( + { + name: 'US Dollar', + ticker: 'usd', + value: 1, + currencyType: 'fiat', + }, + ExchangeRateStruct, + ), + ).not.toThrow(); + }); + + it('rejects negative value', () => { + expect(() => + assert( + { + name: 'US Dollar', + ticker: 'usd', + value: -1, + currencyType: 'fiat', + }, + ExchangeRateStruct, + ), + ).toThrow(StructError); + }); + + it('rejects unknown ticker', () => { + expect(() => + assert( + { + name: 'X', + ticker: 'not-a-ticker', + value: 1, + currencyType: 'crypto', + }, + ExchangeRateStruct, + ), + ).toThrow(StructError); + }); + }); + + describe('FiatExchangeRatesResponseStruct', () => { + it('accepts a record keyed by ticker', () => { + expect(() => + assert( + { + usd: { + name: 'US Dollar', + ticker: 'usd', + value: 1, + currencyType: 'fiat', + }, + btc: { + name: 'Bitcoin', + ticker: 'btc', + value: 50000, + currencyType: 'crypto', + }, + }, + FiatExchangeRatesResponseStruct, + ), + ).not.toThrow(); + }); + + it('rejects invalid top-level key', () => { + expect(() => + assert( + { + notATicker: { + name: 'X', + ticker: 'usd', + value: 1, + currencyType: 'fiat', + }, + }, + FiatExchangeRatesResponseStruct, + ), + ).toThrow(StructError); + }); + }); + + describe('SpotPriceStruct', () => { + it('accepts minimal spot price fields', () => { + expect(() => + assert({ id: 'xlm', price: 0.12 }, SpotPriceStruct), + ).not.toThrow(); + }); + + it('rejects negative price', () => { + expect(() => + assert({ id: 'xlm', price: -0.01 }, SpotPriceStruct), + ).toThrow(StructError); + }); + }); + + describe('SpotPricesStruct', () => { + it('accepts valid spot prices map including null entry', () => { + expect(() => assert(validSpotPrices, SpotPricesStruct)).not.toThrow(); + }); + + it('rejects negative price on an asset', () => { + const spotPricesWithInvalidPrice = cloneDeep(validSpotPrices); + set( + spotPricesWithInvalidPrice, + ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', 'price'], + -4, + ); + + expect(() => + assert(spotPricesWithInvalidPrice, SpotPricesStruct), + ).toThrow(StructError); + }); + + it('rejects invalid CAIP asset key', () => { + expect(() => + assert( + { + 'invalid-asset-key': { id: 'x', price: 1 }, + }, + SpotPricesStruct, + ), + ).toThrow(StructError); + }); + }); + + describe('GetHistoricalPricesParamsStruct', () => { + it('accepts full params', () => { + expect(() => + assert( + { + assetType: stellarClassicUsdc, + timePeriod: '7d', + from: 0, + to: 1, + vsCurrency: 'usd', + }, + GetHistoricalPricesParamsStruct, + ), + ).not.toThrow(); + }); + + it('accepts only required assetType', () => { + expect(() => + assert( + { assetType: stellarClassicUsdc }, + GetHistoricalPricesParamsStruct, + ), + ).not.toThrow(); + }); + + it('rejects invalid timePeriod pattern', () => { + expect(() => + assert( + { + assetType: stellarClassicUsdc, + timePeriod: '0d', + }, + GetHistoricalPricesParamsStruct, + ), + ).toThrow(StructError); + }); + + it('rejects negative from timestamp', () => { + expect(() => + assert( + { + assetType: stellarClassicUsdc, + from: -1, + }, + GetHistoricalPricesParamsStruct, + ), + ).toThrow(StructError); + }); + }); + + describe('GetHistoricalPricesResponseStruct', () => { + it('accepts tuple series arrays', () => { + expect(() => + assert( + { + prices: [ + [1_700_000_000_000, 0.12], + [1_700_006_400_000, 0.13], + ], + marketCaps: [[1_700_000_000_000, 1e9]], + totalVolumes: [[1_700_000_000_000, 5e6]], + }, + GetHistoricalPricesResponseStruct, + ), + ).not.toThrow(); + }); + + it('accepts empty series', () => { + expect(() => + assert( + GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + GetHistoricalPricesResponseStruct, + ), + ).not.toThrow(); + }); + + it('rejects malformed price point', () => { + expect(() => + assert( + { + prices: [[1, 2, 3]], + marketCaps: [], + totalVolumes: [], + }, + GetHistoricalPricesResponseStruct, + ), + ).toThrow(StructError); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/price/price-api/api.ts b/merged-packages/stellar-wallet-snap/src/services/price/price-api/api.ts new file mode 100644 index 00000000..08fec109 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/price/price-api/api.ts @@ -0,0 +1,248 @@ +import type { Infer } from '@metamask/superstruct'; +import { + array, + boolean, + enums, + min, + nullable, + number, + object, + optional, + pattern, + record, + string, + tuple, + union, +} from '@metamask/superstruct'; +import { CaipAssetTypeStruct } from '@metamask/utils'; + +export type PriceApiClientConfig = { + baseUrl: string; +}; + +export const CryptoTickerStruct = enums([ + 'btc', + 'eth', + 'ltc', + 'bch', + 'bnb', + 'eos', + 'xrp', + 'xlm', + 'link', + 'dot', + 'yfi', + 'bits', + 'sats', + 'sol', + 'sei', + 'sonic', +]); + +export const FiatTickerStruct = enums([ + 'usd', + 'aed', + 'amd', + 'ars', + 'aud', + 'bam', + 'bdt', + 'bhd', + 'bmd', + 'brl', + 'cad', + 'chf', + 'clp', + 'cny', + 'cop', + 'crc', + 'czk', + 'dkk', + 'dop', + 'eur', + 'gbp', + 'gel', + 'gtq', + 'hkd', + 'hnl', + 'huf', + 'idr', + 'ils', + 'inr', + 'jpy', + 'kes', + 'krw', + 'kwd', + 'lbp', + 'lkr', + 'mmk', + 'mxn', + 'myr', + 'ngn', + 'nok', + 'nzd', + 'pen', + 'php', + 'pkr', + 'pln', + 'ron', + 'rub', + 'sar', + 'sek', + 'sgd', + 'svc', + 'thb', + 'try', + 'twd', + 'uah', + 'vef', + 'vnd', + 'xdr', + 'zar', + 'zmw', +]); + +export const CommodityTickerStruct = enums(['xag', 'xau']); + +export type CryptoTicker = Infer; +export type FiatTicker = Infer; +export type CommodityTicker = Infer; + +export const TickerStruct = union([ + CryptoTickerStruct, + FiatTickerStruct, + CommodityTickerStruct, +]); + +export type Ticker = Infer; + +/** + * Struct for validating exchange rate data from the API. + * Includes bounds validation to prevent malicious data injection. + */ +export const ExchangeRateStruct = object({ + name: string(), + ticker: TickerStruct, + value: min(number(), 0), + currencyType: enums(['fiat', 'crypto', 'commodity']), +}); + +export type ExchangeRate = Infer; + +/** + * Struct for validating the fiat exchange rates response. + * Maps ticker symbols to their exchange rate data. + * Despite the endpoint name, the response includes all exchange rates (crypto, fiat, commodity). + */ +export const FiatExchangeRatesResponseStruct = record( + TickerStruct, + ExchangeRateStruct, +); + +export type FiatExchangeRatesResponse = Infer< + typeof FiatExchangeRatesResponseStruct +>; + +/** + * The structure of the spot price response from the Price API as described in + * [this file](https://github.com/consensys-vertical-apps/va-mmcx-price-api/blob/main/src/types/price.ts#L46-L71). + * + * For safety, most fields are marked optional and nullable even though it goes against the type in the Price API source code. + */ + +export const SpotPriceStruct = object({ + id: string(), + price: min(number(), 0), + marketCap: optional(nullable(min(number(), 0))), + allTimeHigh: optional(nullable(min(number(), 0))), + allTimeLow: optional(nullable(min(number(), 0))), + totalVolume: optional(nullable(min(number(), 0))), + high1d: optional(nullable(min(number(), 0))), + low1d: optional(nullable(min(number(), 0))), + circulatingSupply: optional(nullable(min(number(), 0))), + dilutedMarketCap: optional(nullable(min(number(), 0))), + marketCapPercentChange1d: optional(nullable(number())), + priceChange1d: optional(nullable(number())), + pricePercentChange1h: optional(nullable(number())), + pricePercentChange1d: optional(nullable(number())), + pricePercentChange7d: optional(nullable(number())), + pricePercentChange14d: optional(nullable(number())), + pricePercentChange30d: optional(nullable(number())), + pricePercentChange200d: optional(nullable(number())), + pricePercentChange1y: optional(nullable(number())), + bondingCurveProgressPercent: optional(nullable(number())), + liquidity: optional(nullable(number())), + totalSupply: optional(nullable(number())), + holderCount: optional(nullable(number())), + isMutable: optional(nullable(boolean())), +}); + +export type SpotPrice = Infer; + +/** + * @example + * { + * "bip122:000000000019d6689c085ae165831e93/slip44:0": { + * "id": "bitcoin", + * "price": 84302, + * "marketCap": 1670808919774, + * "allTimeHigh": 108786, + * "allTimeLow": 67.81, + * "totalVolume": 25784747348, + * "high1d": 84370, + * "low1d": 81426, + * "circulatingSupply": 19844840, + * "dilutedMarketCap": 1670808919774, + * "marketCapPercentChange1d": 3.2788, + * "priceChange1d": 2876.1, + * "pricePercentChange1h": 0.1991278666784771, + * "pricePercentChange1d": 3.5321815522315307, + * "pricePercentChange7d": -3.4056070943823666, + * "pricePercentChange14d": 1.663812725054475, + * "pricePercentChange30d": -1.8166338283570667, + * "pricePercentChange200d": 45.12491105880435, + * "pricePercentChange1y": 21.403818710804778 + * }, + * "eip155:1/slip44:60": { ... }, + * "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501": null + */ +export const SpotPricesStruct = record( + CaipAssetTypeStruct, + nullable(SpotPriceStruct), +); + +export type SpotPrices = Infer; + +// In the Price API source code, the parameters `vsCurrency` and `ticker` represent the same list of values. +// We create aliases here for clarity. +export const VsCurrencyParamStruct = TickerStruct; +export type VsCurrencyParam = Infer; + +export const GetHistoricalPricesParamsStruct = object({ + assetType: CaipAssetTypeStruct, + timePeriod: optional(pattern(string(), /^[1-9][0-9]*[dmy]$/u)), // Supports days, months, years + from: optional(min(number(), 0)), + to: optional(min(number(), 0)), + vsCurrency: optional(VsCurrencyParamStruct), +}); + +export type GetHistoricalPricesParams = Infer< + typeof GetHistoricalPricesParamsStruct +>; + +export const GetHistoricalPricesResponseStruct = object({ + prices: array(tuple([number(), number()])), + marketCaps: array(tuple([number(), number()])), + totalVolumes: array(tuple([number(), number()])), +}); + +export type GetHistoricalPricesResponse = Infer< + typeof GetHistoricalPricesResponseStruct +>; + +export const GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT: GetHistoricalPricesResponse = + { + prices: [], + marketCaps: [], + totalVolumes: [], + }; diff --git a/merged-packages/stellar-wallet-snap/src/services/price/price-api/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/price/price-api/exceptions.ts new file mode 100644 index 00000000..416e6f11 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/price/price-api/exceptions.ts @@ -0,0 +1,6 @@ +export class PriceApiException extends Error { + constructor(message: string) { + super(message); + this.name = 'PriceApiException'; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/price/price-api/index.ts b/merged-packages/stellar-wallet-snap/src/services/price/price-api/index.ts new file mode 100644 index 00000000..9ae90e07 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/price/price-api/index.ts @@ -0,0 +1,3 @@ +export * from './PriceApiClient'; +export type * from './api'; +export * from './exceptions'; diff --git a/merged-packages/stellar-wallet-snap/src/utils/async.ts b/merged-packages/stellar-wallet-snap/src/utils/async.ts index 4f84d756..78fed1a4 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/async.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/async.ts @@ -28,3 +28,21 @@ export async function batchesAllSettled( return results; } + +/** + * Splits items into chunks of a given size. + * + * @param items - Input items; order is preserved in the returned chunks. + * @param chunkSize - Size of each chunk (must be ≥ 1). + * @returns An array of chunks, each containing `chunkSize` items. + */ +export function chunks( + items: readonly TItem[], + chunkSize: number, +): TItem[][] { + const itemsChunks: TItem[][] = []; + for (let index = 0; index < items.length; index += chunkSize) { + itemsChunks.push(items.slice(index, index + chunkSize)); + } + return itemsChunks; +} From 9a572bb2efef1d01bf0594801e873aeedfadb53d Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:50:03 +0800 Subject: [PATCH 041/384] chore: add config --- .../stellar-wallet-snap/.env.example | 4 +++ .../stellar-wallet-snap/snap.config.ts | 8 ++++++ .../stellar-wallet-snap/src/config.ts | 24 ++++++++++++++++- .../src/services/cache/InMemoryCache.ts | 3 +-- .../src/services/cache/index.ts | 1 - .../src/utils/async.test.ts | 27 ++++++++++++++++++- 6 files changed, 62 insertions(+), 5 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/.env.example b/merged-packages/stellar-wallet-snap/.env.example index 95c1daf4..1201c2c8 100644 --- a/merged-packages/stellar-wallet-snap/.env.example +++ b/merged-packages/stellar-wallet-snap/.env.example @@ -36,3 +36,7 @@ TOKEN_API_BASE_URL=http://tokens.api.cx.metamask.io # Static API Base URL STATIC_API_BASE_URL=https://static.api.cx.metamask.io + +# Price API Base URL +PRICE_API_BASE_URL=https://price.api.cx.metamask.io + diff --git a/merged-packages/stellar-wallet-snap/snap.config.ts b/merged-packages/stellar-wallet-snap/snap.config.ts index 2b20afa4..d27b19f3 100644 --- a/merged-packages/stellar-wallet-snap/snap.config.ts +++ b/merged-packages/stellar-wallet-snap/snap.config.ts @@ -24,6 +24,14 @@ const config: SnapConfig = { TOKEN_API_BASE_URL: process.env.TOKEN_API_BASE_URL ?? '', TOKEN_API_CHUNK_SIZE: process.env.TOKEN_API_CHUNK_SIZE ?? '', STATIC_API_BASE_URL: process.env.STATIC_API_BASE_URL ?? '', + PRICE_API_BASE_URL: process.env.PRICE_API_BASE_URL ?? '', + PRICE_API_CHUNK_SIZE: process.env.PRICE_API_CHUNK_SIZE ?? '', + FIAT_EXCHANGE_RATES_TTL_MILLISECONDS: + process.env.FIAT_EXCHANGE_RATES_TTL_MILLISECONDS ?? '', + HISTORICAL_PRICES_TTL_MILLISECONDS: + process.env.HISTORICAL_PRICES_TTL_MILLISECONDS ?? '', + SPOT_PRICES_TTL_MILLISECONDS: + process.env.SPOT_PRICES_TTL_MILLISECONDS ?? '', }, polyfills: true, }; diff --git a/merged-packages/stellar-wallet-snap/src/config.ts b/merged-packages/stellar-wallet-snap/src/config.ts index 3c93151c..98c97c40 100644 --- a/merged-packages/stellar-wallet-snap/src/config.ts +++ b/merged-packages/stellar-wallet-snap/src/config.ts @@ -80,11 +80,22 @@ const ConfigStruct = object({ api: object({ tokenApi: object({ baseUrl: UrlStruct, - chunkSize: parseIntegerStruct(1, 100), + chunkSize: parseIntegerStruct(1, 20), }), staticApi: object({ baseUrl: UrlStruct, }), + priceApi: object({ + baseUrl: UrlStruct, + chunkSize: parseIntegerStruct(1, 20), + }), + }), + cache: object({ + ttlMilliseconds: object({ + spotPrices: parseIntegerStruct(1000, 60 * 60 * 1000 * 1), + fiatExchangeRates: parseIntegerStruct(1000, 60 * 60 * 1000 * 1), + historicalPrices: parseIntegerStruct(1000, 60 * 60 * 1000 * 1), + }), }), }); @@ -132,6 +143,17 @@ export const AppConfig = create( staticApi: { baseUrl: process.env.STATIC_API_BASE_URL, }, + priceApi: { + baseUrl: process.env.PRICE_API_BASE_URL, + chunkSize: process.env.PRICE_API_CHUNK_SIZE, + }, + }, + cache: { + ttlMilliseconds: { + spotPrices: process.env.SPOT_PRICES_TTL_MILLISECONDS, + fiatExchangeRates: process.env.FIAT_EXCHANGE_RATES_TTL_MILLISECONDS, + historicalPrices: process.env.HISTORICAL_PRICES_TTL_MILLISECONDS, + }, }, }, ConfigStruct, diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts b/merged-packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts index 311bdd4f..ffc1dc4c 100644 --- a/merged-packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts +++ b/merged-packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts @@ -1,7 +1,6 @@ import { assert } from '@metamask/utils'; -import type { ICache } from './api'; -import type { CacheEntry } from './api'; +import type { ICache, CacheEntry } from './api'; import type { ILogger } from '../../utils/logger'; import type { Serializable } from '../../utils/serialization'; diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/index.ts b/merged-packages/stellar-wallet-snap/src/services/cache/index.ts index bc3f0ef0..b938e2da 100644 --- a/merged-packages/stellar-wallet-snap/src/services/cache/index.ts +++ b/merged-packages/stellar-wallet-snap/src/services/cache/index.ts @@ -1,6 +1,5 @@ export * from './StateCache'; export * from './InMemoryCache'; -export type * from './ICache'; export type * from './api'; export * from './useCacheUntil'; export * from './useCache'; diff --git a/merged-packages/stellar-wallet-snap/src/utils/async.test.ts b/merged-packages/stellar-wallet-snap/src/utils/async.test.ts index 5f0c80b7..3eb362a0 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/async.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/async.test.ts @@ -1,4 +1,4 @@ -import { batchesAllSettled } from './async'; +import { batchesAllSettled, chunks } from './async'; describe('batchesAllSettled', () => { it('throws when batchSize is less than 1', async () => { @@ -67,3 +67,28 @@ describe('batchesAllSettled', () => { expect(maxConcurrent).toBe(2); }); }); + +describe('chunks', () => { + it('returns empty array for empty items', () => { + const result = chunks([], 3); + expect(result).toStrictEqual([]); + }); + + it('returns single chunk for items less than chunk size', () => { + const result = chunks(['a', 'b', 'c'], 4); + expect(result).toStrictEqual([['a', 'b', 'c']]); + }); + + it('returns multiple chunks for items greater than chunk size', () => { + const result = chunks( + ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], + 3, + ); + expect(result).toStrictEqual([ + ['a', 'b', 'c'], + ['d', 'e', 'f'], + ['g', 'h', 'i'], + ['j'], + ]); + }); +}); From f281679197e885e54f1c87ca2182dd331e3f4b23 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:51:17 +0800 Subject: [PATCH 042/384] chore: update test config --- merged-packages/stellar-wallet-snap/jest.config.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index 0b2d517d..a7659160 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 59.78, - functions: 75.42, - lines: 77.34, - statements: 77.48, + branches: 61.67, + functions: 75.34, + lines: 77.02, + statements: 77.22, }, }, From c8a2cf52cb0be160fe0b3c97134529dae121efb6 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:49:36 +0800 Subject: [PATCH 043/384] fix: comment --- .../stellar-wallet-snap/jest.config.js | 6 +++--- .../src/services/cache/InMemoryCache.ts | 8 ++++---- .../src/services/cache/StateCache.ts | 15 ++++++++------- .../src/services/price/PriceService.ts | 2 +- .../services/price/price-api/PriceApiClient.ts | 3 ++- .../stellar-wallet-snap/src/utils/async.test.ts | 5 +++++ .../stellar-wallet-snap/src/utils/async.ts | 4 ++++ 7 files changed, 27 insertions(+), 16 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index a7659160..0470be6d 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 61.67, + branches: 61.77, functions: 75.34, - lines: 77.02, - statements: 77.22, + lines: 77.05, + statements: 77.24, }, }, diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts b/merged-packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts index ffc1dc4c..5aa36165 100644 --- a/merged-packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts +++ b/merged-packages/stellar-wallet-snap/src/services/cache/InMemoryCache.ts @@ -13,10 +13,10 @@ import type { Serializable } from '../../utils/serialization'; export class InMemoryCache implements ICache { readonly #cache: Map = new Map(); - public readonly logger: ILogger; + readonly #logger: ILogger; constructor(logger: ILogger) { - this.logger = logger; + this.#logger = logger; } #validateTtlOrThrow(ttlMilliseconds?: number): void { @@ -129,12 +129,12 @@ export class InMemoryCache implements ICache { for (const key of keys) { const cacheEntry = this.#cache.get(key); if (!cacheEntry) { - this.logger.info(`[InMemoryCache] ❌ Cache miss for key "${key}"`); + this.#logger.info(`[InMemoryCache] ❌ Cache miss for key "${key}"`); result[key] = undefined; continue; } - this.logger.info(`[InMemoryCache] 🎉 Cache hit for key "${key}"`); + this.#logger.info(`[InMemoryCache] 🎉 Cache hit for key "${key}"`); result[key] = cacheEntry.value; } diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/StateCache.ts b/merged-packages/stellar-wallet-snap/src/services/cache/StateCache.ts index a2d4fcb7..22a1aa12 100644 --- a/merged-packages/stellar-wallet-snap/src/services/cache/StateCache.ts +++ b/merged-packages/stellar-wallet-snap/src/services/cache/StateCache.ts @@ -50,7 +50,8 @@ export type StateValue = { * @example * ```ts * const state = new State({}); // Here we use the real snap's state - * const cache = new StateCache(state, '__cache__my-prefix'); + * const logger = createPrefixedLogger(console, '[💾 StateCache]'); + * const cache = new StateCache(state, logger, '__cache__my-prefix'); * * // state looks like this: * // { @@ -74,7 +75,7 @@ export class StateCache implements ICache { public readonly prefix: CachePrefix; - public readonly logger: ILogger; + readonly #logger: ILogger; constructor( state: IStateManager, @@ -82,7 +83,7 @@ export class StateCache implements ICache { prefix: CachePrefix = '__cache__default', ) { this.#state = state; - this.logger = createPrefixedLogger(logger, '[💾 StateCache]'); + this.#logger = createPrefixedLogger(logger, '[💾 StateCache]'); this.prefix = prefix; } @@ -184,16 +185,16 @@ export class StateCache implements ICache { // First, handle keys that exist in the cache keysAndValues.forEach(([key, cacheEntry]) => { if (cacheEntry === undefined) { - this.logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); + this.#logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); result[key] = undefined; return; } if (cacheEntry.expiresAt < Date.now()) { - this.logger.info(`[StateCache] ⌛ Cache expired for key "${key}"`); + this.#logger.info(`[StateCache] ⌛ Cache expired for key "${key}"`); result[key] = undefined; } else { - this.logger.info(`[StateCache] 🎉 Cache hit for key "${key}"`); + this.#logger.info(`[StateCache] 🎉 Cache hit for key "${key}"`); result[key] = cacheEntry.value; } }); @@ -201,7 +202,7 @@ export class StateCache implements ICache { // Then, handle keys that don't exist in the cache keys.forEach((key) => { if (!(key in result)) { - this.logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); + this.#logger.info(`[StateCache] ❌ Cache miss for key "${key}"`); result[key] = undefined; } }); diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts index c6e98d5b..c4c77e70 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts @@ -50,7 +50,7 @@ export class PriceService { vsCurrency = 'usd', }: { assetIds: KnownCaip19AssetIdOrSlip44Id[]; - vsCurrency: VsCurrencyParam | string; + vsCurrency?: VsCurrencyParam | string; }, refreshCache: boolean = false, ): Promise> { diff --git a/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts b/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts index f7f6fc5b..8bf4fe01 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts @@ -86,7 +86,8 @@ export class PriceApiClient { * @param assetIds - The asset IDs to get the spot prices for. * @param vsCurrency - The currency to convert the prices to. * @returns A promise that resolves to the spot prices for the asset IDs. - * @throws {PriceApiException} If the spot prices cannot be fetched. + * @throws {PriceApiException} When spot price aggregation fails unexpectedly. + * Failed batches are omitted from the result (logged) rather than thrown. */ async getSpotPrices( assetIds: CaipAssetType[], diff --git a/merged-packages/stellar-wallet-snap/src/utils/async.test.ts b/merged-packages/stellar-wallet-snap/src/utils/async.test.ts index 3eb362a0..fba05a12 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/async.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/async.test.ts @@ -91,4 +91,9 @@ describe('chunks', () => { ['j'], ]); }); + + it('throws when chunkSize is less than 1', () => { + const run = () => chunks(['a', 'b', 'c'], 0); + expect(run).toThrow(RangeError); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/utils/async.ts b/merged-packages/stellar-wallet-snap/src/utils/async.ts index 78fed1a4..3c9a817e 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/async.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/async.ts @@ -40,6 +40,10 @@ export function chunks( items: readonly TItem[], chunkSize: number, ): TItem[][] { + if (chunkSize < 1) { + throw new RangeError('chunkSize must be at least 1'); + } + const itemsChunks: TItem[][] = []; for (let index = 0; index < items.length; index += chunkSize) { itemsChunks.push(items.slice(index, index + chunkSize)); From 7e63f18666a8b6aebe5158f63cde58954f695dfa Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Wed, 15 Apr 2026 15:54:22 +0200 Subject: [PATCH 044/384] feat: display all operations and fee in sign transaction confirmation --- .../stellar-wallet-snap/locales/en.json | 3 + .../stellar-wallet-snap/messages.json | 3 + .../stellar-wallet-snap/snap.manifest.json | 2 +- .../ConfirmSignTransaction.tsx | 70 +++++++++++-------- 4 files changed, 49 insertions(+), 29 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/locales/en.json b/merged-packages/stellar-wallet-snap/locales/en.json index 77ffdd4c..2c7985d7 100644 --- a/merged-packages/stellar-wallet-snap/locales/en.json +++ b/merged-packages/stellar-wallet-snap/locales/en.json @@ -313,6 +313,9 @@ "confirmation.transaction.param.startingBalance": { "message": "Starting balance" }, + "confirmation.transaction.param.source": { + "message": "Source" + }, "confirmation.transaction.param.trustor": { "message": "Trustor" }, diff --git a/merged-packages/stellar-wallet-snap/messages.json b/merged-packages/stellar-wallet-snap/messages.json index 6c6e6600..628151ee 100644 --- a/merged-packages/stellar-wallet-snap/messages.json +++ b/merged-packages/stellar-wallet-snap/messages.json @@ -311,6 +311,9 @@ "confirmation.transaction.param.startingBalance": { "message": "Starting balance" }, + "confirmation.transaction.param.source": { + "message": "Source" + }, "confirmation.transaction.param.trustor": { "message": "Trustor" }, diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 9ff6f82d..90a84c4d 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "0VBaqTQVxjgac2XlOE0ndRKfzM/VuKtl8E56JYgZN+8=", + "shasum": "siM6Rlfg0fuU110QtuDAxLq+2fsMisW/eAA3Hx10n0U=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx index c7d10139..b7da39e8 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -174,39 +174,53 @@ export const ConfirmSignTransaction = ({ {getNetworkName(scope)} + + + {t('confirmation.transactionFee')} + + {readableTransaction.feeStroops} stroops +
- {[...readableTransaction.operations] - .slice(0, 2) - .map((operationJson, index) => ( - - - {t( - `confirmation.transaction.${operationJson.type.toLowerCase()}` as LocalizedMessage, - )} - - {operationJson.params.map((param) => - isNullOrUndefined(param.value) ? null : ( - - - {t( - `confirmation.transaction.param.${param.key}` as LocalizedMessage, - )} - - - - ), + {readableTransaction.operations.map((operationJson, index) => ( + + + {t( + `confirmation.transaction.${operationJson.type.toLowerCase()}` as LocalizedMessage, )} + + {[ + ...(operationJson.explicitSource + ? [ + { + key: 'source', + value: operationJson.explicitSource as Json, + type: 'address' as const, + }, + ] + : []), + ...operationJson.params, + ] + .filter((param) => !isNullOrUndefined(param.value)) + .map((param) => ( + + + {t( + `confirmation.transaction.param.${param.key}` as LocalizedMessage, + )} + + + + ))} - {index < operationJson.params.length - 1 && } - - ))} - + {index < readableTransaction.operations.length - 1 && } + + ))}
From 9a3a50b8b666b1f77feab2b52e13ce9923d5619f Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Wed, 15 Apr 2026 16:05:06 +0200 Subject: [PATCH 045/384] chore: add ConfirmSignTransaction render tests --- .../stellar-wallet-snap/jest.config.js | 8 +- .../ConfirmSignTransaction/render.test.tsx | 167 ++++++++++++++++++ 2 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.test.tsx diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index 0b2d517d..c4f2404c 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 59.78, - functions: 75.42, - lines: 77.34, - statements: 77.48, + branches: 62.42, + functions: 78.18, + lines: 79.22, + statements: 79.34, }, }, diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.test.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.test.tsx new file mode 100644 index 00000000..93f66450 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.test.tsx @@ -0,0 +1,167 @@ +import type { GetPreferencesResult } from '@metamask/snaps-sdk'; +import { Keypair } from '@stellar/stellar-sdk'; + +import { render } from './render'; +import { KnownCaip2ChainId } from '../../../../api'; +import { MultichainMethod } from '../../../../handlers/keyring'; +import type { SignTransactionRequest } from '../../../../handlers/keyring'; +import type { StellarKeyringAccount } from '../../../../services/account'; +import { generateMockStellarKeyringAccounts } from '../../../../services/account/__mocks__/account.fixtures'; +import type { Transaction } from '../../../../services/transaction'; +import { buildMockClassicTransaction } from '../../../../services/transaction/__mocks__/transaction.fixtures'; +import * as snapUtils from '../../../../utils/snap'; + +describe('ConfirmSignTransaction render', () => { + const mockAccount = generateMockStellarKeyringAccounts( + 1, + 'entropy-source-1', + )[0] as StellarKeyringAccount; + const mockPreferences: GetPreferencesResult = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + useExternalPricingData: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + showTestnets: false, + }; + + const destination = Keypair.random().publicKey(); + + const createSnapSpies = () => { + const createInterfaceSpy = jest.spyOn(snapUtils, 'createInterface'); + const showDialogSpy = jest.spyOn(snapUtils, 'showDialog'); + const getPreferencesSpy = jest.spyOn(snapUtils, 'getPreferences'); + + createInterfaceSpy.mockResolvedValue('interface-id-123'); + showDialogSpy.mockResolvedValue(true); + getPreferencesSpy.mockResolvedValue(mockPreferences); + + return { createInterfaceSpy, showDialogSpy, getPreferencesSpy }; + }; + + const createRequest = ( + overrides: Partial = {}, + ): SignTransactionRequest => ({ + id: '00000000-0000-4000-8000-000000000001', + origin: 'https://example.com', + account: mockAccount.id, + scope: KnownCaip2ChainId.Testnet, + request: { + method: MultichainMethod.SignTransaction, + params: { transaction: 'dummy-xdr' }, + }, + ...overrides, + }); + + const buildSinglePaymentTx = (): Transaction => + buildMockClassicTransaction([ + { + type: 'payment', + params: { destination, asset: 'native', amount: '100' }, + }, + ]); + + it('renders the confirmation dialog and returns the dialog result', async () => { + const { createInterfaceSpy, showDialogSpy, getPreferencesSpy } = + createSnapSpies(); + + const transaction = buildSinglePaymentTx(); + const result = await render(createRequest(), transaction, mockAccount); + + expect(getPreferencesSpy).toHaveBeenCalled(); + expect(createInterfaceSpy).toHaveBeenCalledTimes(1); + expect(showDialogSpy).toHaveBeenCalledWith('interface-id-123'); + expect(result).toBe(true); + }); + + it('renders with multiple operations', async () => { + const { createInterfaceSpy } = createSnapSpies(); + + const transaction = buildMockClassicTransaction([ + { + type: 'payment', + params: { destination, asset: 'native', amount: '50' }, + }, + { + type: 'createAccount', + params: { destination, startingBalance: '10' }, + }, + { + type: 'changeTrust', + params: { + asset: { code: 'USD', issuer: destination }, + limit: '1000', + }, + }, + ]); + + await render(createRequest(), transaction, mockAccount); + + expect(createInterfaceSpy).toHaveBeenCalledTimes(1); + }); + + it('renders with an operation that has an explicit source', async () => { + const { createInterfaceSpy } = createSnapSpies(); + const opSource = Keypair.random().publicKey(); + + const transaction = buildMockClassicTransaction([ + { + type: 'payment', + params: { + destination, + asset: 'native', + amount: '10', + source: opSource, + }, + }, + ]); + + await render(createRequest(), transaction, mockAccount); + + expect(createInterfaceSpy).toHaveBeenCalledTimes(1); + }); + + it('uses fallback locale when preferences fail to load', async () => { + const { createInterfaceSpy, getPreferencesSpy } = createSnapSpies(); + getPreferencesSpy.mockRejectedValue(new Error('Failed to load')); + + const transaction = buildSinglePaymentTx(); + await render(createRequest(), transaction, mockAccount); + + expect(createInterfaceSpy).toHaveBeenCalledTimes(1); + expect(getPreferencesSpy).toHaveBeenCalled(); + }); + + it('handles missing origin gracefully', async () => { + const { createInterfaceSpy } = createSnapSpies(); + + const transaction = buildSinglePaymentTx(); + await render( + createRequest({ origin: undefined as any }), + transaction, + mockAccount, + ); + + expect(createInterfaceSpy).toHaveBeenCalledTimes(1); + }); + + it('renders with setOptions operation (conditional params)', async () => { + const { createInterfaceSpy } = createSnapSpies(); + + const transaction = buildMockClassicTransaction([ + { + type: 'setOptions', + params: { setFlags: 1, clearFlags: 2 }, + }, + ]); + + await render(createRequest(), transaction, mockAccount); + + expect(createInterfaceSpy).toHaveBeenCalledTimes(1); + }); +}); From 21329f60e7cb44b416eee7e7eea6ad568d46d6ec Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Wed, 15 Apr 2026 17:27:01 +0200 Subject: [PATCH 046/384] feat: show full tx ops in sign UI and extend OperationMapper --- .../stellar-wallet-snap/jest.config.js | 8 +- .../stellar-wallet-snap/locales/en.json | 12 ++ .../stellar-wallet-snap/messages.json | 12 ++ .../stellar-wallet-snap/snap.manifest.json | 2 +- .../services/transaction/OperationMapper.ts | 145 +++++++++++++++--- .../ConfirmSignTransaction.tsx | 8 + 6 files changed, 157 insertions(+), 30 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index c4f2404c..12df2804 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 62.42, - functions: 78.18, - lines: 79.22, - statements: 79.34, + branches: 59.38, + functions: 78, + lines: 76.68, + statements: 76.83, }, }, diff --git a/merged-packages/stellar-wallet-snap/locales/en.json b/merged-packages/stellar-wallet-snap/locales/en.json index 2c7985d7..bfec91ce 100644 --- a/merged-packages/stellar-wallet-snap/locales/en.json +++ b/merged-packages/stellar-wallet-snap/locales/en.json @@ -52,6 +52,9 @@ "confirmation.account": { "message": "Account" }, + "confirmation.memo": { + "message": "Memo" + }, "confirmation.signTransaction.title": { "message": "Sign transaction" }, @@ -202,6 +205,9 @@ "confirmation.transaction.param.clearFlags": { "message": "Clear flags" }, + "confirmation.transaction.param.contractId": { + "message": "Contract ID" + }, "confirmation.transaction.param.destAmount": { "message": "Destination amount" }, @@ -220,6 +226,12 @@ "confirmation.transaction.param.flags": { "message": "Flags" }, + "confirmation.transaction.param.functionName": { + "message": "Function" + }, + "confirmation.transaction.param.arguments": { + "message": "Arguments" + }, "confirmation.transaction.param.from": { "message": "From" }, diff --git a/merged-packages/stellar-wallet-snap/messages.json b/merged-packages/stellar-wallet-snap/messages.json index 628151ee..c720fd61 100644 --- a/merged-packages/stellar-wallet-snap/messages.json +++ b/merged-packages/stellar-wallet-snap/messages.json @@ -50,6 +50,9 @@ "confirmation.account": { "message": "Account" }, + "confirmation.memo": { + "message": "Memo" + }, "confirmation.signTransaction.title": { "message": "Sign transaction" }, @@ -200,6 +203,9 @@ "confirmation.transaction.param.clearFlags": { "message": "Clear flags" }, + "confirmation.transaction.param.contractId": { + "message": "Contract ID" + }, "confirmation.transaction.param.destAmount": { "message": "Destination amount" }, @@ -218,6 +224,12 @@ "confirmation.transaction.param.flags": { "message": "Flags" }, + "confirmation.transaction.param.functionName": { + "message": "Function" + }, + "confirmation.transaction.param.arguments": { + "message": "Arguments" + }, "confirmation.transaction.param.from": { "message": "From" }, diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 90a84c4d..54daf5c9 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "siM6Rlfg0fuU110QtuDAxLq+2fsMisW/eAA3Hx10n0U=", + "shasum": "a8vm7ar6MoNyhL7UOh6IlHPHoJeHPHpgYpv7iEUlRns=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts index 78a2e5cb..979f8d40 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts @@ -1,6 +1,6 @@ import type { Json } from '@metamask/utils'; import type { Asset, Operation } from '@stellar/stellar-sdk'; -import { LiquidityPoolAsset, LiquidityPoolId } from '@stellar/stellar-sdk'; +import { LiquidityPoolAsset, LiquidityPoolId, xdr } from '@stellar/stellar-sdk'; import type { Transaction } from './Transaction'; import type { KnownCaip2ChainId } from '../../api'; @@ -60,6 +60,7 @@ export type ReadableTransactionJson = { operationCount: number; sourceAccount: string; feeSourceAccount: string; + memo: string | null; operations: ReadableOperationJson[]; }; @@ -128,6 +129,7 @@ export class OperationMapper { operationCount: operations.length, sourceAccount, feeSourceAccount: transaction.feeSourceAccount, + memo: transaction.getMemo(), operations, }; } @@ -165,24 +167,59 @@ export class OperationMapper { #mapSorobanPlaceholder(operation: Operation): ReadableOperationField[] { if (operation.type === 'invokeHostFunction') { const hostOp = operation; - let funcXdr: string | null = null; + const rows: ReadableOperationField[] = []; + try { + const { func } = hostOp; + if ( + func && + func.switch() === + xdr.HostFunctionType.hostFunctionTypeInvokeContract() + ) { + const invokeArgs = func.invokeContract(); + const contractIdHex = bufferToUint8Array( + invokeArgs.contractAddress().toXDR(), + ) + .slice(4) + .toString('hex'); + const functionName = invokeArgs.functionName().toString('utf8'); + rows.push(this.#field('contractId', contractIdHex, 'text')); + rows.push(this.#field('functionName', functionName, 'text')); + const args = invokeArgs.args(); + if (args.length > 0) { + rows.push( + this.#field( + 'arguments', + args.map((arg) => arg.toXDR('base64')), + 'json', + ), + ); + } + } + } catch { + // Fall through to XDR fallback + } + if (rows.length === 0) { + rows.push( + this.#field( + 'note', + 'Soroban invokeHostFunction; review contract call on a block explorer or dedicated UI.', + 'text', + ), + ); + } try { if (typeof hostOp.func?.toXDR === 'function') { const raw = hostOp.func.toXDR(); - funcXdr = raw.toString('base64'); + rows.push( + this.#field( + 'hostFunctionXdrBase64', + raw.toString('base64'), + 'text', + ), + ); } } catch { - funcXdr = null; - } - const rows: ReadableOperationField[] = [ - this.#field( - 'note', - 'Soroban invokeHostFunction; review contract call on a block explorer or dedicated UI.', - 'text', - ), - ]; - if (funcXdr) { - rows.push(this.#field('hostFunctionXdrBase64', funcXdr, 'text')); + // XDR serialization failed; skip } return rows; } @@ -423,6 +460,7 @@ export class OperationMapper { 'claimants', createCb.claimants.map((claimant) => ({ destination: claimant.destination, + predicate: OperationMapper.#formatPredicate(claimant.predicate), })), 'json', ), @@ -456,20 +494,34 @@ export class OperationMapper { } case 'setTrustLineFlags': { const trustFlags = operation; - return [ + const setFlagLabels: string[] = []; + const clearFlagLabels: string[] = []; + if (trustFlags.flags.authorized === true) { + setFlagLabels.push('authorized'); + } else if (trustFlags.flags.authorized === false) { + clearFlagLabels.push('authorized'); + } + if (trustFlags.flags.authorizedToMaintainLiabilities === true) { + setFlagLabels.push('authorizedToMaintainLiabilities'); + } else if (trustFlags.flags.authorizedToMaintainLiabilities === false) { + clearFlagLabels.push('authorizedToMaintainLiabilities'); + } + if (trustFlags.flags.clawbackEnabled === true) { + setFlagLabels.push('clawbackEnabled'); + } else if (trustFlags.flags.clawbackEnabled === false) { + clearFlagLabels.push('clawbackEnabled'); + } + const rows: ReadableOperationField[] = [ this.#field('trustor', trustFlags.trustor, 'address'), this.#field('asset', trustFlags.asset.toString(), 'asset'), - this.#field( - 'flags', - { - authorized: trustFlags.flags.authorized ?? null, - authorizedToMaintainLiabilities: - trustFlags.flags.authorizedToMaintainLiabilities ?? null, - clawbackEnabled: trustFlags.flags.clawbackEnabled ?? null, - }, - 'json', - ), ]; + if (setFlagLabels.length > 0) { + rows.push(this.#field('setFlags', setFlagLabels, 'text')); + } + if (clearFlagLabels.length > 0) { + rows.push(this.#field('clearFlags', clearFlagLabels, 'text')); + } + return rows; } case 'liquidityPoolDeposit': { const poolDeposit = operation; @@ -589,4 +641,47 @@ export class OperationMapper { } return line.toString(); } + + static #formatPredicate(predicate: xdr.ClaimPredicate): string { + try { + const type = predicate.switch(); + if (type === xdr.ClaimPredicateType.claimPredicateUnconditional()) { + return 'unconditional'; + } + if (type === xdr.ClaimPredicateType.claimPredicateBeforeAbsoluteTime()) { + const absBeforeVal = predicate.absBefore(); + const seconds = Number(absBeforeVal.toXDR().readBigInt64BE(0)); + return `before ${new Date(seconds * 1000).toISOString()}`; + } + if (type === xdr.ClaimPredicateType.claimPredicateBeforeRelativeTime()) { + const relBeforeVal = predicate.relBefore(); + return `within ${String(relBeforeVal)}s`; + } + if (type === xdr.ClaimPredicateType.claimPredicateAnd()) { + const preds = predicate.andPredicates(); + const left = preds[0]; + const right = preds[1]; + if (left && right) { + return `(${OperationMapper.#formatPredicate(left)} AND ${OperationMapper.#formatPredicate(right)})`; + } + } + if (type === xdr.ClaimPredicateType.claimPredicateOr()) { + const preds = predicate.orPredicates(); + const left = preds[0]; + const right = preds[1]; + if (left && right) { + return `(${OperationMapper.#formatPredicate(left)} OR ${OperationMapper.#formatPredicate(right)})`; + } + } + if (type === xdr.ClaimPredicateType.claimPredicateNot()) { + const inner = predicate.notPredicate(); + return inner + ? `NOT ${OperationMapper.#formatPredicate(inner)}` + : 'NOT(null)'; + } + } catch { + // Fall through + } + return 'unknown predicate'; + } } diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx index b7da39e8..fd8b73ea 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -180,6 +180,14 @@ export const ConfirmSignTransaction = ({ {readableTransaction.feeStroops} stroops + {[readableTransaction.memo].filter(Boolean).map((memo) => ( + + + {t('confirmation.memo' as LocalizedMessage)} + + {memo} + + ))}
From f235db5a537e5a878abdf515fe66b27ebb9d6f14 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Wed, 15 Apr 2026 17:47:57 +0200 Subject: [PATCH 047/384] chore: add tests to increase coverage --- .../stellar-wallet-snap/jest.config.js | 8 +- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../transaction/OperationMapper.test.ts | 474 +++++++++++++++++- 3 files changed, 478 insertions(+), 6 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index 12df2804..f9b6e3a4 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 59.38, - functions: 78, - lines: 76.68, - statements: 76.83, + branches: 66.13, + functions: 78.29, + lines: 80.49, + statements: 80.63, }, }, diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 54daf5c9..9a3be035 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "a8vm7ar6MoNyhL7UOh6IlHPHoJeHPHpgYpv7iEUlRns=", + "shasum": "u7oYAFDKu3LINO5ZfvFAmGbkDNUBbEFEoEXfcp/s5ZQ=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts index 229514c4..f2694e64 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts @@ -1,14 +1,44 @@ import { + Account, + Asset, AuthClawbackEnabledFlag, AuthRequiredFlag, AuthRevocableFlag, + Claimant, Keypair, + Networks, + Operation, + TransactionBuilder as StellarTransactionBuilder, + xdr, } from '@stellar/stellar-sdk'; -import { buildMockClassicTransaction } from './__mocks__/transaction.fixtures'; +import { + buildMockClassicTransaction, + buildMockInvokeHostFunctionTransaction, +} from './__mocks__/transaction.fixtures'; import { OperationMapper } from './OperationMapper'; +import { Transaction } from './Transaction'; import { KnownCaip2ChainId } from '../../api'; +/** + * Builds a Transaction wrapper from raw SDK operations for types the fixture builder doesn't cover. + * + * @param ops - SDK operations to include in the transaction. + * @returns A wrapped Transaction ready for mapper tests. + */ +function buildRawOpTransaction(...ops: any[]): Transaction { + const kp = Keypair.random(); + const account = new Account(kp.publicKey(), '1'); + const builder = new StellarTransactionBuilder(account, { + fee: '200', + networkPassphrase: Networks.TESTNET, + }); + for (const op of ops) { + builder.addOperation(op); + } + return new Transaction(builder.setTimeout(60).build()); +} + describe('OperationMapper', () => { const mapper = new OperationMapper(); @@ -158,4 +188,446 @@ describe('OperationMapper', () => { { key: 'setFlags', value: ['unknown(0x10)'], type: 'text' }, ]); }); + + it('maps accountMerge operation', () => { + const dest = Keypair.random().publicKey(); + const wrapped = buildRawOpTransaction( + Operation.accountMerge({ destination: dest }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('accountMerge'); + expect(op?.params).toStrictEqual([ + { key: 'destination', value: dest, type: 'address' }, + ]); + }); + + it('maps pathPaymentStrictReceive operation', () => { + const dest = Keypair.random().publicKey(); + const issuer = Keypair.random().publicKey(); + const sendAsset = Asset.native(); + const destAsset = new Asset('USD', issuer); + + const wrapped = buildRawOpTransaction( + Operation.pathPaymentStrictReceive({ + sendAsset, + sendMax: '50', + destination: dest, + destAsset, + destAmount: '100', + path: [new Asset('EUR', issuer)], + }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('pathPaymentStrictReceive'); + expect(op?.params).toStrictEqual([ + { + key: 'sendAsset', + value: ['native', '50.0000000'], + type: 'assetWithAmount', + }, + { key: 'destination', value: dest, type: 'address' }, + { + key: 'destAsset', + value: [`USD:${issuer}`, '100.0000000'], + type: 'assetWithAmount', + }, + { key: 'path', value: [`EUR:${issuer}`], type: 'json' }, + ]); + }); + + it('maps pathPaymentStrictSend operation', () => { + const dest = Keypair.random().publicKey(); + const issuer = Keypair.random().publicKey(); + + const wrapped = buildRawOpTransaction( + Operation.pathPaymentStrictSend({ + sendAsset: Asset.native(), + sendAmount: '25', + destination: dest, + destAsset: new Asset('EUR', issuer), + destMin: '20', + path: [], + }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('pathPaymentStrictSend'); + expect(op?.params).toStrictEqual([ + { + key: 'sendAsset', + value: ['native', '25.0000000'], + type: 'assetWithAmount', + }, + { key: 'destination', value: dest, type: 'address' }, + { + key: 'destAsset', + value: [`EUR:${issuer}`, '20.0000000'], + type: 'assetWithAmount', + }, + { key: 'path', value: [], type: 'json' }, + ]); + }); + + it('maps manageSellOffer operation', () => { + const issuer = Keypair.random().publicKey(); + const wrapped = buildRawOpTransaction( + Operation.manageSellOffer({ + selling: Asset.native(), + buying: new Asset('USD', issuer), + amount: '10', + price: '2.5', + offerId: '0', + }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('manageSellOffer'); + expect(op?.params).toStrictEqual([ + { + key: 'selling', + value: ['native', '10.0000000'], + type: 'assetWithAmount', + }, + { key: 'buying', value: `USD:${issuer}`, type: 'asset' }, + { key: 'price', value: '2.5', type: 'price' }, + { key: 'offerId', value: '0', type: 'text' }, + ]); + }); + + it('maps manageBuyOffer operation', () => { + const issuer = Keypair.random().publicKey(); + const wrapped = buildRawOpTransaction( + Operation.manageBuyOffer({ + buying: new Asset('BTC', issuer), + selling: Asset.native(), + buyAmount: '5', + price: '30000', + offerId: '0', + }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('manageBuyOffer'); + expect(op?.params).toStrictEqual([ + { + key: 'buying', + value: [`BTC:${issuer}`, '5.0000000'], + type: 'assetWithAmount', + }, + { key: 'selling', value: 'native', type: 'asset' }, + { key: 'price', value: '30000', type: 'price' }, + { key: 'offerId', value: '0', type: 'text' }, + ]); + }); + + it('maps createPassiveSellOffer operation', () => { + const issuer = Keypair.random().publicKey(); + const wrapped = buildRawOpTransaction( + Operation.createPassiveSellOffer({ + selling: Asset.native(), + buying: new Asset('EUR', issuer), + amount: '100', + price: '1.1', + }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('createPassiveSellOffer'); + expect(op?.params).toStrictEqual([ + { + key: 'selling', + value: ['native', '100.0000000'], + type: 'assetWithAmount', + }, + { key: 'buying', value: `EUR:${issuer}`, type: 'asset' }, + { key: 'price', value: '1.1', type: 'price' }, + ]); + }); + + it('maps manageData operation', () => { + const wrapped = buildRawOpTransaction( + Operation.manageData({ name: 'testKey', value: 'testValue' }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('manageData'); + expect(op?.params[0]).toStrictEqual({ + key: 'name', + value: 'testKey', + type: 'text', + }); + expect(op?.params[1]?.key).toBe('valueBase64'); + expect(op?.params[1]?.type).toBe('text'); + expect(op?.params[1]?.value).toBeDefined(); + }); + + it('maps manageData with null value (delete entry)', () => { + const wrapped = buildRawOpTransaction( + Operation.manageData({ name: 'deleteMe', value: null }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.params).toStrictEqual([ + { key: 'name', value: 'deleteMe', type: 'text' }, + { key: 'valueBase64', value: null, type: 'text' }, + ]); + }); + + it('maps bumpSequence operation', () => { + const wrapped = buildRawOpTransaction( + Operation.bumpSequence({ bumpTo: '999' }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('bumpSequence'); + expect(op?.params).toStrictEqual([ + { key: 'bumpTo', value: '999', type: 'text' }, + ]); + }); + + it('maps inflation operation with empty params', () => { + const wrapped = buildRawOpTransaction(Operation.inflation({})); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('inflation'); + expect(op?.params).toStrictEqual([]); + }); + + it('maps createClaimableBalance with unconditional predicate', () => { + const dest = Keypair.random().publicKey(); + const wrapped = buildRawOpTransaction( + Operation.createClaimableBalance({ + asset: Asset.native(), + amount: '50', + claimants: [ + new Claimant(dest, xdr.ClaimPredicate.claimPredicateUnconditional()), + ], + }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('createClaimableBalance'); + expect(op?.params[0]).toStrictEqual({ + key: 'asset', + value: 'native', + type: 'asset', + }); + expect(op?.params[1]).toStrictEqual({ + key: 'amount', + value: '50.0000000', + type: 'amount', + }); + expect(op?.params[2]?.key).toBe('claimants'); + expect(op?.params[2]?.type).toBe('json'); + const claimants = op?.params[2]?.value as { + destination: string; + predicate: string; + }[]; + expect(claimants).toHaveLength(1); + expect(claimants[0]?.destination).toBe(dest); + expect(claimants[0]?.predicate).toBe('unconditional'); + }); + + it('maps claimClaimableBalance operation', () => { + const balanceId = + '00000000da0d57da7d4850e7fc10d2a9d0ebc731f7afb40574c03395b17d49149b91f5be'; + const wrapped = buildRawOpTransaction( + Operation.claimClaimableBalance({ balanceId }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('claimClaimableBalance'); + expect(op?.params).toStrictEqual([ + { key: 'balanceId', value: balanceId, type: 'text' }, + ]); + }); + + it('maps beginSponsoringFutureReserves operation', () => { + const sponsoredId = Keypair.random().publicKey(); + const wrapped = buildRawOpTransaction( + Operation.beginSponsoringFutureReserves({ sponsoredId }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('beginSponsoringFutureReserves'); + expect(op?.params).toStrictEqual([ + { key: 'sponsoredId', value: sponsoredId, type: 'address' }, + ]); + }); + + it('maps endSponsoringFutureReserves with empty params', () => { + const wrapped = buildRawOpTransaction( + Operation.endSponsoringFutureReserves({}), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('endSponsoringFutureReserves'); + expect(op?.params).toStrictEqual([]); + }); + + it('maps clawback operation', () => { + const issuer = Keypair.random().publicKey(); + const from = Keypair.random().publicKey(); + const wrapped = buildRawOpTransaction( + Operation.clawback({ + asset: new Asset('USD', issuer), + amount: '100', + from, + }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('clawback'); + expect(op?.params).toStrictEqual([ + { key: 'asset', value: `USD:${issuer}`, type: 'asset' }, + { key: 'amount', value: '100.0000000', type: 'amount' }, + { key: 'from', value: from, type: 'address' }, + ]); + }); + + it('maps setTrustLineFlags with set and clear labels', () => { + const trustor = Keypair.random().publicKey(); + const issuer = Keypair.random().publicKey(); + const wrapped = buildRawOpTransaction( + Operation.setTrustLineFlags({ + trustor, + asset: new Asset('USD', issuer), + flags: { + authorized: true, + authorizedToMaintainLiabilities: false, + clawbackEnabled: true, + }, + }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('setTrustLineFlags'); + expect(op?.params).toStrictEqual([ + { key: 'trustor', value: trustor, type: 'address' }, + { key: 'asset', value: `USD:${issuer}`, type: 'asset' }, + { + key: 'setFlags', + value: ['authorized', 'clawbackEnabled'], + type: 'text', + }, + { + key: 'clearFlags', + value: ['authorizedToMaintainLiabilities'], + type: 'text', + }, + ]); + }); + + it('maps liquidityPoolDeposit operation', () => { + const poolId = + 'dd7b1ab831c273310ddbec6f97870aa83c2a7c2f9c0f5978c2e2f0738d5066e8'; + const wrapped = buildRawOpTransaction( + Operation.liquidityPoolDeposit({ + liquidityPoolId: poolId, + maxAmountA: '100', + maxAmountB: '200', + minPrice: '0.5', + maxPrice: '2.0', + }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('liquidityPoolDeposit'); + expect(op?.params).toStrictEqual([ + { key: 'liquidityPoolId', value: poolId, type: 'text' }, + { key: 'maxAmountA', value: '100.0000000', type: 'amount' }, + { key: 'maxAmountB', value: '200.0000000', type: 'amount' }, + { key: 'minPrice', value: '0.5', type: 'price' }, + { key: 'maxPrice', value: '2', type: 'price' }, + ]); + }); + + it('maps liquidityPoolWithdraw operation', () => { + const poolId = + 'dd7b1ab831c273310ddbec6f97870aa83c2a7c2f9c0f5978c2e2f0738d5066e8'; + const wrapped = buildRawOpTransaction( + Operation.liquidityPoolWithdraw({ + liquidityPoolId: poolId, + amount: '50', + minAmountA: '20', + minAmountB: '25', + }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('liquidityPoolWithdraw'); + expect(op?.params).toStrictEqual([ + { key: 'liquidityPoolId', value: poolId, type: 'text' }, + { key: 'amount', value: '50.0000000', type: 'amount' }, + { key: 'minAmountA', value: '20.0000000', type: 'amount' }, + { key: 'minAmountB', value: '25.0000000', type: 'amount' }, + ]); + }); + + it('maps invokeHostFunction with contractId, functionName, and arguments', () => { + const wrapped = buildMockInvokeHostFunctionTransaction('transfer', [ + 42, + 'hello', + ]); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('invokeHostFunction'); + expect(op?.classic).toBe(false); + const keys = op?.params.map((param) => param.key); + expect(keys).toContain('contractId'); + expect(keys).toContain('functionName'); + expect(keys).toContain('arguments'); + expect(keys).toContain('hostFunctionXdrBase64'); + + const fnRow = op?.params.find((param) => param.key === 'functionName'); + expect(fnRow?.value).toBe('transfer'); + }); + + it('maps invokeHostFunction with zero arguments omits arguments row', () => { + const wrapped = buildMockInvokeHostFunctionTransaction('init', []); + const [op] = mapper.mapTransaction(wrapped).operations; + + const keys = op?.params.map((param) => param.key); + expect(keys).toContain('contractId'); + expect(keys).toContain('functionName'); + expect(keys).not.toContain('arguments'); + }); + + it('maps extendFootprintTtl operation', () => { + const kp = Keypair.random(); + const account = new Account(kp.publicKey(), '1'); + const builder = new StellarTransactionBuilder(account, { + fee: '200', + networkPassphrase: Networks.TESTNET, + }); + builder.addOperation(Operation.extendFootprintTtl({ extendTo: 1000 })); + const wrapped = new Transaction(builder.setTimeout(60).build()); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('extendFootprintTtl'); + expect(op?.classic).toBe(false); + expect(op?.params).toStrictEqual([ + { key: 'extendTo', value: 1000, type: 'number' }, + ]); + }); + + it('maps restoreFootprint operation', () => { + const kp = Keypair.random(); + const account = new Account(kp.publicKey(), '1'); + const builder = new StellarTransactionBuilder(account, { + fee: '200', + networkPassphrase: Networks.TESTNET, + }); + builder.addOperation(Operation.restoreFootprint({})); + const wrapped = new Transaction(builder.setTimeout(60).build()); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.type).toBe('restoreFootprint'); + expect(op?.classic).toBe(false); + expect(op?.params).toStrictEqual([ + { key: 'note', value: 'Soroban restoreFootprint.', type: 'text' }, + ]); + }); }); From cc4b979401bdd75b69081032847f7f7c134ed122 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:00:58 +0800 Subject: [PATCH 048/384] feat: add token metadata service --- .../AssetMetadataRepository.test.ts | 135 ++++++++ .../asset-metadata/AssetMetadataRepository.ts | 121 +++++++ .../asset-metadata/AssetMetadataService.ts | 326 ++++++++++++++++++ .../src/services/asset-metadata/api.ts | 32 ++ .../src/services/asset-metadata/exceptions.ts | 12 + .../src/services/asset-metadata/index.ts | 4 + .../token-api/TokenApiClient.test.ts | 208 +++++++++++ .../token-api/TokenApiClient.ts | 183 ++++++++++ .../services/asset-metadata/token-api/api.ts | 32 ++ .../asset-metadata/token-api/exceptions.ts | 6 + .../src/services/asset-metadata/utils.ts | 87 +++++ 11 files changed, 1146 insertions(+) create mode 100644 merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/asset-metadata/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/asset-metadata/exceptions.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/asset-metadata/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/exceptions.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts new file mode 100644 index 00000000..c7a62823 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts @@ -0,0 +1,135 @@ +/* eslint-disable jsdoc/require-jsdoc -- test helpers */ +import { cloneDeep } from 'lodash'; + +import type { AssetMetadataState, StellarAssetMetadata } from './api'; +import { AssetMetadataRepository } from './AssetMetadataRepository'; +import { + AssetType, + KnownCaip2ChainId, + type KnownCaip19AssetId, +} from '../../api'; +import type { IStateManager } from '../state/IStateManager'; + +const classicId = + 'stellar:testnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as KnownCaip19AssetId; + +const sep41Id = + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as KnownCaip19AssetId; + +function generateAssetData( + assetId: KnownCaip19AssetId, + assetType: AssetType, + chainId: KnownCaip2ChainId, +): StellarAssetMetadata { + return { + assetId, + assetType, + chainId, + name: 'N', + symbol: 'S', + fungible: true, + iconUrl: 'https://example.test/icon.png', + units: [{ name: 'N', symbol: 'S', decimals: 7 }], + }; +} + +function createMockStateManager( + initial: AssetMetadataState, +): IStateManager { + let state = cloneDeep(initial); + return { + get: async () => cloneDeep(state), + getKey: async (key: string) => { + if (key === 'assets') { + return cloneDeep(state.assets) as TResponse; + } + return undefined; + }, + setKey: jest.fn(async () => Promise.resolve()), + update: async (updater) => { + state = updater(cloneDeep(state)); + return cloneDeep(state); + }, + deleteKey: jest.fn(async () => Promise.resolve()), + }; +} + +describe('AssetMetadataRepository', () => { + it('returns rows for getByAssetIds in request order', async () => { + const classic = generateAssetData( + classicId, + AssetType.Token, + KnownCaip2ChainId.Testnet, + ); + const sep41 = generateAssetData( + sep41Id, + AssetType.Sep41, + KnownCaip2ChainId.Mainnet, + ); + const manager = createMockStateManager({ + assets: { [classicId]: classic, [sep41Id]: sep41 }, + }); + const repo = new AssetMetadataRepository(manager); + + expect(await repo.getByAssetIds([sep41Id, classicId])).toStrictEqual([ + sep41, + classic, + ]); + }); + + it('filters getByAssetType by assetType and chainId', async () => { + const classic = generateAssetData( + classicId, + AssetType.Token, + KnownCaip2ChainId.Testnet, + ); + const sep41 = generateAssetData( + sep41Id, + AssetType.Sep41, + KnownCaip2ChainId.Mainnet, + ); + const manager = createMockStateManager({ + assets: { [classicId]: classic, [sep41Id]: sep41 }, + }); + const repo = new AssetMetadataRepository(manager); + + expect( + await repo.getByAssetType(AssetType.Sep41, KnownCaip2ChainId.Mainnet), + ).toStrictEqual([sep41]); + }); + + it('sets persistedAt when saving', async () => { + const manager = createMockStateManager({ assets: {} }); + const repo = new AssetMetadataRepository(manager); + const row = generateAssetData( + sep41Id, + AssetType.Sep41, + KnownCaip2ChainId.Mainnet, + ); + const before = Date.now(); + + await repo.saveMany([row]); + + const saved = await repo.getByAssetId(sep41Id); + expect(saved).toMatchObject({ + ...row, + persistedAt: expect.any(Number), + }); + expect(saved?.persistedAt).toBeGreaterThanOrEqual(before); + expect(saved?.persistedAt).toBeLessThanOrEqual(Date.now()); + }); + + it('resolves getByAssetId from assets map', async () => { + const row = generateAssetData( + classicId, + AssetType.Token, + KnownCaip2ChainId.Testnet, + ); + const manager = createMockStateManager({ assets: { [classicId]: row } }); + const repo = new AssetMetadataRepository(manager); + + expect(await repo.getByAssetId(classicId)).toStrictEqual(row); + expect(await repo.getByAssetId(sep41Id)).toBeNull(); + }); +}); +/* eslint-enable jsdoc/require-jsdoc */ diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts new file mode 100644 index 00000000..5edc45b5 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts @@ -0,0 +1,121 @@ +import { cloneDeep } from 'lodash'; + +import type { + AssetMetadataByAssetId, + AssetMetadataState, + StellarAssetMetadata, +} from './api'; +import type { + AssetType, + KnownCaip19AssetId, + KnownCaip2ChainId, +} from '../../api'; +import type { IStateManager } from '../state/IStateManager'; + +export class AssetMetadataRepository { + readonly #state: IStateManager; + + readonly #stateKey = 'assets'; + + constructor(state: IStateManager) { + this.#state = state; + } + + /** + * Returns persisted asset for the given asset ID. + * + * @param assetId - The asset ID to look up. + * @returns A Promise that resolves to the persisted asset if found, otherwise `null`. + */ + async getByAssetId( + assetId: KnownCaip19AssetId, + ): Promise { + const assets = + (await this.#state.getKey(this.#stateKey)) ?? {}; + return assets[assetId] ?? null; + } + + /** + * Returns persisted assets for the given IDs, shaped like `AssetMetadataState.assets`. + * Only keys present in storage are included; missing IDs are omitted. + * + * @param assetIds - The asset IDs to look up. + * @returns A Promise that resolves to the subset of the persisted `assets` map for those IDs. + */ + async getByAssetIds( + assetIds: KnownCaip19AssetId[], + ): Promise { + const assets = + (await this.#state.getKey(this.#stateKey)) ?? {}; + + const result: StellarAssetMetadata[] = []; + + for (const assetId of assetIds) { + const asset = assets[assetId]; + if (asset !== undefined) { + result.push(asset); + } + } + return result; + } + + /** + * Returns all persisted assets. + * + * @returns A Promise that resolves to all persisted assets. + */ + async getAll(): Promise { + const assets = + (await this.#state.getKey(this.#stateKey)) ?? {}; + + return Object.values(assets).filter( + (row): row is StellarAssetMetadata => row !== undefined, + ); + } + + /** + * Returns persisted assets for the given asset type and chain ID. + * + * @param assetType - The asset type to look up. + * @param scope - The chain ID to look up. + * @returns A Promise that resolves to the persisted assets for the given asset type and chain ID. + */ + async getByAssetType( + assetType: AssetType, + scope: KnownCaip2ChainId, + ): Promise { + const assets = + (await this.#state.getKey(this.#stateKey)) ?? {}; + + return Object.values(assets).filter( + (asset): asset is StellarAssetMetadata => + asset !== undefined && + asset.assetType === assetType && + asset.chainId === scope, + ); + } + + /** + * Upserts rows by `assetId`. Stamps `persistedAt` (same value for all rows in this call) + * for future staleness / TTL logic. + * + * @param assets - Full metadata rows; `assetId` must match the CAIP-19 key for that network. + */ + async saveMany(assets: StellarAssetMetadata[]): Promise { + if (assets.length === 0) { + return; + } + const persistedAt = Date.now(); + await this.#state.update((stateValue) => { + const newState = cloneDeep(stateValue); + + for (const asset of assets) { + newState.assets[asset.assetId] = { + ...asset, + persistedAt, + }; + } + return newState; + }); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts new file mode 100644 index 00000000..05439d34 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -0,0 +1,326 @@ +import type { AssetMetadata } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; +import { parseCaipAssetType } from '@metamask/utils'; + +import type { + KnownCaip19AssetId, + KnownCaip19AssetIdOrSlip44Id, +} from '../../api'; +import { + KnownCaip2ChainId, + AssetType, + KnownCaip2ChainIdStruct, +} from '../../api'; +import { AppConfig } from '../../config'; +import { STELLAR_DECIMAL_PLACES } from '../../constants'; +import { + batchesAllSettled, + createPrefixedLogger, + isSep41Id, + isSlip44Id, + parseClassicAssetCodeIssuer, +} from '../../utils'; +import type { ILogger } from '../../utils'; +import type { AssetDataResponse, NetworkService } from '../network'; +import type { StellarAssetMetadata } from './api'; +import type { AssetMetadataRepository } from './AssetMetadataRepository'; +import { AssetMetadataServiceException } from './exceptions'; +import { TokenApiClient } from './token-api/TokenApiClient'; +import { + getIconUrl, + getNativeAssetMetadata, + toStellarAssetMetadata, +} from './utils'; + +/** + * Resolves CAIP-19 asset identifiers and caches fungible asset metadata for lookups. + */ +export class AssetMetadataService { + static readonly #rpcBackfillBatchSize = 5; + + readonly #networkService: NetworkService; + + readonly #tokenApiClient: TokenApiClient; + + readonly #assetMetadataRepository: AssetMetadataRepository; + + readonly #logger: ILogger; + + constructor({ + networkService, + assetMetadataRepository, + logger, + }: { + networkService: NetworkService; + assetMetadataRepository: AssetMetadataRepository; + logger: ILogger; + }) { + this.#networkService = networkService; + this.#tokenApiClient = new TokenApiClient( + { + baseUrl: AppConfig.api.tokenApi.baseUrl, + chunkSize: AppConfig.api.tokenApi.chunkSize, + }, + logger, + ); + this.#assetMetadataRepository = assetMetadataRepository; + this.#logger = createPrefixedLogger(logger, '[🪙 AssetMetadataService]'); + } + + /** + * Loads decimals for the asset; for SEP-41, fetches symbol and contract metadata from the token contract. + * + * @param params - Resolution input. + * @param params.assetId - Native, classic, or SEP-41 CAIP-19 asset id. + * @param params.scope - CAIP-2 chain id. + * @returns Resolved asset data for wallet / transaction use. + */ + async resolve(params: { + assetId: KnownCaip19AssetIdOrSlip44Id; + scope: KnownCaip2ChainId; + }): Promise { + const { assetId } = params; + + if (AppConfig.selectedNetwork === KnownCaip2ChainId.Testnet) { + if (isSlip44Id(assetId)) { + return getNativeAssetMetadata(assetId); + } + const { assetNamespace, chainId, assetReference } = + parseCaipAssetType(assetId); + const { assetCode } = parseClassicAssetCodeIssuer(assetReference); + return { + assetId, + name: assetCode, + symbol: assetCode, + chainId: chainId as KnownCaip2ChainId, + assetType: assetNamespace as AssetType, + fungible: true, + iconUrl: getIconUrl(assetId), + units: [ + { + name: assetCode, + symbol: assetCode, + decimals: STELLAR_DECIMAL_PLACES, + }, + ], + }; + } + + const assets = await this.#fetchAndPersistAssetsByAssetIds([assetId]); + const found = assets.find((asset) => asset.assetId === assetId); + if (!found) { + throw new AssetMetadataServiceException( + `Asset metadata not found for asset id: ${assetId}`, + ); + } + return found; + } + + /** + * Returns all assets for the given asset IDs. + * + * @param assetIds - The asset IDs to look up. + * @returns A Promise that resolves to all assets metadata for the given asset IDs. + */ + async getAssetsMetadataByAssetIds( + assetIds: KnownCaip19AssetIdOrSlip44Id[], + ): Promise> { + this.#logger.debug('Fetching assets metadata by asset ids', { assetIds }); + const list = await this.#fetchAndPersistAssetsByAssetIds(assetIds); + + const metadataByAssetId = {} as Record< + KnownCaip19AssetIdOrSlip44Id, + AssetMetadata | null + >; + + for (const assetId of assetIds) { + metadataByAssetId[assetId] = null; + } + + for (const asset of list) { + metadataByAssetId[asset.assetId] = this.#toAssetMetadata(asset); + } + + return metadataByAssetId; + } + + /** + * Returns all persisted SEP-41 assets for the given chain ID. + * + * @param scope - The chain ID to look up. + * @returns A Promise that resolves to all persisted SEP-41 assets for the given chain ID. + */ + async getAllSep41AssetsMetadata( + scope: KnownCaip2ChainId, + ): Promise { + const persistedAssets = await this.#assetMetadataRepository.getByAssetType( + AssetType.Sep41, + scope, + ); + + return persistedAssets; + } + + /** + * Fetches and persists all Assets for the given chain ID from the token API. + * + * @param scope - The chain ID to fetch and persist assets for. + */ + async synchronize(scope: KnownCaip2ChainId): Promise { + const tokensMetadata = + await this.#tokenApiClient.getAllTokensMetadata(scope); + await this.#assetMetadataRepository.saveMany(tokensMetadata); + } + + async #fetchAndPersistAssetsByAssetIds( + assetIds: KnownCaip19AssetIdOrSlip44Id[], + ): Promise { + const result: StellarAssetMetadata[] = []; + const stellarAssetIds: KnownCaip19AssetId[] = []; + const deduplicatedAssetIds = new Set(); + for (const assetId of assetIds) { + if (isSlip44Id(assetId)) { + result.push(getNativeAssetMetadata(assetId)); + } else { + if (!deduplicatedAssetIds.has(assetId)) { + stellarAssetIds.push(assetId); + } + deduplicatedAssetIds.add(assetId); + } + } + + const { assets, missingAssetIds } = + await this.#getPersistedAssetMetadata(stellarAssetIds); + + if (missingAssetIds.length === 0) { + return result.concat(assets); + } + + const fetchedAssets = + await this.#fetchMissingAssetsMetadata(missingAssetIds); + + if (fetchedAssets.length > 0) { + await this.#assetMetadataRepository.saveMany(fetchedAssets); + } + + return result.concat(assets, fetchedAssets); + } + + async #getPersistedAssetMetadata(assetIds: KnownCaip19AssetId[]): Promise<{ + assets: StellarAssetMetadata[]; + missingAssetIds: KnownCaip19AssetId[]; + }> { + const cachedAssets = + await this.#assetMetadataRepository.getByAssetIds(assetIds); + const { hits: assets, missing: missingAssetIds } = + this.#partitionHitsAndMissingByArray(assetIds, cachedAssets); + + return { assets, missingAssetIds }; + } + + async #fetchMissingAssetsMetadata( + assetIds: KnownCaip19AssetId[], + ): Promise { + const { assets: apiTokenAssets, missingAssetIds } = + await this.#fetchTokenAssetsFromApi(assetIds); + + const rpcTokenAssets = await this.#fetchTokenAssetsFromRpc(missingAssetIds); + + return apiTokenAssets.concat(rpcTokenAssets); + } + + async #fetchTokenAssetsFromApi(assetIds: KnownCaip19AssetId[]): Promise<{ + assets: StellarAssetMetadata[]; + missingAssetIds: KnownCaip19AssetId[]; + }> { + this.#logger.debug('Fetching token assets from API', { assetIds }); + const tokensMetadata = + await this.#tokenApiClient.getTokensMetadata(assetIds); + const { hits: assets, missing: missingAssetIds } = + this.#partitionHitsAndMissingByArray(assetIds, tokensMetadata); + this.#logger.debug('Token assets from API', { missingAssetIds }); + return { assets, missingAssetIds }; + } + + async #fetchTokenAssetsFromRpc( + assetIds: KnownCaip19AssetId[], + ): Promise { + this.#logger.debug('Fetching token assets from RPC', { assetIds }); + const assets: StellarAssetMetadata[] = []; + const missingTokenAssetIds = new Set(assetIds); + + const settled = await batchesAllSettled( + assetIds, + AssetMetadataService.#rpcBackfillBatchSize, + async (assetId) => this.#fetchTokenAssetFromRpc(assetId), + ); + + for (let index = 0; index < assetIds.length; index += 1) { + const assetId = assetIds[index]; + const promiseEntry = settled[index]; + + if (assetId === undefined || promiseEntry?.status !== 'fulfilled') { + continue; + } + + assets.push(toStellarAssetMetadata(promiseEntry.value)); + missingTokenAssetIds.delete(assetId); + } + + if (missingTokenAssetIds.size > 0) { + this.#logger.warn( + `Failed to fetch token metadata for assets: ${Array.from(missingTokenAssetIds).join(', ')}`, + ); + } + + return assets; + } + + async #fetchTokenAssetFromRpc( + assetId: KnownCaip19AssetId, + ): Promise { + const scope = parseCaipAssetType(assetId).chainId; + assert(scope, KnownCaip2ChainIdStruct); + if (isSep41Id(assetId)) { + return this.#networkService.getAssetData(assetId, scope); + } + throw new AssetMetadataServiceException(`Invalid asset id: ${assetId}`); + } + + #toAssetMetadata(assetData: StellarAssetMetadata): AssetMetadata { + return { + fungible: assetData.fungible, + iconUrl: assetData.iconUrl, + units: assetData.units, + symbol: assetData.symbol, + name: assetData.name, + }; + } + + /** + * For each requested id in order: collect cached row if present, else mark missing. + * + * @param ids - Requested asset ids (order preserved for hits). + * @param cachedRows - Rows from cache or token API (may omit some ids). + * @returns Hits in `ids` order, plus ids with no matching row. + */ + #partitionHitsAndMissingByArray< + TId extends string, + TValue extends { assetId: string }, + >(ids: TId[], cachedRows: TValue[]): { hits: TValue[]; missing: TId[] } { + const byId = new Map(cachedRows.map((row) => [row.assetId, row])); + const hits: TValue[] = []; + const missing: TId[] = []; + + for (const id of ids) { + const row = byId.get(id); + if (row === undefined) { + missing.push(id); + } else { + hits.push(row); + } + } + + return { hits, missing }; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/api.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/api.ts new file mode 100644 index 00000000..f4e216a6 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/api.ts @@ -0,0 +1,32 @@ +import type { FungibleAssetMetadata } from '@metamask/snaps-sdk'; +import type { NonEmptyArray } from '@metamask/utils'; + +import type { + AssetType, + KnownCaip19AssetIdOrSlip44Id, + KnownCaip2ChainId, +} from '../../api'; + +export type AssetUnit = { + decimals: number; + symbol: string; + name?: string | undefined; +}; + +export type StellarAssetMetadata = FungibleAssetMetadata & { + assetId: KnownCaip19AssetIdOrSlip44Id; + assetType: AssetType; + chainId: KnownCaip2ChainId; + units: NonEmptyArray; + symbol: string; + persistedAt?: number; +}; + +/** Sparse map persisted under state `assets` (not every id is present). */ +export type AssetMetadataByAssetId = Partial< + Record +>; + +export type AssetMetadataState = { + assets: AssetMetadataByAssetId; +}; diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/exceptions.ts new file mode 100644 index 00000000..24db23e9 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/exceptions.ts @@ -0,0 +1,12 @@ +export class AssetMetadataServiceException extends Error { + constructor(message: string) { + super(message); + this.name = 'AssetMetadataServiceException'; + } +} + +export class InvalidAssetReferenceException extends AssetMetadataServiceException { + constructor(assetReference: string) { + super(`Invalid asset reference: ${assetReference}`); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/index.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/index.ts new file mode 100644 index 00000000..348c2326 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/index.ts @@ -0,0 +1,4 @@ +export * from './AssetMetadataService'; +export * from './AssetMetadataRepository'; +export * from './exceptions'; +export type * from './api'; diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.test.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.test.ts new file mode 100644 index 00000000..7b4a9261 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.test.ts @@ -0,0 +1,208 @@ +import { TokenApiClient } from './TokenApiClient'; +import { AssetType, KnownCaip2ChainId } from '../../../api'; +import { buildUrl, logger } from '../../../utils'; + +jest.mock('../../../config', () => ({ + AppConfig: { + api: { + tokenApi: { + baseUrl: 'https://tokens.test', + chunkSize: 2, + }, + staticApi: { + baseUrl: 'https://static.test', + }, + }, + }, +})); + +jest.mock('../../../utils/logger'); + +const classicAssetId = + 'stellar:testnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as const; + +/** Known-valid SEP-41 ids (see `api/asset.test.ts`). */ +const sep41AssetIdA = + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as const; + +const sep41AssetIdB = + 'stellar:pubnet/sep41:CBIJBDNZNF4X35BJ4FFZWCDBSCKOP5NB4PLG4SNENRMLAPYG4P5FM6VN' as const; + +const jsonResponse = ( + body: unknown, + init: { ok?: boolean; status?: number } = {}, +): Response => { + const { ok = true, status = ok ? 200 : 500 } = init; + return { + ok, + status, + json: async () => body, + } as Response; +}; + +const tokenApiClientOptions = { + baseUrl: 'https://tokens.test', + chunkSize: 2, +} as const; + +describe('TokenApiClient', () => { + const mockFetch = jest.fn() as jest.MockedFunction; + + const createClient = () => + new TokenApiClient(tokenApiClientOptions, logger, mockFetch); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getTokensMetadata', () => { + it('returns empty array when assetIds is empty', async () => { + const client = createClient(); + expect(await client.getTokensMetadata([])).toStrictEqual([]); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('requests token API with batched assetIds in query and maps response', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse([ + { + assetId: classicAssetId, + decimals: 7, + name: 'USD Coin', + symbol: 'USDC', + }, + ]), + ); + + const client = createClient(); + const result = await client.getTokensMetadata([classicAssetId]); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const urlArg = mockFetch.mock.calls[0]?.[0]; + expect(typeof urlArg).toBe('string'); + expect(urlArg).toBe( + buildUrl({ + baseUrl: 'https://tokens.test', + path: '/v3/assets', + queryParams: { assetIds: classicAssetId }, + }), + ); + + const row = result.find((entry) => entry.assetId === classicAssetId); + expect(row).toStrictEqual({ + name: 'USD Coin', + symbol: 'USDC', + assetId: classicAssetId, + chainId: KnownCaip2ChainId.Testnet, + assetType: AssetType.Token, + fungible: true, + iconUrl: buildUrl({ + baseUrl: 'https://static.test', + path: '/api/v2/tokenIcons/assets/{assetId}.png', + pathParams: { + assetId: classicAssetId.replace(/:/gu, '/'), + }, + encodePathParams: false, + }), + units: [{ name: 'USD Coin', symbol: 'USDC', decimals: 7 }], + }); + }); + + it('joins multiple ids per chunk using configured chunkSize', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse([ + { + assetId: sep41AssetIdA, + decimals: 7, + name: 'A', + symbol: 'A', + }, + { + assetId: sep41AssetIdB, + decimals: 18, + name: 'B', + symbol: 'B', + }, + ]), + ); + + const client = createClient(); + await client.getTokensMetadata([sep41AssetIdA, sep41AssetIdB]); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const urlArg = mockFetch.mock.calls[0]?.[0]; + expect(typeof urlArg).toBe('string'); + const assetIdsParam = new URL(urlArg as string).searchParams.get( + 'assetIds', + ); + expect(assetIdsParam).toBe(`${sep41AssetIdA},${sep41AssetIdB}`); + }); + + it('uses UNKNOWN when name and symbol are absent', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse([ + { + assetId: sep41AssetIdA, + decimals: 7, + }, + ]), + ); + + const client = createClient(); + const result = await client.getTokensMetadata([sep41AssetIdA]); + + const row = result.find((entry) => entry.assetId === sep41AssetIdA); + expect(row).toMatchObject({ + name: 'UNKNOWN', + symbol: 'UNKNOWN', + units: [{ name: 'UNKNOWN', symbol: 'UNKNOWN', decimals: 7 }], + assetType: AssetType.Sep41, + }); + }); + + it('uses response iconUrl when provided', async () => { + const iconUrl = 'https://cdn.example/token.png'; + mockFetch.mockResolvedValueOnce( + jsonResponse([ + { + assetId: sep41AssetIdA, + decimals: 7, + name: 'T', + symbol: 'T', + iconUrl, + }, + ]), + ); + + const client = createClient(); + const result = await client.getTokensMetadata([sep41AssetIdA]); + + expect( + result.find((entry) => entry.assetId === sep41AssetIdA)?.iconUrl, + ).toBe(iconUrl); + }); + + it('returns empty array when fetch fails', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse([], { ok: false, status: 503 }), + ); + + const client = createClient(); + expect(await client.getTokensMetadata([classicAssetId])).toStrictEqual( + [], + ); + }); + + it('wraps invalid response bodies in TokenApiException', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ notAnArray: true })); + + const client = createClient(); + await expect( + client.getTokensMetadata([classicAssetId]), + ).rejects.toMatchObject({ + name: 'TokenApiException', + message: 'Failed to fetch token metadata', + }); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts new file mode 100644 index 00000000..0da95b45 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts @@ -0,0 +1,183 @@ +import { + assert, + ensureError, + parseCaipAssetType, + type NonEmptyArray, +} from '@metamask/utils'; + +import type { TokenMetadata, TokenMetadataResponse } from './api'; +import { TokenMetadataResponseStruct } from './api'; +import { TokenApiException } from './exceptions'; +import type { + AssetType, + KnownCaip19AssetIdOrSlip44Id, + KnownCaip2ChainId, +} from '../../../api'; +import { + buildUrl, + batchesAllSettled, + chunks as chunkItems, +} from '../../../utils'; +import type { ILogger } from '../../../utils/logger'; +import type { StellarAssetMetadata, AssetUnit } from '../api'; +import { getIconUrl } from '../utils'; + +export class TokenApiClient { + static readonly #parallelBatchFetchLimit = 3; + + readonly #fetch: typeof globalThis.fetch; + + readonly #logger: ILogger; + + readonly #baseUrl: string; + + readonly #chunkSize: number; + + constructor( + { + baseUrl, + chunkSize, + }: { + baseUrl: string; + chunkSize: number; + }, + logger: ILogger, + _fetch: typeof globalThis.fetch = globalThis.fetch, + ) { + this.#fetch = _fetch; + this.#logger = logger; + this.#baseUrl = baseUrl; + this.#chunkSize = chunkSize; + } + + async #fetchTokenMetadataBatch( + assetIds: KnownCaip19AssetIdOrSlip44Id[], + ): Promise { + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: '/v3/assets', + queryParams: { + assetIds: assetIds.join(','), + }, + }); + + const response = await this.#fetch(url); + + if (!response.ok) { + throw new TokenApiException(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + assert(TokenMetadataResponseStruct, data); + + return data; + } + + async #fetchAllTokensMetadata( + scope: KnownCaip2ChainId, + ): Promise { + // example: https://tokens.api.cx.metamask.io/v3/chains/eip155:1329/assets?first=10&includeIconUrl=true&includeDuplicateSymbolAssets=true&useAggregatorIcons=true + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: `/v3/chains/${scope}/assets`, + queryParams: { + first: '1000', + includeIconUrl: 'true', + includeDuplicateSymbolAssets: 'true', + useAggregatorIcons: 'true', + }, + }); + + const response = await this.#fetch(url); + + if (!response.ok) { + throw new TokenApiException(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + assert(TokenMetadataResponseStruct, data); + + return data; + } + + async getTokensMetadata( + assetIds: KnownCaip19AssetIdOrSlip44Id[], + ): Promise { + try { + // Split addresses into chunks + const chunks = chunkItems(assetIds, this.#chunkSize); + + const settled = await batchesAllSettled( + chunks, + TokenApiClient.#parallelBatchFetchLimit, + async (chunk) => this.#fetchTokenMetadataBatch(chunk), + ); + + const tokenMetadataResponses: TokenMetadataResponse[] = []; + for (const entry of settled) { + if (entry.status === 'rejected') { + this.#logger.logErrorWithDetails( + 'Error fetching token metadata', + ensureError(entry.reason).message, + ); + continue; + } + tokenMetadataResponses.push(entry.value); + } + + const metadatas: StellarAssetMetadata[] = []; + + // Note: it is possible that the token metadata does not contain all the asset ids. + for (const tokenMetadataResponse of tokenMetadataResponses) { + for (const tokenMetadata of tokenMetadataResponse) { + metadatas.push(this.#toAssetMetadata(tokenMetadata)); + } + } + return metadatas; + } catch (error) { + this.#logger.logErrorWithDetails( + 'Error fetching token metadata', + ensureError(error).message, + ); + throw new TokenApiException(`Failed to fetch token metadata`); + } + } + + async getAllTokensMetadata( + scope: KnownCaip2ChainId, + ): Promise { + try { + const tokenMetadataResponses = await this.#fetchAllTokensMetadata(scope); + // Note: it is possible that the token metadata does not contain all the asset ids. + return tokenMetadataResponses.map((tokenMetadata) => + this.#toAssetMetadata(tokenMetadata), + ); + } catch (error) { + this.#logger.logErrorWithDetails('Error fetching token metadata', error); + throw new TokenApiException(`Failed to fetch token metadata`); + } + } + + #toAssetMetadata(tokenMetadata: TokenMetadata): StellarAssetMetadata { + const name = tokenMetadata.name ?? 'UNKNOWN'; + const symbol = tokenMetadata.symbol ?? 'UNKNOWN'; + const { decimals } = tokenMetadata; + const { assetId } = tokenMetadata; + const units: NonEmptyArray = [{ name, symbol, decimals }]; + + const { assetNamespace, chainId } = parseCaipAssetType(assetId); + + return { + name, + symbol, + assetId, + chainId: chainId as KnownCaip2ChainId, + assetType: assetNamespace as AssetType, + fungible: true as const, + iconUrl: tokenMetadata.iconUrl ?? getIconUrl(assetId), + units, + }; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts new file mode 100644 index 00000000..b0c38e97 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts @@ -0,0 +1,32 @@ +import type { Infer } from '@metamask/superstruct'; +import { + array, + integer, + object, + optional, + string, + min, + union, + nonempty, +} from '@metamask/superstruct'; + +import { + KnownCaip19ClassicAssetStruct, + KnownCaip19Sep41AssetStruct, + UrlStruct, +} from '../../../api'; + +export const TokenMetadataStruct = object({ + decimals: min(integer(), 1), + // there should be no slip44 assets in the token metadata response + assetId: union([KnownCaip19ClassicAssetStruct, KnownCaip19Sep41AssetStruct]), + name: optional(nonempty(string())), + symbol: optional(nonempty(string())), + iconUrl: optional(UrlStruct), +}); + +export const TokenMetadataResponseStruct = array(TokenMetadataStruct); + +export type TokenMetadataResponse = Infer; + +export type TokenMetadata = Infer; diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/exceptions.ts new file mode 100644 index 00000000..d5569a88 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/exceptions.ts @@ -0,0 +1,6 @@ +export class TokenApiException extends Error { + constructor(message: string) { + super(message); + this.name = 'TokenApiException'; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts new file mode 100644 index 00000000..593b329d --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts @@ -0,0 +1,87 @@ +import { parseCaipAssetType } from '@metamask/utils'; + +import type { StellarAssetMetadata } from './api'; +import type { + AssetType, + KnownCaip19Slip44Id, + KnownCaip2ChainId, + KnownCaip19AssetIdOrSlip44Id, +} from '../../api'; +import { AppConfig } from '../../config'; +import { + NATIVE_ASSET_NAME, + NATIVE_ASSET_SYMBOL, + STELLAR_DECIMAL_PLACES, +} from '../../constants'; +import { buildUrl } from '../../utils'; + +/** + * Returns the icon URL for a given asset ID. + * + * @param assetId - The asset ID. + * @returns The icon URL. + */ +export function getIconUrl(assetId: KnownCaip19AssetIdOrSlip44Id): string { + return buildUrl({ + baseUrl: AppConfig.api.staticApi.baseUrl, + path: '/api/v2/tokenIcons/assets/{assetId}.png', + pathParams: { + assetId: assetId.replace(/:/gu, '/'), + }, + encodePathParams: false, + }); +} + +/** + * Maps token API fields into {@link StellarAssetMetadata} for confirmations and UI. + * + * @param assetData - Raw asset row from the token API or equivalent. + * @param assetData.assetId - CAIP-19 asset id (classic, slip44, or sep41). + * @param assetData.decimals - Smallest-unit decimal count for the primary unit. + * @param assetData.symbol - Ticker or short symbol for display. + * @param assetData.name - Optional long name; defaults to `symbol` when omitted. + * @returns Keyring-shaped metadata including icon URL and units. + */ +export function toStellarAssetMetadata(assetData: { + assetId: KnownCaip19AssetIdOrSlip44Id; + decimals: number; + symbol: string; + name?: string; +}): StellarAssetMetadata { + const name = assetData.name ?? assetData.symbol; + const { assetNamespace, chainId } = parseCaipAssetType(assetData.assetId); + + return { + assetId: assetData.assetId, + name, + symbol: assetData.symbol, + chainId: chainId as KnownCaip2ChainId, + assetType: assetNamespace as AssetType, + fungible: true, + iconUrl: getIconUrl(assetData.assetId), + units: [ + { + name, + symbol: assetData.symbol, + decimals: assetData.decimals, + }, + ], + }; +} + +/** + * Builds {@link StellarAssetMetadata} for the native XLM slip44 id on the given network. + * + * @param assetId - Slip44 CAIP-19 id for native lumens on a Stellar scope. + * @returns Metadata with standard name, symbol, and 7 decimals. + */ +export function getNativeAssetMetadata( + assetId: KnownCaip19Slip44Id, +): StellarAssetMetadata { + return toStellarAssetMetadata({ + assetId, + decimals: STELLAR_DECIMAL_PLACES, + symbol: NATIVE_ASSET_SYMBOL, + name: NATIVE_ASSET_NAME, + }); +} From 0b300592744e0342ce60430bd689bca98768d67c Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:31:06 +0800 Subject: [PATCH 049/384] chore: update jest --- merged-packages/stellar-wallet-snap/jest.config.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index 0470be6d..839214da 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 61.77, - functions: 75.34, - lines: 77.05, - statements: 77.24, + branches: 60.02, + functions: 73, + lines: 73.45, + statements: 73.56, }, }, From 0af06d6e9c0ed157dead9be86a6fc1a9896d9c1b Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Thu, 16 Apr 2026 17:31:37 +0200 Subject: [PATCH 050/384] fix: KEYRING_ACCOUNT_TYPE and correlationId nesting --- merged-packages/stellar-wallet-snap/src/constants.ts | 5 +++-- .../src/handlers/keyring/keyring.test.ts | 2 +- .../stellar-wallet-snap/src/handlers/keyring/keyring.ts | 7 ++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/constants.ts b/merged-packages/stellar-wallet-snap/src/constants.ts index 1bcf5991..d31f5dbc 100644 --- a/merged-packages/stellar-wallet-snap/src/constants.ts +++ b/merged-packages/stellar-wallet-snap/src/constants.ts @@ -1,3 +1,5 @@ +import { XlmAccountType } from '@metamask/keyring-api'; + /** * The base reserve for the Stellar network. * @@ -66,6 +68,5 @@ export const MAX_INT64_BALANCE = '9223372036854775807'; /** * The type for the keyring account. - * TODO: Replace with the actual account type. */ -export const KEYRING_ACCOUNT_TYPE = 'any:account'; +export const KEYRING_ACCOUNT_TYPE = XlmAccountType.Account; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 12b92b04..5a64d66a 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -249,7 +249,7 @@ describe('KeyringHandler', () => { expect.objectContaining({ account: toKeyringAccount(mockAccount), displayConfirmation: false, - correlationId: '123', + metamask: { correlationId: '123' }, }), ); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 5b3a3f17..4aa9aa51 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -165,8 +165,13 @@ export class KeyringHandler implements Keyring { /** * Internal options to MetaMask that include a correlation ID. We need * to also emit this ID to the Snap keyring. + * Must be nested under `metamask` (keyring API). Do not spread + * `options.metamask` onto params or `correlationId` ends up at + * `params.correlationId` and fails validation (`never`). */ - ...(options?.metamask ?? {}), + ...(options?.metamask?.correlationId !== undefined + ? { metamask: { correlationId: options.metamask.correlationId } } + : {}), }); } From daff414c12990b994ffbb49aed756fc86cb1845e Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Thu, 16 Apr 2026 17:57:36 +0200 Subject: [PATCH 051/384] chore: bump keyring-api --- .../stellar-wallet-snap/package.json | 4 ++-- .../stellar-wallet-snap/snap.manifest.json | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/package.json b/merged-packages/stellar-wallet-snap/package.json index 0f6eaeea..47be038f 100644 --- a/merged-packages/stellar-wallet-snap/package.json +++ b/merged-packages/stellar-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/stellar-wallet-snap", - "version": "0.0.1", + "version": "0.0.1-dev.2", "description": "A Stellar wallet Snap.", "repository": { "type": "git", @@ -44,7 +44,7 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@metamask/key-tree": "^10.1.1", - "@metamask/keyring-api": "^21.4.0", + "@metamask/keyring-api": "23.0.1", "@metamask/keyring-snap-sdk": "^7.2.0", "@metamask/snaps-cli": "^8.4.0", "@metamask/snaps-jest": "^10.1.0", diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 9ff6f82d..875036dd 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.0.1", + "version": "0.0.1-dev.2", "description": "Manage Stellar using MetaMask", "proposedName": "Stellar", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "0VBaqTQVxjgac2XlOE0ndRKfzM/VuKtl8E56JYgZN+8=", + "shasum": "xPCDY/4WF/uyjb/+0lIsi3FSnJqsH9sPMJGC0hFiQ1Q=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -16,18 +16,26 @@ "registry": "https://registry.npmjs.org/" } }, - "locales": ["locales/en.json"] + "locales": [ + "locales/en.json" + ] }, "initialConnections": { "https://portfolio.metamask.io": {} }, "initialPermissions": { "endowment:keyring": { - "allowedOrigins": ["https://portfolio.metamask.io"] + "allowedOrigins": [ + "https://portfolio.metamask.io" + ] }, "snap_getBip32Entropy": [ { - "path": ["m", "44'", "148'"], + "path": [ + "m", + "44'", + "148'" + ], "curve": "ed25519" } ], From 5f21d258e45837dc0fa406c2df34d483d7d49a60 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Thu, 16 Apr 2026 18:12:34 +0200 Subject: [PATCH 052/384] feat: improve sign-tx UI for complex operations --- .../stellar-wallet-snap/jest.config.js | 6 +- .../stellar-wallet-snap/locales/en.json | 15 +++++ .../stellar-wallet-snap/messages.json | 15 +++++ .../stellar-wallet-snap/snap.manifest.json | 2 +- .../transaction/OperationMapper.test.ts | 33 +++++++++- .../services/transaction/OperationMapper.ts | 60 +++++++++++++------ .../ConfirmSignTransaction.tsx | 12 +++- 7 files changed, 118 insertions(+), 25 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index f9b6e3a4..e6f7263b 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 66.13, + branches: 66.1, functions: 78.29, - lines: 80.49, - statements: 80.63, + lines: 80.35, + statements: 80.5, }, }, diff --git a/merged-packages/stellar-wallet-snap/locales/en.json b/merged-packages/stellar-wallet-snap/locales/en.json index bfec91ce..3f3f5cb6 100644 --- a/merged-packages/stellar-wallet-snap/locales/en.json +++ b/merged-packages/stellar-wallet-snap/locales/en.json @@ -319,6 +319,21 @@ "confirmation.transaction.param.signer": { "message": "Signer" }, + "confirmation.transaction.param.signerEd25519": { + "message": "Signer (public key)" + }, + "confirmation.transaction.param.signerSha256Hash": { + "message": "Signer (SHA-256)" + }, + "confirmation.transaction.param.signerPreAuthTx": { + "message": "Signer (pre-auth tx)" + }, + "confirmation.transaction.param.signerSignedPayload": { + "message": "Signer (signed payload)" + }, + "confirmation.transaction.param.signerWeight": { + "message": "Signer weight" + }, "confirmation.transaction.param.sponsoredId": { "message": "Sponsored account" }, diff --git a/merged-packages/stellar-wallet-snap/messages.json b/merged-packages/stellar-wallet-snap/messages.json index c720fd61..d5e5a8a9 100644 --- a/merged-packages/stellar-wallet-snap/messages.json +++ b/merged-packages/stellar-wallet-snap/messages.json @@ -317,6 +317,21 @@ "confirmation.transaction.param.signer": { "message": "Signer" }, + "confirmation.transaction.param.signerEd25519": { + "message": "Signer (public key)" + }, + "confirmation.transaction.param.signerSha256Hash": { + "message": "Signer (SHA-256)" + }, + "confirmation.transaction.param.signerPreAuthTx": { + "message": "Signer (pre-auth tx)" + }, + "confirmation.transaction.param.signerSignedPayload": { + "message": "Signer (signed payload)" + }, + "confirmation.transaction.param.signerWeight": { + "message": "Signer weight" + }, "confirmation.transaction.param.sponsoredId": { "message": "Sponsored account" }, diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 9a3be035..166a878f 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "u7oYAFDKu3LINO5ZfvFAmGbkDNUBbEFEoEXfcp/s5ZQ=", + "shasum": "KYTlsTh6aZmfKg58wVSVQM/XPXF/Rg5mYjbPDfquk8Q=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts index f2694e64..81830b06 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts @@ -189,6 +189,37 @@ describe('OperationMapper', () => { ]); }); + it('maps setOptions with ed25519PublicKey signer to address row', () => { + const signerKey = Keypair.random().publicKey(); + const wrapped = buildRawOpTransaction( + Operation.setOptions({ + signer: { ed25519PublicKey: signerKey, weight: 1 }, + }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.params).toStrictEqual([ + { key: 'signerEd25519', value: signerKey, type: 'address' }, + { key: 'signerWeight', value: 1, type: 'number' }, + ]); + }); + + it('maps setOptions with sha256Hash signer to hex text row', () => { + // eslint-disable-next-line no-restricted-globals -- SDK requires Buffer for sha256Hash + const hashBuf = Buffer.alloc(32, 0xab); + const wrapped = buildRawOpTransaction( + Operation.setOptions({ + signer: { sha256Hash: hashBuf, weight: 2 }, + }), + ); + const [op] = mapper.mapTransaction(wrapped).operations; + + expect(op?.params).toStrictEqual([ + { key: 'signerSha256Hash', value: hashBuf.toString('hex'), type: 'text' }, + { key: 'signerWeight', value: 2, type: 'number' }, + ]); + }); + it('maps accountMerge operation', () => { const dest = Keypair.random().publicKey(); const wrapped = buildRawOpTransaction( @@ -579,7 +610,7 @@ describe('OperationMapper', () => { expect(keys).toContain('contractId'); expect(keys).toContain('functionName'); expect(keys).toContain('arguments'); - expect(keys).toContain('hostFunctionXdrBase64'); + expect(keys).not.toContain('hostFunctionXdrBase64'); const fnRow = op?.params.find((param) => param.key === 'functionName'); expect(fnRow?.value).toBe('transfer'); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts index 979f8d40..85d2829e 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts @@ -207,20 +207,6 @@ export class OperationMapper { ), ); } - try { - if (typeof hostOp.func?.toXDR === 'function') { - const raw = hostOp.func.toXDR(); - rows.push( - this.#field( - 'hostFunctionXdrBase64', - raw.toString('base64'), - 'text', - ), - ); - } - } catch { - // XDR serialization failed; skip - } return rows; } if (operation.type === 'extendFootprintTtl') { @@ -410,9 +396,49 @@ export class OperationMapper { rows.push(this.#field('homeDomain', setOptions.homeDomain, 'text')); } if ('signer' in setOptions && setOptions.signer !== undefined) { - rows.push( - this.#field('signer', JSON.stringify(setOptions.signer), 'text'), - ); + // SDK Signer is a union of disjoint interfaces; cast to Record for key-based branching. + const signer = setOptions.signer as unknown as Record< + string, + unknown + >; + if ('ed25519PublicKey' in signer) { + rows.push( + this.#field( + 'signerEd25519', + signer.ed25519PublicKey as string, + 'address', + ), + ); + } else if ('sha256Hash' in signer) { + rows.push( + this.#field( + 'signerSha256Hash', + bufferToUint8Array(signer.sha256Hash as Buffer).toString('hex'), + 'text', + ), + ); + } else if ('preAuthTx' in signer) { + rows.push( + this.#field( + 'signerPreAuthTx', + bufferToUint8Array(signer.preAuthTx as Buffer).toString('hex'), + 'text', + ), + ); + } else if ('ed25519SignedPayload' in signer) { + rows.push( + this.#field( + 'signerSignedPayload', + signer.ed25519SignedPayload as string, + 'text', + ), + ); + } + if (signer.weight !== undefined) { + rows.push( + this.#field('signerWeight', Number(signer.weight), 'number'), + ); + } } return rows; } diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx index fd8b73ea..9166dd47 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -105,13 +105,14 @@ const RenderReadableParamValue = (params: { return ; case 'asset': return ; + case 'json': + return {JSON.stringify(value, null, 2)}; default: if (Array.isArray(value)) { - // We only have string arrays in the params, so we can safely join them. // eslint-disable-next-line @typescript-eslint/no-base-to-string return {value.join(', ')}; } else if (typeof value === 'object') { - return {JSON.stringify(value)}; + return {JSON.stringify(value, null, 2)}; } return {String(value)}; } @@ -212,7 +213,12 @@ export const ConfirmSignTransaction = ({ ] .filter((param) => !isNullOrUndefined(param.value)) .map((param) => ( - + {t( `confirmation.transaction.param.${param.key}` as LocalizedMessage, From 1963ea33f825e8c4b8a3381b06a62a1ed0a259a0 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Fri, 17 Apr 2026 00:55:55 +0200 Subject: [PATCH 053/384] fix: use vertical layout for long param values in sign-tx UI --- .../stellar-wallet-snap/jest.config.js | 6 +-- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../ConfirmSignTransaction.tsx | 42 ++++++++++--------- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index e6f7263b..434bbd76 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 66.1, + branches: 66.34, functions: 78.29, - lines: 80.35, - statements: 80.5, + lines: 80.36, + statements: 80.51, }, }, diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 166a878f..240b89ec 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "KYTlsTh6aZmfKg58wVSVQM/XPXF/Rg5mYjbPDfquk8Q=", + "shasum": "mx5NkJJhqYupk0R7YOw1AyAWpZFOONTg41Gv8cvv0T4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx index 9166dd47..9198ff4c 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -212,25 +212,29 @@ export const ConfirmSignTransaction = ({ ...operationJson.params, ] .filter((param) => !isNullOrUndefined(param.value)) - .map((param) => ( - - - {t( - `confirmation.transaction.param.${param.key}` as LocalizedMessage, - )} - - - - ))} + .map((param) => { + const useVertical = + param.type === 'json' || + (typeof param.value === 'string' && + param.value.length > 40); + return ( + + + {t( + `confirmation.transaction.param.${param.key}` as LocalizedMessage, + )} + + + + ); + })} {index < readableTransaction.operations.length - 1 && } From 7c6a1ea2b5d47ee491f9196a569778649fc2ae07 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:40:26 +0800 Subject: [PATCH 054/384] chore: refine asset metadata service --- .../AssetMetadataRepository.test.ts | 6 +- .../AssetMetadataService.test.ts | 222 ++++++++++++++++++ .../asset-metadata/AssetMetadataService.ts | 208 +++++++++------- .../src/services/asset-metadata/exceptions.ts | 6 - .../token-api/TokenApiClient.ts | 158 ++++++++----- .../services/asset-metadata/token-api/api.ts | 3 +- .../src/services/asset-metadata/utils.ts | 9 +- .../src/services/network/NetworkService.ts | 58 ++++- .../src/services/network/exceptions.ts | 7 +- .../src/utils/async.test.ts | 54 ++++- .../stellar-wallet-snap/src/utils/async.ts | 52 ++-- .../src/utils/errors.test.ts | 93 +++++++- .../stellar-wallet-snap/src/utils/errors.ts | 25 ++ 13 files changed, 719 insertions(+), 182 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts index c7a62823..abb5141e 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable jsdoc/require-jsdoc -- test helpers */ import { cloneDeep } from 'lodash'; import type { AssetMetadataState, StellarAssetMetadata } from './api'; @@ -33,6 +32,10 @@ function generateAssetData( }; } +/** + * + * @param initial + */ function createMockStateManager( initial: AssetMetadataState, ): IStateManager { @@ -132,4 +135,3 @@ describe('AssetMetadataRepository', () => { expect(await repo.getByAssetId(sep41Id)).toBeNull(); }); }); -/* eslint-enable jsdoc/require-jsdoc */ diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts new file mode 100644 index 00000000..64ef39d1 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts @@ -0,0 +1,222 @@ +import type { AssetMetadata } from '@metamask/snaps-sdk'; + +import type { StellarAssetMetadata } from './api'; +import type { AssetMetadataRepository } from './AssetMetadataRepository'; +import { AssetMetadataService } from './AssetMetadataService'; +import { AssetMetadataServiceException } from './exceptions'; +import { + AssetType, + KnownCaip2ChainId, + type KnownCaip19AssetId, +} from '../../api'; +import { getSlip44AssetId, logger } from '../../utils'; +import type { NetworkService } from '../network'; +import { TokenApiClient } from './token-api/TokenApiClient'; + +jest.mock('../../config', () => ({ + AppConfig: { + api: { + tokenApi: { + baseUrl: 'https://tokens.test', + chunkSize: 10, + }, + staticApi: { + baseUrl: 'https://static.test', + }, + }, + }, +})); + +jest.mock('../../utils/logger'); + +jest.mock('./token-api/TokenApiClient', () => ({ + TokenApiClient: jest.fn(), +})); + +const testnetClassicId = + 'stellar:testnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as KnownCaip19AssetId; + +const pubnetClassicId = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as KnownCaip19AssetId; + +const mockGetTokensMetadata = jest.fn(); + +function createCachedRow( + assetId: KnownCaip19AssetId, + chainId: KnownCaip2ChainId, +): StellarAssetMetadata { + return { + assetId, + assetType: AssetType.Token, + chainId, + name: 'Cached', + symbol: 'CCH', + fungible: true, + iconUrl: 'https://example.test/icon.png', + units: [{ name: 'Cached', symbol: 'CCH', decimals: 7 }], + }; +} + +function createService(deps: { + repo?: Partial; + network?: Partial; +}) { + const defaults = { + getByAssetIds: jest.fn().mockResolvedValue([]), + saveMany: jest.fn().mockResolvedValue(undefined), + getByAssetType: jest.fn().mockResolvedValue([]), + getAll: jest.fn().mockResolvedValue([]), + getByAssetId: jest.fn().mockResolvedValue(null), + }; + + const repo = { + ...defaults, + ...deps.repo, + } as unknown as AssetMetadataRepository; + + const network = { + getAssetsData: jest.fn().mockResolvedValue([]), + getClassicAssetData: jest.fn(), + ...deps.network, + } as unknown as NetworkService; + + (TokenApiClient as jest.Mock).mockImplementation(() => ({ + getTokensMetadata: mockGetTokensMetadata, + getAllTokensMetadata: jest.fn(), + })); + + const service = new AssetMetadataService({ + networkService: network, + assetMetadataRepository: repo, + logger, + }); + + return { + service, + getByAssetIds: repo.getByAssetIds as jest.MockedFunction< + AssetMetadataRepository['getByAssetIds'] + >, + saveMany: repo.saveMany as jest.MockedFunction< + AssetMetadataRepository['saveMany'] + >, + getAssetsData: network.getAssetsData as jest.MockedFunction< + NetworkService['getAssetsData'] + >, + getClassicAssetData: network.getClassicAssetData as jest.MockedFunction< + NetworkService['getClassicAssetData'] + >, + }; +} + +describe('AssetMetadataService', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetTokensMetadata.mockResolvedValue([]); + }); + + it('returns native metadata for slip44 id matching scope', async () => { + const { service, getByAssetIds } = createService({}); + const slipId = getSlip44AssetId(KnownCaip2ChainId.Testnet); + const result = await service.resolve({ + assetId: slipId, + scope: KnownCaip2ChainId.Testnet, + }); + + expect(result.assetId).toBe(slipId); + expect(getByAssetIds).toHaveBeenCalledWith([]); + expect(mockGetTokensMetadata).not.toHaveBeenCalled(); + }); + + it('throws when asset chain does not match scope', async () => { + const { service } = createService({}); + await expect( + service.resolve({ + assetId: pubnetClassicId, + scope: KnownCaip2ChainId.Testnet, + }), + ).rejects.toThrow(AssetMetadataServiceException); + }); + + it('loads testnet classic from Horizon when cache misses and skips token API', async () => { + const rpcRow = { + assetId: testnetClassicId, + symbol: 'USDC', + decimals: 7, + name: 'USD Coin', + }; + const { service, getClassicAssetData } = createService({ + network: { + getClassicAssetData: jest.fn().mockResolvedValue(rpcRow), + }, + }); + + const result = await service.resolve({ + assetId: testnetClassicId, + scope: KnownCaip2ChainId.Testnet, + }); + + expect(result.assetId).toBe(testnetClassicId); + expect(result.symbol).toBe('USDC'); + expect(getClassicAssetData).toHaveBeenCalledWith( + testnetClassicId, + KnownCaip2ChainId.Testnet, + ); + expect(mockGetTokensMetadata).not.toHaveBeenCalled(); + }); + + it('returns cached classic asset without calling token API', async () => { + const cached = createCachedRow(testnetClassicId, KnownCaip2ChainId.Testnet); + const { service, getByAssetIds, saveMany } = createService({ + repo: { + getByAssetIds: jest.fn().mockResolvedValue([cached]), + }, + }); + + const result = await service.resolve({ + assetId: testnetClassicId, + scope: KnownCaip2ChainId.Testnet, + }); + + expect(result).toStrictEqual(cached); + expect(getByAssetIds).toHaveBeenCalledWith([testnetClassicId]); + expect(mockGetTokensMetadata).not.toHaveBeenCalled(); + expect(saveMany).not.toHaveBeenCalled(); + }); + + it('fills keyring metadata map and leaves wrong-scope ids null', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Testnet); + const { service } = createService({}); + + const map = await service.getAssetsMetadataByAssetIds( + [pubnetClassicId, slipId], + KnownCaip2ChainId.Testnet, + ); + + expect(map[pubnetClassicId]).toBeNull(); + expect(map[slipId]).toStrictEqual({ + fungible: true, + iconUrl: expect.any(String), + units: expect.any(Array), + symbol: expect.any(String), + name: expect.any(String), + } satisfies AssetMetadata); + }); + + it('delegates getAllSep41AssetsMetadata to repository', async () => { + const sepRows: StellarAssetMetadata[] = []; + const getByAssetType = jest.fn().mockResolvedValue(sepRows); + const { service } = createService({ + repo: { getByAssetType }, + }); + + const result = await service.getAllSep41AssetsMetadata( + KnownCaip2ChainId.Mainnet, + ); + + expect(result).toBe(sepRows); + expect(getByAssetType).toHaveBeenCalledWith( + AssetType.Sep41, + KnownCaip2ChainId.Mainnet, + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts index 05439d34..b1a94489 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -1,42 +1,42 @@ import type { AssetMetadata } from '@metamask/snaps-sdk'; -import { assert } from '@metamask/superstruct'; -import { parseCaipAssetType } from '@metamask/utils'; +import { ensureError, parseCaipAssetType } from '@metamask/utils'; import type { KnownCaip19AssetId, KnownCaip19AssetIdOrSlip44Id, + KnownCaip19ClassicAssetId, + KnownCaip19Sep41AssetId, } from '../../api'; -import { - KnownCaip2ChainId, - AssetType, - KnownCaip2ChainIdStruct, -} from '../../api'; +import { KnownCaip2ChainId, AssetType } from '../../api'; import { AppConfig } from '../../config'; -import { STELLAR_DECIMAL_PLACES } from '../../constants'; import { batchesAllSettled, + batchesAllSettledWithChunks, createPrefixedLogger, + isClassicAssetId, isSep41Id, isSlip44Id, - parseClassicAssetCodeIssuer, } from '../../utils'; import type { ILogger } from '../../utils'; -import type { AssetDataResponse, NetworkService } from '../network'; +import type { NetworkService } from '../network'; import type { StellarAssetMetadata } from './api'; import type { AssetMetadataRepository } from './AssetMetadataRepository'; import { AssetMetadataServiceException } from './exceptions'; import { TokenApiClient } from './token-api/TokenApiClient'; -import { - getIconUrl, - getNativeAssetMetadata, - toStellarAssetMetadata, -} from './utils'; +import { getNativeAssetMetadata, toStellarAssetMetadata } from './utils'; /** * Resolves CAIP-19 asset identifiers and caches fungible asset metadata for lookups. */ export class AssetMetadataService { - static readonly #rpcBackfillBatchSize = 5; + // Batch sizes for fetching assets from RPC, it is lower because we can fetch multiple assets at once + readonly #sepAssetBatchSize = 5; + + // Chunk size for fetching SEP-41 assets from RPC + readonly #sepAssetChunkSize = 10; + + // Batch sizes for fetching assets from Horizon + readonly #classicAssetBatchSize = 10; readonly #networkService: NetworkService; @@ -79,34 +79,11 @@ export class AssetMetadataService { assetId: KnownCaip19AssetIdOrSlip44Id; scope: KnownCaip2ChainId; }): Promise { - const { assetId } = params; - - if (AppConfig.selectedNetwork === KnownCaip2ChainId.Testnet) { - if (isSlip44Id(assetId)) { - return getNativeAssetMetadata(assetId); - } - const { assetNamespace, chainId, assetReference } = - parseCaipAssetType(assetId); - const { assetCode } = parseClassicAssetCodeIssuer(assetReference); - return { - assetId, - name: assetCode, - symbol: assetCode, - chainId: chainId as KnownCaip2ChainId, - assetType: assetNamespace as AssetType, - fungible: true, - iconUrl: getIconUrl(assetId), - units: [ - { - name: assetCode, - symbol: assetCode, - decimals: STELLAR_DECIMAL_PLACES, - }, - ], - }; - } - - const assets = await this.#fetchAndPersistAssetsByAssetIds([assetId]); + const { assetId, scope } = params; + const assets = await this.#fetchAndPersistAssetsByAssetIds( + [assetId], + scope, + ); const found = assets.find((asset) => asset.assetId === assetId); if (!found) { throw new AssetMetadataServiceException( @@ -120,13 +97,16 @@ export class AssetMetadataService { * Returns all assets for the given asset IDs. * * @param assetIds - The asset IDs to look up. + * @param scope - The chain ID to look up. * @returns A Promise that resolves to all assets metadata for the given asset IDs. */ async getAssetsMetadataByAssetIds( assetIds: KnownCaip19AssetIdOrSlip44Id[], + scope: KnownCaip2ChainId, ): Promise> { this.#logger.debug('Fetching assets metadata by asset ids', { assetIds }); - const list = await this.#fetchAndPersistAssetsByAssetIds(assetIds); + + const list = await this.#fetchAndPersistAssetsByAssetIds(assetIds, scope); const metadataByAssetId = {} as Record< KnownCaip19AssetIdOrSlip44Id, @@ -174,18 +154,23 @@ export class AssetMetadataService { async #fetchAndPersistAssetsByAssetIds( assetIds: KnownCaip19AssetIdOrSlip44Id[], + scope: KnownCaip2ChainId, ): Promise { + const uniqueAssetIds = new Set(assetIds); const result: StellarAssetMetadata[] = []; const stellarAssetIds: KnownCaip19AssetId[] = []; - const deduplicatedAssetIds = new Set(); - for (const assetId of assetIds) { + + for (const assetId of Array.from(uniqueAssetIds)) { + // make sure we only fetch assets for the given scope + const { chainId } = parseCaipAssetType(assetId); + if ((chainId as KnownCaip2ChainId) !== scope) { + continue; + } + if (isSlip44Id(assetId)) { - result.push(getNativeAssetMetadata(assetId)); + result.push(getNativeAssetMetadata(scope)); } else { - if (!deduplicatedAssetIds.has(assetId)) { - stellarAssetIds.push(assetId); - } - deduplicatedAssetIds.add(assetId); + stellarAssetIds.push(assetId); } } @@ -196,8 +181,10 @@ export class AssetMetadataService { return result.concat(assets); } - const fetchedAssets = - await this.#fetchMissingAssetsMetadata(missingAssetIds); + const fetchedAssets = await this.#fetchMissingAssetsMetadata( + missingAssetIds, + scope, + ); if (fetchedAssets.length > 0) { await this.#assetMetadataRepository.saveMany(fetchedAssets); @@ -220,13 +207,41 @@ export class AssetMetadataService { async #fetchMissingAssetsMetadata( assetIds: KnownCaip19AssetId[], + scope: KnownCaip2ChainId, ): Promise { - const { assets: apiTokenAssets, missingAssetIds } = - await this.#fetchTokenAssetsFromApi(assetIds); + let missingAssetIds: KnownCaip19AssetId[] = []; + let apiTokenAssets: StellarAssetMetadata[] = []; + + if (scope === KnownCaip2ChainId.Mainnet) { + // No scope required for the token API, as it only supports mainnet + const apiResult = await this.#fetchTokenAssetsFromApi(assetIds); + apiTokenAssets = apiResult.assets; + missingAssetIds = apiResult.missingAssetIds; + } else { + missingAssetIds = assetIds; + } - const rpcTokenAssets = await this.#fetchTokenAssetsFromRpc(missingAssetIds); + const missingSep41AssetIds: KnownCaip19Sep41AssetId[] = []; + const missingClassicAssetIds: KnownCaip19ClassicAssetId[] = []; - return apiTokenAssets.concat(rpcTokenAssets); + for (const assetId of missingAssetIds) { + if (isSep41Id(assetId)) { + missingSep41AssetIds.push(assetId); + } else if (isClassicAssetId(assetId)) { + missingClassicAssetIds.push(assetId); + } + // there is no other asset type that is not SEP-41 or classic + } + + const sepTokenAssets = await this.#fetchSepTokenAssets( + missingSep41AssetIds, + scope, + ); + const classicTokenAssets = await this.#fetchClassicTokenAssets( + missingClassicAssetIds, + scope, + ); + return apiTokenAssets.concat(sepTokenAssets).concat(classicTokenAssets); } async #fetchTokenAssetsFromApi(assetIds: KnownCaip19AssetId[]): Promise<{ @@ -242,29 +257,33 @@ export class AssetMetadataService { return { assets, missingAssetIds }; } - async #fetchTokenAssetsFromRpc( - assetIds: KnownCaip19AssetId[], + async #fetchSepTokenAssets( + assetIds: KnownCaip19Sep41AssetId[], + scope: KnownCaip2ChainId, ): Promise { - this.#logger.debug('Fetching token assets from RPC', { assetIds }); + this.#logger.debug('Fetching SEP-41 token assets from RPC', { assetIds }); const assets: StellarAssetMetadata[] = []; - const missingTokenAssetIds = new Set(assetIds); + const missingTokenAssetIds = new Set(assetIds); - const settled = await batchesAllSettled( + const settled = await batchesAllSettledWithChunks( assetIds, - AssetMetadataService.#rpcBackfillBatchSize, - async (assetId) => this.#fetchTokenAssetFromRpc(assetId), + this.#sepAssetChunkSize, + this.#sepAssetBatchSize, + async (chunk) => this.#networkService.getAssetsData(chunk, scope), ); - for (let index = 0; index < assetIds.length; index += 1) { - const assetId = assetIds[index]; - const promiseEntry = settled[index]; - - if (assetId === undefined || promiseEntry?.status !== 'fulfilled') { + for (const entry of settled) { + if (entry.status === 'rejected') { + this.#logger.logErrorWithDetails( + 'Error fetching SEP-41 token assets from RPC', + ensureError(entry.reason).message, + ); continue; } - - assets.push(toStellarAssetMetadata(promiseEntry.value)); - missingTokenAssetIds.delete(assetId); + for (const asset of entry.value) { + assets.push(toStellarAssetMetadata(asset)); + missingTokenAssetIds.delete(asset.assetId); + } } if (missingTokenAssetIds.size > 0) { @@ -276,15 +295,42 @@ export class AssetMetadataService { return assets; } - async #fetchTokenAssetFromRpc( - assetId: KnownCaip19AssetId, - ): Promise { - const scope = parseCaipAssetType(assetId).chainId; - assert(scope, KnownCaip2ChainIdStruct); - if (isSep41Id(assetId)) { - return this.#networkService.getAssetData(assetId, scope); + async #fetchClassicTokenAssets( + assetIds: KnownCaip19ClassicAssetId[], + scope: KnownCaip2ChainId, + ): Promise { + this.#logger.debug('Fetching Classic token assets from Horizon', { + assetIds, + }); + const assets: StellarAssetMetadata[] = []; + const missingTokenAssetIds = new Set(assetIds); + + const settled = await batchesAllSettled( + assetIds, + this.#classicAssetBatchSize, + async (assetId) => + this.#networkService.getClassicAssetData(assetId, scope), + ); + + for (const entry of settled) { + if (entry.status === 'rejected') { + this.#logger.logErrorWithDetails( + 'Error fetching Classic token assets from Horizon', + ensureError(entry.reason).message, + ); + continue; + } + assets.push(toStellarAssetMetadata(entry.value)); + missingTokenAssetIds.delete(entry.value.assetId); + } + + if (missingTokenAssetIds.size > 0) { + this.#logger.warn( + `Failed to fetch token metadata for assets: ${Array.from(missingTokenAssetIds).join(', ')}`, + ); } - throw new AssetMetadataServiceException(`Invalid asset id: ${assetId}`); + + return assets; } #toAssetMetadata(assetData: StellarAssetMetadata): AssetMetadata { diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/exceptions.ts index 24db23e9..6f65f4c0 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/exceptions.ts @@ -4,9 +4,3 @@ export class AssetMetadataServiceException extends Error { this.name = 'AssetMetadataServiceException'; } } - -export class InvalidAssetReferenceException extends AssetMetadataServiceException { - constructor(assetReference: string) { - super(`Invalid asset reference: ${assetReference}`); - } -} diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts index 0da95b45..e0c48dc1 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts @@ -14,9 +14,9 @@ import type { KnownCaip2ChainId, } from '../../../api'; import { + batchesAllSettledWithChunks, buildUrl, - batchesAllSettled, - chunks as chunkItems, + rethrowIfInstanceElseThrow, } from '../../../utils'; import type { ILogger } from '../../../utils/logger'; import type { StellarAssetMetadata, AssetUnit } from '../api'; @@ -53,88 +53,115 @@ export class TokenApiClient { async #fetchTokenMetadataBatch( assetIds: KnownCaip19AssetIdOrSlip44Id[], ): Promise { - const url = buildUrl({ - baseUrl: this.#baseUrl, - path: '/v3/assets', - queryParams: { - assetIds: assetIds.join(','), - }, - }); - - const response = await this.#fetch(url); - - if (!response.ok) { - throw new TokenApiException(`HTTP error! status: ${response.status}`); - } + try { + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: '/v3/assets', + queryParams: { + assetIds: assetIds.join(','), + }, + }); + + const response = await this.#fetch(url); + + if (!response.ok) { + throw new TokenApiException(`HTTP error! status: ${response.status}`); + } - const data = await response.json(); + const data = await response.json(); - assert(TokenMetadataResponseStruct, data); + assert(TokenMetadataResponseStruct, data); - return data; + return data; + } catch (error) { + this.#logger.logErrorWithDetails( + 'Error fetching token metadata', + ensureError(error).message, + ); + return rethrowIfInstanceElseThrow( + error, + [TokenApiException], + new TokenApiException(`Failed to fetch token metadata`), + ); + } } + /** + * Fetches all tokens metadata for the given chain ID. + * + * @see https://tokens.api.cx.metamask.io/v3/chains/eip155:1329/assets?first=10&includeIconUrl=true&includeDuplicateSymbolAssets=true&useAggregatorIcons=true + * + * @param scope - The chain ID to fetch all tokens metadata for. + * @returns A Promise that resolves to the token metadata responses. + */ async #fetchAllTokensMetadata( scope: KnownCaip2ChainId, ): Promise { - // example: https://tokens.api.cx.metamask.io/v3/chains/eip155:1329/assets?first=10&includeIconUrl=true&includeDuplicateSymbolAssets=true&useAggregatorIcons=true - const url = buildUrl({ - baseUrl: this.#baseUrl, - path: `/v3/chains/${scope}/assets`, - queryParams: { - first: '1000', - includeIconUrl: 'true', - includeDuplicateSymbolAssets: 'true', - useAggregatorIcons: 'true', - }, - }); - - const response = await this.#fetch(url); - - if (!response.ok) { - throw new TokenApiException(`HTTP error! status: ${response.status}`); - } + try { + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: `/v3/chains/${scope}/assets`, + queryParams: { + first: '1000', + includeIconUrl: 'true', + includeDuplicateSymbolAssets: 'true', + useAggregatorIcons: 'true', + }, + }); + + const response = await this.#fetch(url); + + if (!response.ok) { + throw new TokenApiException(`HTTP error! status: ${response.status}`); + } - const data = await response.json(); + const data = await response.json(); - assert(TokenMetadataResponseStruct, data); + assert(TokenMetadataResponseStruct, data); - return data; + return data; + } catch (error) { + this.#logger.logErrorWithDetails( + 'Error fetching token metadata', + ensureError(error).message, + ); + return rethrowIfInstanceElseThrow( + error, + [TokenApiException], + new TokenApiException(`Failed to fetch token metadata`), + ); + } } + /** + * Fetches all tokens metadata for the given asset IDs. + * + * @param assetIds - The asset IDs to fetch all tokens metadata for. + * @returns A Promise that resolves to the token metadata responses. + */ async getTokensMetadata( assetIds: KnownCaip19AssetIdOrSlip44Id[], ): Promise { try { // Split addresses into chunks - const chunks = chunkItems(assetIds, this.#chunkSize); - - const settled = await batchesAllSettled( - chunks, + const settled = await batchesAllSettledWithChunks( + assetIds, + this.#chunkSize, TokenApiClient.#parallelBatchFetchLimit, async (chunk) => this.#fetchTokenMetadataBatch(chunk), ); - const tokenMetadataResponses: TokenMetadataResponse[] = []; + const metadatas: StellarAssetMetadata[] = []; for (const entry of settled) { if (entry.status === 'rejected') { - this.#logger.logErrorWithDetails( - 'Error fetching token metadata', - ensureError(entry.reason).message, - ); continue; } - tokenMetadataResponses.push(entry.value); - } - - const metadatas: StellarAssetMetadata[] = []; - - // Note: it is possible that the token metadata does not contain all the asset ids. - for (const tokenMetadataResponse of tokenMetadataResponses) { - for (const tokenMetadata of tokenMetadataResponse) { + // Note: it is possible that the token metadata response does not contain all the asset ids. + for (const tokenMetadata of entry.value) { metadatas.push(this.#toAssetMetadata(tokenMetadata)); } } + return metadatas; } catch (error) { this.#logger.logErrorWithDetails( @@ -145,19 +172,20 @@ export class TokenApiClient { } } + /** + * Fetches all tokens metadata for the given chain ID. + * + * @param scope - The chain ID to fetch all tokens metadata for. + * @returns A Promise that resolves to the token metadata responses. + */ async getAllTokensMetadata( scope: KnownCaip2ChainId, ): Promise { - try { - const tokenMetadataResponses = await this.#fetchAllTokensMetadata(scope); - // Note: it is possible that the token metadata does not contain all the asset ids. - return tokenMetadataResponses.map((tokenMetadata) => - this.#toAssetMetadata(tokenMetadata), - ); - } catch (error) { - this.#logger.logErrorWithDetails('Error fetching token metadata', error); - throw new TokenApiException(`Failed to fetch token metadata`); - } + const tokenMetadataResponses = await this.#fetchAllTokensMetadata(scope); + // Note: it is possible that the token metadata does not contain all the asset ids. + return tokenMetadataResponses.map((tokenMetadata) => + this.#toAssetMetadata(tokenMetadata), + ); } #toAssetMetadata(tokenMetadata: TokenMetadata): StellarAssetMetadata { diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts index b0c38e97..9a90da74 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts @@ -5,7 +5,6 @@ import { object, optional, string, - min, union, nonempty, } from '@metamask/superstruct'; @@ -17,7 +16,7 @@ import { } from '../../../api'; export const TokenMetadataStruct = object({ - decimals: min(integer(), 1), + decimals: integer(), // there should be no slip44 assets in the token metadata response assetId: union([KnownCaip19ClassicAssetStruct, KnownCaip19Sep41AssetStruct]), name: optional(nonempty(string())), diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts index 593b329d..d819775d 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts @@ -3,7 +3,6 @@ import { parseCaipAssetType } from '@metamask/utils'; import type { StellarAssetMetadata } from './api'; import type { AssetType, - KnownCaip19Slip44Id, KnownCaip2ChainId, KnownCaip19AssetIdOrSlip44Id, } from '../../api'; @@ -13,7 +12,7 @@ import { NATIVE_ASSET_SYMBOL, STELLAR_DECIMAL_PLACES, } from '../../constants'; -import { buildUrl } from '../../utils'; +import { buildUrl, getSlip44AssetId } from '../../utils'; /** * Returns the icon URL for a given asset ID. @@ -72,14 +71,14 @@ export function toStellarAssetMetadata(assetData: { /** * Builds {@link StellarAssetMetadata} for the native XLM slip44 id on the given network. * - * @param assetId - Slip44 CAIP-19 id for native lumens on a Stellar scope. + * @param scope - The CAIP-2 chain id. * @returns Metadata with standard name, symbol, and 7 decimals. */ export function getNativeAssetMetadata( - assetId: KnownCaip19Slip44Id, + scope: KnownCaip2ChainId, ): StellarAssetMetadata { return toStellarAssetMetadata({ - assetId, + assetId: getSlip44AssetId(scope), decimals: STELLAR_DECIMAL_PLACES, symbol: NATIVE_ASSET_SYMBOL, name: NATIVE_ASSET_NAME, diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index e673858b..27e5bae8 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -31,9 +31,14 @@ import { isAccountNotFoundError, parseScValToNative, } from './utils'; -import type { KnownCaip19Sep41AssetId, KnownCaip2ChainId } from '../../api'; +import type { + KnownCaip19ClassicAssetId, + KnownCaip19Sep41AssetId, + KnownCaip2ChainId, +} from '../../api'; import type { NetworkConfig } from '../../config'; import { AppConfig } from '../../config'; +import { STELLAR_DECIMAL_PLACES } from '../../constants'; import type { ILogger } from '../../utils'; import { isSameStr, @@ -191,7 +196,8 @@ export class NetworkService { ): Promise { try { const client = this.#getRpcClient(scope); - return new OnChainAccount(await client.getAccount(accountAddress), scope); + const loaded = await client.getAccount(accountAddress); + return new OnChainAccount(loaded, scope); } catch (error: unknown) { this.#logger.logErrorWithDetails('Failed to get an account', error); if (isAccountNotFoundError(error, accountAddress)) { @@ -283,6 +289,54 @@ export class NetworkService { } } + /** + * Fetches classic asset data from Horizon via `assets` for a CAIP-19 classic asset id. + * + * @param assetId - CAIP-19 classic asset id (`…/asset:CODE-ISSUER`). + * @param scope - The CAIP-2 chain ID. + * @returns for the classic asset. + * @throws {AssetDataFetchException} When Horizon returns no entry for this asset. + */ + async getClassicAssetData( + assetId: KnownCaip19ClassicAssetId, + scope: KnownCaip2ChainId, + ): Promise { + try { + const client = this.#getHorizonClient(scope); + const { assetCode, assetIssuer } = parseClassicAssetCodeIssuer(assetId); + const assetData = await client + .assets() + .forCode(assetCode) + .forIssuer(assetIssuer) + .call(); + + if ( + !assetData || + assetData.records.length === 0 || + assetData.records[0] === undefined || + assetData.records[0].asset_code !== assetCode || + assetData.records[0].asset_issuer !== assetIssuer + ) { + throw new AssetDataFetchException(scope, assetId); + } + + return { + assetId, + symbol: assetCode, + decimals: STELLAR_DECIMAL_PLACES, + name: assetCode, + }; + } catch (error) { + this.#logger.logErrorWithDetails( + 'Failed to get assets data from Horizon', + error, + ); + throw new NetworkServiceException( + 'Failed to get assets data from Horizon', + ); + } + } + /** * Reads a SEP-41-style token balance via Soroban simulation of `balance(Address)`. * diff --git a/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts index f9892c8f..e27b0fd2 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts @@ -37,11 +37,14 @@ export class AccountLoadException extends NetworkServiceException { /** Thrown when the account does not exist or is not funded on the network. */ export class AccountNotActivatedException extends NetworkServiceException { - readonly reference: string; + readonly address: string; + + readonly scope: KnownCaip2ChainId; constructor(address: string, scope: KnownCaip2ChainId) { super(`Account not activated for address: ${address} for scope: ${scope}`); - this.reference = address; + this.address = address; + this.scope = scope; } } diff --git a/merged-packages/stellar-wallet-snap/src/utils/async.test.ts b/merged-packages/stellar-wallet-snap/src/utils/async.test.ts index fba05a12..e75e03f5 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/async.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/async.test.ts @@ -1,4 +1,8 @@ -import { batchesAllSettled, chunks } from './async'; +import { + batchesAllSettled, + batchesAllSettledWithChunks, + chunks, +} from './async'; describe('batchesAllSettled', () => { it('throws when batchSize is less than 1', async () => { @@ -97,3 +101,51 @@ describe('chunks', () => { expect(run).toThrow(RangeError); }); }); + +describe('batchesAllSettledWithChunks', () => { + it('returns empty array for empty items', async () => { + const result = await batchesAllSettledWithChunks([], 2, 3, async () => 0); + expect(result).toStrictEqual([]); + }); + + it('maps each chunk and preserves chunk order in settled results', async () => { + const settled = await batchesAllSettledWithChunks( + ['a', 'b', 'c', 'd'], + 2, + 2, + async (chunk, chunkIndex) => ({ chunkIndex, joined: chunk.join('') }), + ); + + expect(settled).toHaveLength(2); + expect(settled[0]).toStrictEqual({ + status: 'fulfilled', + value: { chunkIndex: 0, joined: 'ab' }, + }); + expect(settled[1]).toStrictEqual({ + status: 'fulfilled', + value: { chunkIndex: 1, joined: 'cd' }, + }); + }); + + it('records rejected chunk without failing other chunks', async () => { + const mapper = jest + .fn() + .mockRejectedValueOnce(new Error('chunk0 fail')) + .mockResolvedValueOnce(7); + + const settled = await batchesAllSettledWithChunks( + [1, 2, 3, 4], + 2, + 1, + mapper, + ); + + expect(mapper).toHaveBeenNthCalledWith(1, [1, 2], 0); + expect(mapper).toHaveBeenNthCalledWith(2, [3, 4], 1); + expect(settled[0]).toMatchObject({ + status: 'rejected', + reason: expect.objectContaining({ message: 'chunk0 fail' }), + }); + expect(settled[1]).toStrictEqual({ status: 'fulfilled', value: 7 }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/utils/async.ts b/merged-packages/stellar-wallet-snap/src/utils/async.ts index 3c9a817e..645884f3 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/async.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/async.ts @@ -1,3 +1,25 @@ +/** + * Splits items into chunks of a given size. + * + * @param items - Input items; order is preserved in the returned chunks. + * @param chunkSize - Size of each chunk (must be ≥ 1). + * @returns An array of chunks, each containing `chunkSize` items. + */ +export function chunks( + items: readonly TItem[], + chunkSize: number, +): TItem[][] { + if (chunkSize < 1) { + throw new RangeError('chunkSize must be at least 1'); + } + + const itemsChunks: TItem[][] = []; + for (let index = 0; index < items.length; index += chunkSize) { + itemsChunks.push(items.slice(index, index + chunkSize)); + } + return itemsChunks; +} + /** * Runs async work on items in fixed-size waves, using {@link Promise.allSettled} per wave. * The next wave starts only after the current one settles, limiting concurrency to `batchSize`. @@ -30,23 +52,23 @@ export async function batchesAllSettled( } /** - * Splits items into chunks of a given size. + * Splits `items` into consecutive chunks of `chunkSize`, then runs {@link batchesAllSettled} on those chunks. + * Each mapper call receives one chunk; settled results are in chunk order (same order as {@link chunks}). * - * @param items - Input items; order is preserved in the returned chunks. - * @param chunkSize - Size of each chunk (must be ≥ 1). - * @returns An array of chunks, each containing `chunkSize` items. + * @param items - Flat input items. + * @param chunkSize - Items per chunk (must be ≥ 1). + * @param batchSize - Max concurrent chunk mappers per wave (must be ≥ 1). + * @param mapper - Async work for a single chunk; second argument is the chunk index (0-based). + * @returns One settled result per chunk. */ -export function chunks( +export async function batchesAllSettledWithChunks( items: readonly TItem[], chunkSize: number, -): TItem[][] { - if (chunkSize < 1) { - throw new RangeError('chunkSize must be at least 1'); - } - - const itemsChunks: TItem[][] = []; - for (let index = 0; index < items.length; index += chunkSize) { - itemsChunks.push(items.slice(index, index + chunkSize)); - } - return itemsChunks; + batchSize: number, + mapper: (chunk: TItem[], chunkIndex: number) => Promise, +): Promise[]> { + const itemChunks = chunks(items, chunkSize); + return batchesAllSettled(itemChunks, batchSize, async (chunk, chunkIndex) => + mapper(chunk, chunkIndex), + ); } diff --git a/merged-packages/stellar-wallet-snap/src/utils/errors.test.ts b/merged-packages/stellar-wallet-snap/src/utils/errors.test.ts index 28626d67..320acaf1 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/errors.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/errors.test.ts @@ -19,9 +19,10 @@ import { } from '@metamask/snaps-sdk'; import { - withCatchAndThrowSnapError, isSnapRpcError, + rethrowIfInstanceElseThrow, sanitizeSensitiveError, + withCatchAndThrowSnapError, } from './errors'; import { logger } from './logger'; @@ -34,6 +35,96 @@ describe('errors', () => { jest.clearAllMocks(); }); + describe('rethrowIfInstanceElseThrow', () => { + class SampleDomainError extends Error { + constructor(message: string) { + super(message); + this.name = 'SampleDomainError'; + } + } + + class SampleDomainSubError extends SampleDomainError {} + + class OtherDomainError extends Error { + constructor(message: string) { + super(message); + this.name = 'OtherDomainError'; + } + } + + it('rethrows when error matches the sole constructor in the list', () => { + const original = new SampleDomainError('preserved'); + expect(() => + rethrowIfInstanceElseThrow( + original, + [SampleDomainError], + new SampleDomainError('fallback'), + ), + ).toThrow(original); + }); + + it('rethrows subclass instances as the base class match', () => { + const sub = new SampleDomainSubError('sub'); + expect(() => + rethrowIfInstanceElseThrow( + sub, + [SampleDomainError], + new SampleDomainError('fallback'), + ), + ).toThrow(sub); + }); + + it('throws fallback when error is not an instance of any listed class', () => { + expect(() => + rethrowIfInstanceElseThrow( + new Error('generic'), + [SampleDomainError], + new SampleDomainError('wrapped'), + ), + ).toThrow( + expect.objectContaining({ + name: 'SampleDomainError', + message: 'wrapped', + }), + ); + }); + + it('rethrows when error matches any constructor in the list', () => { + const firstMatch = new SampleDomainError('first'); + expect(() => + rethrowIfInstanceElseThrow( + firstMatch, + [SampleDomainError, OtherDomainError], + new SampleDomainError('fallback'), + ), + ).toThrow(firstMatch); + + const secondMatch = new OtherDomainError('second'); + expect(() => + rethrowIfInstanceElseThrow( + secondMatch, + [SampleDomainError, OtherDomainError], + new SampleDomainError('fallback'), + ), + ).toThrow(secondMatch); + }); + + it('throws fallback when error matches none of the constructors', () => { + expect(() => + rethrowIfInstanceElseThrow( + new Error('generic'), + [SampleDomainError, OtherDomainError], + new SampleDomainError('wrapped'), + ), + ).toThrow( + expect.objectContaining({ + name: 'SampleDomainError', + message: 'wrapped', + }), + ); + }); + }); + describe('withCatchAndThrowSnapError', () => { it('returns the result when the function succeeds', async () => { const mockFn = jest.fn().mockResolvedValue('success'); diff --git a/merged-packages/stellar-wallet-snap/src/utils/errors.ts b/merged-packages/stellar-wallet-snap/src/utils/errors.ts index 3e5e0247..47818cc6 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/errors.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/errors.ts @@ -21,6 +21,31 @@ import { import type { ILogger } from './logger'; import { logger as defaultLogger } from './logger'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- must accept arbitrary `Error` subclass ctor signatures +type AnyErrorConstructor = abstract new (...args: any[]) => Error; + +/** + * Re-throws `error` when it is an instance of **any** constructor in `exceptionClasses` (subclasses + * count). Otherwise throws `fallback`. Typical use after logging in an API client `catch` so known + * domain errors propagate unchanged. Use a one-element array when only one type should match. + * + * @param error - Value from a `catch` clause. + * @param exceptionClasses - `Error` subclass constructors to match with `instanceof`, in order. + * @param fallback - Error to throw when nothing matches. + */ +export function rethrowIfInstanceElseThrow( + error: unknown, + exceptionClasses: readonly AnyErrorConstructor[], + fallback: Err, +): never { + for (const ExceptionClass of exceptionClasses) { + if (error instanceof ExceptionClass) { + throw error; + } + } + throw fallback; +} + /** * Sanitizes error messages that may contain sensitive cryptographic information. * This prevents leaking details about private keys, entropy, or derivation paths. From 9ff01a73fc42d360fd27221aba762681778ecf6c Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:53:53 +0800 Subject: [PATCH 055/384] chore: update network service to use the error helper --- .../src/services/network/NetworkService.ts | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index 27e5bae8..24e0cb38 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -46,6 +46,7 @@ import { parseClassicAssetCodeIssuer, toCaip19ClassicAssetId, toCaip19Sep41AssetId, + rethrowIfInstanceElseThrow, } from '../../utils'; import { OnChainAccount } from '../on-chain-account/OnChainAccount'; import { Transaction } from '../transaction/Transaction'; @@ -143,10 +144,11 @@ export class NetworkService { throw new TransactionPollException(transactionHash, result.status, scope); } catch (error: unknown) { this.#logger.logErrorWithDetails('Failed to poll transaction', error); - if (error instanceof TransactionPollException) { - throw error; - } - throw new TransactionPollException(transactionHash, 'unknown', scope); + return rethrowIfInstanceElseThrow( + error, + [TransactionPollException], + new TransactionPollException(transactionHash, 'unknown', scope), + ); } } @@ -326,13 +328,15 @@ export class NetworkService { decimals: STELLAR_DECIMAL_PLACES, name: assetCode, }; - } catch (error) { + } catch (error: unknown) { this.#logger.logErrorWithDetails( 'Failed to get assets data from Horizon', error, ); - throw new NetworkServiceException( - 'Failed to get assets data from Horizon', + return rethrowIfInstanceElseThrow( + error, + [AssetDataFetchException], + new NetworkServiceException('Failed to get assets data from Horizon'), ); } } @@ -399,13 +403,11 @@ export class NetworkService { 'Failed to load SEP-41 token balance', error, ); - if ( - error instanceof SimulationException || - error instanceof NetworkServiceException - ) { - throw error; - } - throw new NetworkServiceException('Failed to load SEP-41 token balance'); + return rethrowIfInstanceElseThrow( + error, + [NetworkServiceException], + new NetworkServiceException('Failed to load SEP-41 token balance'), + ); } } @@ -496,10 +498,11 @@ export class NetworkService { return executedTransaction.hash; } catch (error: unknown) { this.#logger.logErrorWithDetails('Failed to send transaction', error); - if (error instanceof NetworkServiceException) { - throw error; - } - throw new TransactionSendException(scope, 'unknown'); + return rethrowIfInstanceElseThrow( + error, + [NetworkServiceException], + new TransactionSendException(scope, 'unknown'), + ); } } @@ -545,15 +548,12 @@ export class NetworkService { return new Transaction(simulatedTransaction.build()); } catch (error: unknown) { this.#logger.logErrorWithDetails('Failed to simulate transaction', error); - if ( - error instanceof NetworkServiceException || - error instanceof SimulationException - ) { - throw error; - } - - throw new SimulationException( - error instanceof Error ? error.message : 'Unknown error', + return rethrowIfInstanceElseThrow( + error, + [NetworkServiceException], + new SimulationException( + error instanceof Error ? error.message : 'Unknown error', + ), ); } } From 38924d81f65eeaee862a50fc21c784fb92687a33 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:04:56 +0800 Subject: [PATCH 056/384] chore: add quit early logic --- .../services/asset-metadata/AssetMetadataService.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts index b1a94489..1f7a39ca 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -248,6 +248,10 @@ export class AssetMetadataService { assets: StellarAssetMetadata[]; missingAssetIds: KnownCaip19AssetId[]; }> { + if (assetIds.length === 0) { + return { assets: [], missingAssetIds: [] }; + } + this.#logger.debug('Fetching token assets from API', { assetIds }); const tokensMetadata = await this.#tokenApiClient.getTokensMetadata(assetIds); @@ -261,6 +265,10 @@ export class AssetMetadataService { assetIds: KnownCaip19Sep41AssetId[], scope: KnownCaip2ChainId, ): Promise { + if (assetIds.length === 0) { + return []; + } + this.#logger.debug('Fetching SEP-41 token assets from RPC', { assetIds }); const assets: StellarAssetMetadata[] = []; const missingTokenAssetIds = new Set(assetIds); @@ -299,6 +307,10 @@ export class AssetMetadataService { assetIds: KnownCaip19ClassicAssetId[], scope: KnownCaip2ChainId, ): Promise { + if (assetIds.length === 0) { + return []; + } + this.#logger.debug('Fetching Classic token assets from Horizon', { assetIds, }); From 089df0cc93b22acc06f035501de90f012a374013 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Fri, 17 Apr 2026 09:13:53 +0200 Subject: [PATCH 057/384] chore: lint --- .../stellar-wallet-snap/src/handlers/keyring/keyring.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 4aa9aa51..18e64317 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -169,9 +169,9 @@ export class KeyringHandler implements Keyring { * `options.metamask` onto params or `correlationId` ends up at * `params.correlationId` and fails validation (`never`). */ - ...(options?.metamask?.correlationId !== undefined - ? { metamask: { correlationId: options.metamask.correlationId } } - : {}), + ...(options?.metamask?.correlationId === undefined + ? {} + : { metamask: { correlationId: options.metamask.correlationId } }), }); } From 24eb6d58aa3d06d9c15470bf27f2dbb545565e56 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Fri, 17 Apr 2026 09:51:28 +0200 Subject: [PATCH 058/384] chore: lint --- .../stellar-wallet-snap/package.json | 2 +- .../stellar-wallet-snap/snap.manifest.json | 18 +++++------------- .../src/services/price/PriceService.test.ts | 2 +- .../src/services/price/PriceService.ts | 4 ++-- 4 files changed, 9 insertions(+), 17 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/package.json b/merged-packages/stellar-wallet-snap/package.json index 47be038f..b61f6eef 100644 --- a/merged-packages/stellar-wallet-snap/package.json +++ b/merged-packages/stellar-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/stellar-wallet-snap", - "version": "0.0.1-dev.2", + "version": "0.0.1", "description": "A Stellar wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 875036dd..f5e0b51a 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.0.1-dev.2", + "version": "0.0.1", "description": "Manage Stellar using MetaMask", "proposedName": "Stellar", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "xPCDY/4WF/uyjb/+0lIsi3FSnJqsH9sPMJGC0hFiQ1Q=", + "shasum": "G8+uh4JxXeehCEop7UK0rguSRvEDq76Y9hhHYWzYatU=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -16,26 +16,18 @@ "registry": "https://registry.npmjs.org/" } }, - "locales": [ - "locales/en.json" - ] + "locales": ["locales/en.json"] }, "initialConnections": { "https://portfolio.metamask.io": {} }, "initialPermissions": { "endowment:keyring": { - "allowedOrigins": [ - "https://portfolio.metamask.io" - ] + "allowedOrigins": ["https://portfolio.metamask.io"] }, "snap_getBip32Entropy": [ { - "path": [ - "m", - "44'", - "148'" - ], + "path": ["m", "44'", "148'"], "curve": "ed25519" } ], diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts index abe6e11b..4e5b6a05 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts @@ -1,3 +1,4 @@ +import { PriceService } from './PriceService'; import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; import { AppConfig } from '../../config'; import { logger, serialize } from '../../utils'; @@ -6,7 +7,6 @@ import { type FiatExchangeRatesResponse, } from './price-api/api'; import { PriceApiClient } from './price-api/PriceApiClient'; -import { PriceService } from './PriceService'; import { createMemoryCache } from '../cache/__mocks__/cache.fixtures'; jest.mock('../../utils/logger'); diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts index c4c77e70..1022e2af 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts @@ -1,3 +1,5 @@ +import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; +import { AppConfig } from '../../config'; import type { ILogger, Serializable } from '../../utils'; import type { ICache } from '../cache'; import { useCache } from '../cache'; @@ -9,8 +11,6 @@ import type { VsCurrencyParam, } from './price-api/api'; import { PriceApiClient } from './price-api/PriceApiClient'; -import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; -import { AppConfig } from '../../config'; export class PriceService { readonly #priceApiClient: PriceApiClient; From 193b5491716b043c6344ffb529ef41475530bad1 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Fri, 17 Apr 2026 11:15:51 +0200 Subject: [PATCH 059/384] fix: merge asset and amount into single row for claimable balance and clawback --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../transaction/OperationMapper.test.ts | 22 +++++++++---------- .../services/transaction/OperationMapper.ts | 14 ++++++++---- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 240b89ec..f7fc376f 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "mx5NkJJhqYupk0R7YOw1AyAWpZFOONTg41Gv8cvv0T4=", + "shasum": "xO75IaV+/FwpurmSO3eYjDv9rc7CakhroO7d7wQ2g5c=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts index 81830b06..1d53cb07 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts @@ -442,17 +442,12 @@ describe('OperationMapper', () => { expect(op?.type).toBe('createClaimableBalance'); expect(op?.params[0]).toStrictEqual({ key: 'asset', - value: 'native', - type: 'asset', + value: ['native', '50.0000000'], + type: 'assetWithAmount', }); - expect(op?.params[1]).toStrictEqual({ - key: 'amount', - value: '50.0000000', - type: 'amount', - }); - expect(op?.params[2]?.key).toBe('claimants'); - expect(op?.params[2]?.type).toBe('json'); - const claimants = op?.params[2]?.value as { + expect(op?.params[1]?.key).toBe('claimants'); + expect(op?.params[1]?.type).toBe('json'); + const claimants = op?.params[1]?.value as { destination: string; predicate: string; }[]; @@ -512,8 +507,11 @@ describe('OperationMapper', () => { expect(op?.type).toBe('clawback'); expect(op?.params).toStrictEqual([ - { key: 'asset', value: `USD:${issuer}`, type: 'asset' }, - { key: 'amount', value: '100.0000000', type: 'amount' }, + { + key: 'asset', + value: [`USD:${issuer}`, '100.0000000'], + type: 'assetWithAmount', + }, { key: 'from', value: from, type: 'address' }, ]); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts index 85d2829e..caf2d4e0 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts @@ -480,8 +480,11 @@ export class OperationMapper { case 'createClaimableBalance': { const createCb = operation; return [ - this.#field('asset', createCb.asset.toString(), 'asset'), - this.#field('amount', createCb.amount, 'amount'), + this.#field( + 'asset', + [createCb.asset.toString(), createCb.amount], + 'assetWithAmount', + ), this.#field( 'claimants', createCb.claimants.map((claimant) => ({ @@ -509,8 +512,11 @@ export class OperationMapper { case 'clawback': { const clawback = operation; return [ - this.#field('asset', clawback.asset.toString(), 'asset'), - this.#field('amount', clawback.amount, 'amount'), + this.#field( + 'asset', + [clawback.asset.toString(), clawback.amount], + 'assetWithAmount', + ), this.#field('from', clawback.from, 'address'), ]; } From 065639295f3ef199aa51284f5596b078080c38d0 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:37:16 +0800 Subject: [PATCH 060/384] fix: comment --- .../AssetMetadataService.test.ts | 4 +- .../asset-metadata/AssetMetadataService.ts | 68 ++++++++++--------- 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts index 64ef39d1..82383527 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts @@ -202,14 +202,14 @@ describe('AssetMetadataService', () => { } satisfies AssetMetadata); }); - it('delegates getAllSep41AssetsMetadata to repository', async () => { + it('delegates getPersistedSep41AssetsMetadata to repository', async () => { const sepRows: StellarAssetMetadata[] = []; const getByAssetType = jest.fn().mockResolvedValue(sepRows); const { service } = createService({ repo: { getByAssetType }, }); - const result = await service.getAllSep41AssetsMetadata( + const result = await service.getPersistedSep41AssetsMetadata( KnownCaip2ChainId.Mainnet, ); diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts index 1f7a39ca..ce4fd370 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -18,7 +18,7 @@ import { isSlip44Id, } from '../../utils'; import type { ILogger } from '../../utils'; -import type { NetworkService } from '../network'; +import type { AssetDataResponse, NetworkService } from '../network'; import type { StellarAssetMetadata } from './api'; import type { AssetMetadataRepository } from './AssetMetadataRepository'; import { AssetMetadataServiceException } from './exceptions'; @@ -130,7 +130,7 @@ export class AssetMetadataService { * @param scope - The chain ID to look up. * @returns A Promise that resolves to all persisted SEP-41 assets for the given chain ID. */ - async getAllSep41AssetsMetadata( + async getPersistedSep41AssetsMetadata( scope: KnownCaip2ChainId, ): Promise { const persistedAssets = await this.#assetMetadataRepository.getByAssetType( @@ -241,7 +241,7 @@ export class AssetMetadataService { missingClassicAssetIds, scope, ); - return apiTokenAssets.concat(sepTokenAssets).concat(classicTokenAssets); + return [...apiTokenAssets, ...sepTokenAssets, ...classicTokenAssets]; } async #fetchTokenAssetsFromApi(assetIds: KnownCaip19AssetId[]): Promise<{ @@ -270,8 +270,6 @@ export class AssetMetadataService { } this.#logger.debug('Fetching SEP-41 token assets from RPC', { assetIds }); - const assets: StellarAssetMetadata[] = []; - const missingTokenAssetIds = new Set(assetIds); const settled = await batchesAllSettledWithChunks( assetIds, @@ -280,23 +278,12 @@ export class AssetMetadataService { async (chunk) => this.#networkService.getAssetsData(chunk, scope), ); - for (const entry of settled) { - if (entry.status === 'rejected') { - this.#logger.logErrorWithDetails( - 'Error fetching SEP-41 token assets from RPC', - ensureError(entry.reason).message, - ); - continue; - } - for (const asset of entry.value) { - assets.push(toStellarAssetMetadata(asset)); - missingTokenAssetIds.delete(asset.assetId); - } - } + const { assets, missingAssetIds } = + this.#extractSuccessAndMissingFromSettled(settled, assetIds); - if (missingTokenAssetIds.size > 0) { + if (missingAssetIds.length > 0) { this.#logger.warn( - `Failed to fetch token metadata for assets: ${Array.from(missingTokenAssetIds).join(', ')}`, + `Failed to fetch token metadata for assets: ${Array.from(missingAssetIds).join(', ')}`, ); } @@ -314,8 +301,6 @@ export class AssetMetadataService { this.#logger.debug('Fetching Classic token assets from Horizon', { assetIds, }); - const assets: StellarAssetMetadata[] = []; - const missingTokenAssetIds = new Set(assetIds); const settled = await batchesAllSettled( assetIds, @@ -324,25 +309,46 @@ export class AssetMetadataService { this.#networkService.getClassicAssetData(assetId, scope), ); + const { assets, missingAssetIds } = + this.#extractSuccessAndMissingFromSettled(settled, assetIds); + + if (missingAssetIds.length > 0) { + this.#logger.warn( + `Failed to fetch token metadata for assets: ${Array.from(missingAssetIds).join(', ')}`, + ); + } + + return assets; + } + + #extractSuccessAndMissingFromSettled( + settled: PromiseSettledResult[], + assetIds: KnownCaip19AssetId[], + ): { assets: StellarAssetMetadata[]; missingAssetIds: string[] } { + const assets: StellarAssetMetadata[] = []; + const missingTokenAssetIds = new Set(assetIds); + for (const entry of settled) { if (entry.status === 'rejected') { this.#logger.logErrorWithDetails( - 'Error fetching Classic token assets from Horizon', + 'Error fetching assets', ensureError(entry.reason).message, ); continue; } - assets.push(toStellarAssetMetadata(entry.value)); - missingTokenAssetIds.delete(entry.value.assetId); - } - if (missingTokenAssetIds.size > 0) { - this.#logger.warn( - `Failed to fetch token metadata for assets: ${Array.from(missingTokenAssetIds).join(', ')}`, - ); + if (Array.isArray(entry.value)) { + for (const asset of entry.value) { + assets.push(toStellarAssetMetadata(asset)); + missingTokenAssetIds.delete(asset.assetId); + } + } else { + assets.push(toStellarAssetMetadata(entry.value)); + missingTokenAssetIds.delete(entry.value.assetId); + } } - return assets; + return { assets, missingAssetIds: Array.from(missingTokenAssetIds) }; } #toAssetMetadata(assetData: StellarAssetMetadata): AssetMetadata { From ce53e204d169719e75e85e482a288cd38f39994f Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:38:16 +0800 Subject: [PATCH 061/384] fix: network service --- .../src/services/network/NetworkService.ts | 4 +++- .../src/services/network/exceptions.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index 24e0cb38..409cfee0 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -305,7 +305,9 @@ export class NetworkService { ): Promise { try { const client = this.#getHorizonClient(scope); - const { assetCode, assetIssuer } = parseClassicAssetCodeIssuer(assetId); + const { assetCode, assetIssuer } = parseClassicAssetCodeIssuer( + parseCaipAssetType(assetId).assetReference, + ); const assetData = await client .assets() .forCode(assetCode) diff --git a/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts index e27b0fd2..883701f5 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts @@ -1,4 +1,7 @@ -import type { KnownCaip2ChainId } from '../../api'; +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip2ChainId, +} from '../../api'; /** Base for all network-related errors (fees, account load, send, poll). */ export class NetworkServiceException extends Error { @@ -72,9 +75,9 @@ export class SimulationException extends NetworkServiceException { /** Thrown when asset data cannot be fetched from the network. */ export class AssetDataFetchException extends NetworkServiceException { - constructor(scope: KnownCaip2ChainId, address: string) { + constructor(scope: KnownCaip2ChainId, assetId: KnownCaip19AssetIdOrSlip44Id) { super( - `Failed to fetch asset data for contract ${address} for scope: ${scope}`, + `Failed to fetch asset data for asset id: ${assetId} for scope: ${scope}`, ); } } From 04e5fd2bbe56d16d8bea650ca38a5e614dae041a Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Fri, 17 Apr 2026 11:54:55 +0200 Subject: [PATCH 062/384] perf: fetch SEP-41 and Classic assets in parallel --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../services/asset-metadata/AssetMetadataService.ts | 12 ++++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index f5e0b51a..20d0e324 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "G8+uh4JxXeehCEop7UK0rguSRvEDq76Y9hhHYWzYatU=", + "shasum": "IWQMqSlpgPaQiqhJ/u+YJMk04/q6AqfWd8eTNM6aseg=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts index ce4fd370..da6bafe5 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -233,14 +233,10 @@ export class AssetMetadataService { // there is no other asset type that is not SEP-41 or classic } - const sepTokenAssets = await this.#fetchSepTokenAssets( - missingSep41AssetIds, - scope, - ); - const classicTokenAssets = await this.#fetchClassicTokenAssets( - missingClassicAssetIds, - scope, - ); + const [sepTokenAssets, classicTokenAssets] = await Promise.all([ + this.#fetchSepTokenAssets(missingSep41AssetIds, scope), + this.#fetchClassicTokenAssets(missingClassicAssetIds, scope), + ]); return [...apiTokenAssets, ...sepTokenAssets, ...classicTokenAssets]; } From a1595a9d40ca3e922221ca8a92dc7e440bd2c131 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:03:45 +0800 Subject: [PATCH 063/384] chore: add account balance support --- .../src/handlers/keyring/api.ts | 21 ++ .../src/handlers/keyring/exceptions.ts | 68 ++++ .../src/handlers/keyring/keyring.test.ts | 179 ++++++++-- .../src/handlers/keyring/keyring.ts | 172 ++++++++-- .../src/handlers/user-input/userInput.ts | 6 + .../services/account/AccountService.test.ts | 2 +- .../src/services/account/AccountService.ts | 2 +- .../on-chain-account/OnChainAccount.test.ts | 190 ++++++----- .../on-chain-account/OnChainAccount.ts | 312 ++++++++---------- .../OnChainAccountSerializable.ts | 14 + .../OnChainAccountService.test.ts | 37 ++- .../__mocks__/onChainAccount.fixtures.ts | 41 +++ .../src/services/on-chain-account/api.ts | 17 + .../src/services/on-chain-account/index.ts | 1 + .../src/services/on-chain-account/utils.ts | 32 +- 15 files changed, 774 insertions(+), 320 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts index ef4bc218..18a3e261 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts @@ -19,6 +19,11 @@ import { import type { Infer } from '@metamask/superstruct'; import { base64 } from '@metamask/utils'; +import { + KnownCaip19ClassicAssetStruct, + KnownCaip19Sep41AssetStruct, + KnownCaip19Slip44IdStruct, +} from '../../api'; import { StellarAddressStruct } from '../../api/address'; import { KnownCaip2ChainIdStruct } from '../../api/network'; import { Utf8StringStruct } from '../../api/string'; @@ -152,11 +157,27 @@ export const GetAccountRequestStruct = UuidStruct; */ export const DeleteAccountRequestStruct = UuidStruct; +/** + * Validation struct for the listAccountAssets request. + */ +export const ListAccountAssetsRequestStruct = UuidStruct; + /** * Validation struct for the setSelectedAccounts request. */ export const SetSelectedAccountsRequestStruct = array(UuidStruct); +export const GetAccountBalancesRequestStruct = object({ + accountId: UuidStruct, + assets: array( + union([ + KnownCaip19Sep41AssetStruct, + KnownCaip19ClassicAssetStruct, + KnownCaip19Slip44IdStruct, + ]), + ), +}); + /** * The options for the createAccount method. */ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts new file mode 100644 index 00000000..81dec06e --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts @@ -0,0 +1,68 @@ +import type { ResolveAccountAddressJsonRpcRequest } from './api'; +import type { KnownCaip2ChainId } from '../../api/network'; + +export class KeyringException extends Error { + constructor(message: string) { + super(message); + this.name = 'KeyringException'; + } +} + +export class KeyringListAccountsException extends KeyringException { + constructor() { + super(`Failed to list accounts`); + } +} + +export class KeyringGetAccountException extends KeyringException { + constructor(accountId: string) { + super(`Failed to get account for account ${accountId}`); + } +} + +export class KeyringCreateAccountException extends KeyringException { + constructor() { + super('Failed to create account'); + } +} + +export class KeyringListAccountAssetsException extends KeyringException { + constructor(accountId: string) { + super(`Failed to list account assets for account ${accountId}`); + } +} + +export class KeyringListAccountTransactionsException extends KeyringException { + constructor(accountId: string) { + super(`Failed to list account transactions for account ${accountId}`); + } +} + +export class KeyringDiscoverAccountsException extends KeyringException { + constructor() { + super('Failed to discover accounts'); + } +} + +export class KeyringGetAccountBalancesException extends KeyringException { + constructor(accountId: string) { + super(`Failed to get account balances for account ${accountId}`); + } +} + +export class KeyringResolveAccountAddressException extends KeyringException { + constructor( + scope: KnownCaip2ChainId, + request: ResolveAccountAddressJsonRpcRequest, + ) { + super( + `Failed to resolve account address for scope ${scope} and address ${request.params.address}`, + ); + } +} + +export class KeyringDeleteAccountException extends KeyringException { + constructor(accountId: string) { + super(`Failed to delete account for account ${accountId}`); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 5a64d66a..0f1362c2 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -9,9 +9,21 @@ import { handleKeyringRequest, } from '@metamask/keyring-snap-sdk'; import { InvalidParamsError, type JsonRpcRequest } from '@metamask/snaps-sdk'; +import { BigNumber } from 'bignumber.js'; import { MultichainMethod } from './api'; import type { IKeyringRequestHandler } from './base'; +import { + KeyringCreateAccountException, + KeyringDeleteAccountException, + KeyringDiscoverAccountsException, + KeyringGetAccountBalancesException, + KeyringGetAccountException, + KeyringListAccountAssetsException, + KeyringListAccountsException, + KeyringListAccountTransactionsException, + KeyringResolveAccountAddressException, +} from './exceptions'; import { KeyringHandler } from './keyring'; import { KnownCaip2ChainId } from '../../api'; import { KEYRING_ACCOUNT_TYPE } from '../../constants'; @@ -21,8 +33,11 @@ import { } from '../../services/account'; import { generateMockStellarKeyringAccounts } from '../../services/account/__mocks__/account.fixtures'; import { AccountNotFoundException } from '../../services/account/exceptions'; +import type { AssetMetadataService } from '../../services/asset-metadata/AssetMetadataService'; +import { AccountNotActivatedException } from '../../services/network'; import { OnChainAccountService } from '../../services/on-chain-account'; import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; +import type { OnChainAccount } from '../../services/on-chain-account/OnChainAccount'; import { createMockTransactionService, generateMockTransactions, @@ -53,6 +68,8 @@ describe('KeyringHandler', () => { let mockAccountId: string; let mockSignMessageHandler: IKeyringRequestHandler; let mockSignTransactionHandler: IKeyringRequestHandler; + let mockAssetMetadataService: AssetMetadataService; + let getAssetsMetadataByAssetIdsMock: jest.Mock; const toKeyringAccount = (account: StellarKeyringAccount): KeyringAccount => { const { id, address, type, options, methods, scopes } = account; @@ -84,6 +101,10 @@ describe('KeyringHandler', () => { mockSignMessageHandler = { handle: jest.fn() }; mockSignTransactionHandler = { handle: jest.fn() }; + getAssetsMetadataByAssetIdsMock = jest.fn().mockResolvedValue({}); + mockAssetMetadataService = { + getAssetsMetadataByAssetIds: getAssetsMetadataByAssetIdsMock, + } as unknown as AssetMetadataService; const { accountService, onChainAccountService } = mockOnChainAccountService(); @@ -92,6 +113,7 @@ describe('KeyringHandler', () => { logger, accountService, onChainAccountService, + assetMetadataService: mockAssetMetadataService, transactionService, handlers: { [MultichainMethod.SignMessage]: mockSignMessageHandler, @@ -163,7 +185,7 @@ describe('KeyringHandler', () => { .mockRejectedValue(new Error('Account listing failed')); await expect(keyringHandler.listAccounts()).rejects.toThrow( - 'Error listing accounts: Account listing failed', + KeyringListAccountsException, ); }); }); @@ -194,7 +216,7 @@ describe('KeyringHandler', () => { .mockRejectedValue(new Error('Account retrieval failed')); await expect(keyringHandler.getAccount(mockAccountId)).rejects.toThrow( - 'Error getting account: Account retrieval failed', + KeyringGetAccountException, ); }); @@ -259,16 +281,60 @@ describe('KeyringHandler', () => { createAccountSpy.mockRejectedValue(new Error('Account creation failed')); await expect(keyringHandler.createAccount()).rejects.toThrow( - 'Error creating account: Account creation failed', + KeyringCreateAccountException, ); }); }); describe('listAccountAssets', () => { - it('throws `Method not implemented.` error', async () => { - await expect(keyringHandler.listAccountAssets('1')).rejects.toThrow( - 'Method not implemented.', - ); + it('returns on-chain asset ids for the account', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue({ + assetIds: [slipId], + } as unknown as OnChainAccount); + + const result = await keyringHandler.listAccountAssets(mockAccountId); + + expect(result).toStrictEqual([slipId]); + }); + + it('returns empty array when the account is not activated on-chain', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockRejectedValue( + new AccountNotActivatedException( + mockAccount.address, + KnownCaip2ChainId.Mainnet, + ), + ); + + const result = await keyringHandler.listAccountAssets(mockAccountId); + + expect(result).toStrictEqual([]); + }); + + it('throws when listing assets fails for another reason', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockRejectedValue(new Error('Horizon unavailable')); + + await expect( + keyringHandler.listAccountAssets(mockAccountId), + ).rejects.toThrow(KeyringListAccountAssetsException); + }); + + it('rejects invalid account id', async () => { + await expect( + keyringHandler.listAccountAssets('not-uuid'), + ).rejects.toThrow(InvalidParamsError); }); }); @@ -327,6 +393,28 @@ describe('KeyringHandler', () => { next: mockTransactions[10]?.id, }); }); + + it('throws when pagination cursor does not match any transaction', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ + account: mockAccount, + }); + const { transactionServiceFindByAccountsSpy } = + createMockTransactionService(); + const mockTransactions = generateMockTransactions(5, { + account: mockAccountId, + scope: KnownCaip2ChainId.Mainnet, + fromAddress: mockAccount.address, + }); + transactionServiceFindByAccountsSpy.mockResolvedValue(mockTransactions); + + await expect( + keyringHandler.listAccountTransactions(mockAccountId, { + limit: 2, + next: '00000000-0000-4000-8000-000000000000', + }), + ).rejects.toThrow(KeyringListAccountTransactionsException); + }); }); describe('discoverAccounts', () => { @@ -375,7 +463,7 @@ describe('KeyringHandler', () => { 'entropy-source-1', 0, ), - ).rejects.toThrow('Error discovering accounts: Account discovery failed'); + ).rejects.toThrow(KeyringDiscoverAccountsException); }); it('throws an error if the account discovery request is invalid', async () => { @@ -390,12 +478,60 @@ describe('KeyringHandler', () => { }); describe('getAccountBalances', () => { - it('throws `Method not implemented.` error', async () => { + it('returns balances for assets with positive balance and metadata', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + getAssetsMetadataByAssetIdsMock.mockResolvedValue({ + [slipId]: { symbol: 'XLM' }, + }); + jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue({ + assetIds: [slipId], + getAsset: () => ({ balance: new BigNumber('10') }), + } as unknown as OnChainAccount); + + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + slipId, + ]); + + expect(result).toStrictEqual({ + [slipId]: { unit: 'XLM', amount: '10' }, + }); + }); + + it('returns empty record when the account is not activated on-chain', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockRejectedValue( + new AccountNotActivatedException( + mockAccount.address, + KnownCaip2ChainId.Mainnet, + ), + ); + + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + slipId, + ]); + + expect(result).toStrictEqual({}); + }); + + it('throws when balance resolution fails for another reason', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockRejectedValue(new Error('Horizon unavailable')); + await expect( - keyringHandler.getAccountBalances('1', [ - getSlip44AssetId(KnownCaip2ChainId.Mainnet), - ]), - ).rejects.toThrow('Method not implemented.'); + keyringHandler.getAccountBalances(mockAccountId, [slipId]), + ).rejects.toThrow(KeyringGetAccountBalancesException); }); }); @@ -440,9 +576,7 @@ describe('KeyringHandler', () => { jsonrpc: '2.0', params: { address: mockAccount.address }, }), - ).rejects.toThrow( - 'Error resolving account address: Account address resolution failed', - ); + ).rejects.toThrow(KeyringResolveAccountAddressException); }); it('throws an error if the account address resolution request is invalid', async () => { @@ -463,7 +597,7 @@ describe('KeyringHandler', () => { it('throws `Method not implemented.` error', async () => { await expect( keyringHandler.filterAccountChains('1', [KnownCaip2ChainId.Mainnet]), - ).rejects.toThrow('Method not implemented.'); + ).rejects.toThrow('Method not implemented. - filterAccountChains'); }); }); @@ -478,7 +612,7 @@ describe('KeyringHandler', () => { options: {}, methods: [], }), - ).rejects.toThrow('Method not implemented.'); + ).rejects.toThrow('Method not implemented. - updateAccount'); }); }); @@ -495,6 +629,11 @@ describe('KeyringHandler', () => { expect(resolveAccountSpy).toHaveBeenCalledWith({ accountId: mockAccountId, }); + expect(deleteSpy.mock.invocationCallOrder).toHaveLength(1); + expect(emitSnapKeyringEventSpy.mock.invocationCallOrder).toHaveLength(1); + expect( + Number(emitSnapKeyringEventSpy.mock.invocationCallOrder[0]), + ).toBeLessThan(Number(deleteSpy.mock.invocationCallOrder[0])); expect(emitSnapKeyringEventSpy).toHaveBeenCalledWith( getSnapProvider(), KeyringEvent.AccountDeleted, @@ -512,7 +651,7 @@ describe('KeyringHandler', () => { emitSnapKeyringEventSpy.mockResolvedValue(); await expect(keyringHandler.deleteAccount(mockAccountId)).rejects.toThrow( - 'Error deleting account: Account deletion failed', + KeyringDeleteAccountException, ); }); @@ -525,7 +664,7 @@ describe('KeyringHandler', () => { emitSnapKeyringEventSpy.mockResolvedValue(); await expect(keyringHandler.deleteAccount(mockAccountId)).rejects.toThrow( - `Error deleting account: Account not found for address or id: ${mockAccountId}`, + KeyringDeleteAccountException, ); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 18e64317..b1466633 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { DiscoveredAccountType, KeyringEvent, @@ -18,11 +17,7 @@ import { handleKeyringRequest, } from '@metamask/keyring-snap-sdk'; import { type Json, type JsonRpcRequest } from '@metamask/snaps-sdk'; -import { - ensureError, - type CaipAssetType, - type CaipAssetTypeOrId, -} from '@metamask/utils'; +import { ensureError, type CaipAssetTypeOrId } from '@metamask/utils'; import type { CreateAccountOptions, @@ -39,19 +34,39 @@ import { MultichainMethodStruct, ResolveAccountAddressRequestStruct, SetSelectedAccountsRequestStruct, + ListAccountAssetsRequestStruct, + GetAccountBalancesRequestStruct, } from './api'; import type { IKeyringRequestHandler } from './base'; -import type { KnownCaip2ChainId } from '../../api'; +import { + KeyringCreateAccountException, + KeyringDeleteAccountException, + KeyringDiscoverAccountsException, + KeyringGetAccountBalancesException, + KeyringGetAccountException, + KeyringListAccountAssetsException, + KeyringListAccountsException, + KeyringListAccountTransactionsException, + KeyringResolveAccountAddressException, +} from './exceptions'; +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip2ChainId, +} from '../../api'; +import { AppConfig } from '../../config'; import type { AccountService, StellarKeyringAccount, } from '../../services/account'; +import type { AssetMetadataService } from '../../services/asset-metadata'; +import { AccountNotActivatedException } from '../../services/network'; import type { OnChainAccountService } from '../../services/on-chain-account'; import type { TransactionService } from '../../services/transaction/TransactionService'; import type { ILogger } from '../../utils'; import { createPrefixedLogger, getSnapProvider, + rethrowIfInstanceElseThrow, validateOrigin, validateRequest, withCatchAndThrowSnapError, @@ -66,18 +81,22 @@ export class KeyringHandler implements Keyring { readonly #transactionService: TransactionService; + readonly #assetMetadataService: AssetMetadataService; + readonly #handlers: Record; constructor({ logger, accountService, onChainAccountService, + assetMetadataService, transactionService, handlers, }: { logger: ILogger; accountService: AccountService; onChainAccountService: OnChainAccountService; + assetMetadataService: AssetMetadataService; transactionService: TransactionService; handlers: Record; }) { @@ -85,6 +104,7 @@ export class KeyringHandler implements Keyring { this.#accountService = accountService; this.#onChainAccountService = onChainAccountService; this.#transactionService = transactionService; + this.#assetMetadataService = assetMetadataService; this.#handlers = handlers; } @@ -103,7 +123,11 @@ export class KeyringHandler implements Keyring { const accounts = await this.#accountService.listAccounts(); return accounts.map((account) => this.#toKeyringAccount(account)); } catch (error: unknown) { - throw new Error(`Error listing accounts: ${ensureError(error).message}`); + this.#logger.logErrorWithDetails( + 'Failed to list accounts', + ensureError(error).message, + ); + throw new KeyringListAccountsException(); } } @@ -116,7 +140,11 @@ export class KeyringHandler implements Keyring { const account = await this.#accountService.findById(accountId); return account ? this.#toKeyringAccount(account) : undefined; } catch (error: unknown) { - throw new Error(`Error getting account: ${ensureError(error).message}`); + this.#logger.logErrorWithDetails( + 'Failed to get account', + ensureError(error).message, + ); + throw new KeyringGetAccountException(accountId); } } @@ -132,7 +160,11 @@ export class KeyringHandler implements Keyring { return this.#toKeyringAccount(account); } catch (error: unknown) { - throw new Error(`Error creating account: ${ensureError(error).message}`); + this.#logger.logErrorWithDetails( + 'Failed to create account', + ensureError(error).message, + ); + throw new KeyringCreateAccountException(); } } @@ -188,7 +220,32 @@ export class KeyringHandler implements Keyring { } async listAccountAssets(accountId: string): Promise { - throw new Error('Method not implemented. - listAccountAssets'); + validateRequest(accountId, ListAccountAssetsRequestStruct); + + try { + const { account } = await this.#accountService.resolveAccount({ + accountId, + }); + + // We only support one scope in metamask today + const onChainAccount = + await this.#onChainAccountService.resolveOnChainAccount( + account, + AppConfig.selectedNetwork, + ); + + return onChainAccount.assetIds; + } catch (error: unknown) { + // fallback to empty array if the account is not activated + if (error instanceof AccountNotActivatedException) { + return []; + } + this.#logger.logErrorWithDetails( + 'Failed to list account assets', + ensureError(error).message, + ); + throw new KeyringListAccountAssetsException(accountId); + } } async listAccountTransactions( @@ -222,6 +279,13 @@ export class KeyringHandler implements Keyring { ? transactions.findIndex((tx) => tx.id === next) : 0; + // Safeguard: If the next cursor is invalid, throw a RangeError. + if (next !== undefined && next !== null && startIndex === -1) { + throw new KeyringListAccountTransactionsException( + `Invalid transaction pagination cursor: ${next}`, + ); + } + // Get transactions from startIndex to startIndex + limit const accountTransactions = transactions.slice( startIndex, @@ -240,11 +304,13 @@ export class KeyringHandler implements Keyring { }; } catch (error: unknown) { this.#logger.logErrorWithDetails( - 'Error listing account transactions', - error, + 'Failed to list account transactions', + ensureError(error).message, ); - throw new Error( - `Error listing account transactions: ${ensureError(error).message}`, + return rethrowIfInstanceElseThrow( + error, + [KeyringListAccountTransactionsException], + new KeyringListAccountTransactionsException(accountId), ); } } @@ -285,17 +351,62 @@ export class KeyringHandler implements Keyring { }, ]; } catch (error: unknown) { - throw new Error( - `Error discovering accounts: ${ensureError(error).message}`, + this.#logger.logErrorWithDetails( + 'Failed to discover accounts', + ensureError(error).message, ); + throw new KeyringDiscoverAccountsException(); } } async getAccountBalances( accountId: string, - assets: CaipAssetType[], - ): Promise> { - throw new Error('Method not implemented. - getAccountBalances'); + assets: KnownCaip19AssetIdOrSlip44Id[], + ): Promise> { + validateRequest({ accountId, assets }, GetAccountBalancesRequestStruct); + try { + const { account } = await this.#accountService.resolveAccount({ + accountId, + }); + + const assetsMetadata = + await this.#assetMetadataService.getAssetsMetadataByAssetIds( + assets, + AppConfig.selectedNetwork, + ); + // We only support one scope in metamask today + const onChainAccount = + await this.#onChainAccountService.resolveOnChainAccount( + account, + AppConfig.selectedNetwork, + ); + + return onChainAccount.assetIds.reduce( + (acc, assetId) => { + const assetMetadata = assetsMetadata[assetId]; + // TODO: Stellar may also need to return balance for assets with zero balance, + // it may depends on if it is SEP-41 asset or Classic asset. + if (assetMetadata && onChainAccount.getAsset(assetId).balance.gt(0)) { + acc[assetId] = { + unit: assetMetadata.symbol ?? '', + amount: onChainAccount.getAsset(assetId).balance.toString(), + }; + } + return acc; + }, + {} as Record, + ); + } catch (error: unknown) { + if (error instanceof AccountNotActivatedException) { + // fallback to empty object if the account is not activated + return {} as Record; + } + this.#logger.logErrorWithDetails( + 'Failed to get account balances', + ensureError(error).message, + ); + throw new KeyringGetAccountBalancesException(accountId); + } } async resolveAccountAddress( @@ -317,17 +428,19 @@ export class KeyringHandler implements Keyring { }); return { address: `${scope}:${account.address}` }; } catch (error: unknown) { - throw new Error( - `Error resolving account address: ${ensureError(error).message}`, + this.#logger.logErrorWithDetails( + 'Failed to resolve account address', + ensureError(error).message, ); + throw new KeyringResolveAccountAddressException(scope, request); } } - async filterAccountChains(id: string, chains: string[]): Promise { + async filterAccountChains(_id: string, _chains: string[]): Promise { throw new Error('Method not implemented. - filterAccountChains'); } - async updateAccount(account: KeyringAccount): Promise { + async updateAccount(_account: KeyringAccount): Promise { throw new Error('Method not implemented. - updateAccount'); } @@ -339,6 +452,9 @@ export class KeyringHandler implements Keyring { accountId, }); + // The delete event is idempotent, so it is safe to emit it even if the + // account does not exist. + // @see https://github.com/MetaMask/accounts/blob/main/packages/keyring-api/README.md?plain=1#L162 await emitSnapKeyringEvent( getSnapProvider(), KeyringEvent.AccountDeleted, @@ -349,13 +465,16 @@ export class KeyringHandler implements Keyring { await this.#accountService.delete(accountId); } catch (error: unknown) { - throw new Error(`Error deleting account: ${ensureError(error).message}`); + this.#logger.logErrorWithDetails( + 'Failed to delete account', + ensureError(error).message, + ); + throw new KeyringDeleteAccountException(accountId); } } async setSelectedAccounts(accountIds: string[]): Promise { validateRequest(accountIds, SetSelectedAccountsRequestStruct); - // TODO: Implement the setSelectedAccounts method. } async submitRequest(request: KeyringRequest): Promise { @@ -374,4 +493,3 @@ export class KeyringHandler implements Keyring { validateRequest(method, MultichainMethodStruct); } } -/* eslint-enable @typescript-eslint/no-unused-vars */ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts index 2f85c365..4b9b602d 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts @@ -1,6 +1,9 @@ import type { InterfaceContext, UserInputEvent } from '@metamask/snaps-sdk'; import type { UserInputUiEventHandler } from './api'; +import { createEventHandlers as createAccountActivationPromptEvents } from '../../ui/confirmation/views/AccountActivationPrompt/events'; +import { createEventHandlers as createSignChangeTrustOptInEvents } from '../../ui/confirmation/views/ConfirmSignChangeTrustOptIn/events'; +import { createEventHandlers as createSignChangeTrustOptOutEvents } from '../../ui/confirmation/views/ConfirmSignChangeTrustOptOut/events'; import { createEventHandlers as createSignMessageEvents } from '../../ui/confirmation/views/ConfirmSignMessage/events'; import { createEventHandlers as createSignTransactionEvents } from '../../ui/confirmation/views/ConfirmSignTransaction/events'; import { @@ -43,6 +46,9 @@ export class UserInputHandler { const uiEventHandlers: Record = { ...createSignMessageEvents(), ...createSignTransactionEvents(), + ...createSignChangeTrustOptInEvents(), + ...createSignChangeTrustOptOutEvents(), + ...createAccountActivationPromptEvents(), }; /** diff --git a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.test.ts b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.test.ts index 0214b9fa..58847e83 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.test.ts @@ -8,7 +8,7 @@ import { } from './exceptions'; import { KnownCaip2ChainId } from '../../api'; import { KEYRING_ACCOUNT_TYPE } from '../../constants'; -import { MultichainMethod } from '../../handlers/keyring'; +import { MultichainMethod } from '../../handlers/keyring/api'; import { mockBip32Node } from '../../utils/__mocks__/fixtures'; import { getBip32Entropy, getDefaultEntropySource } from '../../utils/snap'; import { WalletService, getDerivationPath } from '../wallet'; diff --git a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts index 589be3d9..f3c8490b 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts @@ -12,7 +12,7 @@ import { assertSameAddress } from './utils'; import type { StellarAddress, KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; import { KEYRING_ACCOUNT_TYPE } from '../../constants'; -import { MultichainMethod } from '../../handlers/keyring'; +import { MultichainMethod } from '../../handlers/keyring/api'; import type { ILogger } from '../../utils'; import { createPrefixedLogger, diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts index b01cbdde..b1131a33 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts @@ -1,9 +1,10 @@ import { Account } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; -import type { OnChainAccountSnapshot } from './api'; -import { OnChainAccountBalanceNotAvailableException } from './exceptions'; -import type { SpendableBalance } from './OnChainAccount'; +import { + OnChainAccountBalanceNotAvailableException, + OnChainAccountException, +} from './exceptions'; import { OnChainAccount } from './OnChainAccount'; import { KnownCaip2ChainId } from '../../api'; import { @@ -11,55 +12,32 @@ import { toCaip19ClassicAssetId, toSmallestUnit, } from '../../utils'; -import type { - AccountBalance, - TrustLineAssetBalance, -} from '../account-balance/api'; import { createMockAccountWithBalances, DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + horizonSource, + unfundedHorizonBinding, } from './__mocks__/onChainAccount.fixtures'; import { getTestWallet } from '../wallet/__mocks__/wallet.fixtures'; -/** - * Maps an on-chain trustline view to persisted {@link TrustLineAssetBalance} shape. - * - * @param row - Classic trustline row from {@link OnChainAccount.getAsset}. - * @returns Balance row as stored by account balance sync. - */ -function trustLineToPersistedBalance( - row: SpendableBalance, -): TrustLineAssetBalance { - const base: TrustLineAssetBalance = { - unit: row.symbol, - amount: row.balance.toString(), - limit: row.limit?.toString() ?? '0', - }; - return { - ...base, - ...(typeof row.authorized === 'boolean' - ? { authorized: row.authorized } - : {}), - ...(row.sponsored ? { sponsored: true } : {}), - }; -} - describe('OnChainAccount', () => { const testWalletSigner = getTestWallet(); + const testMockAccount = createMockAccountWithBalances( + testWalletSigner.address, + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); const testOnChain = new OnChainAccount( - createMockAccountWithBalances( - testWalletSigner.address, - '1', - DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, - ), + testMockAccount, KnownCaip2ChainId.Mainnet, + horizonSource(testMockAccount, KnownCaip2ChainId.Mainnet), ); const createTestWallet = () => { const wallet = getTestWallet(); return { wallet, - onChainAccount: new OnChainAccount( - createMockAccountWithBalances(wallet.address, '1', { + onChainAccount: (() => { + const acc = createMockAccountWithBalances(wallet.address, '1', { nativeBalance: 10, subentryCount: 0, sponsoringCount: 0, @@ -73,9 +51,13 @@ describe('OnChainAccount', () => { balance: 10, }, ], - }), - KnownCaip2ChainId.Mainnet, - ), + }); + return new OnChainAccount( + acc, + KnownCaip2ChainId.Mainnet, + horizonSource(acc, KnownCaip2ChainId.Mainnet), + ); + })(), }; }; @@ -122,9 +104,14 @@ describe('OnChainAccount', () => { }); it('returns false when account has no Horizon balance metadata', () => { + const acc = new Account( + testOnChain.accountId, + testOnChain.sequenceNumber, + ); const onChainAccount = new OnChainAccount( - new Account(testOnChain.accountId, testOnChain.sequenceNumber), + acc, KnownCaip2ChainId.Mainnet, + unfundedHorizonBinding(acc, KnownCaip2ChainId.Mainnet), ); expect( onChainAccount.hasAsset( @@ -164,7 +151,6 @@ describe('OnChainAccount', () => { { assetId: getSlip44AssetId(KnownCaip2ChainId.Mainnet), expected: { - address: undefined, balance: new BigNumber('90000000'), symbol: 'XLM', }, @@ -178,9 +164,14 @@ describe('OnChainAccount', () => { ); it('throws OnChainAccountBalanceNotAvailableException when account has no loaded balances', () => { + const acc = new Account( + testOnChain.accountId, + testOnChain.sequenceNumber, + ); const onChainAccount = new OnChainAccount( - new Account(testOnChain.accountId, testOnChain.sequenceNumber), + acc, KnownCaip2ChainId.Mainnet, + unfundedHorizonBinding(acc, KnownCaip2ChainId.Mainnet), ); expect(() => onChainAccount.getAsset( @@ -254,79 +245,116 @@ describe('OnChainAccount', () => { expected, }) => { const wallet = getTestWallet(); + const acc = createMockAccountWithBalances(wallet.address, '1', { + nativeBalance, + subentryCount, + sponsoringCount, + sponsoredCount, + assets: [], + }); const onChainAccount = new OnChainAccount( - createMockAccountWithBalances(wallet.address, '1', { - nativeBalance, - subentryCount, - sponsoringCount, - sponsoredCount, - assets: [], - }), + acc, KnownCaip2ChainId.Mainnet, + horizonSource(acc, KnownCaip2ChainId.Mainnet), ); expect(onChainAccount.nativeSpendableBalance).toStrictEqual(expected); }, ); - it('throws an error if the balance metadata is not available', () => { + it('throws when native balance is not bound', () => { + const acc = new Account( + testOnChain.accountId, + testOnChain.sequenceNumber, + ); const onChainAccount = new OnChainAccount( - new Account(testOnChain.accountId, testOnChain.sequenceNumber), + acc, KnownCaip2ChainId.Mainnet, + unfundedHorizonBinding(acc, KnownCaip2ChainId.Mainnet), + ); + expect(() => onChainAccount.nativeSpendableBalance).toThrow( + OnChainAccountBalanceNotAvailableException, ); - expect(() => onChainAccount.nativeSpendableBalance).toThrow(Error); }); }); describe('getRaw', () => { it('returns the raw account', () => { - const account = new Account( + const account = createMockAccountWithBalances( 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG', '1', + { nativeBalance: 1, subentryCount: 0, assets: [] }, ); const onChainAccount = new OnChainAccount( account, KnownCaip2ChainId.Mainnet, + horizonSource(account, KnownCaip2ChainId.Mainnet), ); expect(onChainAccount.getRaw()).toBe(account); }); }); - describe('fromSnapshot', () => { - it('matches Horizon-bound balances for native and classic trustline', () => { - const { onChainAccount: ref } = createTestWallet(); + describe('toSerializable', () => { + it('returns meta, scope, header fields, and per-asset balances', () => { + const { onChainAccount } = createTestWallet(); + const ser = onChainAccount.toSerializable(); + expect(ser.accountId).toBe(onChainAccount.accountId); + expect(ser.sequenceNumber).toBe(onChainAccount.sequenceNumber); + expect(ser.scope).toBe(KnownCaip2ChainId.Mainnet); + expect(ser.meta).toStrictEqual({ + subentryCount: onChainAccount.subentryCount, + numSponsoring: onChainAccount.numSponsoring, + numSponsored: onChainAccount.numSponsored, + }); + const nativeId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); const usdcId = toCaip19ClassicAssetId( KnownCaip2ChainId.Mainnet, 'USDC', 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', ); - const nativeId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); - const classicRow = ref.getAsset(usdcId); - const balances: AccountBalance = { - [nativeId]: { - unit: 'XLM', - amount: ref.nativeRawBalance.toString(), - }, - [usdcId]: trustLineToPersistedBalance(classicRow), - }; - const snapshot: OnChainAccountSnapshot = { - accountId: ref.accountId, - sequenceNumber: ref.sequenceNumber, - subentryCount: ref.subentryCount, - numSponsoring: ref.numSponsoring, - numSponsored: ref.numSponsored, - }; - const restored = OnChainAccount.fromSnapshot({ - snapshot, - balances, - scope: KnownCaip2ChainId.Mainnet, - }); + expect(ser.balances[nativeId]).toStrictEqual( + onChainAccount.getAsset(nativeId), + ); + expect(ser.balances[usdcId]).toStrictEqual( + onChainAccount.getAsset(usdcId), + ); + }); + }); + + describe('fromSerializable', () => { + it('round-trips with toSerializable for Horizon-bound wallet', () => { + const { onChainAccount: ref } = createTestWallet(); + const restored = OnChainAccount.fromSerializable(ref.toSerializable()); + expect(restored.accountId).toBe(ref.accountId); + expect(restored.sequenceNumber).toBe(ref.sequenceNumber); + expect(restored.scope).toBe(ref.scope); + expect(restored.subentryCount).toBe(ref.subentryCount); + expect(restored.nativeRawBalance).toStrictEqual(ref.nativeRawBalance); expect(restored.nativeSpendableBalance).toStrictEqual( ref.nativeSpendableBalance, ); - expect(restored.nativeRawBalance).toStrictEqual(ref.nativeRawBalance); - expect(restored.getAsset(usdcId)).toStrictEqual(classicRow); - expect(restored.getAsset(nativeId)).toStrictEqual(ref.getAsset(nativeId)); + const usdcId = toCaip19ClassicAssetId( + KnownCaip2ChainId.Mainnet, + 'USDC', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ); + expect(restored.getAsset(usdcId)).toStrictEqual(ref.getAsset(usdcId)); + expect( + restored.getAsset(getSlip44AssetId(KnownCaip2ChainId.Mainnet)), + ).toStrictEqual( + ref.getAsset(getSlip44AssetId(KnownCaip2ChainId.Mainnet)), + ); + }); + + it('throws when native slip44 balance is missing', () => { + const { onChainAccount } = createTestWallet(); + const ser = onChainAccount.toSerializable(); + const nativeId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const balances = { ...ser.balances }; + delete balances[nativeId]; + expect(() => + OnChainAccount.fromSerializable({ ...ser, balances }), + ).toThrow(OnChainAccountException); }); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts index 8b71300a..946faad7 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts @@ -2,12 +2,14 @@ import type { Horizon } from '@stellar/stellar-sdk'; import { Account as StellarAccount } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; -import type { OnChainAccountSnapshot } from './api'; +import type { SpendableBalance } from './api'; import { OnChainAccountBalanceNotAvailableException, + OnChainAccountException, OnChainAccountMetadataNotAvailableException, } from './exceptions'; -import { calculateSpendableBalance } from './utils'; +import type { OnChainAccountSerializable } from './OnChainAccountSerializable'; +import { calculateSpendableBalance, minimumBalanceStroops } from './utils'; import type { KnownCaip19AssetIdOrSlip44Id, KnownCaip19ClassicAssetId, @@ -16,52 +18,18 @@ import type { import { NATIVE_ASSET_SYMBOL } from '../../constants'; import { entries, - getAssetReference, getSlip44AssetId, isClassicAssetId, isSep41Id, - isSlip44Id, - parseClassicAssetCodeIssuer, toCaip19ClassicAssetId, toSmallestUnit, } from '../../utils'; -import type { - AccountBalance, - BaseAssetBalance, - TrustLineAssetBalance, -} from '../account-balance/api'; - -/** Per-asset view: native, classic trustline (limit + issuer in `address`), or SEP-41. */ -export type SpendableBalance = { - balance: BigNumber; - symbol: string; - limit?: BigNumber; - address?: string; - authorized?: boolean; - sponsored?: boolean; -}; - -/** Ledger fields used for native reserve / spendable math (Horizon or persisted snapshot). */ -export type OnChainAccountLedgerMeta = { - subentryCount: number; - numSponsoring: number; - numSponsored: number; -}; /** - * Where {@link OnChainAccount} builds native + trustline maps from. - * - * - **horizon** — full Horizon account response (human-readable balances). - * - **accountBalance** — persisted {@link AccountBalance} (amounts in stroops as strings). For the slip44 native key, `amount` is **total** (raw) stroops; spendable native is derived at bind time via {@link calculateSpendableBalance} and snapshot meta. + * SDK {@link StellarAccount} plus optional {@link OnChainAccountSerializable} hydration (balances, meta). + * Build via {@link OnChainAccount.fromHorizon}, {@link OnChainAccount.fromSerializable}, or `new OnChainAccount(account, scope)` (RPC: no binding, sequence only; use Horizon for balances). + * Without `binding`, only id, sequence, `scope`, and {@link OnChainAccount.getRaw} are defined; balances and meta need hydration. */ -export type OnChainData = - | { source: 'horizon'; response: Horizon.AccountResponse } - | { - source: 'accountBalance'; - balances: AccountBalance; - meta: OnChainAccountLedgerMeta; - }; - export class OnChainAccount { readonly #account: StellarAccount; @@ -79,25 +47,36 @@ export class OnChainAccount { new Map(); /** - * @param account - Stellar SDK account (id + sequence). Use {@link getRaw}. - * @param scope - CAIP-2 network. - * @param onChainData - When set, hydrates from Horizon or persisted {@link AccountBalance}; when omitted, uses `account.balances` when present (e.g. `loadAccount` result). + * @param account - Stellar SDK account (id + sequence). When `binding` is set, header fields must match. + * @param scope - CAIP-2 network; must match `binding.scope`. + * @param binding - Hydrate from snapshot; omit for RPC-style accounts (see class overview). */ constructor( account: StellarAccount, scope: KnownCaip2ChainId, - onChainData?: OnChainData, + binding?: OnChainAccountSerializable, ) { this.#account = account; this.#scope = scope; - if (onChainData?.source === 'horizon') { - this.#bindFromHorizonResponse(onChainData.response); - } else if (onChainData?.source === 'accountBalance') { - this.#bindFromAccountBalance(onChainData.balances, onChainData.meta); - } else if (onChainData === undefined && this.#isHorizonResponse(account)) { - this.#bindFromHorizonResponse(account); + if (binding === undefined) { + return; + } + + if (binding.scope !== scope) { + throw new OnChainAccountException( + 'Binding scope must match constructor scope', + ); + } + if ( + binding.accountId !== account.accountId() || + binding.sequenceNumber !== account.sequenceNumber() + ) { + throw new OnChainAccountException( + 'Binding account id/sequence must match the Stellar Account instance', + ); } + this.#bindFromSerializable(binding); } get accountId(): string { @@ -153,16 +132,7 @@ export class OnChainAccount { const entry = this.#balances.get(assetId); if (entry !== undefined) { return { - balance: entry.balance, - symbol: entry.symbol, - address: entry.address, - ...(entry.limit === undefined ? {} : { limit: entry.limit }), - ...(entry.sponsored === undefined - ? {} - : { sponsored: entry.sponsored }), - ...(entry.authorized === undefined - ? {} - : { authorized: entry.authorized }), + ...entry, }; } throw new OnChainAccountBalanceNotAvailableException( @@ -186,6 +156,15 @@ export class OnChainAccount { return ids; } + /** + * Gets all asset ids for the on-chain account. + * + * @returns All asset ids for the on-chain account. + */ + get assetIds(): KnownCaip19AssetIdOrSlip44Id[] { + return Array.from(this.#balances.keys()); + } + get nativeSpendableBalance(): BigNumber { const nativeId = getSlip44AssetId(this.#scope); const entry = this.#balances.get(nativeId); @@ -219,7 +198,36 @@ export class OnChainAccount { } /** - * Builds from a Horizon account record (balances and ledger meta from the response). + * Copies id, sequence, network, ledger meta, and all bound balances into a plain object. + * + * @returns suitable for persistence or messaging. + */ + toSerializable(): OnChainAccountSerializable { + const balances = {} as Record< + KnownCaip19AssetIdOrSlip44Id, + SpendableBalance + >; + for (const assetId of this.#balances.keys()) { + balances[assetId] = this.getAsset(assetId); + } + + return { + accountId: this.accountId, + sequenceNumber: this.sequenceNumber, + scope: this.#scope, + meta: { + subentryCount: this.subentryCount, + numSponsoring: this.numSponsoring, + numSponsored: this.numSponsored, + }, + balances, + }; + } + + /** + * Builds from a Horizon `loadAccount` response: maps balances and ledger meta into + * {@link OnChainAccountSerializable} (same shape as {@link OnChainAccount#toSerializable}), then hydrates. + * When the response has no native balance line, the binding omits native so behavior matches a partial load. * * @param response - Horizon `loadAccount` payload. * @param scope - CAIP-2 network. @@ -233,60 +241,22 @@ export class OnChainAccount { response.accountId(), response.sequenceNumber(), ); - return new OnChainAccount(stellarAccount, scope, { - source: 'horizon', - response, - }); - } - - /** - * Hydrates from persisted {@link OnChainAccountSnapshot} plus {@link AccountBalance} (e.g. snap state after sync). - * - * @param params - Snapshot row, per-asset balances, and network. - * @param params.snapshot - Sequence and subentry/sponsoring fields from metadata sync. - * @param params.balances - Persisted balances; native slip44 `amount` is **raw** (total) stroops. - * @param params.scope - CAIP-2 network. - * @returns Hydrated {@link OnChainAccount} for the same id/sequence as the snapshot. - */ - static fromSnapshot(params: { - snapshot: OnChainAccountSnapshot; - balances: AccountBalance; - scope: KnownCaip2ChainId; - }): OnChainAccount { - const { snapshot, balances, scope } = params; - const stellarAccount = new StellarAccount( - snapshot.accountId, - snapshot.sequenceNumber, - ); - return new OnChainAccount(stellarAccount, scope, { - source: 'accountBalance', - balances, - meta: { - subentryCount: snapshot.subentryCount, - numSponsoring: snapshot.numSponsoring, - numSponsored: snapshot.numSponsored, - }, - }); - } - - #bindFromHorizonResponse(response: Horizon.AccountResponse): void { const subentryCount = response.subentry_count ?? 0; const numSponsoring = response.num_sponsoring ?? 0; const numSponsored = response.num_sponsored ?? 0; - this.#subentryCount = subentryCount; - this.#numSponsoring = numSponsoring; - this.#numSponsored = numSponsored; - - const nativeAssetId = getSlip44AssetId(this.#scope); + const meta = { subentryCount, numSponsoring, numSponsored }; + const nativeAssetId = getSlip44AssetId(scope); + const balances = {} as Record< + KnownCaip19AssetIdOrSlip44Id, + SpendableBalance + >; - const horizonBalances = response.balances; + const horizonBalances = response.balances ?? []; for (const balance of horizonBalances) { - // Horizon API return balance as human-readable (e.g. 1.23456789), we need to convert it to stroops const balanceStroops = toSmallestUnit(new BigNumber(balance.balance)); - // Native balance is always return for Horizon response if (balance.asset_type === 'native') { - this.#balances.set(nativeAssetId, { + balances[nativeAssetId] = { balance: calculateSpendableBalance({ nativeBalance: balanceStroops, subentryCount, @@ -294,19 +264,17 @@ export class OnChainAccount { numSponsored, }), symbol: NATIVE_ASSET_SYMBOL, - }); - this.#rawNativeBalance = balanceStroops; + }; } else if ( balance.asset_type === 'credit_alphanum12' || balance.asset_type === 'credit_alphanum4' ) { const authorized = balance.is_authorized ?? true; const assetId = toCaip19ClassicAssetId( - this.#scope, + scope, balance.asset_code, balance.asset_issuer, ); - // Horizon API return limit as human-readable (e.g. 1.23456789), we need to convert it to stroops const limit = toSmallestUnit(new BigNumber(balance.limit ?? 0)); const sponsorId = 'sponsor' in balance && @@ -314,88 +282,92 @@ export class OnChainAccount { ? (balance as { sponsor?: string }).sponsor : undefined; const sponsored = sponsorId !== undefined && sponsorId.length > 0; - this.#balances.set(assetId, { + balances[assetId] = { balance: balanceStroops, symbol: balance.asset_code, address: balance.asset_issuer, limit, authorized, ...(sponsored ? { sponsored: true } : {}), - }); + }; } } + + const data: OnChainAccountSerializable = { + accountId: response.accountId(), + sequenceNumber: response.sequenceNumber(), + scope, + meta, + balances, + }; + + return new OnChainAccount(stellarAccount, scope, data); } - #bindFromAccountBalance( - balances: AccountBalance, - meta: OnChainAccountLedgerMeta, - ): void { + /** + * Rehydrates from {@link OnChainAccountSerializable} (inverse of {@link OnChainAccount#toSerializable}). + * + * Native slip44 `balance` in the payload is **spendable** stroops; raw total is recovered as spendable + minimum balance from `meta`. + * + * @param data - Plain snapshot from {@link OnChainAccount#toSerializable}. + * @returns Bound {@link OnChainAccount} for the same network and balances. + * @throws {@link OnChainAccountException} When the native slip44 row for `data.scope` is missing. + */ + static fromSerializable(data: OnChainAccountSerializable): OnChainAccount { + // Safe guard to ensure the native balance is present. + const nativeId = getSlip44AssetId(data.scope); + if (data.balances[nativeId] === undefined) { + throw new OnChainAccountException( + `Serializable data for ${data.accountId} is missing native balance (${nativeId})`, + ); + } + const stellarAccount = new StellarAccount( + data.accountId, + data.sequenceNumber, + ); + return new OnChainAccount(stellarAccount, data.scope, data); + } + + #bindFromSerializable(data: OnChainAccountSerializable): void { + const { meta, balances: rows, scope } = data; this.#subentryCount = meta.subentryCount; this.#numSponsoring = meta.numSponsoring; this.#numSponsored = meta.numSponsored; - this.#rawNativeBalance = new BigNumber(0); - - const nativeAssetId = getSlip44AssetId(this.#scope); - entries(balances).forEach(([assetId, entry]) => { - if (entry === undefined) { - return; - } + const nativeId = getSlip44AssetId(scope); - if (isSlip44Id(assetId)) { - // raw native balance in stroops - const rawNative = new BigNumber(entry.amount); - this.#rawNativeBalance = rawNative; - this.#balances.set(nativeAssetId, { - balance: calculateSpendableBalance({ - nativeBalance: rawNative, - subentryCount: meta.subentryCount, - numSponsoring: meta.numSponsoring, - numSponsored: meta.numSponsored, - }), - symbol: entry.unit, + for (const [assetId, row] of entries(rows)) { + if (assetId === nativeId) { + this.#balances.set(nativeId, { + balance: row.balance, + symbol: row.symbol, }); - } else if ( - isClassicAssetId(assetId) && - this.#isTrustLineAssetBalance(entry) - ) { - const trust = entry; - const { assetIssuer } = parseClassicAssetCodeIssuer( - getAssetReference(assetId), - ); - const balanceStroops = new BigNumber(trust.amount); - const limitStroops = new BigNumber(trust.limit); + } else if (isClassicAssetId(assetId) && row.limit !== undefined) { this.#balances.set(assetId, { - balance: balanceStroops, - symbol: trust.unit, - limit: limitStroops, - address: assetIssuer, - ...(typeof trust.authorized === 'boolean' - ? { authorized: trust.authorized } - : {}), - ...(trust.sponsored === true ? { sponsored: true } : {}), + balance: row.balance, + symbol: row.symbol, + limit: row.limit, + ...(row.address === undefined ? {} : { address: row.address }), + ...(row.authorized === undefined + ? {} + : { authorized: row.authorized }), + ...(row.sponsored === undefined ? {} : { sponsored: row.sponsored }), }); } else if (isSep41Id(assetId)) { this.#balances.set(assetId, { - balance: new BigNumber(entry.amount), - symbol: entry.unit, + balance: row.balance, + symbol: row.symbol, }); } - }); - } - - #isHorizonResponse( - account: StellarAccount, - ): account is Horizon.AccountResponse { - return account !== undefined && 'balances' in account; - } + } - #isTrustLineAssetBalance( - value: BaseAssetBalance | TrustLineAssetBalance | undefined, - ): value is TrustLineAssetBalance { - return ( - value !== undefined && - typeof (value as TrustLineAssetBalance).limit === 'string' - ); + // we only store spendable balance in the balances map, + // so we need to add the reserved balance to get the raw balance + const nativeSpendable = this.#balances.get(nativeId)?.balance; + if (nativeSpendable !== undefined) { + this.#rawNativeBalance = nativeSpendable.plus( + minimumBalanceStroops(meta), + ); + } } } diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts new file mode 100644 index 00000000..24b3c54c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts @@ -0,0 +1,14 @@ +import type { OnChainAccountLedgerMeta, SpendableBalance } from './api'; +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip2ChainId, +} from '../../api'; + +/** Plain snapshot of {@link OnChainAccount} fields and per-asset balances (e.g. for cache or RPC). */ +export type OnChainAccountSerializable = { + accountId: string; + sequenceNumber: string; + scope: KnownCaip2ChainId; + meta: OnChainAccountLedgerMeta; + balances: Record; +}; diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 41cc6c1a..418bbf57 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -5,6 +5,7 @@ import { KnownCaip2ChainId } from '../../api'; import { createMockAccountWithBalances, DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + horizonSource, mockOnChainAccountService, } from './__mocks__/onChainAccount.fixtures'; import { OnChainAccount } from './OnChainAccount'; @@ -47,14 +48,16 @@ describe('OnChainAccountService', () => { .mockResolvedValue(mockAccount); const { getAccountOrNullSpy } = getNetworkServiceSpies(); const wallet = getTestWallet({ seed }); + const activatedAcc = createMockAccountWithBalances( + wallet.address, + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); getAccountOrNullSpy.mockResolvedValue( new OnChainAccount( - createMockAccountWithBalances( - wallet.address, - '1', - DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, - ), + activatedAcc, KnownCaip2ChainId.Mainnet, + horizonSource(activatedAcc, KnownCaip2ChainId.Mainnet), ), ); @@ -100,13 +103,15 @@ describe('OnChainAccountService', () => { it('returns true when getAccountOrNull returns an account', async () => { const { getAccountOrNullSpy } = getNetworkServiceSpies(); const wallet = getTestWallet({ seed }); + const onChainAcc = createMockAccountWithBalances( + wallet.address, + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); const onChain = new OnChainAccount( - createMockAccountWithBalances( - wallet.address, - '1', - DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, - ), + onChainAcc, KnownCaip2ChainId.Mainnet, + horizonSource(onChainAcc, KnownCaip2ChainId.Mainnet), ); getAccountOrNullSpy.mockResolvedValue(onChain); @@ -142,13 +147,15 @@ describe('OnChainAccountService', () => { 'entropy-source-1', 0, ); + const loadedAcc = createMockAccountWithBalances( + signer.publicKey(), + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); const loaded = new OnChainAccount( - createMockAccountWithBalances( - signer.publicKey(), - '1', - DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, - ), + loadedAcc, KnownCaip2ChainId.Mainnet, + horizonSource(loadedAcc, KnownCaip2ChainId.Mainnet), ); const { loadOnChainAccountSpy } = getNetworkServiceSpies(); loadOnChainAccountSpy.mockResolvedValue(loaded); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts index b849a2a4..71f511f8 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts @@ -1,14 +1,55 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import type { Horizon } from '@stellar/stellar-sdk'; import { Account } from '@stellar/stellar-sdk'; +import type { KnownCaip2ChainId } from '../../../api'; import { logger } from '../../../utils/logger'; import { AccountService } from '../../account/AccountService'; import { AccountsRepository } from '../../account/AccountsRepository'; import { NetworkService } from '../../network'; import { State } from '../../state/State'; import { WalletService } from '../../wallet'; +import { OnChainAccount } from '../OnChainAccount'; +import type { OnChainAccountSerializable } from '../OnChainAccountSerializable'; import { OnChainAccountService } from '../OnChainAccountService'; +/** + * Wraps a Horizon-shaped SDK account as {@link OnChainAccountSerializable} for tests. + * + * @param account - Mock or SDK account that includes Horizon `balances` / meta fields. + * @param scope - CAIP-2 network (must match the `OnChainAccount` constructor scope). + * @returns Serializable binding for {@link OnChainAccount} constructor. + */ +export function horizonSource( + account: Account, + scope: KnownCaip2ChainId, +): OnChainAccountSerializable { + return OnChainAccount.fromHorizon( + account as unknown as Horizon.AccountResponse, + scope, + ).toSerializable(); +} + +/** + * Serializable binding with no balance lines (sequence exists, no asset rows yet). + * + * @param account - Bare SDK `Account` instance (mutated to add empty `balances`). + * @param scope - CAIP-2 network. + * @returns Binding for {@link OnChainAccount} constructor. + */ +export function unfundedHorizonBinding( + account: Account, + scope: KnownCaip2ChainId, +): OnChainAccountSerializable { + const response = Object.assign(account, { + balances: [], + subentry_count: 0, + num_sponsoring: 0, + num_sponsored: 0, + }) as unknown as Horizon.AccountResponse; + return OnChainAccount.fromHorizon(response, scope).toSerializable(); +} + export type MockAssetLine = { assetType: string; assetCode: string; diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts index d14cd0ed..a170e767 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts @@ -1,5 +1,22 @@ import type { KnownCaip2ChainId } from '../../api'; +/** Per-asset view: native, classic trustline (limit + issuer in `address`), or SEP-41. */ +export type SpendableBalance = { + balance: BigNumber; + symbol: string; + limit?: BigNumber; + address?: string; + authorized?: boolean; + sponsored?: boolean; +}; + +/** Ledger fields used for native reserve / spendable math (Horizon or persisted snapshot). */ +export type OnChainAccountLedgerMeta = { + subentryCount: number; + numSponsoring: number; + numSponsored: number; +}; + /** * Persisted on-chain account header fields for one keyring account on one network, refreshed on sync. * Does not include trustline balances (see `accountBalances` state). diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts index e664d1ed..d7f0a807 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts @@ -1,3 +1,4 @@ export type * from './api'; +export type * from './OnChainAccountSerializable'; export * from './OnChainAccount'; export * from './OnChainAccountService'; diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts index 6126c349..6d5f102e 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts @@ -9,6 +9,28 @@ type CalculateSpendableBalanceParams = { numSponsored: number; }; +type MinimumBalanceLedgerMeta = { + subentryCount: number; + numSponsoring: number; + numSponsored: number; +}; + +/** + * Minimum account balance in stroops for reserve calculation. + * + * @param meta - Ledger fields from Horizon or persisted snapshot. + * @returns Minimum balance in stroops. + */ +export function minimumBalanceStroops( + meta: MinimumBalanceLedgerMeta, +): BigNumber { + return new BigNumber(2) + .plus(meta.subentryCount) + .plus(meta.numSponsoring) + .minus(meta.numSponsored) + .times(BASE_RESERVE_STROOPS); +} + /** * Spendable native balance (stroops): total native minus minimum balance. * @@ -27,11 +49,11 @@ export function calculateSpendableBalance( params: CalculateSpendableBalanceParams, ): BigNumber { const { nativeBalance, subentryCount, numSponsoring, numSponsored } = params; - const minBalanceStroops = new BigNumber(2) - .plus(subentryCount) - .plus(numSponsoring) - .minus(numSponsored) - .times(BASE_RESERVE_STROOPS); + const minBalanceStroops = minimumBalanceStroops({ + subentryCount, + numSponsoring, + numSponsored, + }); return BigNumber.maximum(nativeBalance.minus(minBalanceStroops), 0); } From 83d1b8fba8038cc3bf0230f025853e6664c57e5b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:11:00 +0800 Subject: [PATCH 064/384] chore: update context --- merged-packages/stellar-wallet-snap/jest.config.js | 8 ++++---- merged-packages/stellar-wallet-snap/src/context.ts | 12 ++++++++++++ .../src/handlers/user-input/userInput.ts | 6 ------ 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index 839214da..383744f4 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 60.02, - functions: 73, - lines: 73.45, - statements: 73.56, + branches: 61.36, + functions: 76.1, + lines: 77.78, + statements: 77.96, }, }, diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 2d9ebaa7..12717e72 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -11,6 +11,10 @@ import { import { UserInputHandler } from './handlers/user-input/userInput'; import { AccountService, AccountsRepository } from './services/account'; import type { AccountBalanceState } from './services/account-balance'; +import { + AssetMetadataRepository, + AssetMetadataService, +} from './services/asset-metadata'; import { NetworkService } from './services/network'; import type { OnChainAccountSnapshotState } from './services/on-chain-account'; import { OnChainAccountService } from './services/on-chain-account'; @@ -38,6 +42,7 @@ const state = new State({ const accountsRepository = new AccountsRepository(state); const transactionRepository = new TransactionRepository(state); +const assetMetadataRepository = new AssetMetadataRepository(state); /** ------------------------------ Services ------------------------------ */ const networkService = new NetworkService({ logger }); @@ -63,6 +68,12 @@ const transactionService = new TransactionService({ networkService, }); +const assetMetadataService = new AssetMetadataService({ + networkService, + assetMetadataRepository, + logger, +}); + /** ------------------------------ Keyring Handler ------------------------------ */ const signTransactionHandler = new SignTransactionHandler({ @@ -92,6 +103,7 @@ const keyringHandler = new KeyringHandler({ accountService, onChainAccountService, transactionService, + assetMetadataService, handlers: keyringMethodHandlers, }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts index 4b9b602d..2f85c365 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts @@ -1,9 +1,6 @@ import type { InterfaceContext, UserInputEvent } from '@metamask/snaps-sdk'; import type { UserInputUiEventHandler } from './api'; -import { createEventHandlers as createAccountActivationPromptEvents } from '../../ui/confirmation/views/AccountActivationPrompt/events'; -import { createEventHandlers as createSignChangeTrustOptInEvents } from '../../ui/confirmation/views/ConfirmSignChangeTrustOptIn/events'; -import { createEventHandlers as createSignChangeTrustOptOutEvents } from '../../ui/confirmation/views/ConfirmSignChangeTrustOptOut/events'; import { createEventHandlers as createSignMessageEvents } from '../../ui/confirmation/views/ConfirmSignMessage/events'; import { createEventHandlers as createSignTransactionEvents } from '../../ui/confirmation/views/ConfirmSignTransaction/events'; import { @@ -46,9 +43,6 @@ export class UserInputHandler { const uiEventHandlers: Record = { ...createSignMessageEvents(), ...createSignTransactionEvents(), - ...createSignChangeTrustOptInEvents(), - ...createSignChangeTrustOptOutEvents(), - ...createAccountActivationPromptEvents(), }; /** From 7abd2de47b018c187c47ae97be8b42fa41f97939 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:01:01 +0800 Subject: [PATCH 065/384] chore: update keyring --- .../src/handlers/keyring/exceptions.ts | 6 ++ .../src/handlers/keyring/keyring.ts | 86 ++++++++++++------- 2 files changed, 62 insertions(+), 30 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts index 81dec06e..2364a2fb 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts @@ -66,3 +66,9 @@ export class KeyringDeleteAccountException extends KeyringException { super(`Failed to delete account for account ${accountId}`); } } + +export class KeyringEmitAccountCreatedEventException extends KeyringException { + constructor() { + super('Failed to emit account created event'); + } +} \ No newline at end of file diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index b1466633..a9bdf3f6 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -42,6 +42,7 @@ import { KeyringCreateAccountException, KeyringDeleteAccountException, KeyringDiscoverAccountsException, + KeyringEmitAccountCreatedEventException, KeyringGetAccountBalancesException, KeyringGetAccountException, KeyringListAccountAssetsException, @@ -65,12 +66,15 @@ import type { TransactionService } from '../../services/transaction/TransactionS import type { ILogger } from '../../utils'; import { createPrefixedLogger, + getSlip44AssetId, getSnapProvider, + isSnapRpcError, rethrowIfInstanceElseThrow, validateOrigin, validateRequest, withCatchAndThrowSnapError, } from '../../utils'; +import { NATIVE_ASSET_SYMBOL } from '../../constants'; export class KeyringHandler implements Keyring { readonly #logger: ILogger; @@ -183,28 +187,36 @@ export class KeyringHandler implements Keyring { ): Promise { const keyringAccount = this.#toKeyringAccount(account); - await emitSnapKeyringEvent(getSnapProvider(), KeyringEvent.AccountCreated, { - /** - * We can't pass the `keyringAccount` object because it contains the index - * and the Snaps SDK does not allow extra properties. - */ - account: keyringAccount, - /** - * Skip account creation confirmation dialogs to make it look like a native - * account creation flow. - */ - displayConfirmation: false, - /** - * Internal options to MetaMask that include a correlation ID. We need - * to also emit this ID to the Snap keyring. - * Must be nested under `metamask` (keyring API). Do not spread - * `options.metamask` onto params or `correlationId` ends up at - * `params.correlationId` and fails validation (`never`). - */ - ...(options?.metamask?.correlationId === undefined - ? {} - : { metamask: { correlationId: options.metamask.correlationId } }), - }); + try { + await emitSnapKeyringEvent(getSnapProvider(), KeyringEvent.AccountCreated, { + /** + * We can't pass the `keyringAccount` object because it contains the index + * and the Snaps SDK does not allow extra properties. + */ + account: keyringAccount, + /** + * Skip account creation confirmation dialogs to make it look like a native + * account creation flow. + */ + displayConfirmation: false, + /** + * Internal options to MetaMask that include a correlation ID. We need + * to also emit this ID to the Snap keyring. + * Must be nested under `metamask` (keyring API). Do not spread + * `options.metamask` onto params or `correlationId` ends up at + * `params.correlationId` and fails validation (`never`). + */ + ...(options?.metamask?.correlationId === undefined + ? {} + : { metamask: { correlationId: options.metamask.correlationId } }), + }); + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to emit account created event', + error, + ); + throw new KeyringEmitAccountCreatedEventException(); + } } #toKeyringAccount(account: StellarKeyringAccount): KeyringAccount { @@ -221,7 +233,6 @@ export class KeyringHandler implements Keyring { async listAccountAssets(accountId: string): Promise { validateRequest(accountId, ListAccountAssetsRequestStruct); - try { const { account } = await this.#accountService.resolveAccount({ accountId, @@ -236,9 +247,12 @@ export class KeyringHandler implements Keyring { return onChainAccount.assetIds; } catch (error: unknown) { - // fallback to empty array if the account is not activated + // fallback to single native asset if the account is not activated` if (error instanceof AccountNotActivatedException) { - return []; + const slip44AssetId = getSlip44AssetId(AppConfig.selectedNetwork); + return [ + slip44AssetId, + ]; } this.#logger.logErrorWithDetails( 'Failed to list account assets', @@ -364,6 +378,8 @@ export class KeyringHandler implements Keyring { assets: KnownCaip19AssetIdOrSlip44Id[], ): Promise> { validateRequest({ accountId, assets }, GetAccountBalancesRequestStruct); + const slip44AssetId = getSlip44AssetId(AppConfig.selectedNetwork); + try { const { account } = await this.#accountService.resolveAccount({ accountId, @@ -381,12 +397,12 @@ export class KeyringHandler implements Keyring { AppConfig.selectedNetwork, ); + const nativeAsset = onChainAccount.getAsset(slip44AssetId); return onChainAccount.assetIds.reduce( (acc, assetId) => { const assetMetadata = assetsMetadata[assetId]; - // TODO: Stellar may also need to return balance for assets with zero balance, - // it may depends on if it is SEP-41 asset or Classic asset. - if (assetMetadata && onChainAccount.getAsset(assetId).balance.gt(0)) { + // We dont filter by balance here because a asset is bind with trustline in Stellar Account. + if (assetMetadata) { acc[assetId] = { unit: assetMetadata.symbol ?? '', amount: onChainAccount.getAsset(assetId).balance.toString(), @@ -394,12 +410,22 @@ export class KeyringHandler implements Keyring { } return acc; }, - {} as Record, + { + [slip44AssetId]: { + unit: nativeAsset.symbol, + amount: nativeAsset.balance.toString(), + }, + } as Record, ); } catch (error: unknown) { if (error instanceof AccountNotActivatedException) { // fallback to empty object if the account is not activated - return {} as Record; + return { + [slip44AssetId]: { + unit: NATIVE_ASSET_SYMBOL, + amount: '0', + }, + } as Record; } this.#logger.logErrorWithDetails( 'Failed to get account balances', From 89dd0c865839488b404769d31eded154d8a84922 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:28:30 +0800 Subject: [PATCH 066/384] feat: imp list account and get account balance --- .../src/handlers/keyring/exceptions.ts | 2 +- .../src/handlers/keyring/keyring.test.ts | 11 +- .../src/handlers/keyring/keyring.ts | 108 +++++++++--------- 3 files changed, 61 insertions(+), 60 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts index 2364a2fb..d8699cf4 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts @@ -71,4 +71,4 @@ export class KeyringEmitAccountCreatedEventException extends KeyringException { constructor() { super('Failed to emit account created event'); } -} \ No newline at end of file +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 0f1362c2..ed4b8072 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -302,7 +302,8 @@ describe('KeyringHandler', () => { expect(result).toStrictEqual([slipId]); }); - it('returns empty array when the account is not activated on-chain', async () => { + it('returns native asset id when the account is not activated on-chain', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); jest @@ -316,7 +317,7 @@ describe('KeyringHandler', () => { const result = await keyringHandler.listAccountAssets(mockAccountId); - expect(result).toStrictEqual([]); + expect(result).toStrictEqual([slipId]); }); it('throws when listing assets fails for another reason', async () => { @@ -501,7 +502,7 @@ describe('KeyringHandler', () => { }); }); - it('returns empty record when the account is not activated on-chain', async () => { + it('returns zero native balance when the account is not activated on-chain', async () => { const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); @@ -518,7 +519,9 @@ describe('KeyringHandler', () => { slipId, ]); - expect(result).toStrictEqual({}); + expect(result).toStrictEqual({ + [slipId]: { unit: 'XLM', amount: '0' }, + }); }); it('throws when balance resolution fails for another reason', async () => { diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index a9bdf3f6..f95410f5 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -60,6 +60,7 @@ import type { StellarKeyringAccount, } from '../../services/account'; import type { AssetMetadataService } from '../../services/asset-metadata'; +import { getNativeAssetMetadata } from '../../services/asset-metadata/utils'; import { AccountNotActivatedException } from '../../services/network'; import type { OnChainAccountService } from '../../services/on-chain-account'; import type { TransactionService } from '../../services/transaction/TransactionService'; @@ -68,13 +69,11 @@ import { createPrefixedLogger, getSlip44AssetId, getSnapProvider, - isSnapRpcError, rethrowIfInstanceElseThrow, validateOrigin, validateRequest, withCatchAndThrowSnapError, } from '../../utils'; -import { NATIVE_ASSET_SYMBOL } from '../../constants'; export class KeyringHandler implements Keyring { readonly #logger: ILogger; @@ -188,28 +187,32 @@ export class KeyringHandler implements Keyring { const keyringAccount = this.#toKeyringAccount(account); try { - await emitSnapKeyringEvent(getSnapProvider(), KeyringEvent.AccountCreated, { - /** - * We can't pass the `keyringAccount` object because it contains the index - * and the Snaps SDK does not allow extra properties. - */ - account: keyringAccount, - /** - * Skip account creation confirmation dialogs to make it look like a native - * account creation flow. - */ - displayConfirmation: false, - /** - * Internal options to MetaMask that include a correlation ID. We need - * to also emit this ID to the Snap keyring. - * Must be nested under `metamask` (keyring API). Do not spread - * `options.metamask` onto params or `correlationId` ends up at - * `params.correlationId` and fails validation (`never`). - */ - ...(options?.metamask?.correlationId === undefined - ? {} - : { metamask: { correlationId: options.metamask.correlationId } }), - }); + await emitSnapKeyringEvent( + getSnapProvider(), + KeyringEvent.AccountCreated, + { + /** + * We can't pass the `keyringAccount` object because it contains the index + * and the Snaps SDK does not allow extra properties. + */ + account: keyringAccount, + /** + * Skip account creation confirmation dialogs to make it look like a native + * account creation flow. + */ + displayConfirmation: false, + /** + * Internal options to MetaMask that include a correlation ID. We need + * to also emit this ID to the Snap keyring. + * Must be nested under `metamask` (keyring API). Do not spread + * `options.metamask` onto params or `correlationId` ends up at + * `params.correlationId` and fails validation (`never`). + */ + ...(options?.metamask?.correlationId === undefined + ? {} + : { metamask: { correlationId: options.metamask.correlationId } }), + }, + ); } catch (error: unknown) { this.#logger.logErrorWithDetails( 'Failed to emit account created event', @@ -250,9 +253,7 @@ export class KeyringHandler implements Keyring { // fallback to single native asset if the account is not activated` if (error instanceof AccountNotActivatedException) { const slip44AssetId = getSlip44AssetId(AppConfig.selectedNetwork); - return [ - slip44AssetId, - ]; + return [slip44AssetId]; } this.#logger.logErrorWithDetails( 'Failed to list account assets', @@ -378,7 +379,17 @@ export class KeyringHandler implements Keyring { assets: KnownCaip19AssetIdOrSlip44Id[], ): Promise> { validateRequest({ accountId, assets }, GetAccountBalancesRequestStruct); - const slip44AssetId = getSlip44AssetId(AppConfig.selectedNetwork); + + const nativeAssetMetadata = getNativeAssetMetadata( + AppConfig.selectedNetwork, + ); + + const defaultAsset = { + [nativeAssetMetadata.assetId]: { + unit: nativeAssetMetadata.symbol, + amount: '0', + }, + } as Record; try { const { account } = await this.#accountService.resolveAccount({ @@ -397,35 +408,22 @@ export class KeyringHandler implements Keyring { AppConfig.selectedNetwork, ); - const nativeAsset = onChainAccount.getAsset(slip44AssetId); - return onChainAccount.assetIds.reduce( - (acc, assetId) => { - const assetMetadata = assetsMetadata[assetId]; - // We dont filter by balance here because a asset is bind with trustline in Stellar Account. - if (assetMetadata) { - acc[assetId] = { - unit: assetMetadata.symbol ?? '', - amount: onChainAccount.getAsset(assetId).balance.toString(), - }; - } - return acc; - }, - { - [slip44AssetId]: { - unit: nativeAsset.symbol, - amount: nativeAsset.balance.toString(), - }, - } as Record, - ); + // onChainAccount.assetIds will always include the native asset + return onChainAccount.assetIds.reduce((acc, assetId) => { + const assetMetadata = assetsMetadata[assetId]; + // We dont filter by balance here because a asset is bind with trustline in Stellar Account. + if (assetMetadata) { + acc[assetId] = { + unit: assetMetadata.symbol ?? '', + amount: onChainAccount.getAsset(assetId).balance.toString(), + }; + } + return acc; + }, defaultAsset); } catch (error: unknown) { if (error instanceof AccountNotActivatedException) { - // fallback to empty object if the account is not activated - return { - [slip44AssetId]: { - unit: NATIVE_ASSET_SYMBOL, - amount: '0', - }, - } as Record; + // fallback to default asset if the account is not activated + return defaultAsset; } this.#logger.logErrorWithDetails( 'Failed to get account balances', From 7a0f9bfa5637001c469c1ae0432195793e759a47 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:33:21 +0800 Subject: [PATCH 067/384] chore: update jest --- merged-packages/stellar-wallet-snap/jest.config.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index 383744f4..fbdb1392 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 61.36, - functions: 76.1, - lines: 77.78, - statements: 77.96, + branches: 61.27, + functions: 75.92, + lines: 77.72, + statements: 77.9, }, }, From 0efc19ae11a18b04e1655747e9e9fe12c64cfe14 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:35:13 +0800 Subject: [PATCH 068/384] chore: update keyring --- .../stellar-wallet-snap/src/context.ts | 13 -- .../stellar-wallet-snap/src/handlers/base.ts | 2 +- .../src/handlers/keyring/keyring.test.ts | 44 ++--- .../src/handlers/keyring/keyring.ts | 159 ++++++++++-------- .../on-chain-account/OnChainAccount.test.ts | 6 +- .../on-chain-account/OnChainAccount.ts | 39 +++-- .../OnChainAccountService.test.ts | 100 ++++------- .../on-chain-account/OnChainAccountService.ts | 70 ++------ .../__mocks__/onChainAccount.fixtures.ts | 9 +- 9 files changed, 185 insertions(+), 257 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 12717e72..abc4a0d7 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -11,10 +11,6 @@ import { import { UserInputHandler } from './handlers/user-input/userInput'; import { AccountService, AccountsRepository } from './services/account'; import type { AccountBalanceState } from './services/account-balance'; -import { - AssetMetadataRepository, - AssetMetadataService, -} from './services/asset-metadata'; import { NetworkService } from './services/network'; import type { OnChainAccountSnapshotState } from './services/on-chain-account'; import { OnChainAccountService } from './services/on-chain-account'; @@ -42,7 +38,6 @@ const state = new State({ const accountsRepository = new AccountsRepository(state); const transactionRepository = new TransactionRepository(state); -const assetMetadataRepository = new AssetMetadataRepository(state); /** ------------------------------ Services ------------------------------ */ const networkService = new NetworkService({ logger }); @@ -59,7 +54,6 @@ const accountService = new AccountService({ const onChainAccountService = new OnChainAccountService({ networkService, - accountService, }); const transactionService = new TransactionService({ @@ -68,12 +62,6 @@ const transactionService = new TransactionService({ networkService, }); -const assetMetadataService = new AssetMetadataService({ - networkService, - assetMetadataRepository, - logger, -}); - /** ------------------------------ Keyring Handler ------------------------------ */ const signTransactionHandler = new SignTransactionHandler({ @@ -103,7 +91,6 @@ const keyringHandler = new KeyringHandler({ accountService, onChainAccountService, transactionService, - assetMetadataService, handlers: keyringMethodHandlers, }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/base.ts index e4cef5f5..8d69114f 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/base.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/base.ts @@ -156,7 +156,7 @@ export abstract class WithActiveAccountResolve< if (loadOnChain) { promises.push( this.onChainAccountService.resolveOnChainAccount( - account, + account.address, AppConfig.selectedNetwork, ), ); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index ed4b8072..65a059a4 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -33,7 +33,6 @@ import { } from '../../services/account'; import { generateMockStellarKeyringAccounts } from '../../services/account/__mocks__/account.fixtures'; import { AccountNotFoundException } from '../../services/account/exceptions'; -import type { AssetMetadataService } from '../../services/asset-metadata/AssetMetadataService'; import { AccountNotActivatedException } from '../../services/network'; import { OnChainAccountService } from '../../services/on-chain-account'; import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; @@ -68,8 +67,6 @@ describe('KeyringHandler', () => { let mockAccountId: string; let mockSignMessageHandler: IKeyringRequestHandler; let mockSignTransactionHandler: IKeyringRequestHandler; - let mockAssetMetadataService: AssetMetadataService; - let getAssetsMetadataByAssetIdsMock: jest.Mock; const toKeyringAccount = (account: StellarKeyringAccount): KeyringAccount => { const { id, address, type, options, methods, scopes } = account; @@ -87,10 +84,6 @@ describe('KeyringHandler', () => { listAccountsSpy: jest.spyOn(AccountService.prototype, 'listAccounts'), findByIdSpy: jest.spyOn(AccountService.prototype, 'findById'), deleteSpy: jest.spyOn(AccountService.prototype, 'delete'), - discoverOnChainAccountSpy: jest.spyOn( - OnChainAccountService.prototype, - 'discoverOnChainAccount', - ), resolveAccountSpy: jest.spyOn(AccountService.prototype, 'resolveAccount'), createAccountSpy: jest.spyOn(AccountService.prototype, 'create'), }); @@ -101,10 +94,6 @@ describe('KeyringHandler', () => { mockSignMessageHandler = { handle: jest.fn() }; mockSignTransactionHandler = { handle: jest.fn() }; - getAssetsMetadataByAssetIdsMock = jest.fn().mockResolvedValue({}); - mockAssetMetadataService = { - getAssetsMetadataByAssetIds: getAssetsMetadataByAssetIdsMock, - } as unknown as AssetMetadataService; const { accountService, onChainAccountService } = mockOnChainAccountService(); @@ -113,7 +102,6 @@ describe('KeyringHandler', () => { logger, accountService, onChainAccountService, - assetMetadataService: mockAssetMetadataService, transactionService, handlers: { [MultichainMethod.SignMessage]: mockSignMessageHandler, @@ -420,9 +408,12 @@ describe('KeyringHandler', () => { describe('discoverAccounts', () => { it('discovers an account', async () => { - jest - .spyOn(OnChainAccountService.prototype, 'discoverOnChainAccount') + const deriveKeyringAccountSpy = jest + .spyOn(AccountService.prototype, 'deriveKeyringAccount') .mockResolvedValue(mockAccount); + const isAccountActivatedSpy = jest + .spyOn(OnChainAccountService.prototype, 'isAccountActivated') + .mockResolvedValue(true); const result = await keyringHandler.discoverAccounts( [KnownCaip2ChainId.Mainnet], @@ -430,6 +421,14 @@ describe('KeyringHandler', () => { 0, ); + expect(deriveKeyringAccountSpy).toHaveBeenCalledWith({ + entropySource: 'entropy-source-1', + index: 0, + }); + expect(isAccountActivatedSpy).toHaveBeenCalledWith({ + accountAddress: mockAccount.address, + scope: KnownCaip2ChainId.Mainnet, + }); expect(result).toStrictEqual([ { type: DiscoveredAccountType.Bip44, @@ -441,8 +440,11 @@ describe('KeyringHandler', () => { it('returns empty array if the account is not activated on the Stellar network', async () => { jest - .spyOn(OnChainAccountService.prototype, 'discoverOnChainAccount') - .mockResolvedValue(null); + .spyOn(AccountService.prototype, 'deriveKeyringAccount') + .mockResolvedValue(mockAccount); + jest + .spyOn(OnChainAccountService.prototype, 'isAccountActivated') + .mockResolvedValue(false); const result = await keyringHandler.discoverAccounts( [KnownCaip2ChainId.Mainnet], @@ -455,7 +457,7 @@ describe('KeyringHandler', () => { it('throws an error if the account discovery fails', async () => { jest - .spyOn(OnChainAccountService.prototype, 'discoverOnChainAccount') + .spyOn(AccountService.prototype, 'deriveKeyringAccount') .mockRejectedValue(new Error('Account discovery failed')); await expect( @@ -483,14 +485,14 @@ describe('KeyringHandler', () => { const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - getAssetsMetadataByAssetIdsMock.mockResolvedValue({ - [slipId]: { symbol: 'XLM' }, - }); jest .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') .mockResolvedValue({ assetIds: [slipId], - getAsset: () => ({ balance: new BigNumber('10') }), + getAsset: () => ({ + balance: new BigNumber('10'), + symbol: 'XLM', + }), } as unknown as OnChainAccount); const result = await keyringHandler.getAccountBalances(mockAccountId, [ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index f95410f5..58095958 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -59,16 +59,20 @@ import type { AccountService, StellarKeyringAccount, } from '../../services/account'; -import type { AssetMetadataService } from '../../services/asset-metadata'; import { getNativeAssetMetadata } from '../../services/asset-metadata/utils'; import { AccountNotActivatedException } from '../../services/network'; -import type { OnChainAccountService } from '../../services/on-chain-account'; +import type { + OnChainAccount, + OnChainAccountService, +} from '../../services/on-chain-account'; import type { TransactionService } from '../../services/transaction/TransactionService'; import type { ILogger } from '../../utils'; import { createPrefixedLogger, getSlip44AssetId, getSnapProvider, + isSep41Id, + isSlip44Id, rethrowIfInstanceElseThrow, validateOrigin, validateRequest, @@ -84,22 +88,18 @@ export class KeyringHandler implements Keyring { readonly #transactionService: TransactionService; - readonly #assetMetadataService: AssetMetadataService; - readonly #handlers: Record; constructor({ logger, accountService, onChainAccountService, - assetMetadataService, transactionService, handlers, }: { logger: ILogger; accountService: AccountService; onChainAccountService: OnChainAccountService; - assetMetadataService: AssetMetadataService; transactionService: TransactionService; handlers: Record; }) { @@ -107,7 +107,6 @@ export class KeyringHandler implements Keyring { this.#accountService = accountService; this.#onChainAccountService = onChainAccountService; this.#transactionService = transactionService; - this.#assetMetadataService = assetMetadataService; this.#handlers = handlers; } @@ -158,7 +157,7 @@ export class KeyringHandler implements Keyring { const account = await this.#accountService.create( options, async (stellarKeyringAccount: StellarKeyringAccount) => - await this.#emitCreatedAccountEvent(stellarKeyringAccount, options), + this.#emitCreatedAccountEvent(stellarKeyringAccount, options), ); return this.#toKeyringAccount(account); @@ -236,24 +235,25 @@ export class KeyringHandler implements Keyring { async listAccountAssets(accountId: string): Promise { validateRequest(accountId, ListAccountAssetsRequestStruct); + + const scope = AppConfig.selectedNetwork; + try { - const { account } = await this.#accountService.resolveAccount({ + const { onChainAccount } = await this.#resolveAccountByAccountId( accountId, - }); + scope, + ); - // We only support one scope in metamask today - const onChainAccount = - await this.#onChainAccountService.resolveOnChainAccount( - account, - AppConfig.selectedNetwork, + // Non-SEP-41 (native + classic): always list. SEP-41: only if row exists and balance > 0. + return onChainAccount.assetIds.filter((assetId) => { + return ( + !isSep41Id(assetId) || onChainAccount.getAsset(assetId)?.balance.gt(0) ); - - return onChainAccount.assetIds; + }); } catch (error: unknown) { - // fallback to single native asset if the account is not activated` + // Always include native asset in the response when the account is not activated if (error instanceof AccountNotActivatedException) { - const slip44AssetId = getSlip44AssetId(AppConfig.selectedNetwork); - return [slip44AssetId]; + return [getSlip44AssetId(scope)]; } this.#logger.logErrorWithDetails( 'Failed to list account assets', @@ -270,15 +270,15 @@ export class KeyringHandler implements Keyring { data: Transaction[]; next: string | null; }> { - try { - validateRequest( - { accountId, pagination }, - ListAccountTransactionsRequestStruct, - ); + validateRequest( + { accountId, pagination }, + ListAccountTransactionsRequestStruct, + ); + try { const { limit, next } = pagination; - // we dont necessary to check if the account is activated + // It is not necessary to check if the account is activated // because we are not fetching the transactions from the network. const { account: keyringAccount } = await this.#accountService.resolveAccount({ @@ -294,7 +294,8 @@ export class KeyringHandler implements Keyring { ? transactions.findIndex((tx) => tx.id === next) : 0; - // Safeguard: If the next cursor is invalid, throw a RangeError. + // Safeguard: If the next cursor is invalid, throw the account-based exception + // with the correct account identifier. if (next !== undefined && next !== null && startIndex === -1) { throw new KeyringListAccountTransactionsException( `Invalid transaction pagination cursor: ${next}`, @@ -340,21 +341,24 @@ export class KeyringHandler implements Keyring { DiscoverAccountsStruct, ); - // it is a never case because the struct validation ensures that the scopes length is 1 - if (scopes.length !== 1 || scopes[0] === undefined) { - throw new Error('Invalid scope'); + // DiscoverAccountsStruct enforces exactly one scope; this guards TypeScript. + const scope = scopes[0]; + if (scope === undefined) { + throw new Error('Invariant: discoverAccounts requires one scope'); } try { - // Discover an account if it exists on the blockchain. - const account = await this.#onChainAccountService.discoverOnChainAccount({ + const account = await this.#accountService.deriveKeyringAccount({ entropySource, index: groupIndex, - // we assume only one scope supported - scope: scopes[0], + }); + // Discover an account if it exists on the blockchain. + const isActivated = await this.#onChainAccountService.isAccountActivated({ + accountAddress: account.address, + scope, }); - if (!account) { + if (!isActivated) { return []; } @@ -380,51 +384,42 @@ export class KeyringHandler implements Keyring { ): Promise> { validateRequest({ accountId, assets }, GetAccountBalancesRequestStruct); - const nativeAssetMetadata = getNativeAssetMetadata( - AppConfig.selectedNetwork, - ); - - const defaultAsset = { - [nativeAssetMetadata.assetId]: { - unit: nativeAssetMetadata.symbol, - amount: '0', - }, - } as Record; + const scope = AppConfig.selectedNetwork; + const assetBalances = {} as Record; try { - const { account } = await this.#accountService.resolveAccount({ + const { onChainAccount } = await this.#resolveAccountByAccountId( accountId, - }); - - const assetsMetadata = - await this.#assetMetadataService.getAssetsMetadataByAssetIds( - assets, - AppConfig.selectedNetwork, - ); - // We only support one scope in metamask today - const onChainAccount = - await this.#onChainAccountService.resolveOnChainAccount( - account, - AppConfig.selectedNetwork, - ); + scope, + ); - // onChainAccount.assetIds will always include the native asset - return onChainAccount.assetIds.reduce((acc, assetId) => { - const assetMetadata = assetsMetadata[assetId]; - // We dont filter by balance here because a asset is bind with trustline in Stellar Account. - if (assetMetadata) { - acc[assetId] = { - unit: assetMetadata.symbol ?? '', - amount: onChainAccount.getAsset(assetId).balance.toString(), - }; + for (const assetId of assets) { + const asset = onChainAccount.getAsset(assetId); + if (asset === undefined) { + continue; + } + // Native / classic trustlines: always include. SEP-41: only non-zero. + if (isSep41Id(assetId) && !asset.balance.gt(0)) { + continue; } - return acc; - }, defaultAsset); + assetBalances[assetId] = { + unit: asset.symbol ?? '', + amount: asset.balance.toString(), + }; + } + return assetBalances; } catch (error: unknown) { if (error instanceof AccountNotActivatedException) { - // fallback to default asset if the account is not activated - return defaultAsset; + const nativeAssetId = assets.find(isSlip44Id); + if (nativeAssetId !== undefined) { + assetBalances[nativeAssetId] = { + unit: getNativeAssetMetadata(scope).symbol ?? '', + amount: '0', + }; + } + return assetBalances; } + this.#logger.logErrorWithDetails( 'Failed to get account balances', ensureError(error).message, @@ -516,4 +511,24 @@ export class KeyringHandler implements Keyring { #assertMethodIsValid(method: string): asserts method is MultichainMethod { validateRequest(method, MultichainMethodStruct); } + + async #resolveAccountByAccountId( + accountId: string, + scope: KnownCaip2ChainId, + ): Promise<{ + account: StellarKeyringAccount; + onChainAccount: OnChainAccount; + }> { + const { account } = await this.#accountService.resolveAccount({ + accountId, + }); + + const onChainAccount = + await this.#onChainAccountService.resolveOnChainAccount( + account.address, + scope, + ); + + return { account, onChainAccount }; + } } diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts index b1131a33..0de46a1f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts @@ -163,7 +163,7 @@ describe('OnChainAccount', () => { }, ); - it('throws OnChainAccountBalanceNotAvailableException when account has no loaded balances', () => { + it('returns undefined when the account has no balance row for the asset id', () => { const acc = new Account( testOnChain.accountId, testOnChain.sequenceNumber, @@ -173,7 +173,7 @@ describe('OnChainAccount', () => { KnownCaip2ChainId.Mainnet, unfundedHorizonBinding(acc, KnownCaip2ChainId.Mainnet), ); - expect(() => + expect( onChainAccount.getAsset( toCaip19ClassicAssetId( KnownCaip2ChainId.Mainnet, @@ -181,7 +181,7 @@ describe('OnChainAccount', () => { 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', ), ), - ).toThrow(OnChainAccountBalanceNotAvailableException); + ).toBeUndefined(); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts index 946faad7..da3c809e 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts @@ -126,19 +126,16 @@ export class OnChainAccount { * Gets the balance for a given asset id. * * @param assetId - The asset id to get the balance for. - * @returns The balance for the given asset id. + * @returns A shallow copy of the row, or `undefined` when this account has no balance for the id. */ - getAsset(assetId: KnownCaip19AssetIdOrSlip44Id): SpendableBalance { + getAsset( + assetId: KnownCaip19AssetIdOrSlip44Id, + ): SpendableBalance | undefined { const entry = this.#balances.get(assetId); - if (entry !== undefined) { - return { - ...entry, - }; + if (entry === undefined) { + return undefined; } - throw new OnChainAccountBalanceNotAvailableException( - assetId, - this.accountId, - ); + return { ...entry }; } /** @@ -165,6 +162,15 @@ export class OnChainAccount { return Array.from(this.#balances.keys()); } + /** + * Native (XLM) balance available to spend after minimum reserve and trustline reserves, in stroops. + * + * Requires a hydrated native slip44 row (Horizon or snapshot). Missing data is **not** reported as + * `0` or `undefined`—the account may be unfunded or balances may not be loaded yet. + * + * @returns Spendable native balance in stroops. + * @throws {OnChainAccountBalanceNotAvailableException} When native balance rows are not bound. + */ get nativeSpendableBalance(): BigNumber { const nativeId = getSlip44AssetId(this.#scope); const entry = this.#balances.get(nativeId); @@ -177,6 +183,15 @@ export class OnChainAccount { return entry.balance; } + /** + * Total native (XLM) balance on the ledger in stroops (spendable + minimum balance / reserves). + * + * Derived from the bound spendable native row and ledger meta. Same rule as {@link nativeSpendableBalance}: + * unknown hydration is an error, not zero. + * + * @returns Total native balance in stroops. + * @throws {OnChainAccountBalanceNotAvailableException} When native raw total could not be derived. + */ get nativeRawBalance(): BigNumber { const nativeId = getSlip44AssetId(this.#scope); if (this.#rawNativeBalance === undefined) { @@ -207,8 +222,8 @@ export class OnChainAccount { KnownCaip19AssetIdOrSlip44Id, SpendableBalance >; - for (const assetId of this.#balances.keys()) { - balances[assetId] = this.getAsset(assetId); + for (const [assetId, entry] of this.#balances) { + balances[assetId] = { ...entry }; } return { diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 418bbf57..f88a3eed 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -10,9 +10,8 @@ import { } from './__mocks__/onChainAccount.fixtures'; import { OnChainAccount } from './OnChainAccount'; import { bufferToUint8Array } from '../../utils/buffer'; -import type { StellarKeyringAccount } from '../account'; import { generateStellarKeyringAccount } from '../account/__mocks__/account.fixtures'; -import { AccountService } from '../account/AccountService'; +import { DerivedAccountAddressMismatchException } from '../account/exceptions'; import { NetworkService } from '../network'; import { getTestWallet } from '../wallet/__mocks__/wallet.fixtures'; @@ -35,70 +34,6 @@ describe('OnChainAccountService', () => { ), }); - describe('discoverOnChainAccount', () => { - it('returns derived account when activated on the network', async () => { - const mockAccount = generateStellarKeyringAccount( - globalThis.crypto.randomUUID(), - Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)).publicKey(), - 'entropy-source-default', - 0, - ); - const deriveKeyringAccountSpy = jest - .spyOn(AccountService.prototype, 'deriveKeyringAccount') - .mockResolvedValue(mockAccount); - const { getAccountOrNullSpy } = getNetworkServiceSpies(); - const wallet = getTestWallet({ seed }); - const activatedAcc = createMockAccountWithBalances( - wallet.address, - '1', - DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, - ); - getAccountOrNullSpy.mockResolvedValue( - new OnChainAccount( - activatedAcc, - KnownCaip2ChainId.Mainnet, - horizonSource(activatedAcc, KnownCaip2ChainId.Mainnet), - ), - ); - - const { onChainAccountService } = mockOnChainAccountService(); - const account = await onChainAccountService.discoverOnChainAccount({ - entropySource: mockAccount.entropySource, - index: mockAccount.index, - scope: KnownCaip2ChainId.Mainnet, - }); - - expect(deriveKeyringAccountSpy).toHaveBeenCalledWith({ - entropySource: mockAccount.entropySource, - index: mockAccount.index, - }); - expect(account).toStrictEqual(mockAccount); - }); - - it('returns null when the account is not activated on the Stellar network', async () => { - const mockAccount = generateStellarKeyringAccount( - globalThis.crypto.randomUUID(), - Keypair.random().publicKey(), - 'entropy-source-default', - 0, - ); - jest - .spyOn(AccountService.prototype, 'deriveKeyringAccount') - .mockResolvedValue(mockAccount); - const { getAccountOrNullSpy } = getNetworkServiceSpies(); - getAccountOrNullSpy.mockResolvedValue(null); - - const { onChainAccountService } = mockOnChainAccountService(); - const account = await onChainAccountService.discoverOnChainAccount({ - entropySource: mockAccount.entropySource, - index: mockAccount.index, - scope: KnownCaip2ChainId.Mainnet, - }); - - expect(account).toBeNull(); - }); - }); - describe('isAccountActivated', () => { it('returns true when getAccountOrNull returns an account', async () => { const { getAccountOrNullSpy } = getNetworkServiceSpies(); @@ -139,9 +74,9 @@ describe('OnChainAccountService', () => { }); describe('resolveOnChainAccount', () => { - it('returns loaded account when id matches keyring address', async () => { + it('returns loaded account when Horizon account id matches the requested address', async () => { const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); - const mockAccount: StellarKeyringAccount = generateStellarKeyringAccount( + const keyringAccount = generateStellarKeyringAccount( globalThis.crypto.randomUUID(), signer.publicKey(), 'entropy-source-1', @@ -162,15 +97,40 @@ describe('OnChainAccountService', () => { const { onChainAccountService } = mockOnChainAccountService(); const result = await onChainAccountService.resolveOnChainAccount( - mockAccount, + keyringAccount.address, KnownCaip2ChainId.Mainnet, ); expect(result.accountId).toStrictEqual(signer.publicKey()); expect(loadOnChainAccountSpy).toHaveBeenCalledWith( - mockAccount.address, + keyringAccount.address, KnownCaip2ChainId.Mainnet, ); }); + + it('throws when loaded account id does not match the requested address', async () => { + const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + const other = Keypair.random(); + const loadedAcc = createMockAccountWithBalances( + other.publicKey(), + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const loaded = new OnChainAccount( + loadedAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(loadedAcc, KnownCaip2ChainId.Mainnet), + ); + const { loadOnChainAccountSpy } = getNetworkServiceSpies(); + loadOnChainAccountSpy.mockResolvedValue(loaded); + + const { onChainAccountService } = mockOnChainAccountService(); + await expect( + onChainAccountService.resolveOnChainAccount( + signer.publicKey(), + KnownCaip2ChainId.Mainnet, + ), + ).rejects.toThrow(DerivedAccountAddressMismatchException); + }); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index bc313e96..4cbf7761 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -1,65 +1,17 @@ -import type { EntropySourceId } from '@metamask/keyring-api'; - -import type { KnownCaip2ChainId } from '../../api'; -import type { AccountService, StellarKeyringAccount } from '../account'; import type { OnChainAccount } from './OnChainAccount'; +import type { KnownCaip2ChainId } from '../../api'; import { assertSameAddress } from '../account/utils'; import type { NetworkService } from '../network'; /** - * Stellar on-chain account operations: activation checks, loading {@link OnChainAccount}. - * - * Signing keypairs are derived by {@link WalletService}; this service does not depend on it. + * Stellar on-chain account operations: activation checks and loading {@link OnChainAccount} + * via {@link NetworkService}. */ export class OnChainAccountService { readonly #networkService: NetworkService; - readonly #accountService: AccountService; - - constructor({ - networkService, - accountService, - }: { - networkService: NetworkService; - accountService: AccountService; - }) { + constructor({ networkService }: { networkService: NetworkService }) { this.#networkService = networkService; - this.#accountService = accountService; - } - - /** - * Derives a keyring-shaped account and returns it when that address is activated on Stellar. - * - * @param options - Discovery inputs. - * @param options.entropySource - Entropy source used to derive the address. - * @param options.index - Derivation index. - * @param options.scope - CAIP-2 network to check activation on. - * @returns The derived keyring-shaped account if funded on-chain, otherwise `null`. - */ - async discoverOnChainAccount({ - entropySource, - index, - scope, - }: { - entropySource: EntropySourceId; - index: number; - scope: KnownCaip2ChainId; - }): Promise { - const account = await this.#accountService.deriveKeyringAccount({ - entropySource, - index, - }); - - const isActivated = await this.isAccountActivated({ - accountAddress: account.address, - scope, - }); - - if (!isActivated) { - return null; - } - - return account; } /** @@ -82,24 +34,24 @@ export class OnChainAccountService { } /** - * Loads activated on-chain state for a keyring row on the given network and verifies the loaded - * account id matches the keyring address. + * Loads activated on-chain state for an address on the given network and verifies the loaded + * account id matches that address. * - * @param account - Keyring account whose address must match the Horizon account id. + * @param accountAddress - Stellar address (strkey) expected to match Horizon `account_id`. * @param scope - CAIP-2 network to load the account from (Horizon `loadAccount`). * @returns Loaded {@link OnChainAccount} for simulation, fees, and sequence. * @throws {AccountNotActivatedException} When the account is not funded (from {@link NetworkService.loadOnChainAccount}). - * @throws {DerivedAccountAddressMismatchException} When loaded id does not match `account.address`. + * @throws {DerivedAccountAddressMismatchException} When loaded id does not match `accountAddress`. */ async resolveOnChainAccount( - account: StellarKeyringAccount, + accountAddress: string, scope: KnownCaip2ChainId, ): Promise { const loaded = await this.#networkService.loadOnChainAccount( - account.address, + accountAddress, scope, ); - assertSameAddress(account.address, loaded.accountId); + assertSameAddress(accountAddress, loaded.accountId); return loaded; } } diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts index 71f511f8..ec227e99 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts @@ -141,8 +141,8 @@ export const createMockAccountWithBalances = ( }; /** - * Builds {@link OnChainAccountService} with real {@link AccountService}, shared {@link State}, - * and {@link NetworkService}, for integration-style tests. + * Builds {@link OnChainAccountService} with {@link NetworkService}, plus {@link AccountService} + * on shared {@link State} for tests that need derivation or persistence. * * @returns On-chain service plus the account and wallet services wired to the same state. */ @@ -161,10 +161,7 @@ export function mockOnChainAccountService() { walletService, }); const networkService = new NetworkService({ logger }); - const onChainAccountService = new OnChainAccountService({ - networkService, - accountService, - }); + const onChainAccountService = new OnChainAccountService({ networkService }); return { onChainAccountService, From 3b6e94987e460af9eb4b9a1a2a5e901a96804ce3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:38:23 +0800 Subject: [PATCH 069/384] chore: update kerying exception --- .../stellar-wallet-snap/src/handlers/keyring/exceptions.ts | 6 ++++-- .../stellar-wallet-snap/src/handlers/keyring/keyring.ts | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts index d8699cf4..5e71c484 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts @@ -33,8 +33,10 @@ export class KeyringListAccountAssetsException extends KeyringException { } export class KeyringListAccountTransactionsException extends KeyringException { - constructor(accountId: string) { - super(`Failed to list account transactions for account ${accountId}`); + constructor(accountId: string, message?: string) { + super( + `Failed to list account transactions for account ${accountId}${message ? `: ${message}` : ''}`, + ); } } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 58095958..f9745be8 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -298,6 +298,7 @@ export class KeyringHandler implements Keyring { // with the correct account identifier. if (next !== undefined && next !== null && startIndex === -1) { throw new KeyringListAccountTransactionsException( + accountId, `Invalid transaction pagination cursor: ${next}`, ); } From 37ee0ee261c31c49d7c41b89e0d25ba09f9dbd27 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:42:47 +0800 Subject: [PATCH 070/384] chore: update coverage limit --- merged-packages/stellar-wallet-snap/jest.config.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index fbdb1392..a4cf658d 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 61.27, - functions: 75.92, - lines: 77.72, - statements: 77.9, + branches: 61.05, + functions: 75.86, + lines: 77.65, + statements: 77.83, }, }, From 8c0705c8f041b3742e6af41f65fad1102930f08f Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:05:22 +0800 Subject: [PATCH 071/384] chore: update onchain account --- .../on-chain-account/OnChainAccount.test.ts | 164 +++++++++++-- .../on-chain-account/OnChainAccount.ts | 222 +++++++++++------- .../OnChainAccountSerializable.ts | 91 ++++++- .../__mocks__/onChainAccount.fixtures.ts | 25 +- 4 files changed, 376 insertions(+), 126 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts index 0de46a1f..c03b10a3 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts @@ -1,25 +1,42 @@ -import { Account } from '@stellar/stellar-sdk'; +import { Account, Keypair } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; +import { + createMockAccountWithBalances, + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + horizonSource, + unfundedHorizonBinding, +} from './__mocks__/onChainAccount.fixtures'; import { OnChainAccountBalanceNotAvailableException, OnChainAccountException, } from './exceptions'; import { OnChainAccount } from './OnChainAccount'; +import type { + OnChainAccountSerializable, + OnChainAccountSerializableFull, +} from './OnChainAccountSerializable'; +import { OnChainAccountSerializableFullStruct } from './OnChainAccountSerializable'; +import { calculateSpendableBalance, minimumBalanceStroops } from './utils'; import { KnownCaip2ChainId } from '../../api'; import { getSlip44AssetId, toCaip19ClassicAssetId, toSmallestUnit, } from '../../utils'; -import { - createMockAccountWithBalances, - DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, - horizonSource, - unfundedHorizonBinding, -} from './__mocks__/onChainAccount.fixtures'; import { getTestWallet } from '../wallet/__mocks__/wallet.fixtures'; +function expectDefined(value: ValueType | undefined): ValueType { + expect(value).toBeDefined(); + return value as ValueType; +} + +function optionalBigNumberString( + value: BigNumber | undefined, +): string | undefined { + return value === undefined ? undefined : value.toString(); +} + describe('OnChainAccount', () => { const testWalletSigner = getTestWallet(); const testMockAccount = createMockAccountWithBalances( @@ -295,13 +312,30 @@ describe('OnChainAccount', () => { }); describe('toSerializable', () => { - it('returns meta, scope, header fields, and per-asset balances', () => { + it('returns minimal serializable when not fully hydrated', () => { + const acc = new Account( + testOnChain.accountId, + testOnChain.sequenceNumber, + ); + const onChainAccount = new OnChainAccount( + acc, + KnownCaip2ChainId.Mainnet, + unfundedHorizonBinding(acc, KnownCaip2ChainId.Mainnet), + ); + expect(onChainAccount.toSerializable()).toStrictEqual( + onChainAccount.toMinimalSerializable(), + ); + }); + + it('returns full payload with string numerics in balances and rawNativeBalance', () => { const { onChainAccount } = createTestWallet(); const ser = onChainAccount.toSerializable(); + expect(OnChainAccountSerializableFullStruct.is(ser)).toBe(true); + const fullSer = ser as OnChainAccountSerializableFull; expect(ser.accountId).toBe(onChainAccount.accountId); expect(ser.sequenceNumber).toBe(onChainAccount.sequenceNumber); expect(ser.scope).toBe(KnownCaip2ChainId.Mainnet); - expect(ser.meta).toStrictEqual({ + expect(fullSer.meta).toStrictEqual({ subentryCount: onChainAccount.subentryCount, numSponsoring: onChainAccount.numSponsoring, numSponsored: onChainAccount.numSponsored, @@ -312,12 +346,62 @@ describe('OnChainAccount', () => { 'USDC', 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', ); - expect(ser.balances[nativeId]).toStrictEqual( - onChainAccount.getAsset(nativeId), + const nativeRow = expectDefined(onChainAccount.getAsset(nativeId)); + const usdcRow = expectDefined(onChainAccount.getAsset(usdcId)); + expect(fullSer.balances[nativeId]).toStrictEqual({ + balance: nativeRow.balance.toString(), + symbol: nativeRow.symbol, + limit: optionalBigNumberString(nativeRow.limit), + address: nativeRow.address, + authorized: nativeRow.authorized, + sponsored: nativeRow.sponsored, + }); + expect(fullSer.balances[usdcId]).toStrictEqual({ + balance: usdcRow.balance.toString(), + symbol: usdcRow.symbol, + address: usdcRow.address, + limit: optionalBigNumberString(usdcRow.limit), + authorized: usdcRow.authorized, + sponsored: usdcRow.sponsored, + }); + expect(fullSer.rawNativeBalance).toBe( + onChainAccount.nativeRawBalance.toFixed(0), ); - expect(ser.balances[usdcId]).toStrictEqual( - onChainAccount.getAsset(usdcId), + }); + + it('serializes zero classic trustline limit as string zero in snapshot', () => { + const scope = KnownCaip2ChainId.Mainnet; + const nativeId = getSlip44AssetId(scope); + const usdcId = toCaip19ClassicAssetId( + scope, + 'USDC', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', ); + const accountId = Keypair.random().publicKey(); + const data: OnChainAccountSerializableFull = { + accountId, + sequenceNumber: '1', + scope, + meta: { subentryCount: 0, numSponsoring: 0, numSponsored: 0 }, + rawNativeBalance: '200000000', + balances: { + [nativeId]: { balance: '0', symbol: 'XLM' }, + [usdcId]: { + balance: '1000000', + symbol: 'USDC', + address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + limit: '0', + authorized: true, + }, + } as OnChainAccountSerializableFull['balances'], + }; + const acc = new Account(accountId, '1'); + const onChainAccount = new OnChainAccount(acc, scope, data); + const ser = onChainAccount.toSerializable(); + expect(OnChainAccountSerializableFullStruct.is(ser)).toBe(true); + expect( + (ser as OnChainAccountSerializableFull).balances[usdcId]?.limit, + ).toBe('0'); }); }); @@ -346,15 +430,59 @@ describe('OnChainAccount', () => { ); }); - it('throws when native slip44 balance is missing', () => { + it('throws when binding is partial (meta and balances but no rawNativeBalance)', () => { const { onChainAccount } = createTestWallet(); const ser = onChainAccount.toSerializable(); - const nativeId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); - const balances = { ...ser.balances }; - delete balances[nativeId]; + expect(OnChainAccountSerializableFullStruct.is(ser)).toBe(true); + const fullSer = ser as OnChainAccountSerializableFull; + const { rawNativeBalance: _omitRaw, ...rest } = fullSer; expect(() => - OnChainAccount.fromSerializable({ ...ser, balances }), + OnChainAccount.fromSerializable(rest as OnChainAccountSerializable), ).toThrow(OnChainAccountException); }); + + it('round-trips minimal binding via toMinimalSerializable', () => { + const acc = new Account( + testOnChain.accountId, + testOnChain.sequenceNumber, + ); + const binding = unfundedHorizonBinding(acc, KnownCaip2ChainId.Mainnet); + const restored = OnChainAccount.fromSerializable(binding); + expect(restored.toMinimalSerializable()).toStrictEqual(binding); + }); + + it('uses rawNativeBalance for raw native when spendable is clamped to zero', () => { + const scope = KnownCaip2ChainId.Mainnet; + const nativeId = getSlip44AssetId(scope); + const accountId = Keypair.random().publicKey(); + const meta = { + subentryCount: 100, + numSponsoring: 0, + numSponsored: 0, + }; + const totalNative = new BigNumber('1000'); + const minReserve = minimumBalanceStroops(meta); + expect(totalNative.lt(minReserve)).toBe(true); + const spendable = calculateSpendableBalance({ + nativeBalance: totalNative, + ...meta, + }); + expect(spendable.isZero()).toBe(true); + + const data: OnChainAccountSerializableFull = { + accountId, + sequenceNumber: '1', + scope, + meta, + balances: { + [nativeId]: { balance: spendable.toString(), symbol: 'XLM' }, + } as OnChainAccountSerializableFull['balances'], + rawNativeBalance: totalNative.toFixed(0), + }; + const acc = new Account(accountId, '1'); + const onChain = new OnChainAccount(acc, scope, data); + expect(onChain.nativeRawBalance).toStrictEqual(totalNative); + expect(onChain.nativeSpendableBalance).toStrictEqual(spendable); + }); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts index da3c809e..c17e6060 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts @@ -8,8 +8,17 @@ import { OnChainAccountException, OnChainAccountMetadataNotAvailableException, } from './exceptions'; -import type { OnChainAccountSerializable } from './OnChainAccountSerializable'; -import { calculateSpendableBalance, minimumBalanceStroops } from './utils'; +import type { + OnChainAccountMinimalSerializable, + OnChainAccountSerializable, + OnChainAccountSerializableFull, + SerializableSpendableBalance, +} from './OnChainAccountSerializable'; +import { + OnChainAccountMinimalSerializableStruct, + OnChainAccountSerializableFullStruct, +} from './OnChainAccountSerializable'; +import { calculateSpendableBalance } from './utils'; import type { KnownCaip19AssetIdOrSlip44Id, KnownCaip19ClassicAssetId, @@ -26,9 +35,10 @@ import { } from '../../utils'; /** - * SDK {@link StellarAccount} plus optional {@link OnChainAccountSerializable} hydration (balances, meta). - * Build via {@link OnChainAccount.fromHorizon}, {@link OnChainAccount.fromSerializable}, or `new OnChainAccount(account, scope)` (RPC: no binding, sequence only; use Horizon for balances). - * Without `binding`, only id, sequence, `scope`, and {@link OnChainAccount.getRaw} are defined; balances and meta need hydration. + * SDK {@link StellarAccount} plus optional {@link OnChainAccountSerializable} hydration. + * Binding is **either** minimal (`accountId`, `sequenceNumber`, `scope` only) **or** full + * (meta + balances + `rawNativeBalance`). Build via {@link OnChainAccount.fromHorizon}, + * {@link OnChainAccount.fromSerializable}, or `new OnChainAccount(account, scope)` (no binding). */ export class OnChainAccount { readonly #account: StellarAccount; @@ -49,7 +59,7 @@ export class OnChainAccount { /** * @param account - Stellar SDK account (id + sequence). When `binding` is set, header fields must match. * @param scope - CAIP-2 network; must match `binding.scope`. - * @param binding - Hydrate from snapshot; omit for RPC-style accounts (see class overview). + * @param binding - Minimal or full snapshot; omit for RPC-style accounts (see class overview). */ constructor( account: StellarAccount, @@ -164,12 +174,10 @@ export class OnChainAccount { /** * Native (XLM) balance available to spend after minimum reserve and trustline reserves, in stroops. - * - * Requires a hydrated native slip44 row (Horizon or snapshot). Missing data is **not** reported as - * `0` or `undefined`—the account may be unfunded or balances may not be loaded yet. + * Sourced from the bound native slip44 row (set on full bind from `rawNativeBalance` + meta). * * @returns Spendable native balance in stroops. - * @throws {OnChainAccountBalanceNotAvailableException} When native balance rows are not bound. + * @throws {OnChainAccountBalanceNotAvailableException} When native balance is not bound. */ get nativeSpendableBalance(): BigNumber { const nativeId = getSlip44AssetId(this.#scope); @@ -184,13 +192,12 @@ export class OnChainAccount { } /** - * Total native (XLM) balance on the ledger in stroops (spendable + minimum balance / reserves). + * Total native (XLM) balance on the ledger in stroops (Horizon `balance`, not clamped spendable). * - * Derived from the bound spendable native row and ledger meta. Same rule as {@link nativeSpendableBalance}: - * unknown hydration is an error, not zero. + * Sourced from {@link OnChainAccountSerializableFull.rawNativeBalance} on full bind, or from Horizon. * * @returns Total native balance in stroops. - * @throws {OnChainAccountBalanceNotAvailableException} When native raw total could not be derived. + * @throws {OnChainAccountBalanceNotAvailableException} When native raw total is not bound. */ get nativeRawBalance(): BigNumber { const nativeId = getSlip44AssetId(this.#scope); @@ -213,17 +220,40 @@ export class OnChainAccount { } /** - * Copies id, sequence, network, ledger meta, and all bound balances into a plain object. + * Snapshot for persistence: returns a **full** payload when meta and on-ledger native total are + * bound; otherwise returns a **minimal** payload (`accountId`, `sequenceNumber`, `scope` only). + * Full `balances` use string numerics for JSON; native slip44 row matches in-memory `BigNumber`s. * - * @returns suitable for persistence or messaging. + * @returns {@link OnChainAccountSerializableFull} when bound, otherwise {@link OnChainAccountMinimalSerializable}. */ toSerializable(): OnChainAccountSerializable { - const balances = {} as Record< - KnownCaip19AssetIdOrSlip44Id, - SpendableBalance - >; + const subentryCount = this.#subentryCount; + const numSponsoring = this.#numSponsoring; + const numSponsored = this.#numSponsored; + + if ( + subentryCount === undefined || + numSponsoring === undefined || + numSponsored === undefined || + this.#rawNativeBalance === undefined + ) { + return { + accountId: this.accountId, + sequenceNumber: this.sequenceNumber, + scope: this.#scope, + }; + } + + const balances = {} as SerializableSpendableBalance; for (const [assetId, entry] of this.#balances) { - balances[assetId] = { ...entry }; + balances[assetId] = { + balance: entry.balance.toString(), + symbol: entry.symbol, + limit: entry.limit === undefined ? undefined : entry.limit.toString(), + address: entry.address, + authorized: entry.authorized, + sponsored: entry.sponsored, + }; } return { @@ -231,22 +261,35 @@ export class OnChainAccount { sequenceNumber: this.sequenceNumber, scope: this.#scope, meta: { - subentryCount: this.subentryCount, - numSponsoring: this.numSponsoring, - numSponsored: this.numSponsored, + subentryCount, + numSponsoring, + numSponsored, }, balances, + rawNativeBalance: this.#rawNativeBalance.toFixed(0), + }; + } + + /** + * Header-only snapshot for minimally bound accounts. + * + * @returns `accountId`, `sequenceNumber`, and `scope`. + */ + toMinimalSerializable(): OnChainAccountMinimalSerializable { + return { + accountId: this.accountId, + sequenceNumber: this.sequenceNumber, + scope: this.#scope, }; } /** - * Builds from a Horizon `loadAccount` response: maps balances and ledger meta into - * {@link OnChainAccountSerializable} (same shape as {@link OnChainAccount#toSerializable}), then hydrates. - * When the response has no native balance line, the binding omits native so behavior matches a partial load. + * Builds from a Horizon `loadAccount` response. + * With a native balance line → full binding; otherwise → minimal binding (sequence-only style). * * @param response - Horizon `loadAccount` payload. * @param scope - CAIP-2 network. - * @returns Hydrated {@link OnChainAccount} backed by a minimal SDK `Account` plus derived maps. + * @returns Hydrated {@link OnChainAccount}. */ static fromHorizon( response: Horizon.AccountResponse, @@ -261,23 +304,23 @@ export class OnChainAccount { const numSponsored = response.num_sponsored ?? 0; const meta = { subentryCount, numSponsoring, numSponsored }; const nativeAssetId = getSlip44AssetId(scope); - const balances = {} as Record< - KnownCaip19AssetIdOrSlip44Id, - SpendableBalance - >; + const balances = {} as SerializableSpendableBalance; const horizonBalances = response.balances ?? []; + let rawNativeBalance: string | undefined; + for (const balance of horizonBalances) { const balanceStroops = toSmallestUnit(new BigNumber(balance.balance)); if (balance.asset_type === 'native') { + rawNativeBalance = balanceStroops.toFixed(0); balances[nativeAssetId] = { balance: calculateSpendableBalance({ nativeBalance: balanceStroops, subentryCount, numSponsoring, numSponsored, - }), + }).toString(), symbol: NATIVE_ASSET_SYMBOL, }; } else if ( @@ -298,44 +341,44 @@ export class OnChainAccount { : undefined; const sponsored = sponsorId !== undefined && sponsorId.length > 0; balances[assetId] = { - balance: balanceStroops, + balance: balanceStroops.toString(), symbol: balance.asset_code, address: balance.asset_issuer, - limit, + limit: limit.toString(), authorized, ...(sponsored ? { sponsored: true } : {}), }; } } - const data: OnChainAccountSerializable = { + if (rawNativeBalance === undefined) { + return new OnChainAccount(stellarAccount, scope, { + accountId: response.accountId(), + sequenceNumber: response.sequenceNumber(), + scope, + }); + } + + const data: OnChainAccountSerializableFull = { accountId: response.accountId(), sequenceNumber: response.sequenceNumber(), scope, meta, balances, + rawNativeBalance, }; return new OnChainAccount(stellarAccount, scope, data); } /** - * Rehydrates from {@link OnChainAccountSerializable} (inverse of {@link OnChainAccount#toSerializable}). + * Rehydrates from {@link OnChainAccountSerializable} (minimal or full). * - * Native slip44 `balance` in the payload is **spendable** stroops; raw total is recovered as spendable + minimum balance from `meta`. - * - * @param data - Plain snapshot from {@link OnChainAccount#toSerializable}. - * @returns Bound {@link OnChainAccount} for the same network and balances. - * @throws {@link OnChainAccountException} When the native slip44 row for `data.scope` is missing. + * @param data - Minimal header or full snapshot. + * @returns Bound {@link OnChainAccount}. + * @throws {@link OnChainAccountException} When the payload is neither minimal nor full. */ static fromSerializable(data: OnChainAccountSerializable): OnChainAccount { - // Safe guard to ensure the native balance is present. - const nativeId = getSlip44AssetId(data.scope); - if (data.balances[nativeId] === undefined) { - throw new OnChainAccountException( - `Serializable data for ${data.accountId} is missing native balance (${nativeId})`, - ); - } const stellarAccount = new StellarAccount( data.accountId, data.sequenceNumber, @@ -344,44 +387,55 @@ export class OnChainAccount { } #bindFromSerializable(data: OnChainAccountSerializable): void { - const { meta, balances: rows, scope } = data; - this.#subentryCount = meta.subentryCount; - this.#numSponsoring = meta.numSponsoring; - this.#numSponsored = meta.numSponsored; - - const nativeId = getSlip44AssetId(scope); - - for (const [assetId, row] of entries(rows)) { - if (assetId === nativeId) { - this.#balances.set(nativeId, { - balance: row.balance, - symbol: row.symbol, - }); - } else if (isClassicAssetId(assetId) && row.limit !== undefined) { - this.#balances.set(assetId, { - balance: row.balance, - symbol: row.symbol, - limit: row.limit, - ...(row.address === undefined ? {} : { address: row.address }), - ...(row.authorized === undefined - ? {} - : { authorized: row.authorized }), - ...(row.sponsored === undefined ? {} : { sponsored: row.sponsored }), - }); - } else if (isSep41Id(assetId)) { - this.#balances.set(assetId, { - balance: row.balance, - symbol: row.symbol, - }); + if (OnChainAccountSerializableFullStruct.is(data)) { + const { meta, balances: rows, scope } = data; + this.#subentryCount = meta.subentryCount; + this.#numSponsoring = meta.numSponsoring; + this.#numSponsored = meta.numSponsored; + this.#rawNativeBalance = new BigNumber(data.rawNativeBalance); + + const nativeId = getSlip44AssetId(scope); + + for (const [assetId, row] of entries(rows)) { + if (assetId === nativeId) { + continue; + } + if (isClassicAssetId(assetId) && row.limit !== undefined) { + this.#balances.set(assetId, { + balance: new BigNumber(row.balance), + symbol: row.symbol, + limit: new BigNumber(row.limit), + ...(row.address === undefined ? {} : { address: row.address }), + ...(row.authorized === undefined + ? {} + : { authorized: row.authorized }), + ...(row.sponsored === undefined + ? {} + : { sponsored: row.sponsored }), + }); + } else if (isSep41Id(assetId)) { + this.#balances.set(assetId, { + balance: new BigNumber(row.balance), + symbol: row.symbol, + }); + } } + + this.#balances.set(nativeId, { + balance: calculateSpendableBalance({ + nativeBalance: this.#rawNativeBalance, + subentryCount: meta.subentryCount, + numSponsoring: meta.numSponsoring, + numSponsored: meta.numSponsored, + }), + symbol: NATIVE_ASSET_SYMBOL, + }); + return; } - // we only store spendable balance in the balances map, - // so we need to add the reserved balance to get the raw balance - const nativeSpendable = this.#balances.get(nativeId)?.balance; - if (nativeSpendable !== undefined) { - this.#rawNativeBalance = nativeSpendable.plus( - minimumBalanceStroops(meta), + if (!OnChainAccountMinimalSerializableStruct.is(data)) { + throw new OnChainAccountException( + 'Binding must be minimal (accountId, sequenceNumber, scope only) or full (meta, balances, rawNativeBalance)', ); } } diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts index 24b3c54c..fc882a52 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts @@ -1,14 +1,81 @@ -import type { OnChainAccountLedgerMeta, SpendableBalance } from './api'; -import type { - KnownCaip19AssetIdOrSlip44Id, - KnownCaip2ChainId, +import type { Infer } from '@metamask/superstruct'; +import { + assign, + boolean, + number, + object, + optional, + record, + string, + union, +} from '@metamask/superstruct'; + +import { + KnownCaip19ClassicAssetStruct, + KnownCaip19Sep41AssetStruct, + KnownCaip19Slip44IdStruct, + KnownCaip2ChainIdStruct, } from '../../api'; -/** Plain snapshot of {@link OnChainAccount} fields and per-asset balances (e.g. for cache or RPC). */ -export type OnChainAccountSerializable = { - accountId: string; - sequenceNumber: string; - scope: KnownCaip2ChainId; - meta: OnChainAccountLedgerMeta; - balances: Record; -}; +/** Header-only binding: RPC-style account (id + sequence + network), no balances or ledger meta. */ +export const OnChainAccountMinimalSerializableStruct = object({ + accountId: string(), + sequenceNumber: string(), + scope: KnownCaip2ChainIdStruct, +}); + +export type OnChainAccountMinimalSerializable = Infer< + typeof OnChainAccountMinimalSerializableStruct +>; + +/** + * JSON-safe balance rows: numeric fields are decimal strings (stroops). Callers that produce this + * shape (Horizon sync, persisted snap) are expected to supply valid numeric strings; stricter + * superstruct refinements can be added later if needed. + */ +export const SerializableSpendableBalanceStruct = record( + union([ + KnownCaip19ClassicAssetStruct, + KnownCaip19Sep41AssetStruct, + KnownCaip19Slip44IdStruct, + ]), + object({ + balance: string(), + symbol: string(), + limit: optional(string()), + address: optional(string()), + authorized: optional(boolean()), + sponsored: optional(boolean()), + }), +); + +export type SerializableSpendableBalance = Infer< + typeof SerializableSpendableBalanceStruct +>; + +/** + * Full binding: ledger meta, all balance rows, and on-ledger native total as a stroops integer string. + * Native slip44 spendable is always recomputed on bind from `rawNativeBalance` + meta via + * `calculateSpendableBalance` (same as Horizon). + */ +export const OnChainAccountSerializableFullStruct = assign( + OnChainAccountMinimalSerializableStruct, + object({ + meta: object({ + subentryCount: number(), + numSponsoring: number(), + numSponsored: number(), + }), + balances: SerializableSpendableBalanceStruct, + rawNativeBalance: string(), + }), +); + +export type OnChainAccountSerializableFull = Infer< + typeof OnChainAccountSerializableFullStruct +>; + +/** Minimal header or validated full snapshot; partial shapes are rejected at bind time. */ +export type OnChainAccountSerializable = + | OnChainAccountMinimalSerializable + | OnChainAccountSerializableFull; diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts index ec227e99..7cf87e0a 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts @@ -10,7 +10,10 @@ import { NetworkService } from '../../network'; import { State } from '../../state/State'; import { WalletService } from '../../wallet'; import { OnChainAccount } from '../OnChainAccount'; -import type { OnChainAccountSerializable } from '../OnChainAccountSerializable'; +import type { + OnChainAccountMinimalSerializable, + OnChainAccountSerializable, +} from '../OnChainAccountSerializable'; import { OnChainAccountService } from '../OnChainAccountService'; /** @@ -18,7 +21,7 @@ import { OnChainAccountService } from '../OnChainAccountService'; * * @param account - Mock or SDK account that includes Horizon `balances` / meta fields. * @param scope - CAIP-2 network (must match the `OnChainAccount` constructor scope). - * @returns Serializable binding for {@link OnChainAccount} constructor. + * @returns Full serializable binding for {@link OnChainAccount} constructor. */ export function horizonSource( account: Account, @@ -31,23 +34,21 @@ export function horizonSource( } /** - * Serializable binding with no balance lines (sequence exists, no asset rows yet). + * Minimal header binding (no native line on Horizon): id, sequence, scope only. * - * @param account - Bare SDK `Account` instance (mutated to add empty `balances`). + * @param account - Bare SDK `Account` instance. * @param scope - CAIP-2 network. * @returns Binding for {@link OnChainAccount} constructor. */ export function unfundedHorizonBinding( account: Account, scope: KnownCaip2ChainId, -): OnChainAccountSerializable { - const response = Object.assign(account, { - balances: [], - subentry_count: 0, - num_sponsoring: 0, - num_sponsored: 0, - }) as unknown as Horizon.AccountResponse; - return OnChainAccount.fromHorizon(response, scope).toSerializable(); +): OnChainAccountMinimalSerializable { + return { + accountId: account.accountId(), + sequenceNumber: account.sequenceNumber(), + scope, + }; } export type MockAssetLine = { From d703138048a7450170788f4abb9957ebdb12db77 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:11:41 +0800 Subject: [PATCH 072/384] fix: lint --- .../src/services/on-chain-account/OnChainAccount.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts index c17e6060..31d3cf39 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts @@ -224,7 +224,7 @@ export class OnChainAccount { * bound; otherwise returns a **minimal** payload (`accountId`, `sequenceNumber`, `scope` only). * Full `balances` use string numerics for JSON; native slip44 row matches in-memory `BigNumber`s. * - * @returns {@link OnChainAccountSerializableFull} when bound, otherwise {@link OnChainAccountMinimalSerializable}. + * @returns when bound, otherwise {@link OnChainAccountMinimalSerializable}. */ toSerializable(): OnChainAccountSerializable { const subentryCount = this.#subentryCount; From 2ad014cf8e429f2da3087f3bda2fb91680fe3e3b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:51:41 +0800 Subject: [PATCH 073/384] chore: use struct for serialzation --- .../on-chain-account/OnChainAccount.test.ts | 34 ++--- .../on-chain-account/OnChainAccount.ts | 138 +++++++++++------- .../OnChainAccountSerializable.ts | 59 +++++--- 3 files changed, 141 insertions(+), 90 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts index c03b10a3..b05acf7a 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts @@ -346,17 +346,14 @@ describe('OnChainAccount', () => { 'USDC', 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', ); - const nativeRow = expectDefined(onChainAccount.getAsset(nativeId)); const usdcRow = expectDefined(onChainAccount.getAsset(usdcId)); - expect(fullSer.balances[nativeId]).toStrictEqual({ - balance: nativeRow.balance.toString(), - symbol: nativeRow.symbol, - limit: optionalBigNumberString(nativeRow.limit), - address: nativeRow.address, - authorized: nativeRow.authorized, - sponsored: nativeRow.sponsored, - }); - expect(fullSer.balances[usdcId]).toStrictEqual({ + expect(fullSer.balances.some((row) => row.assetId === nativeId)).toBe( + false, + ); + expect( + fullSer.balances.find((row) => row.assetId === usdcId), + ).toStrictEqual({ + assetId: usdcId, balance: usdcRow.balance.toString(), symbol: usdcRow.symbol, address: usdcRow.address, @@ -371,7 +368,6 @@ describe('OnChainAccount', () => { it('serializes zero classic trustline limit as string zero in snapshot', () => { const scope = KnownCaip2ChainId.Mainnet; - const nativeId = getSlip44AssetId(scope); const usdcId = toCaip19ClassicAssetId( scope, 'USDC', @@ -384,24 +380,21 @@ describe('OnChainAccount', () => { scope, meta: { subentryCount: 0, numSponsoring: 0, numSponsored: 0 }, rawNativeBalance: '200000000', - balances: { - [nativeId]: { balance: '0', symbol: 'XLM' }, - [usdcId]: { + balances: [ + { + assetId: usdcId, balance: '1000000', symbol: 'USDC', address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', limit: '0', authorized: true, }, - } as OnChainAccountSerializableFull['balances'], + ], }; const acc = new Account(accountId, '1'); const onChainAccount = new OnChainAccount(acc, scope, data); const ser = onChainAccount.toSerializable(); expect(OnChainAccountSerializableFullStruct.is(ser)).toBe(true); - expect( - (ser as OnChainAccountSerializableFull).balances[usdcId]?.limit, - ).toBe('0'); }); }); @@ -453,7 +446,6 @@ describe('OnChainAccount', () => { it('uses rawNativeBalance for raw native when spendable is clamped to zero', () => { const scope = KnownCaip2ChainId.Mainnet; - const nativeId = getSlip44AssetId(scope); const accountId = Keypair.random().publicKey(); const meta = { subentryCount: 100, @@ -474,9 +466,7 @@ describe('OnChainAccount', () => { sequenceNumber: '1', scope, meta, - balances: { - [nativeId]: { balance: spendable.toString(), symbol: 'XLM' }, - } as OnChainAccountSerializableFull['balances'], + balances: [], rawNativeBalance: totalNative.toFixed(0), }; const acc = new Account(accountId, '1'); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts index 31d3cf39..4b70c90b 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts @@ -17,6 +17,8 @@ import type { import { OnChainAccountMinimalSerializableStruct, OnChainAccountSerializableFullStruct, + SerializableClassicSpendableBalanceStruct, + SerializableSep41SpendableBalanceStruct, } from './OnChainAccountSerializable'; import { calculateSpendableBalance } from './utils'; import type { @@ -26,7 +28,6 @@ import type { } from '../../api'; import { NATIVE_ASSET_SYMBOL } from '../../constants'; import { - entries, getSlip44AssetId, isClassicAssetId, isSep41Id, @@ -222,9 +223,9 @@ export class OnChainAccount { /** * Snapshot for persistence: returns a **full** payload when meta and on-ledger native total are * bound; otherwise returns a **minimal** payload (`accountId`, `sequenceNumber`, `scope` only). - * Full `balances` use string numerics for JSON; native slip44 row matches in-memory `BigNumber`s. + * Full snapshots put native total in `rawNativeBalance` (stroops string); `balances` is only store non-native rows (classic + SEP-41), each with string numerics for JSON. * - * @returns when bound, otherwise {@link OnChainAccountMinimalSerializable}. + * @returns Full or minimal serializable shape for this binding. */ toSerializable(): OnChainAccountSerializable { const subentryCount = this.#subentryCount; @@ -244,16 +245,48 @@ export class OnChainAccount { }; } - const balances = {} as SerializableSpendableBalance; + const nativeId = getSlip44AssetId(this.#scope); + const balances: SerializableSpendableBalance[] = []; for (const [assetId, entry] of this.#balances) { - balances[assetId] = { - balance: entry.balance.toString(), - symbol: entry.symbol, - limit: entry.limit === undefined ? undefined : entry.limit.toString(), - address: entry.address, - authorized: entry.authorized, - sponsored: entry.sponsored, - }; + if (assetId === nativeId) { + continue; + } + if (isClassicAssetId(assetId)) { + if (entry.limit === undefined) { + throw new OnChainAccountException( + `Classic balance row missing limit for asset ${assetId}`, + ); + } + if (entry.address === undefined) { + throw new OnChainAccountException( + `Classic balance row missing address for asset ${assetId}`, + ); + } + if (entry.authorized === undefined) { + throw new OnChainAccountException( + `Classic balance row missing authorized for asset ${assetId}`, + ); + } + balances.push( + SerializableClassicSpendableBalanceStruct.create({ + assetId, + balance: entry.balance.toString(), + symbol: entry.symbol, + limit: entry.limit.toString(), + address: entry.address, + authorized: entry.authorized, + sponsored: entry.sponsored, + }), + ); + } else if (isSep41Id(assetId)) { + balances.push({ + assetId, + balance: entry.balance.toString(), + symbol: entry.symbol, + }); + } else { + throw new OnChainAccountException(`Asset id not supported: ${assetId}`); + } } return { @@ -303,8 +336,7 @@ export class OnChainAccount { const numSponsoring = response.num_sponsoring ?? 0; const numSponsored = response.num_sponsored ?? 0; const meta = { subentryCount, numSponsoring, numSponsored }; - const nativeAssetId = getSlip44AssetId(scope); - const balances = {} as SerializableSpendableBalance; + const balances: SerializableSpendableBalance[] = []; const horizonBalances = response.balances ?? []; @@ -314,15 +346,8 @@ export class OnChainAccount { const balanceStroops = toSmallestUnit(new BigNumber(balance.balance)); if (balance.asset_type === 'native') { rawNativeBalance = balanceStroops.toFixed(0); - balances[nativeAssetId] = { - balance: calculateSpendableBalance({ - nativeBalance: balanceStroops, - subentryCount, - numSponsoring, - numSponsored, - }).toString(), - symbol: NATIVE_ASSET_SYMBOL, - }; + // native asset is handled with rawNativeBalance field + continue; } else if ( balance.asset_type === 'credit_alphanum12' || balance.asset_type === 'credit_alphanum4' @@ -340,14 +365,15 @@ export class OnChainAccount { ? (balance as { sponsor?: string }).sponsor : undefined; const sponsored = sponsorId !== undefined && sponsorId.length > 0; - balances[assetId] = { + balances.push({ + assetId, balance: balanceStroops.toString(), symbol: balance.asset_code, address: balance.asset_issuer, limit: limit.toString(), authorized, ...(sponsored ? { sponsored: true } : {}), - }; + }); } } @@ -396,31 +422,6 @@ export class OnChainAccount { const nativeId = getSlip44AssetId(scope); - for (const [assetId, row] of entries(rows)) { - if (assetId === nativeId) { - continue; - } - if (isClassicAssetId(assetId) && row.limit !== undefined) { - this.#balances.set(assetId, { - balance: new BigNumber(row.balance), - symbol: row.symbol, - limit: new BigNumber(row.limit), - ...(row.address === undefined ? {} : { address: row.address }), - ...(row.authorized === undefined - ? {} - : { authorized: row.authorized }), - ...(row.sponsored === undefined - ? {} - : { sponsored: row.sponsored }), - }); - } else if (isSep41Id(assetId)) { - this.#balances.set(assetId, { - balance: new BigNumber(row.balance), - symbol: row.symbol, - }); - } - } - this.#balances.set(nativeId, { balance: calculateSpendableBalance({ nativeBalance: this.#rawNativeBalance, @@ -430,6 +431,43 @@ export class OnChainAccount { }), symbol: NATIVE_ASSET_SYMBOL, }); + + rows.forEach((row) => { + // native asset is handled separately above + if (row.assetId === nativeId) { + return; + } + // check if asset id already exists in the balances map + if (this.#balances.has(row.assetId)) { + throw new OnChainAccountException( + 'Asset id already exists in the balances map', + ); + } + + if (SerializableClassicSpendableBalanceStruct.is(row)) { + const { balance, symbol, limit, address, authorized, sponsored } = + row; + + this.#balances.set(row.assetId, { + balance: new BigNumber(balance), + symbol, + limit: new BigNumber(limit), + address, + authorized, + ...(sponsored === undefined ? {} : { sponsored }), + }); + } else if (SerializableSep41SpendableBalanceStruct.is(row)) { + const { balance, symbol } = row; + this.#balances.set(row.assetId, { + balance: new BigNumber(balance), + symbol, + }); + } else { + throw new OnChainAccountException( + `Unsupported balance row for asset: ${String(row.assetId)}`, + ); + } + }); return; } diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts index fc882a52..a27fd6b5 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts @@ -1,11 +1,11 @@ import type { Infer } from '@metamask/superstruct'; import { + array, assign, boolean, number, object, optional, - record, string, union, } from '@metamask/superstruct'; @@ -28,27 +28,50 @@ export type OnChainAccountMinimalSerializable = Infer< typeof OnChainAccountMinimalSerializableStruct >; +export const SerializableClassicSpendableBalanceStruct = object({ + assetId: KnownCaip19ClassicAssetStruct, + balance: string(), + symbol: string(), + limit: string(), + address: string(), + authorized: boolean(), + sponsored: optional(boolean()), +}); + +export type SerializableClassicSpendableBalance = Infer< + typeof SerializableClassicSpendableBalanceStruct +>; + +export const SerializableSep41SpendableBalanceStruct = object({ + assetId: KnownCaip19Sep41AssetStruct, + balance: string(), + symbol: string(), +}); + +export type SerializableSep41SpendableBalance = Infer< + typeof SerializableSep41SpendableBalanceStruct +>; + +export const SerializableSlip44SpendableBalanceStruct = object({ + assetId: KnownCaip19Slip44IdStruct, + balance: string(), + symbol: string(), +}); + +export type SerializableSlip44SpendableBalance = Infer< + typeof SerializableSlip44SpendableBalanceStruct +>; + /** * JSON-safe balance rows: numeric fields are decimal strings (stroops). Callers that produce this * shape (Horizon sync, persisted snap) are expected to supply valid numeric strings; stricter * superstruct refinements can be added later if needed. */ -export const SerializableSpendableBalanceStruct = record( - union([ - KnownCaip19ClassicAssetStruct, - KnownCaip19Sep41AssetStruct, - KnownCaip19Slip44IdStruct, - ]), - object({ - balance: string(), - symbol: string(), - limit: optional(string()), - address: optional(string()), - authorized: optional(boolean()), - sponsored: optional(boolean()), - }), -); - +export const SerializableSpendableBalanceStruct = union([ + SerializableClassicSpendableBalanceStruct, + SerializableSep41SpendableBalanceStruct, + SerializableSlip44SpendableBalanceStruct, +]); export type SerializableSpendableBalance = Infer< typeof SerializableSpendableBalanceStruct >; @@ -66,7 +89,7 @@ export const OnChainAccountSerializableFullStruct = assign( numSponsoring: number(), numSponsored: number(), }), - balances: SerializableSpendableBalanceStruct, + balances: array(SerializableSpendableBalanceStruct), rawNativeBalance: string(), }), ); From 281d5150b192b28b6d48731fec78a8745bad09e3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:56:13 +0800 Subject: [PATCH 074/384] chore: update test --- merged-packages/stellar-wallet-snap/jest.config.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index a4cf658d..419138d1 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -33,10 +33,10 @@ const config = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 61.05, + branches: 60.96, functions: 75.86, - lines: 77.65, - statements: 77.83, + lines: 77.51, + statements: 77.69, }, }, From 1b4abfa75d40747be8d694859525cc65c1d64f88 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:00:10 +0800 Subject: [PATCH 075/384] chore: disable jest coverage --- merged-packages/stellar-wallet-snap/jest.config.js | 2 +- merged-packages/stellar-wallet-snap/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index 419138d1..adad808c 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -4,7 +4,7 @@ */ const config = { // Indicates whether the coverage information should be collected while executing the test - collectCoverage: true, + collectCoverage: false, // An array of glob patterns indicating a set of files for which coverage information should be collected collectCoverageFrom: ['./src/**/*.ts', './src/**/*.tsx'], diff --git a/merged-packages/stellar-wallet-snap/package.json b/merged-packages/stellar-wallet-snap/package.json index b61f6eef..ac9b3ecc 100644 --- a/merged-packages/stellar-wallet-snap/package.json +++ b/merged-packages/stellar-wallet-snap/package.json @@ -38,7 +38,7 @@ "publish:preview": "yarn npm publish --tag preview", "serve": "mm-snap serve", "start": "node scripts/update-manifest-local.js && concurrently \"mm-snap watch\" \"yarn build:locale:watch\"", - "test": "jest --passWithNoTests && yarn jest-it-up", + "test": "jest --passWithNoTests --coverage=false && yarn jest-it-up --coverage=false", "test:integration": "./integration-test/run-integration.sh" }, "devDependencies": { From 64177d5c234562791fcd79afd2ff9e870478cdca Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:11:19 +0800 Subject: [PATCH 076/384] chore: disable coverage update --- merged-packages/stellar-wallet-snap/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/stellar-wallet-snap/package.json b/merged-packages/stellar-wallet-snap/package.json index ac9b3ecc..91bed226 100644 --- a/merged-packages/stellar-wallet-snap/package.json +++ b/merged-packages/stellar-wallet-snap/package.json @@ -38,7 +38,7 @@ "publish:preview": "yarn npm publish --tag preview", "serve": "mm-snap serve", "start": "node scripts/update-manifest-local.js && concurrently \"mm-snap watch\" \"yarn build:locale:watch\"", - "test": "jest --passWithNoTests --coverage=false && yarn jest-it-up --coverage=false", + "test": "jest --passWithNoTests --coverage=false", "test:integration": "./integration-test/run-integration.sh" }, "devDependencies": { From 31656442a5f6cf639af1b09ed4a4465f520d5c4f Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:29:08 +0800 Subject: [PATCH 077/384] fix: lint --- merged-packages/stellar-wallet-snap/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/merged-packages/stellar-wallet-snap/package.json b/merged-packages/stellar-wallet-snap/package.json index 91bed226..6440dd79 100644 --- a/merged-packages/stellar-wallet-snap/package.json +++ b/merged-packages/stellar-wallet-snap/package.json @@ -39,6 +39,7 @@ "serve": "mm-snap serve", "start": "node scripts/update-manifest-local.js && concurrently \"mm-snap watch\" \"yarn build:locale:watch\"", "test": "jest --passWithNoTests --coverage=false", + "test:coverage": "jest --passWithNoTests --coverage=true && yarn jest-it-up", "test:integration": "./integration-test/run-integration.sh" }, "devDependencies": { From e2de36865736b2d6f7a713acf35ddbbd1be10f28 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:01:31 +0800 Subject: [PATCH 078/384] feat: add asset handlers --- .../stellar-wallet-snap/src/api/asset.test.ts | 20 + .../stellar-wallet-snap/src/api/asset.ts | 14 + .../stellar-wallet-snap/src/context.ts | 29 +- .../src/handlers/asset/api.test.ts | 30 ++ .../src/handlers/asset/api.ts | 17 + .../src/handlers/asset/assets.test.ts | 165 +++++++ .../src/handlers/asset/assets.ts | 107 +++++ .../stellar-wallet-snap/src/index.ts | 18 + .../AssetMetadataService.test.ts | 140 ++++-- .../asset-metadata/AssetMetadataService.ts | 106 ++-- .../__mocks__/assets.fixtures.ts | 121 +++++ .../src/services/asset-metadata/api.ts | 8 +- .../src/services/asset-metadata/utils.ts | 74 ++- .../src/services/price/PriceService.test.ts | 6 +- .../src/services/price/PriceService.ts | 452 +++++++++++++++++- .../price/__mocks__/price.fixtures.ts | 59 +++ .../src/services/price/api.ts | 8 + .../src/services/price/exceptions.ts | 6 + .../price/price-api/PriceApiClient.test.ts | 2 +- .../price/price-api/PriceApiClient.ts | 77 +-- .../src/services/price/price-api/api.test.ts | 2 +- .../src/services/price/price-api/api.ts | 7 - .../src/utils/currency.test.ts | 96 +++- .../stellar-wallet-snap/src/utils/currency.ts | 79 +++ 24 files changed, 1462 insertions(+), 181 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/asset/api.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/asset/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/asset/assets.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/asset/assets.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/price/__mocks__/price.fixtures.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/price/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/price/exceptions.ts diff --git a/merged-packages/stellar-wallet-snap/src/api/asset.test.ts b/merged-packages/stellar-wallet-snap/src/api/asset.test.ts index a52162d1..4e39056f 100644 --- a/merged-packages/stellar-wallet-snap/src/api/asset.test.ts +++ b/merged-packages/stellar-wallet-snap/src/api/asset.test.ts @@ -1,6 +1,7 @@ import { assert, StructError } from '@metamask/superstruct'; import { + FiatCaipAssetStruct, KnownCaip19ClassicAssetStruct, KnownCaip19Sep41AssetStruct, KnownCaip19Slip44IdStruct, @@ -40,6 +41,25 @@ describe('KnownCaip19Sep41AssetStruct', () => { }); }); +describe('FiatCaipAssetStruct', () => { + it.each(['swift:0/iso4217:USD', 'swift:0/iso4217:eur'])( + 'accepts a valid fiat CAIP-19 asset id', + (assetId) => { + expect(() => assert(assetId, FiatCaipAssetStruct)).not.toThrow(); + }, + ); + + it.each([ + 'stellar:pubnet/slip44:148', + 'eip155:1/swift:0/iso4217:USD', + 'swift:0/iso4217:US', + 'swift:0/iso4217:USDC', + 'eip155:1/notswift:0/iso4217:USD', + ])('rejects a non-fiat CAIP-19 asset id', (assetId) => { + expect(() => assert(assetId, FiatCaipAssetStruct)).toThrow(StructError); + }); +}); + describe('KnownCaip19Slip44IdStruct', () => { it('accepts a valid CAIP-19 asset', () => { expect(() => diff --git a/merged-packages/stellar-wallet-snap/src/api/asset.ts b/merged-packages/stellar-wallet-snap/src/api/asset.ts index bf2cf8c9..62c35143 100644 --- a/merged-packages/stellar-wallet-snap/src/api/asset.ts +++ b/merged-packages/stellar-wallet-snap/src/api/asset.ts @@ -1,4 +1,5 @@ import type { Infer } from '@metamask/superstruct'; +import type { CaipAssetType } from '@metamask/utils'; import { definePattern } from '@metamask/utils'; import { KnownCaip2ChainId } from './network'; @@ -44,6 +45,19 @@ export const KnownCaip19Sep41AssetStruct = /^stellar:(?:pubnet|testnet)\/sep41:C[A-Z2-7]{55}$/u, ); +/** + * Fiat asset id in SWIFT / ISO 4217 form only: `swift:0/iso4217:{code}` (3-letter code). + * + * @see https://github.com/MetaMask/core/blob/main/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts#L44 + */ +export const FiatCaipAssetStruct = definePattern( + 'FiatCaipAsset', + /^swift:0\/iso4217:[A-Za-z]{3}$/u, +); + +/** Fiat CAIP-19 asset id (SWIFT / ISO 4217). */ +export type FiatCaipAssetId = Infer; + /** CAIP-19 Sep41 asset ID */ export type KnownCaip19Sep41AssetId = Infer; diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index abc4a0d7..03d8b107 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -2,6 +2,7 @@ import { assert, object } from '@metamask/superstruct'; import { AppConfig } from './config'; import { KeyringHandler } from './handlers'; +import { AssetsHandler } from './handlers/asset/assets'; import type { IKeyringRequestHandler } from './handlers/keyring'; import { MultichainMethod, @@ -11,9 +12,15 @@ import { import { UserInputHandler } from './handlers/user-input/userInput'; import { AccountService, AccountsRepository } from './services/account'; import type { AccountBalanceState } from './services/account-balance'; +import { + AssetMetadataRepository, + AssetMetadataService, +} from './services/asset-metadata'; +import { StateCache } from './services/cache'; import { NetworkService } from './services/network'; import type { OnChainAccountSnapshotState } from './services/on-chain-account'; import { OnChainAccountService } from './services/on-chain-account'; +import { PriceService } from './services/price'; import { State } from './services/state'; import { TransactionBuilder, @@ -38,6 +45,7 @@ const state = new State({ const accountsRepository = new AccountsRepository(state); const transactionRepository = new TransactionRepository(state); +const assetMetadataRepository = new AssetMetadataRepository(state); /** ------------------------------ Services ------------------------------ */ const networkService = new NetworkService({ logger }); @@ -62,8 +70,18 @@ const transactionService = new TransactionService({ networkService, }); -/** ------------------------------ Keyring Handler ------------------------------ */ +const assetMetadataService = new AssetMetadataService({ + networkService, + assetMetadataRepository, + logger, +}); + +const priceService = new PriceService({ + cache: new StateCache(state, logger, '__cache__price'), + logger, +}); +/** ------------------------------ Keyring Handler ------------------------------ */ const signTransactionHandler = new SignTransactionHandler({ logger, accountService, @@ -94,11 +112,20 @@ const keyringHandler = new KeyringHandler({ handlers: keyringMethodHandlers, }); +/** ------------------------------ User Handler ------------------------------ */ const userInputHandler = new UserInputHandler({ logger, }); +/** ------------------------------ Asset Handler ------------------------------ */ +const assetsHandler = new AssetsHandler({ + logger, + assetMetadataService, + priceService, +}); + export { + assetsHandler, keyringHandler, userInputHandler, signTransactionHandler, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/asset/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/asset/api.test.ts new file mode 100644 index 00000000..bcd10826 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/asset/api.test.ts @@ -0,0 +1,30 @@ +import { assert, StructError } from '@metamask/superstruct'; + +import { OnAssetsLookupRequestStruct } from './api'; +import { + NATIVE, + USDC_CLASSIC, + USDC_SEP41, +} from '../../services/asset-metadata/__mocks__/assets.fixtures'; + +describe('OnAssetsLookupRequestStruct', () => { + it.each([ + { assets: [USDC_CLASSIC] }, + { assets: [USDC_SEP41] }, + { assets: [NATIVE] }, + { assets: [USDC_CLASSIC, USDC_SEP41, NATIVE] }, + ])('accepts valid assets request', (request) => { + expect(() => assert(request, OnAssetsLookupRequestStruct)).not.toThrow(); + }); + + it.each([ + { assets: ['invalid-asset-id'] }, + { assets: ['eip155:1/erc20:0x0000000000000000000000000000000000000000'] }, + { assets: 'stellar:pubnet/slip44:148' }, + {}, + ])('rejects invalid assets request', (assetId) => { + expect(() => assert(assetId, OnAssetsLookupRequestStruct)).toThrow( + StructError, + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/asset/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/asset/api.ts new file mode 100644 index 00000000..f0b72c15 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/asset/api.ts @@ -0,0 +1,17 @@ +import { array, object, union } from '@metamask/superstruct'; + +import { + KnownCaip19ClassicAssetStruct, + KnownCaip19Sep41AssetStruct, + KnownCaip19Slip44IdStruct, +} from '../../api'; + +export const OnAssetsLookupAssetStruct = union([ + KnownCaip19ClassicAssetStruct, + KnownCaip19Slip44IdStruct, + KnownCaip19Sep41AssetStruct, +]); + +export const OnAssetsLookupRequestStruct = object({ + assets: array(OnAssetsLookupAssetStruct), +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/asset/assets.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/asset/assets.test.ts new file mode 100644 index 00000000..e98121f1 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/asset/assets.test.ts @@ -0,0 +1,165 @@ +import type { + CaipAssetType, + FungibleAssetMarketData, +} from '@metamask/snaps-sdk'; + +import { AssetsHandler } from './assets'; +import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; +import { KnownCaip2ChainId } from '../../api'; +import { + createMockAssetMetadataService, + generateMockKeyringAssetMetadata, + USDC_CLASSIC, +} from '../../services/asset-metadata/__mocks__/assets.fixtures'; +import { createMockPriceService } from '../../services/price/__mocks__/price.fixtures'; +import { logger } from '../../utils/logger'; + +jest.mock('../../utils/logger'); + +describe('AssetsHandler', () => { + const setupHandlers = () => { + const { service: assetMetadataService, getAssetsMetadataByAssetIdsSpy } = + createMockAssetMetadataService(); + + const { + service: priceService, + getSpotPricesSpy, + getFiatExchangeRatesSpy, + getHistoricalPricesSpy, + getMultipleTokensMarketDataSpy, + getMultipleTokenConversionsSpy, + getHistoricalPriceWithAllTimePeriodsSpy, + } = createMockPriceService(); + + const handler = new AssetsHandler({ + logger, + assetMetadataService, + priceService, + }); + + return { + handler, + getAssetsMetadataByAssetIdsSpy, + getSpotPricesSpy, + getFiatExchangeRatesSpy, + getHistoricalPricesSpy, + getMultipleTokensMarketDataSpy, + getMultipleTokenConversionsSpy, + getHistoricalPriceWithAllTimePeriodsSpy, + }; + }; + + describe('onAssetsLookup', () => { + it('calls asset metadata once per chain and returns merged metadata', async () => { + const { handler, getAssetsMetadataByAssetIdsSpy } = setupHandlers(); + const expectedResponse = generateMockKeyringAssetMetadata(); + getAssetsMetadataByAssetIdsSpy.mockResolvedValue(expectedResponse); + + const assets = Object.keys( + expectedResponse, + ) as KnownCaip19AssetIdOrSlip44Id[]; + + const result = await handler.onAssetsLookup({ assets }); + + expect(getAssetsMetadataByAssetIdsSpy).toHaveBeenCalledTimes(1); + expect(getAssetsMetadataByAssetIdsSpy).toHaveBeenCalledWith(assets); + + expect(result).toMatchObject({ + assets: expectedResponse, + }); + }); + }); + + describe('onAssetsMarketData', () => { + it('calls price service', async () => { + const { handler, getMultipleTokensMarketDataSpy } = setupHandlers(); + const expectedResponse: Record< + CaipAssetType, + Record + > = { + [USDC_CLASSIC]: { + [USDC_CLASSIC]: { + fungible: true, + }, + }, + }; + getMultipleTokensMarketDataSpy.mockResolvedValue(expectedResponse); + + const result = await handler.onAssetsMarketData({ + assets: [ + { + asset: USDC_CLASSIC, + unit: 'swift:0/iso4217:USD', + }, + ], + }); + + expect(getMultipleTokensMarketDataSpy).toHaveBeenCalledWith([ + { + asset: USDC_CLASSIC, + unit: 'swift:0/iso4217:USD', + }, + ]); + expect(result).toMatchObject({ + marketData: expectedResponse, + }); + }); + }); + + describe('onAssetsConversion', () => { + it('calls price service', async () => { + const { handler, getMultipleTokenConversionsSpy } = setupHandlers(); + const expectedResponse = { + [USDC_CLASSIC]: { + [USDC_CLASSIC]: { + rate: '1', + conversionTime: Date.now(), + }, + }, + }; + getMultipleTokenConversionsSpy.mockResolvedValue(expectedResponse); + + const result = await handler.onAssetsConversion({ + conversions: [ + { + from: USDC_CLASSIC, + to: 'swift:0/iso4217:USD', + }, + ], + }); + + expect(getMultipleTokenConversionsSpy).toHaveBeenCalledWith([ + { + from: USDC_CLASSIC, + to: 'swift:0/iso4217:USD', + }, + ]); + expect(result).toMatchObject({ + conversionRates: expectedResponse, + }); + }); + }); + + describe('onAssetHistoricalPrice', () => { + it('calls price service', async () => { + const { handler, getHistoricalPriceWithAllTimePeriodsSpy } = + setupHandlers(); + + const result = await handler.onAssetHistoricalPrice({ + from: USDC_CLASSIC, + to: 'swift:0/iso4217:USD', + }); + + expect(getHistoricalPriceWithAllTimePeriodsSpy).toHaveBeenCalledWith( + USDC_CLASSIC, + 'swift:0/iso4217:USD', + ); + + expect(result).toMatchObject({ + historicalPrice: { + intervals: {}, + }, + }); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/asset/assets.ts b/merged-packages/stellar-wallet-snap/src/handlers/asset/assets.ts new file mode 100644 index 00000000..f8a3da50 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/asset/assets.ts @@ -0,0 +1,107 @@ +import type { + OnAssetHistoricalPriceArguments, + OnAssetHistoricalPriceResponse, + OnAssetsConversionArguments, + OnAssetsConversionResponse, + OnAssetsLookupArguments, + OnAssetsLookupResponse, + OnAssetsMarketDataArguments, + OnAssetsMarketDataResponse, +} from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; + +import { OnAssetsLookupRequestStruct } from './api'; +import type { AssetMetadataService } from '../../services/asset-metadata/AssetMetadataService'; +import type { PriceService } from '../../services/price/PriceService'; +import { withCatchAndThrowSnapError } from '../../utils/errors'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; + +export class AssetsHandler { + readonly #logger: ILogger; + + readonly #assetMetadataService: AssetMetadataService; + + readonly #priceService: PriceService; + + constructor({ + logger, + assetMetadataService, + priceService, + }: { + logger: ILogger; + assetMetadataService: AssetMetadataService; + priceService: PriceService; + }) { + this.#logger = createPrefixedLogger(logger, '[🪙 AssetsHandler]'); + this.#assetMetadataService = assetMetadataService; + this.#priceService = priceService; + } + + async onAssetHistoricalPrice( + params: OnAssetHistoricalPriceArguments, + ): Promise { + return await withCatchAndThrowSnapError(async () => { + this.#logger.log('[📈 onAssetHistoricalPrice]', params); + + const { from, to } = params; + + const historicalPrice = + await this.#priceService.getHistoricalPriceWithAllTimePeriods(from, to); + + return { + historicalPrice, + }; + }); + } + + async onAssetsConversion( + params: OnAssetsConversionArguments, + ): Promise { + return await withCatchAndThrowSnapError(async () => { + this.#logger.log('[📈 onAssetsConversion]', params); + + const { conversions } = params; + + const conversionRates = + await this.#priceService.getMultipleTokenConversions(conversions); + + return { + conversionRates, + }; + }); + } + + async onAssetsLookup( + params: OnAssetsLookupArguments, + ): Promise { + return await withCatchAndThrowSnapError(async () => { + this.#logger.log('[🔍 onAssetsLookup]', params); + // Ensure we only support Stellar assets here. + assert(params, OnAssetsLookupRequestStruct); + + const assetMetadata = + await this.#assetMetadataService.getAssetsMetadataByAssetIds( + params.assets, + ); + + return { + assets: assetMetadata, + }; + }); + } + + async onAssetsMarketData( + params: OnAssetsMarketDataArguments, + ): Promise { + return await withCatchAndThrowSnapError(async () => { + this.#logger.log('[🔍 onAssetsMarketData]', params); + + const marketData = await this.#priceService.getMultipleTokensMarketData( + params.assets, + ); + + return { marketData }; + }); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/index.ts b/merged-packages/stellar-wallet-snap/src/index.ts index 811f49d2..1dd333cf 100644 --- a/merged-packages/stellar-wallet-snap/src/index.ts +++ b/merged-packages/stellar-wallet-snap/src/index.ts @@ -2,6 +2,10 @@ import type { OnUserInputHandler, OnKeyringRequestHandler, OnRpcRequestHandler, + OnAssetHistoricalPriceHandler, + OnAssetsConversionHandler, + OnAssetsLookupHandler, + OnAssetsMarketDataHandler, } from '@metamask/snaps-sdk'; import { MethodNotFoundError } from '@metamask/snaps-sdk'; import type { JsonRpcRequest } from '@metamask/utils'; @@ -11,8 +15,22 @@ import { signMessageHandler, userInputHandler, signTransactionHandler, + assetsHandler, } from './context'; +export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ( + args, +) => assetsHandler.onAssetHistoricalPrice(args); + +export const onAssetsConversion: OnAssetsConversionHandler = async (args) => + assetsHandler.onAssetsConversion(args); + +export const onAssetsLookup: OnAssetsLookupHandler = async (args) => + assetsHandler.onAssetsLookup(args); + +export const onAssetsMarketData: OnAssetsMarketDataHandler = async (args) => + assetsHandler.onAssetsMarketData(args); + export const onKeyringRequest: OnKeyringRequestHandler = async ({ origin, request, diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts index 82383527..ba67b1f8 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts @@ -1,9 +1,6 @@ -import type { AssetMetadata } from '@metamask/snaps-sdk'; - import type { StellarAssetMetadata } from './api'; import type { AssetMetadataRepository } from './AssetMetadataRepository'; import { AssetMetadataService } from './AssetMetadataService'; -import { AssetMetadataServiceException } from './exceptions'; import { AssetType, KnownCaip2ChainId, @@ -12,6 +9,11 @@ import { import { getSlip44AssetId, logger } from '../../utils'; import type { NetworkService } from '../network'; import { TokenApiClient } from './token-api/TokenApiClient'; +import { NATIVE_ASSET_NAME, NATIVE_ASSET_SYMBOL } from '../../constants'; + +/** Mainnet classic USDC (matches CAIP-19 pattern used across Stellar fixtures). */ +const MAINNET_CLASSIC_USDC = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as KnownCaip19AssetId; jest.mock('../../config', () => ({ AppConfig: { @@ -33,12 +35,6 @@ jest.mock('./token-api/TokenApiClient', () => ({ TokenApiClient: jest.fn(), })); -const testnetClassicId = - 'stellar:testnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as KnownCaip19AssetId; - -const pubnetClassicId = - 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as KnownCaip19AssetId; - const mockGetTokensMetadata = jest.fn(); function createCachedRow( @@ -114,32 +110,20 @@ describe('AssetMetadataService', () => { mockGetTokensMetadata.mockResolvedValue([]); }); - it('returns native metadata for slip44 id matching scope', async () => { + it('returns native metadata for mainnet slip44 id', async () => { const { service, getByAssetIds } = createService({}); - const slipId = getSlip44AssetId(KnownCaip2ChainId.Testnet); - const result = await service.resolve({ - assetId: slipId, - scope: KnownCaip2ChainId.Testnet, - }); + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const result = await service.resolve(slipId); expect(result.assetId).toBe(slipId); expect(getByAssetIds).toHaveBeenCalledWith([]); expect(mockGetTokensMetadata).not.toHaveBeenCalled(); }); - it('throws when asset chain does not match scope', async () => { - const { service } = createService({}); - await expect( - service.resolve({ - assetId: pubnetClassicId, - scope: KnownCaip2ChainId.Testnet, - }), - ).rejects.toThrow(AssetMetadataServiceException); - }); - - it('loads testnet classic from Horizon when cache misses and skips token API', async () => { + it('loads mainnet classic from Horizon when token API and cache miss', async () => { + const classicId = MAINNET_CLASSIC_USDC; const rpcRow = { - assetId: testnetClassicId, + assetId: classicId, symbol: 'USDC', decimals: 7, name: 'USD Coin', @@ -150,56 +134,108 @@ describe('AssetMetadataService', () => { }, }); - const result = await service.resolve({ - assetId: testnetClassicId, - scope: KnownCaip2ChainId.Testnet, - }); + const result = await service.resolve(classicId); - expect(result.assetId).toBe(testnetClassicId); + expect(result.assetId).toBe(classicId); expect(result.symbol).toBe('USDC'); + expect(mockGetTokensMetadata).toHaveBeenCalled(); expect(getClassicAssetData).toHaveBeenCalledWith( - testnetClassicId, - KnownCaip2ChainId.Testnet, + classicId, + KnownCaip2ChainId.Mainnet, ); - expect(mockGetTokensMetadata).not.toHaveBeenCalled(); }); - it('returns cached classic asset without calling token API', async () => { - const cached = createCachedRow(testnetClassicId, KnownCaip2ChainId.Testnet); + it('returns cached mainnet classic asset without calling token API', async () => { + const classicId = MAINNET_CLASSIC_USDC; + const cached = createCachedRow(classicId, KnownCaip2ChainId.Mainnet); const { service, getByAssetIds, saveMany } = createService({ repo: { getByAssetIds: jest.fn().mockResolvedValue([cached]), }, }); - const result = await service.resolve({ - assetId: testnetClassicId, - scope: KnownCaip2ChainId.Testnet, - }); + const result = await service.resolve(classicId); expect(result).toStrictEqual(cached); - expect(getByAssetIds).toHaveBeenCalledWith([testnetClassicId]); + expect(getByAssetIds).toHaveBeenCalledWith([classicId]); expect(mockGetTokensMetadata).not.toHaveBeenCalled(); expect(saveMany).not.toHaveBeenCalled(); }); - it('fills keyring metadata map and leaves wrong-scope ids null', async () => { - const slipId = getSlip44AssetId(KnownCaip2ChainId.Testnet); - const { service } = createService({}); + it('fills keyring metadata map for mainnet slip44 and classic', async () => { + const classicId = MAINNET_CLASSIC_USDC; + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const rpcRow = { + assetId: classicId, + symbol: 'USDC', + decimals: 7, + name: 'USD Coin', + }; + const { service, saveMany } = createService({ + network: { + getClassicAssetData: jest.fn().mockResolvedValue(rpcRow), + }, + }); - const map = await service.getAssetsMetadataByAssetIds( - [pubnetClassicId, slipId], - KnownCaip2ChainId.Testnet, - ); + const map = await service.getAssetsMetadataByAssetIds([classicId, slipId]); - expect(map[pubnetClassicId]).toBeNull(); - expect(map[slipId]).toStrictEqual({ + expect(map[classicId]).toMatchObject({ + fungible: true, + symbol: 'USDC', + name: 'USD Coin', + iconUrl: expect.any(String), + units: expect.any(Array), + }); + expect(map[slipId]).toMatchObject({ fungible: true, iconUrl: expect.any(String), units: expect.any(Array), symbol: expect.any(String), name: expect.any(String), - } satisfies AssetMetadata); + }); + expect(saveMany).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + assetId: classicId, + symbol: 'USDC', + }), + ]), + ); + }); + + it('deduplicates duplicate asset ids before fetch pipeline', async () => { + const classicId = MAINNET_CLASSIC_USDC; + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const rpcRow = { + assetId: classicId, + symbol: 'USDC', + decimals: 7, + name: 'USD Coin', + }; + const { service, getByAssetIds, getClassicAssetData } = createService({ + network: { + getClassicAssetData: jest.fn().mockResolvedValue(rpcRow), + }, + }); + + const result = await service.getAssetsMetadataByAssetIds([ + classicId, + classicId, + slipId, + slipId, + ]); + + expect(getByAssetIds).toHaveBeenCalledWith([classicId]); + expect(mockGetTokensMetadata).toHaveBeenCalledWith([classicId]); + expect(getClassicAssetData).toHaveBeenCalledTimes(1); + expect(result[classicId]).toMatchObject({ + symbol: 'USDC', + name: 'USD Coin', + }); + expect(result[slipId]).toMatchObject({ + symbol: NATIVE_ASSET_SYMBOL, + name: NATIVE_ASSET_NAME, + }); }); it('delegates getPersistedSep41AssetsMetadata to repository', async () => { diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts index ce4fd370..42cfc9e1 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -1,5 +1,4 @@ -import type { AssetMetadata } from '@metamask/snaps-sdk'; -import { ensureError, parseCaipAssetType } from '@metamask/utils'; +import { ensureError } from '@metamask/utils'; import type { KnownCaip19AssetId, @@ -15,15 +14,22 @@ import { createPrefixedLogger, isClassicAssetId, isSep41Id, - isSlip44Id, } from '../../utils'; import type { ILogger } from '../../utils'; import type { AssetDataResponse, NetworkService } from '../network'; -import type { StellarAssetMetadata } from './api'; +import type { + KeyringAssetMetadataByAssetId, + StellarAssetMetadata, +} from './api'; import type { AssetMetadataRepository } from './AssetMetadataRepository'; import { AssetMetadataServiceException } from './exceptions'; import { TokenApiClient } from './token-api/TokenApiClient'; -import { getNativeAssetMetadata, toStellarAssetMetadata } from './utils'; +import { + getNativeAssetMetadata, + groupAssetsByChainId, + toKeyringAssetMetadata, + toStellarAssetMetadata, +} from './utils'; /** * Resolves CAIP-19 asset identifiers and caches fungible asset metadata for lookups. @@ -70,20 +76,13 @@ export class AssetMetadataService { /** * Loads decimals for the asset; for SEP-41, fetches symbol and contract metadata from the token contract. * - * @param params - Resolution input. - * @param params.assetId - Native, classic, or SEP-41 CAIP-19 asset id. - * @param params.scope - CAIP-2 chain id. + * @param assetId - Native, classic, or SEP-41 CAIP-19 asset id. * @returns Resolved asset data for wallet / transaction use. */ - async resolve(params: { - assetId: KnownCaip19AssetIdOrSlip44Id; - scope: KnownCaip2ChainId; - }): Promise { - const { assetId, scope } = params; - const assets = await this.#fetchAndPersistAssetsByAssetIds( - [assetId], - scope, - ); + async resolve( + assetId: KnownCaip19AssetIdOrSlip44Id, + ): Promise { + const assets = await this.#fetchAndPersistAssetsByAssetIds([assetId]); const found = assets.find((asset) => asset.assetId === assetId); if (!found) { throw new AssetMetadataServiceException( @@ -94,31 +93,26 @@ export class AssetMetadataService { } /** - * Returns all assets for the given asset IDs. + * Returns all assets in keyring format for the given asset IDs. * * @param assetIds - The asset IDs to look up. - * @param scope - The chain ID to look up. * @returns A Promise that resolves to all assets metadata for the given asset IDs. */ async getAssetsMetadataByAssetIds( assetIds: KnownCaip19AssetIdOrSlip44Id[], - scope: KnownCaip2ChainId, - ): Promise> { + ): Promise { this.#logger.debug('Fetching assets metadata by asset ids', { assetIds }); - const list = await this.#fetchAndPersistAssetsByAssetIds(assetIds, scope); + const metadataByAssetId = {} as KeyringAssetMetadataByAssetId; - const metadataByAssetId = {} as Record< - KnownCaip19AssetIdOrSlip44Id, - AssetMetadata | null - >; + const list = await this.#fetchAndPersistAssetsByAssetIds(assetIds); for (const assetId of assetIds) { metadataByAssetId[assetId] = null; } for (const asset of list) { - metadataByAssetId[asset.assetId] = this.#toAssetMetadata(asset); + metadataByAssetId[asset.assetId] = toKeyringAssetMetadata(asset); } return metadataByAssetId; @@ -154,43 +148,43 @@ export class AssetMetadataService { async #fetchAndPersistAssetsByAssetIds( assetIds: KnownCaip19AssetIdOrSlip44Id[], - scope: KnownCaip2ChainId, ): Promise { - const uniqueAssetIds = new Set(assetIds); + const { nativeAssets: nativeAssetsByChainId, assets: assetsByChainId } = + groupAssetsByChainId(assetIds); const result: StellarAssetMetadata[] = []; - const stellarAssetIds: KnownCaip19AssetId[] = []; - - for (const assetId of Array.from(uniqueAssetIds)) { - // make sure we only fetch assets for the given scope - const { chainId } = parseCaipAssetType(assetId); - if ((chainId as KnownCaip2ChainId) !== scope) { - continue; - } - - if (isSlip44Id(assetId)) { - result.push(getNativeAssetMetadata(scope)); - } else { - stellarAssetIds.push(assetId); - } + // fetch native assets + for (const [chainId] of nativeAssetsByChainId) { + result.push(getNativeAssetMetadata(chainId)); } + // fetch assets from state + const allNonNativeAssetIds = [...assetsByChainId.values()].flat(); const { assets, missingAssetIds } = - await this.#getPersistedAssetMetadata(stellarAssetIds); + await this.#getPersistedAssetMetadata(allNonNativeAssetIds); if (missingAssetIds.length === 0) { return result.concat(assets); } - const fetchedAssets = await this.#fetchMissingAssetsMetadata( - missingAssetIds, - scope, - ); + // fetch missing assets by chain id + const missingAssets: StellarAssetMetadata[] = []; + const { assets: missingAssetIdsByChainId } = + groupAssetsByChainId(missingAssetIds); - if (fetchedAssets.length > 0) { - await this.#assetMetadataRepository.saveMany(fetchedAssets); + for (const [chainId, chainAssetIds] of missingAssetIdsByChainId) { + const fetchedAssets = await this.#fetchMissingAssetsMetadata( + chainAssetIds, + chainId, + ); + missingAssets.push(...fetchedAssets); + } + + // Backfill in state + if (missingAssets.length > 0) { + await this.#assetMetadataRepository.saveMany(missingAssets); } - return result.concat(assets, fetchedAssets); + return result.concat(assets, missingAssets); } async #getPersistedAssetMetadata(assetIds: KnownCaip19AssetId[]): Promise<{ @@ -351,16 +345,6 @@ export class AssetMetadataService { return { assets, missingAssetIds: Array.from(missingTokenAssetIds) }; } - #toAssetMetadata(assetData: StellarAssetMetadata): AssetMetadata { - return { - fungible: assetData.fungible, - iconUrl: assetData.iconUrl, - units: assetData.units, - symbol: assetData.symbol, - name: assetData.name, - }; - } - /** * For each requested id in order: collect cached row if present, else mark missing. * diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts new file mode 100644 index 00000000..ae392472 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts @@ -0,0 +1,121 @@ +import type { KnownCaip19AssetIdOrSlip44Id } from '../../../api'; +import { AssetType, KnownCaip2ChainId } from '../../../api'; +import { NATIVE_ASSET_NAME, NATIVE_ASSET_SYMBOL } from '../../../constants'; +import { getSlip44AssetId } from '../../../utils/caip'; +import { logger } from '../../../utils/logger'; +import { NetworkService } from '../../network'; +import { State } from '../../state'; +import type { + AssetMetadataByAssetId, + KeyringAssetMetadataByAssetId, +} from '../api'; +import { AssetMetadataRepository } from '../AssetMetadataRepository'; +import { AssetMetadataService } from '../AssetMetadataService'; + +export const NATIVE: KnownCaip19AssetIdOrSlip44Id = `${getSlip44AssetId(KnownCaip2ChainId.Mainnet)}`; +export const USDC_CLASSIC: KnownCaip19AssetIdOrSlip44Id = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; +export const USDC_SEP41: KnownCaip19AssetIdOrSlip44Id = + 'stellar:pubnet/sep41:CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'; + +export const generateMockStellarAssetMetadata = (): AssetMetadataByAssetId => { + return { + [NATIVE]: { + assetId: NATIVE, + assetType: AssetType.Native, + chainId: KnownCaip2ChainId.Mainnet, + name: NATIVE_ASSET_NAME, + symbol: NATIVE_ASSET_SYMBOL, + fungible: true, + iconUrl: 'https://example.test/icon.png', + }, + [USDC_CLASSIC]: { + assetId: USDC_CLASSIC, + assetType: AssetType.Token, + chainId: KnownCaip2ChainId.Mainnet, + name: 'USDC', + symbol: 'USDC', + fungible: true, + iconUrl: 'https://example.test/icon.png', + units: [{ name: 'USDC', symbol: 'USDC', decimals: 7 }], + }, + [USDC_SEP41]: { + assetId: USDC_SEP41, + assetType: AssetType.Sep41, + chainId: KnownCaip2ChainId.Mainnet, + name: 'USDC', + symbol: 'USDC', + fungible: true, + iconUrl: 'https://example.test/icon.png', + units: [{ name: 'USDC', symbol: 'USDC', decimals: 7 }], + }, + } as AssetMetadataByAssetId; +}; + +export const generateMockKeyringAssetMetadata = + (): KeyringAssetMetadataByAssetId => { + return { + [NATIVE]: { + name: NATIVE_ASSET_NAME, + symbol: NATIVE_ASSET_SYMBOL, + fungible: true, + iconUrl: 'https://example.test/icon.png', + units: [ + { + name: NATIVE_ASSET_NAME, + symbol: NATIVE_ASSET_SYMBOL, + decimals: 7, + }, + ], + }, + [USDC_CLASSIC]: { + name: 'USDC', + symbol: 'USDC', + fungible: true, + iconUrl: 'https://example.test/icon.png', + units: [{ name: 'USDC', symbol: 'USDC', decimals: 7 }], + }, + [USDC_SEP41]: { + name: 'USDC', + symbol: 'USDC', + fungible: true, + iconUrl: 'https://example.test/icon.png', + units: [{ name: 'USDC', symbol: 'USDC', decimals: 7 }], + }, + } as KeyringAssetMetadataByAssetId; + }; + +export const createMockAssetMetadataService = () => { + const service = new AssetMetadataService({ + networkService: new NetworkService({ logger }), + assetMetadataRepository: new AssetMetadataRepository( + new State({ + encrypted: false, + defaultState: { assets: generateMockStellarAssetMetadata() }, + }), + ), + logger, + }); + + const assetMetadataRepositorySaveManySpy = jest.spyOn( + AssetMetadataRepository.prototype, + 'saveMany', + ); + + const assetMetadataRepositoryGetByAssetIdsSpy = jest.spyOn( + AssetMetadataRepository.prototype, + 'getByAssetIds', + ); + + const getAssetsMetadataByAssetIdsSpy = jest.spyOn( + AssetMetadataService.prototype, + 'getAssetsMetadataByAssetIds', + ); + + return { + service, + assetMetadataRepositorySaveManySpy, + assetMetadataRepositoryGetByAssetIdsSpy, + getAssetsMetadataByAssetIdsSpy, + }; +}; diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/api.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/api.ts index f4e216a6..dc4f71b9 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/api.ts @@ -1,4 +1,4 @@ -import type { FungibleAssetMetadata } from '@metamask/snaps-sdk'; +import type { AssetMetadata, FungibleAssetMetadata } from '@metamask/snaps-sdk'; import type { NonEmptyArray } from '@metamask/utils'; import type { @@ -27,6 +27,12 @@ export type AssetMetadataByAssetId = Partial< Record >; +/** A map of asset IDs to Keyring asset metadata. */ +export type KeyringAssetMetadataByAssetId = Record< + KnownCaip19AssetIdOrSlip44Id, + AssetMetadata | null +>; + export type AssetMetadataState = { assets: AssetMetadataByAssetId; }; diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts index d819775d..dcf1ed79 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts @@ -1,3 +1,5 @@ +import type { AssetMetadata } from '@metamask/snaps-sdk'; +import { assert } from '@metamask/superstruct'; import { parseCaipAssetType } from '@metamask/utils'; import type { StellarAssetMetadata } from './api'; @@ -5,14 +7,17 @@ import type { AssetType, KnownCaip2ChainId, KnownCaip19AssetIdOrSlip44Id, + KnownCaip19Slip44Id, + KnownCaip19AssetId, } from '../../api'; +import { KnownCaip2ChainIdStruct } from '../../api'; import { AppConfig } from '../../config'; import { NATIVE_ASSET_NAME, NATIVE_ASSET_SYMBOL, STELLAR_DECIMAL_PLACES, } from '../../constants'; -import { buildUrl, getSlip44AssetId } from '../../utils'; +import { buildUrl, getSlip44AssetId, isSlip44Id } from '../../utils'; /** * Returns the icon URL for a given asset ID. @@ -68,6 +73,24 @@ export function toStellarAssetMetadata(assetData: { }; } +/** + * Maps {@link StellarAssetMetadata} to {@link AssetMetadata}. + * + * @param assetData - The Stellar asset metadata. + * @returns The Keyring asset metadata. + */ +export function toKeyringAssetMetadata( + assetData: StellarAssetMetadata, +): AssetMetadata { + return { + fungible: assetData.fungible, + iconUrl: assetData.iconUrl, + units: assetData.units, + symbol: assetData.symbol, + name: assetData.name, + }; +} + /** * Builds {@link StellarAssetMetadata} for the native XLM slip44 id on the given network. * @@ -84,3 +107,52 @@ export function getNativeAssetMetadata( name: NATIVE_ASSET_NAME, }); } + +/** + * Groups asset IDs by chain ID and separates native assets from non-native assets. + * This function also deduplicates asset ids. + * + * @param assetIds - The asset IDs to group. + * @returns An object with two maps of chain IDs to asset IDs: one for native assets and one for non-native assets. + */ +export function groupAssetsByChainId( + assetIds: KnownCaip19AssetIdOrSlip44Id[], +): { + nativeAssets: Map; + assets: Map; +} { + const assets = new Map(); + + const nativeAssets = new Map(); + + const uniqueAssetIds = new Set(); + + // group assets by chain id + for (const assetId of assetIds) { + // deduplicate asset ids + if (uniqueAssetIds.has(assetId)) { + continue; + } + uniqueAssetIds.add(assetId); + + const { chainId } = parseCaipAssetType(assetId); + + assert(chainId, KnownCaip2ChainIdStruct); + + if (isSlip44Id(assetId)) { + if (!nativeAssets.has(chainId)) { + nativeAssets.set(chainId, []); + } + nativeAssets.get(chainId)?.push(assetId); + } else { + if (!assets.has(chainId)) { + assets.set(chainId, []); + } + assets.get(chainId)?.push(assetId); + } + } + return { + nativeAssets, + assets, + }; +} diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts index 4e5b6a05..8e397659 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts @@ -1,11 +1,9 @@ +import { GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT } from './api'; import { PriceService } from './PriceService'; import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; import { AppConfig } from '../../config'; import { logger, serialize } from '../../utils'; -import { - GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, - type FiatExchangeRatesResponse, -} from './price-api/api'; +import type { FiatExchangeRatesResponse } from './price-api/api'; import { PriceApiClient } from './price-api/PriceApiClient'; import { createMemoryCache } from '../cache/__mocks__/cache.fixtures'; diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts index 1022e2af..adaaf8d9 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts @@ -1,20 +1,43 @@ -import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; -import { AppConfig } from '../../config'; -import type { ILogger, Serializable } from '../../utils'; +import type { + AssetConversion, + FungibleAssetMarketData, + HistoricalPriceIntervals, +} from '@metamask/snaps-sdk'; +import type { CaipAssetType } from '@metamask/utils'; +import { parseCaipAssetType } from '@metamask/utils'; +import { pick } from 'lodash'; + +import { + createPrefixedLogger, + getFiatTicker, + isFiat, + type ILogger, + type Serializable, +} from '../../utils'; import type { ICache } from '../cache'; import { useCache } from '../cache'; +import { GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT } from './api'; import type { FiatExchangeRatesResponse, GetHistoricalPricesParams, GetHistoricalPricesResponse, + SpotPrice, SpotPrices, + Ticker, VsCurrencyParam, } from './price-api/api'; import { PriceApiClient } from './price-api/PriceApiClient'; +import { AppConfig } from '../../config'; +/** + * Fetches and caches price data from the MetaMask Price API: spot quotes, fiat + * exchange rates, historical intervals, cross-asset conversions, and market metrics. + */ export class PriceService { readonly #priceApiClient: PriceApiClient; + readonly #logger: ILogger; + readonly #cache: ICache; constructor({ @@ -32,24 +55,26 @@ export class PriceService { logger, ); this.#cache = cache; + this.#logger = createPrefixedLogger(logger, '[🪙 PriceService]'); } /** - * Get the spot prices for a list of asset IDs. + * Gets spot prices for the given CAIP asset IDs from the Price API. * Results are cached for `AppConfig.cache.ttlMilliseconds.spotPrices`. * - * @param params - The parameters for the request. - * @param params.assetIds - The asset IDs to get the spot prices for. - * @param params.vsCurrency - The currency to convert the prices to. - * @param refreshCache - Whether to refresh the cache. - * @returns The spot prices for the asset IDs. + * @param params - Request parameters. + * @param params.assetIds - CAIP asset types to quote. + * @param params.vsCurrency - Quote currency (defaults to `usd`). + * @param refreshCache - When true, bypasses the cache for this call. + * @returns A promise that resolves to spot price entries keyed by asset ID. + * Omitted or null entries mean the API did not return data for that asset. */ async getSpotPrices( { assetIds, vsCurrency = 'usd', }: { - assetIds: KnownCaip19AssetIdOrSlip44Id[]; + assetIds: CaipAssetType[]; vsCurrency?: VsCurrencyParam | string; }, refreshCache: boolean = false, @@ -66,11 +91,13 @@ export class PriceService { } /** - * Get the fiat exchange rates. + * Gets exchange rates from the Price API (same payload shape as the fiat-rates + * endpoint: tickers keyed to name, value, and currency type). * Results are cached for `AppConfig.cache.ttlMilliseconds.fiatExchangeRates`. * - * @param refreshCache - Whether to refresh the cache. - * @returns The fiat exchange rates. + * @param refreshCache - When true, bypasses the cache for this call. + * @returns A promise that resolves to rates keyed by ticker (fiat, crypto, and + * commodity symbols). */ async getFiatExchangeRates( refreshCache: boolean = false, @@ -87,13 +114,19 @@ export class PriceService { } /** - * Get the historical prices for a token. + * Gets historical OHLC-style series for a single asset from the Price API. * Results are cached for `AppConfig.cache.ttlMilliseconds.historicalPrices`. * - * @param params - The parameters for the request. - * @param params.vsCurrency - Defaults to `usd` when omitted. - * @param refreshCache - Whether to refresh the cache. - * @returns The historical prices for the token. + * @param params - Request parameters. + * @param params.assetType - CAIP asset type to chart. + * @param params.timePeriod - Optional window such as `7d` (mutually exclusive + * with `from`/`to` in typical API usage). + * @param params.from - Optional range start (unix ms). + * @param params.to - Optional range end (unix ms). + * @param params.vsCurrency - Quote currency; defaults to `usd` when omitted. + * @param refreshCache - When true, bypasses the cache for this call. + * @returns A promise that resolves to price, market cap, and volume series for the + * requested range. */ async getHistoricalPrices( params: GetHistoricalPricesParams, @@ -117,4 +150,387 @@ export class PriceService { vsCurrency, }); } + + /** + * Loads historical prices for `from` in each configured calendar period, + * quoted in the asset reference parsed from `to` (used as `vsCurrency`). + * Failed periods return empty series via + * {@link GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT} so other periods still succeed. + * + * @param from - Base CAIP asset type. + * @param to - Quote asset; its CAIP `assetReference` becomes the vs ticker (lowercase). + * @returns A promise that resolves to an object with `intervals` (ISO 8601 duration keys, + * for example `P7D`, mapped to `[timestamp, price]` pairs with string prices), `updateTime`, + * and optional `expirationTime` so the caller can decide when to refresh cached data. + * @see https://github.com/MetaMask/core/blob/main/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.ts#L556 + */ + async getHistoricalPriceWithAllTimePeriods( + from: CaipAssetType, + to: CaipAssetType, + ): Promise<{ + intervals: HistoricalPriceIntervals; + updateTime: number; + expirationTime?: number; + }> { + const toTicker = parseCaipAssetType(to).assetReference.toLowerCase(); + + // For each time period, call the Price API to fetch the historical prices + const promises = ['1d', '7d', '1m', '3m', '1y', '1000y'].map( + async (timePeriod) => + this.getHistoricalPrices( + { + assetType: from, + timePeriod, + // It is possible that the toTicker is not a valid vsCurrency, + // but we can safely cast it to VsCurrencyParam because the Price API will throw an error if it is not a valid value + vsCurrency: toTicker as VsCurrencyParam, + }, + // Refresh the cache to ensure we get the latest data + true, + ) + // Wrap the response in an object with the time period and the response for easier reducing + .then((response) => ({ + timePeriod, + response, + })) + // Gracefully handle individual errors to avoid breaking the entire operation + .catch((error) => { + this.#logger.logErrorWithDetails( + `Error fetching historical prices for ${from} to ${to} with time period ${timePeriod}. Returning null object.`, + error, + ); + return { + timePeriod, + response: GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + }; + }), + ); + + const wrappedHistoricalPrices = await Promise.all(promises); + + // Format the response into the expected intervals format + const intervals = wrappedHistoricalPrices.reduce( + (acc, { timePeriod, response }) => { + const iso8601Interval = `P${timePeriod.toUpperCase()}`; + acc[iso8601Interval] = response.prices.map((price) => [ + price[0], + price[1].toString(), + ]); + return acc; + }, + {}, + ); + + // TODO: replace with more accurate expiration time for the result based on the data itself. + const now = Date.now(); + + const result = { + intervals, + updateTime: now, + expirationTime: now + AppConfig.cache.ttlMilliseconds.historicalPrices, + }; + + return result; + } + + /** + * Computes pairwise conversion rates between assets (fiat or crypto CAIP IDs). + * Uses {@link getFiatExchangeRates} and {@link getSpotPrices} (vs USD), + * then divides USD-equivalent values to obtain each `from`→`to` rate. + * That USD bridge is an approximation when both legs are not USD-quoted spot. + * Each {@link AssetConversion}'s `expirationTime` uses the shorter of the spot and + * fiat-exchange-rate cache TTLs. + * + * @param conversions - Pairs of `from` and `to` CAIP asset types. + * @returns A promise that resolves to a nested record `from` → `to` → + * {@link AssetConversion} or `null` when either leg has no usable rate. + */ + async getMultipleTokenConversions( + conversions: { from: CaipAssetType; to: CaipAssetType }[], + ): Promise< + Record> + > { + if (conversions.length === 0) { + return {}; + } + + /** + * `from` and `to` can represent both fiat and crypto assets. For us to get their values + * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. + */ + const allAssets = conversions.flatMap((conversion) => [ + conversion.from, + conversion.to, + ]); + + // Expired time is not being used by the caller, + // so we should use the cached results. + const { fiatExchangeRates, cryptoPrices } = + await this.#fetchPriceData(allAssets); + + /** + * Now that we have the data, convert the `from`s to `to`s. + * + * We need to handle the following cases: + * 1. `from` and `to` are both fiat + * 2. `from` and `to` are both crypto + * 3. `from` is fiat and `to` is crypto + * 4. `from` is crypto and `to` is fiat + * + * We also need to keep in mind that although `cryptoPrices` are indexed + * by CAIP 19 IDs, the `fiatExchangeRates` are indexed by currency symbols. + * To convert fiat currency symbols to CAIP 19 IDs, we can use the + * `this.#fiatSymbolToCaip19Id` method. + */ + const result: Record< + CaipAssetType, + Record + > = {}; + + conversions.forEach((conversion) => { + const { from, to } = conversion; + + result[from] ??= {}; + + const fromUsdRate = this.#calculateConversionRate({ + asset: from, + fiatExchangeRates, + cryptoPrices, + }); + + const toUsdRate = this.#calculateConversionRate({ + asset: to, + fiatExchangeRates, + cryptoPrices, + }); + + if (fromUsdRate.isZero() || toUsdRate.isZero()) { + result[from][to] = null; + return; + } + + const rate = fromUsdRate.dividedBy(toUsdRate).toString(); + + const now = Date.now(); + + // Caller is not using the expiration time, + // so we can just use the minimum of the two fixed TTLs as placeholder. + const expirationTime = Math.min( + AppConfig.cache.ttlMilliseconds.spotPrices, + AppConfig.cache.ttlMilliseconds.historicalPrices, + ); + + result[from][to] = { + rate, + conversionTime: now, + expirationTime: now + expirationTime, + }; + }); + + return result; + } + + /** + * Returns fungible market metrics for each crypto `asset`, with monetary fields + * expressed in the given `unit` (fiat or crypto) using the same USD bridge as + * {@link getMultipleTokenConversions}. + * + * @param assets - Rows with `asset` (must have spot data) and pricing `unit`. + * @returns A promise that resolves to a nested record `asset` → `unit` → + * {@link FungibleAssetMarketData}. Assets without spot prices or with a zero + * `unit` USD rate are omitted from the result. + */ + async getMultipleTokensMarketData( + assets: { + asset: CaipAssetType; + unit: CaipAssetType; + }[], + ): Promise< + Record> + > { + if (assets.length === 0) { + return {}; + } + + /** + * `asset` and `unit` can represent both fiat and crypto assets. For us to get their values + * the best approach is to use Price API's `getFiatExchangeRates` method for fiat prices, + * `getMultipleSpotPrices` for crypto prices and then using USD as an intermediate currency + * to convert the prices to the correct currency. + */ + const allAssets = assets.flatMap((asset) => [asset.asset, asset.unit]); + + const { fiatExchangeRates, cryptoPrices } = + await this.#fetchPriceData(allAssets); + + const result: Record< + CaipAssetType, + Record + > = {}; + + assets.forEach((asset) => { + const { asset: assetType, unit } = asset; + + // Skip if we don't have price data for the asset + if (!cryptoPrices[assetType]) { + return; + } + + const unitUsdRate = this.#calculateConversionRate({ + asset: unit, + fiatExchangeRates, + cryptoPrices, + }); + + if (unitUsdRate.isZero()) { + return; + } + + // Initialize the nested structure for the asset if it doesn't exist + result[assetType] ??= {}; + + // Store the market data with the unit as the key + result[assetType][unit] = this.#computeMarketData( + cryptoPrices[assetType], + unitUsdRate, + ); + }); + + return result; + } + + /** + * Converts USD-denominated spot metrics into the display `unit` by dividing + * each monetary field by the USD value of one `unit` (see {@link #calculateConversionRate}). + * Percent change fields are copied unchanged. + * + * @param spotPrice - Spot payload for the base asset (from the Price API, vs USD). + * @param rate - Non-zero USD price of one unit of the quote asset. + * @returns Market data scaled to the quote `unit`; empty strings where inputs are nullish. + */ + #computeMarketData( + spotPrice: SpotPrice, + rate: BigNumber, + ): FungibleAssetMarketData { + const marketDataInUsd = pick(spotPrice, [ + 'marketCap', + 'totalVolume', + 'circulatingSupply', + 'allTimeHigh', + 'allTimeLow', + 'pricePercentChange1h', + 'pricePercentChange1d', + 'pricePercentChange7d', + 'pricePercentChange14d', + 'pricePercentChange30d', + 'pricePercentChange200d', + 'pricePercentChange1y', + ]); + + // Variations in percent don't need to be converted, they are independent of the currency + const pricePercentChange = { + ...this.#includeIfDefined('PT1H', marketDataInUsd.pricePercentChange1h), + ...this.#includeIfDefined('P1D', marketDataInUsd.pricePercentChange1d), + ...this.#includeIfDefined('P7D', marketDataInUsd.pricePercentChange7d), + ...this.#includeIfDefined('P14D', marketDataInUsd.pricePercentChange14d), + ...this.#includeIfDefined('P30D', marketDataInUsd.pricePercentChange30d), + ...this.#includeIfDefined( + 'P200D', + marketDataInUsd.pricePercentChange200d, + ), + ...this.#includeIfDefined('P1Y', marketDataInUsd.pricePercentChange1y), + }; + + const marketDataInToCurrency = { + fungible: true, + marketCap: this.#toCurrencySafe(marketDataInUsd.marketCap, rate), + totalVolume: this.#toCurrencySafe(marketDataInUsd.totalVolume, rate), + circulatingSupply: (marketDataInUsd.circulatingSupply ?? 0).toString(), // Circulating supply counts the number of tokens in circulation, so we don't convert + allTimeHigh: this.#toCurrencySafe(marketDataInUsd.allTimeHigh, rate), + allTimeLow: this.#toCurrencySafe(marketDataInUsd.allTimeLow, rate), + // Add pricePercentChange field only if it has values + ...(Object.keys(pricePercentChange).length > 0 + ? { pricePercentChange } + : {}), + } as FungibleAssetMarketData; + + return marketDataInToCurrency; + } + + /** + * Loads exchange rates and USD spot prices needed for conversion and market views. + * Shared by {@link getMultipleTokenConversions} and {@link getMultipleTokensMarketData}. + * + * @param allAssets - Every `from`/`to` or `asset`/`unit` CAIP id involved (duplicates allowed). + * @param refreshCache - When true, bypasses the cache for this call. + * @returns A promise that resolves to the full fiat rate table plus a partial spot + * map for non-fiat ids only (fiat entries are not requested from spot pricing). + */ + async #fetchPriceData( + allAssets: CaipAssetType[], + refreshCache: boolean = false, + ): Promise<{ + fiatExchangeRates: FiatExchangeRatesResponse; + cryptoPrices: Partial; + }> { + const assetIds = allAssets.filter((asset) => !isFiat(asset)); + + const [fiatExchangeRates, cryptoPrices] = await Promise.all([ + this.getFiatExchangeRates(refreshCache), + this.getSpotPrices( + { + assetIds, + vsCurrency: 'usd', + }, + refreshCache, + ), + ]); + + return { fiatExchangeRates, cryptoPrices }; + } + + #calculateConversionRate({ + asset, + fiatExchangeRates, + cryptoPrices, + }: { + asset: CaipAssetType; + fiatExchangeRates: FiatExchangeRatesResponse; + cryptoPrices: Partial; + }): BigNumber { + if (isFiat(asset)) { + /** + * Beware: + * We need to invert the fiat exchange rate because exchange rate != spot price + */ + const ticker = getFiatTicker(asset) as Ticker; + const fiatExchangeRate = fiatExchangeRates[ticker]?.value; + + // if it is falsy, return 0 + if (!fiatExchangeRate) { + return new BigNumber(0); + } + + return new BigNumber(1).dividedBy(fiatExchangeRate); + } + return new BigNumber(cryptoPrices[asset]?.price ?? 0); + } + + #includeIfDefined( + key: string, + value: number | null | undefined, + ): Record { + return value === null || value === undefined ? {} : { [key]: value }; + } + + readonly #toCurrencySafe = ( + value: number | null | undefined, + rate: BigNumber, + ): string => { + return value === null || value === undefined + ? '' + : new BigNumber(value).dividedBy(rate).toString(); + }; } diff --git a/merged-packages/stellar-wallet-snap/src/services/price/__mocks__/price.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/price/__mocks__/price.fixtures.ts new file mode 100644 index 00000000..3bc4a9d9 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/price/__mocks__/price.fixtures.ts @@ -0,0 +1,59 @@ +import { logger } from '../../../utils/logger'; +import { createMemoryCache } from '../../cache/__mocks__/cache.fixtures'; +import { GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT } from '../api'; +import type { FiatExchangeRatesResponse } from '../price-api/api'; +import { PriceApiClient } from '../price-api/PriceApiClient'; +import { PriceService } from '../PriceService'; + +const fiatExchangeRatesBody = { + usd: { + name: 'US Dollar', + ticker: 'usd' as const, + value: 1, + currencyType: 'fiat' as const, + }, +} as FiatExchangeRatesResponse; + +export const createMockPriceService = () => { + const { cache, store } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + const getSpotPricesSpy = jest + .spyOn(PriceApiClient.prototype, 'getSpotPrices') + .mockResolvedValue({}); + + const getFiatExchangeRatesSpy = jest + .spyOn(PriceApiClient.prototype, 'getFiatExchangeRates') + .mockResolvedValue(fiatExchangeRatesBody); + + const getHistoricalPricesSpy = jest + .spyOn(PriceApiClient.prototype, 'getHistoricalPrices') + .mockResolvedValue(GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT); + + const getMultipleTokensMarketDataSpy = jest.spyOn( + PriceService.prototype, + 'getMultipleTokensMarketData', + ); + + const getMultipleTokenConversionsSpy = jest.spyOn( + PriceService.prototype, + 'getMultipleTokenConversions', + ); + + const getHistoricalPriceWithAllTimePeriodsSpy = jest.spyOn( + PriceService.prototype, + 'getHistoricalPriceWithAllTimePeriods', + ); + + return { + service, + cache, + store, + getSpotPricesSpy, + getFiatExchangeRatesSpy, + getHistoricalPricesSpy, + getMultipleTokensMarketDataSpy, + getMultipleTokenConversionsSpy, + getHistoricalPriceWithAllTimePeriodsSpy, + }; +}; diff --git a/merged-packages/stellar-wallet-snap/src/services/price/api.ts b/merged-packages/stellar-wallet-snap/src/services/price/api.ts new file mode 100644 index 00000000..c2d12ecf --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/price/api.ts @@ -0,0 +1,8 @@ +import type { GetHistoricalPricesResponse } from './price-api'; + +export const GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT: GetHistoricalPricesResponse = + { + prices: [], + marketCaps: [], + totalVolumes: [], + }; diff --git a/merged-packages/stellar-wallet-snap/src/services/price/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/price/exceptions.ts new file mode 100644 index 00000000..400b1036 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/price/exceptions.ts @@ -0,0 +1,6 @@ +export class PriceServiceException extends Error { + constructor(message: string) { + super(message); + this.name = 'PriceServiceException'; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.test.ts b/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.test.ts index 69839ca0..8ef97e93 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.test.ts @@ -1,4 +1,4 @@ -import { GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT } from './api'; +import { GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT } from '../api'; import { PriceApiException } from './exceptions'; import { PriceApiClient } from './PriceApiClient'; import { buildUrl, logger } from '../../../utils'; diff --git a/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts b/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts index 8bf4fe01..afea33f3 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/price-api/PriceApiClient.ts @@ -18,10 +18,10 @@ import { PriceApiException } from './exceptions'; import { UrlStruct } from '../../../api'; import type { ILogger } from '../../../utils'; import { - batchesAllSettled, + batchesAllSettledWithChunks, buildUrl, - chunks as chunkItems, logger, + rethrowIfInstanceElseThrow, } from '../../../utils'; export class PriceApiClient { @@ -64,7 +64,7 @@ export class PriceApiClient { const response = await this.#fetch(url); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + throw new PriceApiException(`HTTP error! status: ${response.status}`); } const data = await response.json(); @@ -76,7 +76,11 @@ export class PriceApiClient { 'Error fetching fiat exchange rates', error, ); - throw new PriceApiException('Error fetching fiat exchange rates'); + return rethrowIfInstanceElseThrow( + error, + [PriceApiException], + new PriceApiException('Error fetching fiat exchange rates'), + ); } } @@ -100,11 +104,9 @@ export class PriceApiClient { const deduplicatedAssetIds = [...new Set(assetIds)]; - // Split into chunks - const chunks = chunkItems(deduplicatedAssetIds, this.#chunkSize); - - const settled = await batchesAllSettled( - chunks, + const settled = await batchesAllSettledWithChunks( + deduplicatedAssetIds, + this.#chunkSize, PriceApiClient.#parallelBatchFetchLimit, async (chunk) => this.#fetchSpotPricesBatch(chunk, vsCurrency), ); @@ -112,10 +114,6 @@ export class PriceApiClient { const response: Partial = {}; for (const entry of settled) { if (entry.status === 'rejected') { - this.#logger.logErrorWithDetails( - 'Error fetching spot prices', - entry.reason, - ); continue; } for (const [assetId, spotPrice] of Object.entries(entry.value)) { @@ -135,26 +133,35 @@ export class PriceApiClient { assetIds: CaipAssetType[], vsCurrency: VsCurrencyParam | string = 'usd', ): Promise { - const url = buildUrl({ - baseUrl: this.#baseUrl, - path: '/v3/spot-prices', - queryParams: { - vsCurrency, - assetIds: assetIds.join(','), - includeMarketData: 'true', - }, - }); - - const response = await this.#fetch(url); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } + try { + const url = buildUrl({ + baseUrl: this.#baseUrl, + path: '/v3/spot-prices', + queryParams: { + vsCurrency, + assetIds: assetIds.join(','), + includeMarketData: 'true', + }, + }); + + const response = await this.#fetch(url); + + if (!response.ok) { + throw new PriceApiException(`HTTP error! status: ${response.status}`); + } - const spotPrices = await response.json(); - assert(spotPrices, SpotPricesStruct); + const spotPrices = await response.json(); + assert(spotPrices, SpotPricesStruct); - return spotPrices; + return spotPrices; + } catch (error) { + this.#logger.logErrorWithDetails('Error fetching spot prices', error); + return rethrowIfInstanceElseThrow( + error, + [PriceApiException], + new PriceApiException('Error fetching spot prices'), + ); + } } /** @@ -195,7 +202,7 @@ export class PriceApiClient { const response = await this.#fetch(url); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + throw new PriceApiException(`HTTP error! status: ${response.status}`); } const historicalPrices = await response.json(); @@ -207,7 +214,11 @@ export class PriceApiClient { 'Error fetching historical prices', error, ); - throw new PriceApiException('Error fetching historical prices'); + return rethrowIfInstanceElseThrow( + error, + [PriceApiException], + new PriceApiException('Error fetching historical prices'), + ); } } } diff --git a/merged-packages/stellar-wallet-snap/src/services/price/price-api/api.test.ts b/merged-packages/stellar-wallet-snap/src/services/price/price-api/api.test.ts index 0327c76f..628cd14f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/price-api/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/price-api/api.test.ts @@ -6,11 +6,11 @@ import { FiatExchangeRatesResponseStruct, GetHistoricalPricesParamsStruct, GetHistoricalPricesResponseStruct, - GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, SpotPriceStruct, SpotPricesStruct, type SpotPrices, } from './api'; +import { GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT } from '../api'; const stellarClassicUsdc = 'stellar:testnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as const; diff --git a/merged-packages/stellar-wallet-snap/src/services/price/price-api/api.ts b/merged-packages/stellar-wallet-snap/src/services/price/price-api/api.ts index 08fec109..81270d7e 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/price-api/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/price-api/api.ts @@ -239,10 +239,3 @@ export const GetHistoricalPricesResponseStruct = object({ export type GetHistoricalPricesResponse = Infer< typeof GetHistoricalPricesResponseStruct >; - -export const GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT: GetHistoricalPricesResponse = - { - prices: [], - marketCaps: [], - totalVolumes: [], - }; diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts index a343feaa..21e919fa 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts @@ -1,6 +1,14 @@ +import type { CaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { normalizeAmount, toSmallestUnit } from './currency'; +import { + formatFiat, + getFiatTicker, + isFiat, + normalizeAmount, + tokenToFiat, + toSmallestUnit, +} from './currency'; describe('toSmallestUnit', () => { it('converts human amount to stroops', () => { @@ -12,6 +20,10 @@ describe('toSmallestUnit', () => { it('converts integer XLM to stroops', () => { expect(toSmallestUnit(new BigNumber(1)).toFixed(0)).toBe('10000000'); }); + + it('uses custom decimal places when provided', () => { + expect(toSmallestUnit(new BigNumber('1.23'), 2).toFixed(0)).toBe('123'); + }); }); describe('normalizeAmount', () => { @@ -20,6 +32,10 @@ describe('normalizeAmount', () => { '12.3456789', ); }); + + it('uses custom decimal places when provided', () => { + expect(normalizeAmount(new BigNumber(123), 2).toString()).toBe('1.23'); + }); }); describe('toSmallestUnit and normalizeAmount', () => { @@ -29,3 +45,81 @@ describe('toSmallestUnit and normalizeAmount', () => { expect(normalizeAmount(stroops).toString()).toBe(human.toString()); }); }); + +describe('formatFiat', () => { + it('rounds to two decimals before locale formatting and passes currency options', () => { + const toLocaleStringSpy = jest + .spyOn(Number.prototype, 'toLocaleString') + .mockImplementation(function formatFiatLocaleSpy( + this: number, + locales?: Intl.LocalesArgument, + options?: Intl.NumberFormatOptions, + ) { + const locale = locales; + expect(this.valueOf()).toBe(12.35); + expect(locale).toBe('en'); + expect(options).toStrictEqual({ + style: 'currency', + currency: 'USD', + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }); + return 'formatted'; + }); + + expect(formatFiat('12.345', 'USD', 'en_US')).toBe('formatted'); + + expect(toLocaleStringSpy).toHaveBeenCalledTimes(1); + + toLocaleStringSpy.mockRestore(); + }); + + it('throws when amount is not finite', () => { + expect(() => formatFiat('NaN', 'USD', 'en-US')).toThrow(RangeError); + }); +}); + +describe('tokenToFiat', () => { + it('multiplies token amount by rate as decimal strings', () => { + expect(tokenToFiat('10', '2.5')).toBe('25'); + }); + + it('handles fractional token amounts', () => { + expect(tokenToFiat('0.5', '4')).toBe('2'); + }); +}); + +describe('isFiat', () => { + it('returns true for swift ISO4217 ids', () => { + expect(isFiat('swift:0/iso4217:USD' as CaipAssetType)).toBe(true); + }); + + it('returns false for chain-prefixed fiat ids', () => { + expect(isFiat('eip155:1/swift:0/iso4217:USD' as CaipAssetType)).toBe(false); + }); + + it('returns false for stellar asset ids', () => { + expect( + isFiat( + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as CaipAssetType, + ), + ).toBe(false); + }); + + it('returns false when ISO4217 segment is not exactly three letters', () => { + expect(isFiat('swift:0/iso4217:US' as CaipAssetType)).toBe(false); + expect(isFiat('swift:0/iso4217:USDC' as CaipAssetType)).toBe(false); + }); +}); + +describe('getFiatTicker', () => { + it('throws when asset id is not fiat', () => { + expect(() => + getFiatTicker('stellar:pubnet/slip44:148' as CaipAssetType), + ).toThrow('Passed assetId is not a fiat asset'); + }); + + it('returns lowercase asset reference from parser', () => { + expect(getFiatTicker('swift:0/iso4217:EUR' as CaipAssetType)).toBe('eur'); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.ts index c4c07013..8a29ffac 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/currency.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.ts @@ -1,5 +1,9 @@ +import { is } from '@metamask/superstruct'; +import type { CaipAssetType } from '@metamask/utils'; +import { parseCaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; +import { FiatCaipAssetStruct } from '../api/asset'; import { STELLAR_DECIMAL_PLACES } from '../constants'; /** @@ -33,3 +37,78 @@ export function normalizeAmount( ): BigNumber { return amount.dividedBy(BigNumber(10).pow(decimalPlaces)); } + +/** + * Formats a number as currency (half-up rounded to 2 decimal places). + * + * @param amount - The amount of money. + * @param currency - The currency to format the amount as. + * @param locale - The locale to use for number formatting. + * @returns The formatted currency string. + * @throws {RangeError} If the amount is not a finite number. + */ +export function formatFiat( + amount: string, + currency: string, + locale: string, +): string { + const rounded = new BigNumber(amount).decimalPlaces( + 2, + BigNumber.ROUND_HALF_UP, + ); + + if (!rounded.isFinite()) { + throw new RangeError('Amount must be a finite number for fiat formatting'); + } + + const amountNumber = rounded.toNumber(); + const [localeCode] = locale.split('_'); + + return amountNumber.toLocaleString(localeCode, { + style: 'currency', + currency, + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }); +} + +/** + * Converts a token amount to fiat currency using the provided conversion rate. + * + * @param tokenAmount - The amount of tokens to convert. + * @param rateConversion - The conversion rate from token to fiat. + * @returns The fiat value of the token amount. + */ +export function tokenToFiat( + tokenAmount: string, + rateConversion: string, +): string { + const bigAmount = new BigNumber(tokenAmount); + return bigAmount.multipliedBy(new BigNumber(rateConversion)).toString(); +} + +/** + * Checks if a CAIP-19 asset type is a fiat asset. + * + * @param assetId - The CAIP-19 asset type. + * @returns True if the asset is a fiat asset, false otherwise. + */ +export function isFiat(assetId: CaipAssetType): boolean { + return is(assetId, FiatCaipAssetStruct); +} + +/** + * Extracts the ISO 4217 currency code (aka fiat ticker) from a fiat CAIP-19 asset ID. + * + * @param assetId - The CAIP-19 asset ID. + * @returns The fiat ticker. + */ +export function getFiatTicker(assetId: CaipAssetType): string { + if (!isFiat(assetId)) { + throw new Error('Passed assetId is not a fiat asset'); + } + + const fiatTicker = parseCaipAssetType(assetId).assetReference.toLowerCase(); + + return fiatTicker; +} From 99278fb9e75652cf8ac005be214c9dbd65e4b14c Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:19:56 +0800 Subject: [PATCH 079/384] feat: adding fee refresh ux --- .../stellar-wallet-snap/locales/en.json | 39 +++++ .../stellar-wallet-snap/messages.json | 39 +++++ .../stellar-wallet-snap/snap.manifest.json | 7 +- .../src/api/fetch-status.ts | 7 + .../stellar-wallet-snap/src/constants.ts | 5 +- .../stellar-wallet-snap/src/context.ts | 41 ++++- .../stellar-wallet-snap/src/handlers/base.ts | 117 ++++++++----- .../src/handlers/cronjob/api.ts | 96 +++++++++++ .../src/handlers/cronjob/base.ts | 34 ++++ .../src/handlers/cronjob/cronjob.ts | 44 +++++ .../src/handlers/cronjob/exceptions.ts | 13 ++ .../cronjob/refreshConfirmationPrices.ts | 155 ++++++++++++++++++ .../src/handlers/cronjob/trackTransaction.ts | 45 +++++ .../stellar-wallet-snap/src/handlers/index.ts | 1 + .../stellar-wallet-snap/src/index.ts | 5 + .../transaction/TransactionService.ts | 26 +++ .../__mocks__/transaction.fixtures.ts | 2 + .../src/ui/confirmation/api.ts | 22 +++ .../src/ui/confirmation/components/Asset.tsx | 57 +++++++ .../ui/confirmation/components/AssetIcon.tsx | 37 +++++ .../ui/confirmation/components/AssetText.tsx | 24 +++ .../src/ui/confirmation/components/Fee.tsx | 49 ++++++ .../src/ui/confirmation/components/index.ts | 3 + .../src/ui/confirmation/utils.ts | 69 +++++++- .../src/ui/images/account-active-method-1.svg | 3 + .../src/ui/images/account-active-method-2.svg | 3 + .../src/ui/images/index.ts | 11 ++ .../src/ui/images/question-mark.svg | 3 + .../src/ui/images/slip44:148.svg | 7 + .../src/utils/currency.test.ts | 84 +++++++++- .../stellar-wallet-snap/src/utils/currency.ts | 68 ++++++++ .../stellar-wallet-snap/src/utils/i18n.ts | 5 +- .../src/utils/serialization.ts | 19 +++ .../stellar-wallet-snap/src/utils/snap.ts | 23 +-- 34 files changed, 1106 insertions(+), 57 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/api/fetch-status.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/base.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/exceptions.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Asset.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetIcon.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetText.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Fee.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-1.svg create mode 100644 merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-2.svg create mode 100644 merged-packages/stellar-wallet-snap/src/ui/images/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/ui/images/question-mark.svg create mode 100644 merged-packages/stellar-wallet-snap/src/ui/images/slip44:148.svg diff --git a/merged-packages/stellar-wallet-snap/locales/en.json b/merged-packages/stellar-wallet-snap/locales/en.json index 77ffdd4c..03e520fd 100644 --- a/merged-packages/stellar-wallet-snap/locales/en.json +++ b/merged-packages/stellar-wallet-snap/locales/en.json @@ -43,6 +43,9 @@ "confirmation.cancelButton": { "message": "Cancel" }, + "confirmation.closeButton": { + "message": "Close" + }, "confirmation.signMessage.title": { "message": "Sign message" }, @@ -52,6 +55,9 @@ "confirmation.account": { "message": "Account" }, + "confirmation.asset": { + "message": "Asset" + }, "confirmation.signTransaction.title": { "message": "Sign transaction" }, @@ -319,6 +325,15 @@ "confirmation.transaction.param.valueBase64": { "message": "Value (base64)" }, + "confirmation.signChangeTrustOptIn.title": { + "message": "Add {asset} trustline" + }, + "confirmation.signChangeTrustOptIn.updateTitle": { + "message": "Update {asset} trustline limit" + }, + "confirmation.signChangeTrustOptOut.title": { + "message": "Remove {asset} trustline" + }, "transactionScan.errors.unknownError": { "message": "An unknown error occurred" }, @@ -336,6 +351,30 @@ }, "transactionScan.errors.unsupportedEIP712Message": { "message": "Unsupported method" + }, + "confirmation.accountActivation.title": { + "message": "Activate Stellar Wallet" + }, + "confirmation.accountActivation.description": { + "message": "Your Stellar wallet needs XLM." + }, + "confirmation.accountActivation.address": { + "message": "Your wallet address." + }, + "confirmation.accountActivation.copyAddress": { + "message": "Copy Address" + }, + "confirmation.accountActivation.method1.title": { + "message": "Ask someone to send XLM" + }, + "confirmation.accountActivation.method1.description": { + "message": "Share your address · come back when received" + }, + "confirmation.accountActivation.method2.title": { + "message": "Fund from exchange" + }, + "confirmation.accountActivation.method2.description": { + "message": "Coinbase, Binance, etc → send to address below" } } } diff --git a/merged-packages/stellar-wallet-snap/messages.json b/merged-packages/stellar-wallet-snap/messages.json index 6c6e6600..82a62b52 100644 --- a/merged-packages/stellar-wallet-snap/messages.json +++ b/merged-packages/stellar-wallet-snap/messages.json @@ -41,6 +41,9 @@ "confirmation.cancelButton": { "message": "Cancel" }, + "confirmation.closeButton": { + "message": "Close" + }, "confirmation.signMessage.title": { "message": "Sign message" }, @@ -50,6 +53,9 @@ "confirmation.account": { "message": "Account" }, + "confirmation.asset": { + "message": "Asset" + }, "confirmation.signTransaction.title": { "message": "Sign transaction" }, @@ -317,6 +323,15 @@ "confirmation.transaction.param.valueBase64": { "message": "Value (base64)" }, + "confirmation.signChangeTrustOptIn.title": { + "message": "Add {asset} trustline" + }, + "confirmation.signChangeTrustOptIn.updateTitle": { + "message": "Update {asset} trustline limit" + }, + "confirmation.signChangeTrustOptOut.title": { + "message": "Remove {asset} trustline" + }, "transactionScan.errors.unknownError": { "message": "An unknown error occurred" }, @@ -334,5 +349,29 @@ }, "transactionScan.errors.unsupportedEIP712Message": { "message": "Unsupported method" + }, + "confirmation.accountActivation.title": { + "message": "Activate Stellar Wallet" + }, + "confirmation.accountActivation.description": { + "message": "Your Stellar wallet needs XLM." + }, + "confirmation.accountActivation.address": { + "message": "Your wallet address." + }, + "confirmation.accountActivation.copyAddress": { + "message": "Copy Address" + }, + "confirmation.accountActivation.method1.title": { + "message": "Ask someone to send XLM" + }, + "confirmation.accountActivation.method1.description": { + "message": "Share your address · come back when received" + }, + "confirmation.accountActivation.method2.title": { + "message": "Fund from exchange" + }, + "confirmation.accountActivation.method2.description": { + "message": "Coinbase, Binance, etc → send to address below" } } diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 20d0e324..74df3e53 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "IWQMqSlpgPaQiqhJ/u+YJMk04/q6AqfWd8eTNM6aseg=", + "shasum": "7NK5wy795vJbzK7s14IYAKjX21lgo1/YV6dEfXVSvhw=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -35,8 +35,9 @@ "snap_manageAccounts": {}, "snap_manageState": {}, "snap_dialog": {}, - "snap_getPreferences": {} + "snap_getPreferences": {}, + "endowment:cronjob": {} }, - "platformVersion": "10.3.0", + "platformVersion": "10.4.0", "manifestVersion": "0.1" } diff --git a/merged-packages/stellar-wallet-snap/src/api/fetch-status.ts b/merged-packages/stellar-wallet-snap/src/api/fetch-status.ts new file mode 100644 index 00000000..21ccdfcb --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/api/fetch-status.ts @@ -0,0 +1,7 @@ +export enum FetchStatus { + Initial = 'initial', + Fetching = 'fetching', + Fetched = 'fetched', + // eslint-disable-next-line @typescript-eslint/no-shadow + Error = 'error', +} diff --git a/merged-packages/stellar-wallet-snap/src/constants.ts b/merged-packages/stellar-wallet-snap/src/constants.ts index d31f5dbc..e394a162 100644 --- a/merged-packages/stellar-wallet-snap/src/constants.ts +++ b/merged-packages/stellar-wallet-snap/src/constants.ts @@ -59,12 +59,15 @@ export const NATIVE_ASSET_NAME = 'Lumen'; */ export const BASE_FEE = 100; +/** TTL for caching Stellar network base fee lookups (milliseconds). */ +export const BASE_FEE_CACHE_TTL_MILLISECONDS = 60_000; + /** * The maximum int64 balance for the Stellar network. * * @see https://stellar.org/learn/lumens */ -export const MAX_INT64_BALANCE = '9223372036854775807'; +export const MAX_INT64 = '9223372036854775807'; /** * The type for the keyring account. diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 2d9ebaa7..82d69fb5 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -1,19 +1,24 @@ import { assert, object } from '@metamask/superstruct'; import { AppConfig } from './config'; -import { KeyringHandler } from './handlers'; +import { KeyringHandler, CronjobHandler, UserInputHandler } from './handlers'; +import type { ICronjobRequestHandler } from './handlers/cronjob/api'; +import { BackgroundEventMethod } from './handlers/cronjob/api'; +import { RefreshConfirmationPricesHandler } from './handlers/cronjob/refreshConfirmationPrices'; +import { TrackTransactionHandler } from './handlers/cronjob/trackTransaction'; import type { IKeyringRequestHandler } from './handlers/keyring'; import { MultichainMethod, SignMessageHandler, SignTransactionHandler, } from './handlers/keyring'; -import { UserInputHandler } from './handlers/user-input/userInput'; import { AccountService, AccountsRepository } from './services/account'; import type { AccountBalanceState } from './services/account-balance'; +import { StateCache } from './services/cache'; import { NetworkService } from './services/network'; import type { OnChainAccountSnapshotState } from './services/on-chain-account'; import { OnChainAccountService } from './services/on-chain-account'; +import { PriceService } from './services/price/PriceService'; import { State } from './services/state'; import { TransactionBuilder, @@ -61,6 +66,12 @@ const transactionService = new TransactionService({ logger, transactionRepository, networkService, + cache: new StateCache(state, logger, '__cache__transaction'), +}); + +const priceService = new PriceService({ + cache: new StateCache(state, logger, '__cache__price'), + logger, }); /** ------------------------------ Keyring Handler ------------------------------ */ @@ -95,11 +106,37 @@ const keyringHandler = new KeyringHandler({ handlers: keyringMethodHandlers, }); +/** ------------------------------ Input Handler ------------------------------ */ const userInputHandler = new UserInputHandler({ logger, }); +/** ------------------------------ Cronjob Handler ------------------------------ */ + +const refreshConfirmationPricesHandler = new RefreshConfirmationPricesHandler({ + logger, + priceService, +}); + +const trackTransactionHandler = new TrackTransactionHandler({ + logger, +}); + +const cronjobMethodHandlers: Record< + BackgroundEventMethod, + ICronjobRequestHandler +> = { + [BackgroundEventMethod.RefreshConfirmationPrices]: + refreshConfirmationPricesHandler, + [BackgroundEventMethod.TrackTransaction]: trackTransactionHandler, +}; + +const cronjobHandler = new CronjobHandler({ + handlers: cronjobMethodHandlers, +}); + export { + cronjobHandler, keyringHandler, userInputHandler, signTransactionHandler, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/base.ts index e4cef5f5..9a8a5d28 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/base.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/base.ts @@ -12,8 +12,9 @@ import type { OnChainAccountService } from '../services/on-chain-account'; import { OnChainAccount } from '../services/on-chain-account'; import type { WalletService } from '../services/wallet'; import { Wallet } from '../services/wallet'; +import { render as renderAccountActivationPrompt } from '../ui/confirmation/views/AccountActivationPrompt/render'; import type { ILogger } from '../utils'; -import { validateRequest, validateResponse } from '../utils'; +import { serializeToString, validateRequest, validateResponse } from '../utils'; export const DEFAULT_RESOLVE_ACCOUNT_OPTIONS = { onChainAccount: true, @@ -41,6 +42,72 @@ export type ResolvedActivatedAccountFor = { export type ResolvedActivatedAccount = ResolvedActivatedAccountFor; +export abstract class BaseHandler< + RequestType extends Json, + ResponseType extends Json, +> { + protected readonly requestStruct: Struct; + + protected readonly responseStruct: Struct; + + protected readonly logger: ILogger; + + constructor({ + logger, + requestStruct, + responseStruct, + }: { + logger: ILogger; + requestStruct: Struct; + responseStruct: Struct; + }) { + this.logger = logger; + this.requestStruct = requestStruct; + this.responseStruct = responseStruct; + } + + protected abstract handleRequest( + request: RequestType, + ): Promise; + + /** + * Handles a JSON-RPC request by resolving an activated account and calling the _handle method. + * + * @param request - The JSON-RPC request to handle. + * @returns The result of the _handle method. + */ + async handle( + request: RequestType | JsonRpcRequest | Json, + ): Promise { + this.logger.debug('Handling request', { + request: serializeToString({ value: request }), + }); + + const validatedRequest = validateRequest(request, this.requestStruct); + + let result: ResponseType | Json; + try { + this.logger.debug(`Starting handle transformed request`, { + request: serializeToString({ value: validatedRequest }), + }); + result = await this.handleRequest(validatedRequest); + } catch (error: unknown) { + this.logger.logErrorWithDetails( + 'Error handling request', + ensureError(error).message, + ); + throw error; + } + + this.logger.debug('Handled request', { + result: serializeToString({ value: result }), + }); + + validateResponse(result, this.responseStruct); + + return result; + } +} /** * A base class for client request handlers that require an activated account. */ @@ -48,19 +115,13 @@ export abstract class WithActiveAccountResolve< RequestType extends Json, ResponseType extends Json, Opts extends ResolveAccountOptions = DefaultResolveAccountOptions, -> { - protected readonly logger: ILogger; - +> extends BaseHandler { protected readonly accountService: AccountService; protected readonly onChainAccountService: OnChainAccountService; protected readonly walletService: WalletService; - protected readonly requestStruct: Struct; - - protected readonly responseStruct: Struct; - readonly #resolveAccountOptions: ResolveAccountOptions; constructor({ @@ -81,12 +142,10 @@ export abstract class WithActiveAccountResolve< /** Partial override; omitted flags default to {@link DEFAULT_RESOLVE_ACCOUNT_OPTIONS}. */ resolveAccountOptions?: Partial; }) { - this.logger = logger; + super({ logger, requestStruct, responseStruct }); this.accountService = accountService; this.onChainAccountService = onChainAccountService; this.walletService = walletService; - this.requestStruct = requestStruct; - this.responseStruct = responseStruct; this.#resolveAccountOptions = { onChainAccount: resolveAccountOptions?.onChainAccount ?? @@ -107,38 +166,23 @@ export abstract class WithActiveAccountResolve< * @param request - The JSON-RPC request to handle. * @returns The result of the _handle method. */ - async handle( - request: RequestType | JsonRpcRequest | Json, + protected async handleRequest( + request: RequestType, ): Promise { - this.logger.debug('Handling request', { request }); - - const validatedRequest = validateRequest(request, this.requestStruct); + this.logger.debug('resolve account request', { + resolveOptions: this.#resolveAccountOptions, + }); let resolvedAccount: ResolvedActivatedAccountFor; try { - resolvedAccount = await this.resolveAccount(validatedRequest); + resolvedAccount = await this.resolveAccount(request); } catch (error: unknown) { if (error instanceof AccountNotActivatedException) { return await this.handleAccountNotActivatedError(error); } throw ensureError(error); } - - let result: ResponseType | Json; - try { - result = await this._handle(resolvedAccount, validatedRequest); - } catch (error: unknown) { - this.logger.logErrorWithDetails('Error handling request', error); - throw error; - } - - this.logger.debug('Handled request', { - result: JSON.stringify(result, null, 2), - }); - - validateResponse(result, this.responseStruct); - - return result; + return await this._handle(resolvedAccount, request); } protected async resolveAccount( @@ -189,9 +233,8 @@ export abstract class WithActiveAccountResolve< */ protected abstract getAccountId(request: RequestType): string; - async #showAccountNotActivatedAlert(): Promise { - // TODO: Implement account not activated alert - throw new Error('Account not activated: user alert not implemented'); + async #showAccountNotActivatedAlert(address: string): Promise { + await renderAccountActivationPrompt(address); } /** @@ -204,7 +247,7 @@ export abstract class WithActiveAccountResolve< protected async handleAccountNotActivatedError( error: AccountNotActivatedException, ): Promise { - await this.#showAccountNotActivatedAlert(); + await this.#showAccountNotActivatedAlert(error.address); throw error; } } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts new file mode 100644 index 00000000..407e87c0 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts @@ -0,0 +1,96 @@ +import type { Infer } from '@metamask/superstruct'; +import { + array, + assign, + boolean, + enums, + literal, + nonempty, + object, + string, + type, +} from '@metamask/superstruct'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import { + JsonRpcRequestStruct, + KnownCaip2ChainIdStruct, + UuidStruct, +} from '../../api'; + +/** + * Interface for the client request handler. + */ +export type ICronjobRequestHandler = { + handle: (request: JsonRpcRequest) => Promise; +}; + +export enum CronjobMethod { + SynchronizeAssets = 'synchronizeAssets', +} + +export enum BackgroundEventMethod { + RefreshConfirmationPrices = 'refreshConfirmationPrices', + TrackTransaction = 'trackTransaction', +} + +export enum ConfirmationInterfaceKey { + ChangeTrustlineOptIn = 'ChangeTrustlineOptIn', + ChangeTrustlineOptOut = 'ChangeTrustlineOptOut', +} + +export const BackgroundEventMethodStruct = enums( + Object.values(BackgroundEventMethod), +); + +export const ConfirmationInterfaceKeyStruct = enums( + Object.values(ConfirmationInterfaceKey), +); + +export const RefreshConfirmationPricesParamsStruct = type({ + scope: KnownCaip2ChainIdStruct, + interfaceId: nonempty(string()), + interfaceKey: ConfirmationInterfaceKeyStruct, +}); + +export const TrackTransactionParamsStruct = type({ + txId: nonempty(string()), + scope: KnownCaip2ChainIdStruct, + accountIds: nonempty(array(UuidStruct)), +}); + +export const RefreshConfirmationPricesJsonRpcRequestStruct = assign( + JsonRpcRequestStruct, + object({ + method: literal(BackgroundEventMethod.RefreshConfirmationPrices), + params: RefreshConfirmationPricesParamsStruct, + }), +); + +export const TrackTransactionJsonRpcRequestStruct = assign( + JsonRpcRequestStruct, + object({ + method: literal(BackgroundEventMethod.TrackTransaction), + params: TrackTransactionParamsStruct, + }), +); + +export const CrobJobJsonRpcRequestStruct = object({ + status: boolean(), +}); + +export type CrobJobJsonRpcRequest = Infer; + +export type RefreshConfirmationPricesJsonRpcRequest = Infer< + typeof RefreshConfirmationPricesJsonRpcRequestStruct +>; + +export type RefreshConfirmationPricesParams = Infer< + typeof RefreshConfirmationPricesParamsStruct +>; + +export type TrackTransactionJsonRpcRequest = Infer< + typeof TrackTransactionJsonRpcRequestStruct +>; + +export type TrackTransactionParams = Infer; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/base.ts new file mode 100644 index 00000000..51a88c69 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/base.ts @@ -0,0 +1,34 @@ +import type { Struct } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; + +import { BaseHandler } from '../base'; +import type { CrobJobJsonRpcRequest } from './api'; +import { CrobJobJsonRpcRequestStruct } from './api'; +import type { ILogger } from '../../utils'; + +export abstract class CronjobBaseHandler< + RequestType extends Json, +> extends BaseHandler { + constructor({ + logger, + requestStruct, + }: { + logger: ILogger; + requestStruct: Struct; + }) { + super({ + logger, + requestStruct, + responseStruct: CrobJobJsonRpcRequestStruct, + }); + } + + protected async handleRequest(request: RequestType): Promise { + await this.handleCronJobRequest(request); + return { + status: true, + }; + } + + abstract handleCronJobRequest(request: RequestType): Promise; +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts new file mode 100644 index 00000000..4ac9f9f4 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts @@ -0,0 +1,44 @@ +import type { JsonRpcRequest } from '@metamask/snaps-sdk'; +import { ensureError } from '@metamask/utils'; + +import type { BackgroundEventMethod, ICronjobRequestHandler } from './api'; +import { BackgroundEventMethodStruct } from './api'; +import { CronjobMethodNotFoundError } from './exceptions'; +import { getClientStatus } from '../../utils/snap'; + +export class CronjobHandler { + readonly #handlers: Record; + + constructor({ + handlers, + }: { + handlers: Record; + }) { + this.#handlers = handlers; + } + + async handle(request: JsonRpcRequest): Promise { + const { active, locked } = await getClientStatus(); + + // if the client is not active or locked, we dont execute the cronjob + if (!active || locked) { + return; + } + + await this.#handleClientRequest(request); + } + + async #handleClientRequest(request: JsonRpcRequest): Promise { + const { method } = request; + + const [validateError, validatedMethod] = + BackgroundEventMethodStruct.validate(method); + if (validateError !== undefined) { + throw ensureError(new CronjobMethodNotFoundError(method)); + } + + const handler = this.#handlers[validatedMethod]; + + await handler.handle(request); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/exceptions.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/exceptions.ts new file mode 100644 index 00000000..16d89ab8 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/exceptions.ts @@ -0,0 +1,13 @@ +export class CronjobError extends Error { + constructor(message: string) { + super(message); + this.name = 'CronjobError'; + } +} + +export class CronjobMethodNotFoundError extends CronjobError { + constructor(method: string) { + super(`Unknown cronjob method: ${method}`); + this.name = 'CronjobMethodNotFoundError'; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts new file mode 100644 index 00000000..606caaaf --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts @@ -0,0 +1,155 @@ +import { + BackgroundEventMethod, + ConfirmationInterfaceKey, + RefreshConfirmationPricesJsonRpcRequestStruct, +} from './api'; +import type { + RefreshConfirmationPricesJsonRpcRequest, + RefreshConfirmationPricesParams, +} from './api'; +import { CronjobBaseHandler } from './base'; +import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; +import type { PriceService } from '../../services/price'; +import type { ContextWithPrices } from '../../ui/confirmation/api'; +import { FetchStatus } from '../../ui/confirmation/api'; +import { refreshConfirmationPrices as refreshConfirmationPricesChangeTrustlineOptIn } from '../../ui/confirmation/views/ConfirmSignChangeTrustOptIn/render'; +import { refreshConfirmationPrices as refreshConfirmationPricesChangeTrustlineOptOut } from '../../ui/confirmation/views/ConfirmSignChangeTrustOptOut/render'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; +import { + getInterfaceContextIfExists, + scheduleBackgroundEvent, +} from '../../utils/snap'; + +export class RefreshConfirmationPricesHandler extends CronjobBaseHandler { + readonly #priceService: PriceService; + + static readonly duration = 'PT20S'; + + static async scheduleBackgroundEvent( + params: RefreshConfirmationPricesParams, + duration: string = RefreshConfirmationPricesHandler.duration, + ): Promise { + await scheduleBackgroundEvent({ + method: BackgroundEventMethod.RefreshConfirmationPrices, + params, + duration, + }); + } + + constructor({ + logger, + priceService, + }: { + logger: ILogger; + priceService: PriceService; + }) { + const prefixedLogger = createPrefixedLogger( + logger, + '[🔄 RefreshConfirmationPricesHandler]', + ); + super({ + logger: prefixedLogger, + requestStruct: RefreshConfirmationPricesJsonRpcRequestStruct, + }); + this.#priceService = priceService; + } + + async handleCronJobRequest( + request: RefreshConfirmationPricesJsonRpcRequest, + ): Promise { + this.logger.info('Refreshing confirmation prices...'); + const { interfaceId, scope, interfaceKey } = request.params; + + const interfaceContext = + await getInterfaceContextIfExists(interfaceId); + if (!interfaceContext) { + this.logger.info('Interface no longer exists, cleaning up'); + return; + } + + try { + const uniqueAssetCaipIds: KnownCaip19AssetIdOrSlip44Id[] = [ + ...Object.keys(interfaceContext.tokenPrices), + ] as KnownCaip19AssetIdOrSlip44Id[]; + + const prices = await this.#priceService.getSpotPrices({ + assetIds: uniqueAssetCaipIds, + vsCurrency: interfaceContext.currency, + }); + + const latestContext = + await getInterfaceContextIfExists(interfaceId); + if (!latestContext) { + this.logger.info('Interface dismissed during price fetch, cleaning up'); + return; + } + + const updatedTokenPrices = uniqueAssetCaipIds.reduce< + ContextWithPrices['tokenPrices'] + >( + (acc, assetId) => { + if (prices[assetId]) { + acc[assetId] = prices[assetId]?.price.toString() ?? null; + } else { + acc[assetId] = null; + } + return acc; + }, + {} as ContextWithPrices['tokenPrices'], + ); + + const updatedContext: ContextWithPrices = { + ...latestContext, + tokenPrices: updatedTokenPrices, + tokenPricesFetchStatus: FetchStatus.Fetched, + }; + + await this.#reRenderConfirmationPrices({ + interfaceId, + updatedContext, + interfaceKey, + }); + + await RefreshConfirmationPricesHandler.scheduleBackgroundEvent({ + scope, + interfaceId, + interfaceKey, + }); + } catch (error) { + this.logger.error('Error refreshing confirmation prices:', error); + + const currentContext = + await getInterfaceContextIfExists(interfaceId); + if (currentContext) { + const errorContext: ContextWithPrices = { + ...currentContext, + tokenPricesFetchStatus: FetchStatus.Error, + }; + + await this.#reRenderConfirmationPrices({ + interfaceId, + updatedContext: errorContext, + interfaceKey, + }); + } + } + } + + async #reRenderConfirmationPrices(params: { + interfaceId: string; + updatedContext: ContextWithPrices; + interfaceKey: ConfirmationInterfaceKey; + }): Promise { + const { interfaceId, interfaceKey, updatedContext } = params; + const render = + interfaceKey === ConfirmationInterfaceKey.ChangeTrustlineOptIn + ? refreshConfirmationPricesChangeTrustlineOptIn + : refreshConfirmationPricesChangeTrustlineOptOut; + + await render({ + interfaceId, + updatedContext, + }); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts new file mode 100644 index 00000000..8b478c7c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts @@ -0,0 +1,45 @@ +import type { + TrackTransactionJsonRpcRequest, + TrackTransactionParams, +} from './api'; +import { + BackgroundEventMethod, + TrackTransactionJsonRpcRequestStruct, +} from './api'; +import { CronjobBaseHandler } from './base'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; +import { scheduleBackgroundEvent } from '../../utils/snap'; + +export class TrackTransactionHandler extends CronjobBaseHandler { + static readonly duration = 'PT1S'; + + static async scheduleBackgroundEvent( + params: TrackTransactionParams, + duration: string = TrackTransactionHandler.duration, + ): Promise { + await scheduleBackgroundEvent({ + method: BackgroundEventMethod.RefreshConfirmationPrices, + params, + duration, + }); + } + + constructor({ logger }: { logger: ILogger }) { + const prefixedLogger = createPrefixedLogger( + logger, + '[TrackTransactionHandler]', + ); + super({ + logger: prefixedLogger, + requestStruct: TrackTransactionJsonRpcRequestStruct, + }); + } + + async handleCronJobRequest( + _request: TrackTransactionJsonRpcRequest, + ): Promise { + // TODO: Implement transaction tracking. + this.logger.info('Tracking transaction...'); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/index.ts b/merged-packages/stellar-wallet-snap/src/handlers/index.ts index a82e968d..1d72ab59 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/index.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/index.ts @@ -1,2 +1,3 @@ export * from './keyring/keyring'; export * from './user-input/userInput'; +export * from './cronjob/cronjob'; diff --git a/merged-packages/stellar-wallet-snap/src/index.ts b/merged-packages/stellar-wallet-snap/src/index.ts index 811f49d2..46268699 100644 --- a/merged-packages/stellar-wallet-snap/src/index.ts +++ b/merged-packages/stellar-wallet-snap/src/index.ts @@ -2,6 +2,7 @@ import type { OnUserInputHandler, OnKeyringRequestHandler, OnRpcRequestHandler, + OnCronjobHandler, } from '@metamask/snaps-sdk'; import { MethodNotFoundError } from '@metamask/snaps-sdk'; import type { JsonRpcRequest } from '@metamask/utils'; @@ -11,6 +12,7 @@ import { signMessageHandler, userInputHandler, signTransactionHandler, + cronjobHandler, } from './context'; export const onKeyringRequest: OnKeyringRequestHandler = async ({ @@ -21,6 +23,9 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ export const onUserInput: OnUserInputHandler = async (params) => userInputHandler.handle(params); +export const onCronjob: OnCronjobHandler = async ({ request }) => + cronjobHandler.handle(request); + export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { const { method } = request; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index dd21ea26..d8e492c6 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -7,9 +7,13 @@ import type { KnownCaip19AssetIdOrSlip44Id, KnownCaip2ChainId, } from '../../api'; +import { BASE_FEE_CACHE_TTL_MILLISECONDS } from '../../constants'; import type { ILogger } from '../../utils/logger'; import { createPrefixedLogger } from '../../utils/logger'; +import type { Serializable } from '../../utils/serialization'; import type { StellarKeyringAccount } from '../account/api'; +import type { ICache } from '../cache'; +import { useCache } from '../cache'; import type { NetworkService } from '../network'; export class TransactionService { @@ -19,18 +23,40 @@ export class TransactionService { readonly #networkService: NetworkService; + readonly #cache: ICache; + constructor({ logger, transactionRepository, networkService, + cache, }: { logger: ILogger; transactionRepository: TransactionRepository; networkService: NetworkService; + cache: ICache; }) { this.#logger = createPrefixedLogger(logger, '[🧾 TransactionService]'); this.#transactionRepository = transactionRepository; this.#networkService = networkService; + this.#cache = cache; + } + + /** + * Gets the base fee for a transaction. + * + * @param scope - The CAIP-2 chain id. + * @returns A promise that resolves to the base fee. + */ + async getBaseFee(scope: KnownCaip2ChainId): Promise { + return useCache( + this.#networkService.getBaseFee.bind(this.#networkService), + this.#cache, + { + functionName: 'TransactionService:getBaseFee', + ttlMilliseconds: BASE_FEE_CACHE_TTL_MILLISECONDS, + }, + )(scope); } /** diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts index d45da2f0..87a06ad7 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts @@ -15,6 +15,7 @@ import { import type { KnownCaip19AssetIdOrSlip44Id } from '../../../api'; import { KnownCaip2ChainId } from '../../../api'; import { getSlip44AssetId, logger } from '../../../utils'; +import { createMemoryCache } from '../../cache/__mocks__/cache.fixtures'; import { NetworkService } from '../../network'; import { State } from '../../state/State'; import { generateStellarAddress } from '../../wallet/__mocks__/wallet.fixtures'; @@ -36,6 +37,7 @@ export const createMockTransactionService = () => { }, }), ), + cache: createMemoryCache().cache, networkService, }); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts new file mode 100644 index 00000000..ce2b1877 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts @@ -0,0 +1,22 @@ +import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; + +export type FeeData = { + assetId: KnownCaip19AssetIdOrSlip44Id; + symbol: string; + iconUrl: string; + amount: string; +}; + +export enum FetchStatus { + Initial = 'initial', + Fetching = 'fetching', + Fetched = 'fetched', + // eslint-disable-next-line @typescript-eslint/no-shadow + Error = 'error', +} + +export type ContextWithPrices = { + tokenPrices: Record; + tokenPricesFetchStatus: FetchStatus; + currency: string; +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Asset.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Asset.tsx new file mode 100644 index 00000000..16d9e02d --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Asset.tsx @@ -0,0 +1,57 @@ +import type { + ComponentOrElement, + GetPreferencesResult, +} from '@metamask/snaps-sdk'; +import { Box, Skeleton, Text as SnapText } from '@metamask/snaps-sdk/jsx'; + +import { AssetIcon } from './AssetIcon'; +import { AssetText } from './AssetText'; +import { formatFiat, tokenToFiat } from '../../../utils'; + +type AssetProps = { + symbol: string; + amount?: string; + iconUrl?: string; + price?: string | null; + preferences?: GetPreferencesResult; + priceLoading?: boolean; + link?: string; +}; + +/** + * Asset component for displaying assets with optional icon, amount, and price. + * Pure component with no business logic - just visual display. + * + * @param props - The props for the asset component. + * @returns The rendered asset element. + */ +export const Asset = (props: AssetProps): ComponentOrElement => { + const { symbol, link, amount, iconUrl, price, preferences, priceLoading } = + props; + + const fiatValue = + preferences && price && amount !== undefined + ? formatFiat( + tokenToFiat(amount, price), + preferences.currency, + preferences.locale, + ) + : ''; + + const showPriceInfo = preferences !== undefined && amount !== undefined; + const showSkeleton = showPriceInfo && priceLoading; + const showFiat = showPriceInfo && !priceLoading && fiatValue; + const assetText = amount === undefined ? symbol : `${amount} ${symbol}`; + + return ( + + {showSkeleton ? : null} + {showFiat ? {fiatValue} : null} + + + + + + + ); +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetIcon.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetIcon.tsx new file mode 100644 index 00000000..2343a937 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetIcon.tsx @@ -0,0 +1,37 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { Image } from '@metamask/snaps-sdk/jsx'; + +import questionMarkSvg from '../../images/question-mark.svg'; + +type AssetIconProps = { + iconUrl?: string; + size: 'sm' | 'md' | 'lg' | 'xl'; +}; + +const sizeMap: Record<'sm' | 'md' | 'lg' | 'xl', number> = { + sm: 16, + md: 24, + lg: 32, + xl: 48, +}; + +/** + * AssetIcon component for displaying assets with optional icon. + * + * @param props - The props for the asset component. + * @returns The rendered asset element. + */ +export const AssetIcon = (props: AssetIconProps): ComponentOrElement => { + const { iconUrl, size = 'md' } = props; + + const iconSrc = iconUrl ?? questionMarkSvg; + + return ( + + ); +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetText.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetText.tsx new file mode 100644 index 00000000..39dc5388 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetText.tsx @@ -0,0 +1,24 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { Link, Text as SnapText } from '@metamask/snaps-sdk/jsx'; + +type AssetTextProps = { + /** The link to the asset. if provided, the asset text will be a link. */ + link?: string; + /** The asset text to display. */ + aseset: string; +}; + +/** + * AssetText component for displaying assets with optional link. + * Pure component with no business logic - just visual display. + * + * @param props - The props for the asset text component. + * @returns The rendered asset text element. + */ +export const AssetText = (props: AssetTextProps): ComponentOrElement => { + const { aseset, link } = props; + if (link) { + return {aseset}; + } + return ${aseset}; +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Fee.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Fee.tsx new file mode 100644 index 00000000..12ddc518 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Fee.tsx @@ -0,0 +1,49 @@ +import type { + ComponentOrElement, + GetPreferencesResult, +} from '@metamask/snaps-sdk'; +import { Box, Text as SnapText } from '@metamask/snaps-sdk/jsx'; + +import { Asset } from './Asset'; +import { FetchStatus } from '../../../api/fetch-status'; +import { i18n } from '../../../utils/i18n'; +import xlmSvg from '../../images/slip44:148.svg'; +import type { FeeData } from '../api'; + +type FeesProps = { + fee: FeeData; + price: string | null; + preferences: GetPreferencesResult; + tokenPricesFetchStatus?: FetchStatus; +}; + +export const FeeRow = ({ + fee, + preferences, + price, + tokenPricesFetchStatus = FetchStatus.Initial, +}: FeesProps): ComponentOrElement => { + const translate = i18n(preferences.locale); + const priceLoading = tokenPricesFetchStatus === FetchStatus.Fetching; + + return ( + + + {/* Left side - show text only for first item (native TRX) */} + + {translate('confirmation.transactionFee')} + + + {/* Right side - fee value with asset display including price */} + + + + ); +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts new file mode 100644 index 00000000..2cc68b9f --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts @@ -0,0 +1,3 @@ +export * from './Fee'; +export * from './AssetIcon'; +export * from './Asset'; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts index 3a74b464..625a3c79 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts @@ -1,7 +1,17 @@ +import type { GetPreferencesResult } from '@metamask/snaps-sdk'; +import type { CaipAccountId } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { FeeData } from './api'; import { KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; +import { getNativeAssetMetadata } from '../../services/asset-metadata/utils'; import type { Locale } from '../../utils'; -import { FALLBACK_LANGUAGE, getPreferences } from '../../utils'; +import { + FALLBACK_LANGUAGE, + getPreferences, + normalizeAmount, +} from '../../utils'; const NetworkName = { [KnownCaip2ChainId.Mainnet]: 'Mainnet', @@ -57,6 +67,27 @@ export async function getLocale(): Promise { ); } +/** + * Gets the preferences with fallback. + * + * @returns The preferences with fallback. + */ +export async function getPreferencesWithFallback(): Promise { + return getPreferences().catch(() => ({ + locale: FALLBACK_LANGUAGE, + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + useExternalPricingData: true, + showTestnets: true, + })); +} + /** * Gets the classic asset explorer url for a given asset reference. * @@ -80,3 +111,39 @@ export function getSepAssetExplorerUrl(assetReference: string): string { AppConfig.networks[AppConfig.selectedNetwork].explorerBaseUrl }/contract/${assetReference}`; } + +/** + * Gets the account name for a given CAIP-2 chain id and address. + * + * @param scope - The CAIP-2 chain id. + * @param address - The account address. + * @returns The account name. + */ +export function getAccountName( + scope: KnownCaip2ChainId, + address: string, +): `0x${string}` | CaipAccountId { + return `${scope}:${address}` as `0x${string}` | CaipAccountId; +} + +/** + * Formats the fee data for a given CAIP-2 chain id and amount in stroops. + * It converts the amount in stroops to the native asset amount and returns the fee data. + * + * @param scope - The CAIP-2 chain id. + * @param amountInStroops - The amount in stroops. + * @returns The fee data that can be used to display the fee in the UI. + */ +export function formatFeeData( + scope: KnownCaip2ChainId, + amountInStroops: string, +): FeeData { + const nativeAssetMetadata = getNativeAssetMetadata(scope); + const amountInLumen = normalizeAmount(new BigNumber(amountInStroops)); + return { + assetId: nativeAssetMetadata.assetId, + symbol: nativeAssetMetadata.symbol, + iconUrl: nativeAssetMetadata.iconUrl, + amount: amountInLumen.toString(), + }; +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-1.svg b/merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-1.svg new file mode 100644 index 00000000..53abff3e --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-1.svg @@ -0,0 +1,3 @@ + + + diff --git a/merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-2.svg b/merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-2.svg new file mode 100644 index 00000000..5047a760 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-2.svg @@ -0,0 +1,3 @@ + + + diff --git a/merged-packages/stellar-wallet-snap/src/ui/images/index.ts b/merged-packages/stellar-wallet-snap/src/ui/images/index.ts new file mode 100644 index 00000000..d5510f7c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/images/index.ts @@ -0,0 +1,11 @@ +import accountActiveMethod1Icon from './account-active-method-1.svg'; +import accountActiveMethod2Icon from './account-active-method-2.svg'; +import questionMarkIcon from './question-mark.svg'; +import xlmIcon from './slip44:148.svg'; + +export { + xlmIcon, + questionMarkIcon, + accountActiveMethod1Icon, + accountActiveMethod2Icon, +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/images/question-mark.svg b/merged-packages/stellar-wallet-snap/src/ui/images/question-mark.svg new file mode 100644 index 00000000..8d216574 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/images/question-mark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/merged-packages/stellar-wallet-snap/src/ui/images/slip44:148.svg b/merged-packages/stellar-wallet-snap/src/ui/images/slip44:148.svg new file mode 100644 index 00000000..e645740f --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/images/slip44:148.svg @@ -0,0 +1,7 @@ + + + Xlm Streamline Icon: https://streamlinehq.com + + + + \ No newline at end of file diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts index a343feaa..25f2c56b 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts @@ -1,6 +1,15 @@ +import type { CaipAssetType } from '@metamask/utils'; +import * as metamaskUtils from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { normalizeAmount, toSmallestUnit } from './currency'; +import { + formatFiat, + getFiatTicker, + isFiat, + normalizeAmount, + tokenToFiat, + toSmallestUnit, +} from './currency'; describe('toSmallestUnit', () => { it('converts human amount to stroops', () => { @@ -12,6 +21,10 @@ describe('toSmallestUnit', () => { it('converts integer XLM to stroops', () => { expect(toSmallestUnit(new BigNumber(1)).toFixed(0)).toBe('10000000'); }); + + it('uses custom decimal places when provided', () => { + expect(toSmallestUnit(new BigNumber('1.23'), 2).toFixed(0)).toBe('123'); + }); }); describe('normalizeAmount', () => { @@ -20,6 +33,10 @@ describe('normalizeAmount', () => { '12.3456789', ); }); + + it('uses custom decimal places when provided', () => { + expect(normalizeAmount(new BigNumber(123), 2).toString()).toBe('1.23'); + }); }); describe('toSmallestUnit and normalizeAmount', () => { @@ -29,3 +46,68 @@ describe('toSmallestUnit and normalizeAmount', () => { expect(normalizeAmount(stroops).toString()).toBe(human.toString()); }); }); + +describe('formatFiat', () => { + it('uses locale segment before underscore and currency style options', () => { + const toLocaleStringSpy = jest + .spyOn(Number.prototype, 'toLocaleString') + .mockReturnValue('formatted'); + + expect(formatFiat('12.345', 'USD', 'en_US')).toBe('formatted'); + + expect(toLocaleStringSpy).toHaveBeenCalledWith('en', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }); + + toLocaleStringSpy.mockRestore(); + }); +}); + +describe('tokenToFiat', () => { + it('multiplies token amount by rate as decimal strings', () => { + expect(tokenToFiat('10', '2.5')).toBe('25'); + }); + + it('handles fractional token amounts', () => { + expect(tokenToFiat('0.5', '4')).toBe('2'); + }); +}); + +describe('isFiat', () => { + it('returns true for CAIP asset ids containing swift ISO4217 segment', () => { + expect(isFiat('eip155:1/swift:0/iso4217:USD' as CaipAssetType)).toBe(true); + }); + + it('returns false for stellar asset ids', () => { + expect( + isFiat( + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as CaipAssetType, + ), + ).toBe(false); + }); +}); + +describe('getFiatTicker', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('throws when asset id is not fiat', () => { + expect(() => + getFiatTicker('stellar:pubnet/slip44:148' as CaipAssetType), + ).toThrow('Passed assetId is not a fiat asset'); + }); + + it('returns lowercase asset reference from parser', () => { + jest.spyOn(metamaskUtils, 'parseCaipAssetType').mockReturnValue({ + assetReference: 'EUR', + } as ReturnType); + + expect(getFiatTicker('ignored/swift:0/iso4217:EUR' as CaipAssetType)).toBe( + 'eur', + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.ts index c4c07013..563eda50 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/currency.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.ts @@ -1,3 +1,5 @@ +import type { CaipAssetType } from '@metamask/utils'; +import { parseCaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { STELLAR_DECIMAL_PLACES } from '../constants'; @@ -33,3 +35,69 @@ export function normalizeAmount( ): BigNumber { return amount.dividedBy(BigNumber(10).pow(decimalPlaces)); } + +/** + * Formats a number as currency. + * + * @param amount - The amount of money. + * @param currency - The currency to format the amount as. + * @param locale - The locale to use for number formatting. + * @returns The formatted currency string. + */ +export function formatFiat( + amount: string, + currency: string, + locale: string, +): string { + const bigAmount = new BigNumber(amount); + const amountNumber = bigAmount.toNumber(); + const [localeCode] = locale.split('_'); + + return amountNumber.toLocaleString(localeCode, { + style: 'currency', + currency, + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }); +} + +/** + * Converts a token amount to fiat currency using the provided conversion rate. + * + * @param tokenAmount - The amount of tokens to convert. + * @param rateConversion - The conversion rate from token to fiat. + * @returns The fiat value of the token amount. + */ +export function tokenToFiat( + tokenAmount: string, + rateConversion: string, +): string { + const bigAmount = new BigNumber(tokenAmount); + return bigAmount.multipliedBy(new BigNumber(rateConversion)).toString(); +} + +/** + * Checks if a CAIP-19 asset type is a fiat asset. + * + * @param assetId - The CAIP-19 asset type. + * @returns True if the asset is a fiat asset, false otherwise. + */ +export function isFiat(assetId: CaipAssetType): boolean { + return assetId.includes('swift:0/iso4217:'); +} + +/** + * Extracts the ISO 4217 currency code (aka fiat ticker) from a fiat CAIP-19 asset ID. + * + * @param assetId - The CAIP-19 asset ID. + * @returns The fiat ticker. + */ +export function getFiatTicker(assetId: CaipAssetType): string { + if (!isFiat(assetId)) { + throw new Error('Passed assetId is not a fiat asset'); + } + + const fiatTicker = parseCaipAssetType(assetId).assetReference.toLowerCase(); + + return fiatTicker; +} diff --git a/merged-packages/stellar-wallet-snap/src/utils/i18n.ts b/merged-packages/stellar-wallet-snap/src/utils/i18n.ts index 79dc6d54..8ef560a5 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/i18n.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/i18n.ts @@ -22,10 +22,11 @@ export type LocalizedMessage = [MessageKeys] extends [never] * @param locale - The user's preferred locale. * @returns A function that gets the translation for a given key. */ -export function i18n(locale: Locale) { +export function i18n(locale: string) { // Needs to be casted as EN is the main language and we can have the case where // messages are not yet completed for the other languages (e.g. empty `es` map). - const messages = (locales[locale] ?? locales[FALLBACK_LANGUAGE]) as Partial< + const messages = (locales[locale as Locale] ?? + locales[FALLBACK_LANGUAGE]) as Partial< Record >; diff --git a/merged-packages/stellar-wallet-snap/src/utils/serialization.ts b/merged-packages/stellar-wallet-snap/src/utils/serialization.ts index e4b07456..9fcaaee3 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/serialization.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/serialization.ts @@ -100,4 +100,23 @@ export const deserialize = (serializedValue: Json): Serializable => return value; }); + +/** + * Serializes the passed value to a string. + * + * @param params - The parameters to serialize. + * @param params.value - The value to serialize. + * @param params.replacer - The replacer function to use. + * @param params.indent - The indent to use. + * @returns The serialized value. + */ +export const serializeToString = ({ + value, + replacer = null, + indent = 2, +}: { + value: Serializable; + replacer?: (number | string)[] | null; + indent?: number; +}): string => JSON.stringify(serialize(value), replacer, indent); /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/merged-packages/stellar-wallet-snap/src/utils/snap.ts b/merged-packages/stellar-wallet-snap/src/utils/snap.ts index 3477f0dd..890756f9 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/snap.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/snap.ts @@ -216,12 +216,19 @@ export async function scheduleBackgroundEvent({ * @returns True if the error indicates the interface was not found. */ function isInterfaceNotFoundError(error: unknown): boolean { + let message = ''; if (error instanceof Error) { - const message = error.message.toLowerCase(); - return message.includes('interface') && message.includes('not found'); + message = error.message.toLowerCase(); + } else if ( + typeof error === 'object' && + error !== null && + 'message' in error + ) { + message = (error.message as string).toLowerCase(); + } else { + message = String(error).toLowerCase(); } - - return false; + return message.includes('interface') && message.includes('not found'); } /** @@ -232,9 +239,7 @@ function isInterfaceNotFoundError(error: unknown): boolean { * @returns The created interface id. */ export async function createInterface( - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ui: any, + ui: ComponentOrElement, context: TContext & Record, ): Promise { return getSnapProvider().request({ @@ -257,9 +262,7 @@ export async function createInterface( */ export async function updateInterfaceIfExists( id: string, - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ui: any, + ui: ComponentOrElement, context: TContext & Record, ): Promise { try { From 4f7816a5bc2aaf28414a99d668bee54be860ef98 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Mon, 20 Apr 2026 17:08:50 +0200 Subject: [PATCH 080/384] fix: fix lint --- merged-packages/stellar-wallet-snap/snap.manifest.json | 2 +- .../stellar-wallet-snap/src/handlers/asset/assets.test.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index f5e0b51a..565953ea 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "G8+uh4JxXeehCEop7UK0rguSRvEDq76Y9hhHYWzYatU=", + "shasum": "pTk/etrA2lj4lQeWVAK7eMl0n9pPhhgbWhuFczkYIAQ=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/asset/assets.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/asset/assets.test.ts index e98121f1..1aac810b 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/asset/assets.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/asset/assets.test.ts @@ -5,7 +5,6 @@ import type { import { AssetsHandler } from './assets'; import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; -import { KnownCaip2ChainId } from '../../api'; import { createMockAssetMetadataService, generateMockKeyringAssetMetadata, From c3ffb6ef8fd30425c5a3b118611d0068587bd71b Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Mon, 20 Apr 2026 17:31:16 +0200 Subject: [PATCH 081/384] fix: fix cursor bot comments --- merged-packages/stellar-wallet-snap/snap.manifest.json | 2 +- .../stellar-wallet-snap/src/services/price/PriceService.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 565953ea..0c4c1936 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "pTk/etrA2lj4lQeWVAK7eMl0n9pPhhgbWhuFczkYIAQ=", + "shasum": "QxdwIuy0z6kszhLAdtKxNQw+PByJXZamTOghOWb4nLs=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts index adaaf8d9..3e808831 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts @@ -319,7 +319,7 @@ export class PriceService { // so we can just use the minimum of the two fixed TTLs as placeholder. const expirationTime = Math.min( AppConfig.cache.ttlMilliseconds.spotPrices, - AppConfig.cache.ttlMilliseconds.historicalPrices, + AppConfig.cache.ttlMilliseconds.fiatExchangeRates, ); result[from][to] = { @@ -447,7 +447,9 @@ export class PriceService { fungible: true, marketCap: this.#toCurrencySafe(marketDataInUsd.marketCap, rate), totalVolume: this.#toCurrencySafe(marketDataInUsd.totalVolume, rate), - circulatingSupply: (marketDataInUsd.circulatingSupply ?? 0).toString(), // Circulating supply counts the number of tokens in circulation, so we don't convert + // Circulating supply counts the number of tokens in circulation, so we don't convert. + // Use empty string for nullish to match the docstring contract and stay consistent with other fields. + circulatingSupply: marketDataInUsd.circulatingSupply?.toString() ?? '', allTimeHigh: this.#toCurrencySafe(marketDataInUsd.allTimeHigh, rate), allTimeLow: this.#toCurrencySafe(marketDataInUsd.allTimeLow, rate), // Add pricePercentChange field only if it has values From ebfb2b607dae93b0e0760921c73b2e5c45d72ae8 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:10:29 +0800 Subject: [PATCH 082/384] fix: bignumber issue and add test --- .../src/services/price/PriceService.test.ts | 412 +++++++++++++++++- .../src/services/price/PriceService.ts | 82 ++-- 2 files changed, 460 insertions(+), 34 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts index 8e397659..f01d3c16 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts @@ -1,9 +1,19 @@ +import type { CaipAssetType } from '@metamask/utils'; + import { GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT } from './api'; -import { PriceService } from './PriceService'; +import { + HISTORICAL_PRICE_TIME_PERIODS, + type HistoricalPriceTimePeriod, + PriceService, +} from './PriceService'; import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; import { AppConfig } from '../../config'; import { logger, serialize } from '../../utils'; -import type { FiatExchangeRatesResponse } from './price-api/api'; +import type { + FiatExchangeRatesResponse, + GetHistoricalPricesResponse, + SpotPrice, +} from './price-api/api'; import { PriceApiClient } from './price-api/PriceApiClient'; import { createMemoryCache } from '../cache/__mocks__/cache.fixtures'; @@ -12,6 +22,13 @@ jest.mock('../../utils/logger'); const stellarClassicUsdc = 'stellar:testnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as const satisfies KnownCaip19AssetIdOrSlip44Id; +const fiatUsdCaip = 'swift:0/iso4217:USD' as CaipAssetType; + +const fiatEurCaip = 'swift:0/iso4217:EUR' as CaipAssetType; + +const stellarTestnetMockAsset = + 'stellar:testnet/asset:MOCK-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as const satisfies KnownCaip19AssetIdOrSlip44Id; + const fiatExchangeRatesBody = { usd: { name: 'US Dollar', @@ -21,6 +38,21 @@ const fiatExchangeRatesBody = { }, } as FiatExchangeRatesResponse; +const fiatExchangeRatesUsdEur: FiatExchangeRatesResponse = { + ...fiatExchangeRatesBody, + eur: { + name: 'Euro', + ticker: 'eur' as const, + value: 2, + currencyType: 'fiat' as const, + }, +}; + +const minimalSpot = (id: string, price: number): SpotPrice => ({ + id, + price, +}); + const cacheKeySpotPrices = ( assetIds: KnownCaip19AssetIdOrSlip44Id[], vsCurrency: string, @@ -249,4 +281,380 @@ describe('PriceService', () => { }); }); }); + + describe('getHistoricalPriceWithAllTimePeriods', () => { + it('requests each configured time period with vsCurrency from quote asset', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + getHistoricalPricesSpy.mockResolvedValue({ + prices: [[1_700_000_000_000, 0.12]], + marketCaps: [], + totalVolumes: [], + }); + + await service.getHistoricalPriceWithAllTimePeriods( + stellarClassicUsdc, + fiatUsdCaip, + ); + + expect(getHistoricalPricesSpy).toHaveBeenCalledTimes( + HISTORICAL_PRICE_TIME_PERIODS.length, + ); + + HISTORICAL_PRICE_TIME_PERIODS.forEach((timePeriod) => { + expect(getHistoricalPricesSpy).toHaveBeenCalledWith({ + assetType: stellarClassicUsdc, + timePeriod, + vsCurrency: 'usd', + }); + }); + }); + + it('returns intervals keyed by ISO 8601 durations with stringified prices', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + const historicalByPeriod = { + '1d': { prices: [[1, 0.5]], marketCaps: [], totalVolumes: [] }, + '7d': { prices: [[1, 2]], marketCaps: [], totalVolumes: [] }, + '1m': { prices: [[1, 2]], marketCaps: [], totalVolumes: [] }, + '3m': { prices: [[1, 2]], marketCaps: [], totalVolumes: [] }, + '1y': { prices: [[1, 2]], marketCaps: [], totalVolumes: [] }, + '1000y': { prices: [[1, 2]], marketCaps: [], totalVolumes: [] }, + } satisfies Record< + HistoricalPriceTimePeriod, + GetHistoricalPricesResponse + >; + + getHistoricalPricesSpy.mockImplementation(async (params) => { + const period = params.timePeriod as keyof typeof historicalByPeriod; + return historicalByPeriod[period]; + }); + + const { intervals } = await service.getHistoricalPriceWithAllTimePeriods( + stellarClassicUsdc, + fiatUsdCaip, + ); + + const expectedIntervalKeys = new Set( + HISTORICAL_PRICE_TIME_PERIODS.map( + (period) => `P${period.toUpperCase()}`, + ), + ); + expect(new Set(Object.keys(intervals))).toStrictEqual( + expectedIntervalKeys, + ); + expect(intervals.P1D).toStrictEqual([[1, '0.5']]); + expect(intervals.P7D).toStrictEqual([[1, '2']]); + }); + + it('sets updateTime and expirationTime using historical prices cache TTL', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-06-01T12:00:00.000Z')); + + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + getHistoricalPricesSpy.mockResolvedValue({ + prices: [], + marketCaps: [], + totalVolumes: [], + }); + + const now = Date.now(); + const result = await service.getHistoricalPriceWithAllTimePeriods( + stellarClassicUsdc, + fiatUsdCaip, + ); + + expect(result.updateTime).toBe(now); + expect(result.expirationTime).toBe( + now + AppConfig.cache.ttlMilliseconds.historicalPrices, + ); + + jest.useRealTimers(); + }); + + it('uses empty price series for a period when the historical request fails', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + const successResponse: GetHistoricalPricesResponse = { + prices: [[10, 1]], + marketCaps: [], + totalVolumes: [], + }; + + const historicalHandlers: Record< + HistoricalPriceTimePeriod, + () => Promise + > = { + '1d': async () => successResponse, + '7d': async () => successResponse, + '1m': async () => successResponse, + '3m': async () => Promise.reject(new Error('network')), + '1y': async () => successResponse, + '1000y': async () => successResponse, + }; + + getHistoricalPricesSpy.mockImplementation(async (params) => { + const period = params.timePeriod as keyof typeof historicalHandlers; + return historicalHandlers[period](); + }); + + const { intervals } = await service.getHistoricalPriceWithAllTimePeriods( + stellarClassicUsdc, + fiatUsdCaip, + ); + + expect(intervals.P3M).toStrictEqual([]); + expect(intervals.P1D).toStrictEqual([[10, '1']]); + }); + }); + + describe('getMultipleTokenConversions', () => { + it('returns empty record when conversions list is empty', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + expect(await service.getMultipleTokenConversions([])).toStrictEqual({}); + }); + + it('derives crypto to crypto rate from USD spot prices', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + getSpotPricesSpy.mockResolvedValue({ + [stellarClassicUsdc]: minimalSpot('usdc', 2), + [stellarTestnetMockAsset]: minimalSpot('mock', 0.5), + }); + + const result = await service.getMultipleTokenConversions([ + { from: stellarClassicUsdc, to: stellarTestnetMockAsset }, + ]); + + expect( + result[stellarClassicUsdc]?.[stellarTestnetMockAsset], + ).toMatchObject({ + rate: '4', + }); + expect(getSpotPricesSpy).toHaveBeenCalledWith( + [stellarClassicUsdc, stellarTestnetMockAsset], + 'usd', + ); + }); + + it('returns null when a crypto leg has no usable USD price', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + getSpotPricesSpy.mockResolvedValue({ + [stellarClassicUsdc]: minimalSpot('usdc', 1), + }); + + const result = await service.getMultipleTokenConversions([ + { from: stellarClassicUsdc, to: stellarTestnetMockAsset }, + ]); + + expect(result[stellarClassicUsdc]?.[stellarTestnetMockAsset]).toBeNull(); + }); + + it('derives fiat to fiat rate using inverted exchange rate values', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + getFiatExchangeRatesSpy.mockResolvedValue(fiatExchangeRatesUsdEur); + getSpotPricesSpy.mockResolvedValue({}); + + const result = await service.getMultipleTokenConversions([ + { from: fiatUsdCaip, to: fiatEurCaip }, + ]); + + // Fiat USD leg: 1 / usd.value = 1/1. Fiat EUR leg: 1 / eur.value = 1/2. + // USD→EUR amount multiplier: fromUsdRate / toUsdRate = 1 / 0.5 = 2. + expect(result[fiatUsdCaip]?.[fiatEurCaip]).toMatchObject({ + rate: '2', + }); + }); + + it('sets expirationTime from the shorter spot or fiat cache TTL', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-15T00:00:00.000Z')); + + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + getSpotPricesSpy.mockResolvedValue({ + [stellarClassicUsdc]: minimalSpot('usdc', 1), + [stellarTestnetMockAsset]: minimalSpot('mock', 1), + }); + + const now = Date.now(); + const ttl = Math.min( + AppConfig.cache.ttlMilliseconds.spotPrices, + AppConfig.cache.ttlMilliseconds.fiatExchangeRates, + ); + + const conversions = await service.getMultipleTokenConversions([ + { from: stellarClassicUsdc, to: stellarTestnetMockAsset }, + ]); + const row = conversions[stellarClassicUsdc]?.[stellarTestnetMockAsset]; + + expect(row).toMatchObject({ + conversionTime: now, + expirationTime: now + ttl, + }); + + jest.useRealTimers(); + }); + }); + + describe('getMultipleTokensMarketData', () => { + it('returns empty record when assets list is empty', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + expect(await service.getMultipleTokensMarketData([])).toStrictEqual({}); + }); + + it('omits rows when the base asset has no spot entry', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + getSpotPricesSpy.mockResolvedValue({}); + + const result = await service.getMultipleTokensMarketData([ + { asset: stellarClassicUsdc, unit: fiatUsdCaip }, + ]); + + expect(result).toStrictEqual({}); + }); + + it('omits rows when the unit has no usable conversion rate', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + getSpotPricesSpy.mockResolvedValue({ + [stellarClassicUsdc]: minimalSpot('usdc', 1), + }); + + const result = await service.getMultipleTokensMarketData([ + { asset: stellarClassicUsdc, unit: fiatEurCaip }, + ]); + + expect(result).toStrictEqual({}); + }); + + it('scales USD monetary fields to the quote unit without converting circulating supply', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + getFiatExchangeRatesSpy.mockResolvedValue(fiatExchangeRatesUsdEur); + getSpotPricesSpy.mockResolvedValue({ + [stellarClassicUsdc]: { + id: 'usdc', + price: 1, + marketCap: 1000, + totalVolume: 200, + circulatingSupply: 500, + allTimeHigh: 2, + allTimeLow: 0.5, + }, + }); + + const result = await service.getMultipleTokensMarketData([ + { asset: stellarClassicUsdc, unit: fiatEurCaip }, + ]); + + expect(result[stellarClassicUsdc]?.[fiatEurCaip]).toMatchObject({ + fungible: true, + marketCap: '2000', + totalVolume: '400', + circulatingSupply: '500', + allTimeHigh: '4', + allTimeLow: '1', + }); + }); + + it('includes pricePercentChange when spot returns percent fields', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + getSpotPricesSpy.mockResolvedValue({ + [stellarClassicUsdc]: { + id: 'usdc', + price: 1, + pricePercentChange1d: 1.5, + pricePercentChange7d: -2, + }, + }); + + const result = await service.getMultipleTokensMarketData([ + { asset: stellarClassicUsdc, unit: fiatUsdCaip }, + ]); + + expect( + result[stellarClassicUsdc]?.[fiatUsdCaip]?.pricePercentChange, + ).toStrictEqual({ + P1D: 1.5, + P7D: -2, + }); + }); + + it('uses string zero for circulating supply when spot omits, nulls, or sends zero', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + getSpotPricesSpy.mockResolvedValue({ + [stellarClassicUsdc]: { + id: 'usdc', + price: 1, + marketCap: 100, + }, + }); + + const omitted = await service.getMultipleTokensMarketData([ + { asset: stellarClassicUsdc, unit: fiatUsdCaip }, + ]); + + expect( + omitted[stellarClassicUsdc]?.[fiatUsdCaip]?.circulatingSupply, + ).toBe('0'); + + getSpotPricesSpy.mockResolvedValue({ + [stellarClassicUsdc]: { + id: 'usdc', + price: 1, + marketCap: 100, + circulatingSupply: null, + }, + }); + + const nulled = await service.getMultipleTokensMarketData([ + { asset: stellarClassicUsdc, unit: fiatUsdCaip }, + ]); + + expect(nulled[stellarClassicUsdc]?.[fiatUsdCaip]?.circulatingSupply).toBe( + '0', + ); + + getSpotPricesSpy.mockResolvedValue({ + [stellarClassicUsdc]: { + id: 'usdc', + price: 1, + marketCap: 1, + circulatingSupply: 0, + }, + }); + + const explicitZero = await service.getMultipleTokensMarketData([ + { asset: stellarClassicUsdc, unit: fiatUsdCaip }, + ]); + + expect( + explicitZero[stellarClassicUsdc]?.[fiatUsdCaip]?.circulatingSupply, + ).toBe('0'); + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts index 3e808831..2b24bb29 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts @@ -5,6 +5,7 @@ import type { } from '@metamask/snaps-sdk'; import type { CaipAssetType } from '@metamask/utils'; import { parseCaipAssetType } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; import { pick } from 'lodash'; import { @@ -29,6 +30,22 @@ import type { import { PriceApiClient } from './price-api/PriceApiClient'; import { AppConfig } from '../../config'; +/** + * Time window tokens passed to the Price API for multichain historical snapshots. + * Single source of truth for {@link PriceService.getHistoricalPriceWithAllTimePeriods}. + */ +export const HISTORICAL_PRICE_TIME_PERIODS = [ + '1d', + '7d', + '1m', + '3m', + '1y', + '1000y', +] as const; + +export type HistoricalPriceTimePeriod = + (typeof HISTORICAL_PRICE_TIME_PERIODS)[number]; + /** * Fetches and caches price data from the MetaMask Price API: spot quotes, fiat * exchange rates, historical intervals, cross-asset conversions, and market metrics. @@ -175,35 +192,34 @@ export class PriceService { const toTicker = parseCaipAssetType(to).assetReference.toLowerCase(); // For each time period, call the Price API to fetch the historical prices - const promises = ['1d', '7d', '1m', '3m', '1y', '1000y'].map( - async (timePeriod) => - this.getHistoricalPrices( - { - assetType: from, - timePeriod, - // It is possible that the toTicker is not a valid vsCurrency, - // but we can safely cast it to VsCurrencyParam because the Price API will throw an error if it is not a valid value - vsCurrency: toTicker as VsCurrencyParam, - }, - // Refresh the cache to ensure we get the latest data - true, - ) - // Wrap the response in an object with the time period and the response for easier reducing - .then((response) => ({ + const promises = HISTORICAL_PRICE_TIME_PERIODS.map(async (timePeriod) => + this.getHistoricalPrices( + { + assetType: from, + timePeriod, + // It is possible that the toTicker is not a valid vsCurrency, + // but we can safely cast it to VsCurrencyParam because the Price API will throw an error if it is not a valid value + vsCurrency: toTicker as VsCurrencyParam, + }, + // Refresh the cache to ensure we get the latest data + true, + ) + // Wrap the response in an object with the time period and the response for easier reducing + .then((response) => ({ + timePeriod, + response, + })) + // Gracefully handle individual errors to avoid breaking the entire operation + .catch((error) => { + this.#logger.logErrorWithDetails( + `Error fetching historical prices for ${from} to ${to} with time period ${timePeriod}. Returning null object.`, + error, + ); + return { timePeriod, - response, - })) - // Gracefully handle individual errors to avoid breaking the entire operation - .catch((error) => { - this.#logger.logErrorWithDetails( - `Error fetching historical prices for ${from} to ${to} with time period ${timePeriod}. Returning null object.`, - error, - ); - return { - timePeriod, - response: GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, - }; - }), + response: GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + }; + }), ); const wrappedHistoricalPrices = await Promise.all(promises); @@ -408,7 +424,9 @@ export class PriceService { * * @param spotPrice - Spot payload for the base asset (from the Price API, vs USD). * @param rate - Non-zero USD price of one unit of the quote asset. - * @returns Market data scaled to the quote `unit`; empty strings where inputs are nullish. + * @returns Market data scaled to the quote `unit`; empty strings where converted + * monetary inputs are nullish. Circulating supply is not currency-converted; when + * the spot payload omits or nulls it, the value is `'0'` by design (same as numeric zero). */ #computeMarketData( spotPrice: SpotPrice, @@ -447,9 +465,9 @@ export class PriceService { fungible: true, marketCap: this.#toCurrencySafe(marketDataInUsd.marketCap, rate), totalVolume: this.#toCurrencySafe(marketDataInUsd.totalVolume, rate), - // Circulating supply counts the number of tokens in circulation, so we don't convert. - // Use empty string for nullish to match the docstring contract and stay consistent with other fields. - circulatingSupply: marketDataInUsd.circulatingSupply?.toString() ?? '', + // Circulating supply counts tokens in circulation (not a fiat amount); do not divide by `rate`. + // By design, missing or null from the API is treated as zero (`'0'`), matching other snaps. + circulatingSupply: (marketDataInUsd.circulatingSupply ?? 0).toString(), allTimeHigh: this.#toCurrencySafe(marketDataInUsd.allTimeHigh, rate), allTimeLow: this.#toCurrencySafe(marketDataInUsd.allTimeLow, rate), // Add pricePercentChange field only if it has values From cc4aaab81adb8d986365e021232d16a85fc63711 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:11:10 +0800 Subject: [PATCH 083/384] chore: add activation prompt --- .../AccountActivationPrompt.tsx | 101 ++++++++++++++++++ .../views/AccountActivationPrompt/events.tsx | 34 ++++++ .../views/AccountActivationPrompt/render.tsx | 24 +++++ 3 files changed, 159 insertions(+) create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/AccountActivationPrompt.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/events.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/render.tsx diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/AccountActivationPrompt.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/AccountActivationPrompt.tsx new file mode 100644 index 00000000..679dbaa3 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/AccountActivationPrompt.tsx @@ -0,0 +1,101 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { + Box, + Button, + Container, + Copyable, + Footer, + Heading, + Image, + Section, + Text as SnapText, +} from '@metamask/snaps-sdk/jsx'; + +import { AccountActivationPromptFormNames } from './events'; +import type { Locale } from '../../../../utils'; +import { i18n } from '../../../../utils'; +import { + xlmIcon, + accountActiveMethod1Icon, + accountActiveMethod2Icon, +} from '../../../images'; +import { AssetIcon } from '../../components/AssetIcon'; + +export type AccountActivationPromptProps = { + accountAddress: string; + locale: Locale; +}; + +export const AccountActivationPrompt = ({ + accountAddress, + locale, +}: AccountActivationPromptProps): ComponentOrElement => { + const translate = i18n(locale); + + return ( + + + + {null} + + {translate('confirmation.accountActivation.title')} + + {null} + + + + + + {translate('confirmation.accountActivation.description')} + + + {null} + {null} + +
+ + + {translate('confirmation.accountActivation.address')} + + + +
+
+ + + + + {translate('confirmation.accountActivation.method1.title')} + + + {translate( + 'confirmation.accountActivation.method1.description', + )} + + + +
+
+ + + + + {translate('confirmation.accountActivation.method2.title')} + + + {translate( + 'confirmation.accountActivation.method2.description', + )} + + + +
+
+
+ +
+
+ ); +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/events.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/events.tsx new file mode 100644 index 00000000..ae4afe59 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/events.tsx @@ -0,0 +1,34 @@ +import type { + UserInputUiEventHandler, + UserInputUiEventHandlerContext, +} from '../../../../handlers/user-input/api'; +import { resolveInterface } from '../../../../utils'; + +/** + * Handles the click event for the cancel button. + * + * @param options - The user input handler context from `onUserInput`. + * @returns A promise that resolves when the interface has been updated. + */ +async function onCloseButtonClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id } = options; + await resolveInterface(id, true); +} + +export enum AccountActivationPromptFormNames { + Close = 'account-activation-prompt-close', +} + +/** + * Create event handlers bound to a SnapClient instance. + * + * @returns Object containing event handlers. + */ +export function createEventHandlers(): Record { + return { + [AccountActivationPromptFormNames.Close]: async (options) => + onCloseButtonClick(options), + }; +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/render.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/render.tsx new file mode 100644 index 00000000..10fa4a07 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/render.tsx @@ -0,0 +1,24 @@ +import type { DialogResult } from '@metamask/snaps-sdk'; + +import { AccountActivationPrompt } from './AccountActivationPrompt'; +import { createInterface, showDialog } from '../../../../utils'; +import { getLocale } from '../../utils'; + +/** + * Renders the account activation prompt. + * + * @param accountAddress - The account address. + * @returns The account activation prompt dialog result. + */ +export async function render(accountAddress: string): Promise { + const locale = await getLocale(); + + const id = await createInterface( + , + {}, + ); + + const dialogPromise = showDialog(id); + + return dialogPromise; +} From ecb7561a11d92ac9353aeadfcc26c4b317c76984 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:05:37 +0800 Subject: [PATCH 084/384] chore: update endowment --- merged-packages/stellar-wallet-snap/snap.manifest.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 0c4c1936..26a098fc 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -35,7 +35,10 @@ "snap_manageAccounts": {}, "snap_manageState": {}, "snap_dialog": {}, - "snap_getPreferences": {} + "snap_getPreferences": {}, + "endowment:assets": { + "scopes": ["stellar:pubnet"] + } }, "platformVersion": "10.3.0", "manifestVersion": "0.1" From 5a4c0c5cd74ff2ce769bedc49490ccbcb706b327 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:48:52 +0800 Subject: [PATCH 085/384] fix: balance issue --- .../stellar-wallet-snap/src/context.ts | 1 + .../src/handlers/keyring/keyring.test.ts | 5 ++- .../src/handlers/keyring/keyring.ts | 39 ++++++++++++++++--- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 03d8b107..229a0798 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -109,6 +109,7 @@ const keyringHandler = new KeyringHandler({ accountService, onChainAccountService, transactionService, + assetMetadataService, handlers: keyringMethodHandlers, }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 65a059a4..46b93bce 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -33,6 +33,7 @@ import { } from '../../services/account'; import { generateMockStellarKeyringAccounts } from '../../services/account/__mocks__/account.fixtures'; import { AccountNotFoundException } from '../../services/account/exceptions'; +import { createMockAssetMetadataService } from '../../services/asset-metadata/__mocks__/assets.fixtures'; import { AccountNotActivatedException } from '../../services/network'; import { OnChainAccountService } from '../../services/on-chain-account'; import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; @@ -98,11 +99,13 @@ describe('KeyringHandler', () => { const { accountService, onChainAccountService } = mockOnChainAccountService(); const { transactionService } = createMockTransactionService(); + const { service: assetMetadataService } = createMockAssetMetadataService(); keyringHandler = new KeyringHandler({ logger, accountService, onChainAccountService, transactionService, + assetMetadataService, handlers: { [MultichainMethod.SignMessage]: mockSignMessageHandler, [MultichainMethod.SignTransaction]: mockSignTransactionHandler, @@ -500,7 +503,7 @@ describe('KeyringHandler', () => { ]); expect(result).toStrictEqual({ - [slipId]: { unit: 'XLM', amount: '10' }, + [slipId]: { unit: 'XLM', amount: '0.000001' }, }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index f9745be8..edd3193e 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -17,6 +17,7 @@ import { handleKeyringRequest, } from '@metamask/keyring-snap-sdk'; import { type Json, type JsonRpcRequest } from '@metamask/snaps-sdk'; +import { FungibleAssetMetadataStruct } from '@metamask/snaps-sdk'; import { ensureError, type CaipAssetTypeOrId } from '@metamask/utils'; import type { @@ -59,6 +60,7 @@ import type { AccountService, StellarKeyringAccount, } from '../../services/account'; +import type { AssetMetadataService } from '../../services/asset-metadata'; import { getNativeAssetMetadata } from '../../services/asset-metadata/utils'; import { AccountNotActivatedException } from '../../services/network'; import type { @@ -73,6 +75,7 @@ import { getSnapProvider, isSep41Id, isSlip44Id, + normalizeAmount, rethrowIfInstanceElseThrow, validateOrigin, validateRequest, @@ -88,6 +91,8 @@ export class KeyringHandler implements Keyring { readonly #transactionService: TransactionService; + readonly #assetMetadataService: AssetMetadataService; + readonly #handlers: Record; constructor({ @@ -95,18 +100,21 @@ export class KeyringHandler implements Keyring { accountService, onChainAccountService, transactionService, + assetMetadataService, handlers, }: { logger: ILogger; accountService: AccountService; onChainAccountService: OnChainAccountService; transactionService: TransactionService; + assetMetadataService: AssetMetadataService; handlers: Record; }) { this.#logger = createPrefixedLogger(logger, '[🔑 KeyringHandler]'); this.#accountService = accountService; this.#onChainAccountService = onChainAccountService; this.#transactionService = transactionService; + this.#assetMetadataService = assetMetadataService; this.#handlers = handlers; } @@ -394,18 +402,37 @@ export class KeyringHandler implements Keyring { scope, ); + const assetsMetadata = + await this.#assetMetadataService.getAssetsMetadataByAssetIds(assets); + for (const assetId of assets) { const asset = onChainAccount.getAsset(assetId); - if (asset === undefined) { - continue; - } - // Native / classic trustlines: always include. SEP-41: only non-zero. - if (isSep41Id(assetId) && !asset.balance.gt(0)) { + const assetMetadata = assetsMetadata[assetId]; + // We support get balacne for a asset when: + // - Asset is found from the on-chain account + // - Asset metadata is found + // - Asset metadata is a fungible asset + // - Asset is Native / classic trustlines: always include. + // - Asset is SEP-41: only include if balance is greater than zero. + if ( + asset === undefined || + assetMetadata === undefined || + assetMetadata === null || + !FungibleAssetMetadataStruct.is(assetMetadata) || + assetMetadata.units[0]?.decimals === undefined || + (isSep41Id(assetId) && !asset.balance.gt(0)) + ) { continue; } + + const decimal = assetMetadata.units[0].decimals; assetBalances[assetId] = { unit: asset.symbol ?? '', - amount: asset.balance.toString(), + amount: normalizeAmount( + asset.balance, + decimal, + // TODO: Handle decimal places overflow + ).toString(), }; } return assetBalances; From 7fe72298ebe66aea6c4338b86011724ac653c4e1 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:45:59 +0800 Subject: [PATCH 086/384] chore: add ux controller --- .../stellar-wallet-snap/src/context.ts | 9 + .../src/handlers/cronjob/api.ts | 10 +- .../cronjob/refreshConfirmationPrices.ts | 69 ++++-- .../src/handlers/keyring/signMessage.test.ts | 62 ++++-- .../src/handlers/keyring/signMessage.ts | 30 ++- .../src/ui/confirmation/api.ts | 49 ++++- .../src/ui/confirmation/controller.tsx | 202 ++++++++++++++++++ .../ConfirmSignTransaction.tsx | 4 +- .../views/ConfirmSignTransaction/render.tsx | 17 +- .../src/utils/currency.test.ts | 9 +- 10 files changed, 398 insertions(+), 63 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 82d69fb5..c2ceb445 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -26,6 +26,7 @@ import { TransactionService, } from './services/transaction'; import { WalletService } from './services/wallet'; +import { ConfirmationUXController } from './ui/confirmation/controller'; import { logger } from './utils'; assert(AppConfig, object()); @@ -74,6 +75,11 @@ const priceService = new PriceService({ logger, }); +/** UX Controller */ +const confirmationUIController = new ConfirmationUXController({ + logger, +}); + /** ------------------------------ Keyring Handler ------------------------------ */ const signTransactionHandler = new SignTransactionHandler({ @@ -89,6 +95,7 @@ const signMessageHandler = new SignMessageHandler({ logger, accountService, onChainAccountService, + confirmationUIController, walletService, }); @@ -116,6 +123,7 @@ const userInputHandler = new UserInputHandler({ const refreshConfirmationPricesHandler = new RefreshConfirmationPricesHandler({ logger, priceService, + confirmationUIController, }); const trackTransactionHandler = new TrackTransactionHandler({ @@ -141,4 +149,5 @@ export { userInputHandler, signTransactionHandler, signMessageHandler, + confirmationUIController, }; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts index 407e87c0..2499047f 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts @@ -17,6 +17,7 @@ import { KnownCaip2ChainIdStruct, UuidStruct, } from '../../api'; +import { ConfirmationInterfaceKeyStruct } from '../../ui/confirmation/api'; /** * Interface for the client request handler. @@ -34,19 +35,10 @@ export enum BackgroundEventMethod { TrackTransaction = 'trackTransaction', } -export enum ConfirmationInterfaceKey { - ChangeTrustlineOptIn = 'ChangeTrustlineOptIn', - ChangeTrustlineOptOut = 'ChangeTrustlineOptOut', -} - export const BackgroundEventMethodStruct = enums( Object.values(BackgroundEventMethod), ); -export const ConfirmationInterfaceKeyStruct = enums( - Object.values(ConfirmationInterfaceKey), -); - export const RefreshConfirmationPricesParamsStruct = type({ scope: KnownCaip2ChainIdStruct, interfaceId: nonempty(string()), diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts index 606caaaf..7d9ca016 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts @@ -1,6 +1,7 @@ +import type { Json } from '@metamask/utils'; + import { BackgroundEventMethod, - ConfirmationInterfaceKey, RefreshConfirmationPricesJsonRpcRequestStruct, } from './api'; import type { @@ -10,10 +11,15 @@ import type { import { CronjobBaseHandler } from './base'; import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; import type { PriceService } from '../../services/price'; -import type { ContextWithPrices } from '../../ui/confirmation/api'; -import { FetchStatus } from '../../ui/confirmation/api'; -import { refreshConfirmationPrices as refreshConfirmationPricesChangeTrustlineOptIn } from '../../ui/confirmation/views/ConfirmSignChangeTrustOptIn/render'; -import { refreshConfirmationPrices as refreshConfirmationPricesChangeTrustlineOptOut } from '../../ui/confirmation/views/ConfirmSignChangeTrustOptOut/render'; +import type { + ConfirmationInterfaceKey, + ContextWithPrices, +} from '../../ui/confirmation/api'; +import { + ContextWithPricesStruct, + FetchStatus, +} from '../../ui/confirmation/api'; +import type { ConfirmationUXController } from '../../ui/confirmation/controller'; import type { ILogger } from '../../utils/logger'; import { createPrefixedLogger } from '../../utils/logger'; import { @@ -24,6 +30,7 @@ import { export class RefreshConfirmationPricesHandler extends CronjobBaseHandler { readonly #priceService: PriceService; + // Refresh interval static readonly duration = 'PT20S'; static async scheduleBackgroundEvent( @@ -37,12 +44,16 @@ export class RefreshConfirmationPricesHandler extends CronjobBaseHandler { this.logger.info('Refreshing confirmation prices...'); const { interfaceId, scope, interfaceKey } = request.params; + // Find the interface context const interfaceContext = await getInterfaceContextIfExists(interfaceId); + + // TODO: check if the interfaceContext match the ContextWithPrices if (!interfaceContext) { this.logger.info('Interface no longer exists, cleaning up'); return; } + if (!ContextWithPricesStruct.is(interfaceContext)) { + this.logger.warn( + 'Interface context does not match the ContextWithPrices interface, skipping refresh', + ); + return; + } + try { - const uniqueAssetCaipIds: KnownCaip19AssetIdOrSlip44Id[] = [ + // Extract CAIP IDs from context + const uniqueAssetCaipIds = [ ...Object.keys(interfaceContext.tokenPrices), ] as KnownCaip19AssetIdOrSlip44Id[]; + // Fetch fresh prices via lazy cache mechanism const prices = await this.#priceService.getSpotPrices({ assetIds: uniqueAssetCaipIds, vsCurrency: interfaceContext.currency, }); - const latestContext = - await getInterfaceContextIfExists(interfaceId); - if (!latestContext) { - this.logger.info('Interface dismissed during price fetch, cleaning up'); - return; - } - + // Fill the context with the new prices const updatedTokenPrices = uniqueAssetCaipIds.reduce< ContextWithPrices['tokenPrices'] >( @@ -99,18 +122,29 @@ export class RefreshConfirmationPricesHandler extends CronjobBaseHandler(interfaceId); + if (!latestContext) { + this.logger.info('Interface dismissed during price fetch, cleaning up'); + return; + } + + // Update the context with the new prices const updatedContext: ContextWithPrices = { ...latestContext, tokenPrices: updatedTokenPrices, tokenPricesFetchStatus: FetchStatus.Fetched, }; + // Re-render the Component based on the interface key await this.#reRenderConfirmationPrices({ interfaceId, updatedContext, interfaceKey, }); + // Schedule the next background event await RefreshConfirmationPricesHandler.scheduleBackgroundEvent({ scope, interfaceId, @@ -122,6 +156,7 @@ export class RefreshConfirmationPricesHandler extends CronjobBaseHandler(interfaceId); if (currentContext) { + // Update the context with the error status const errorContext: ContextWithPrices = { ...currentContext, tokenPricesFetchStatus: FetchStatus.Error, @@ -132,6 +167,7 @@ export class RefreshConfirmationPricesHandler extends CronjobBaseHandler { const { interfaceId, interfaceKey, updatedContext } = params; - const render = - interfaceKey === ConfirmationInterfaceKey.ChangeTrustlineOptIn - ? refreshConfirmationPricesChangeTrustlineOptIn - : refreshConfirmationPricesChangeTrustlineOptOut; - await render({ + await this.#confirmationUIController.updateConfirmation({ interfaceId, updatedContext, + interfaceKey, }); } } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts index c71100d4..d6381e49 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts @@ -9,13 +9,10 @@ import { generateStellarKeyringAccount } from '../../services/account/__mocks__/ import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; import { WalletService } from '../../services/wallet'; import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; -import { render as confirmSignMessageRender } from '../../ui/confirmation/views/ConfirmSignMessage/render'; +import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import type { ConfirmationUXController } from '../../ui/confirmation/controller'; import { logger } from '../../utils/logger'; -jest.mock('../../ui/confirmation/views/ConfirmSignMessage/render', () => ({ - render: jest.fn(), -})); - jest.mock('../../utils/logger'); describe('SignMessageHandler', () => { @@ -45,6 +42,7 @@ describe('SignMessageHandler', () => { handler: SignMessageHandler; mockAccount: StellarKeyringAccount; wallet: ReturnType; + renderConfirmationDialog: jest.Mock; } { const wallet = getTestWallet(); const mockAccount = generateStellarKeyringAccount( @@ -65,33 +63,54 @@ describe('SignMessageHandler', () => { .spyOn(WalletService.prototype, 'resolveWallet') .mockResolvedValue(wallet); + const renderConfirmationDialog = jest.fn(); + const confirmationUIController = { + renderConfirmationDialog, + } as Pick< + ConfirmationUXController, + 'renderConfirmationDialog' + > as unknown as ConfirmationUXController; + const handler = new SignMessageHandler({ logger, accountService, onChainAccountService, walletService, + confirmationUIController, }); - return { handler, mockAccount, wallet }; + return { handler, mockAccount, wallet, renderConfirmationDialog }; } it('returns signature when confirmation accepts', async () => { - const { handler, mockAccount, wallet } = setupSignMessageHandler(); - jest.mocked(confirmSignMessageRender).mockResolvedValue(true); + const { handler, mockAccount, wallet, renderConfirmationDialog } = + setupSignMessageHandler(); + renderConfirmationDialog.mockResolvedValue(true); const request = buildRequest(mockAccount); const result = await handler.handle(request); const expectedSignature = await wallet.signMessage(encodedMessage); - expect(confirmSignMessageRender).toHaveBeenCalledTimes(1); - expect(confirmSignMessageRender).toHaveBeenCalledWith(request, mockAccount); + expect(renderConfirmationDialog).toHaveBeenCalledTimes(1); + expect(renderConfirmationDialog).toHaveBeenCalledWith( + expect.objectContaining({ + scope: request.scope, + origin: request.origin, + interfaceKey: ConfirmationInterfaceKey.SignMessage, + renderContext: expect.objectContaining({ + account: mockAccount, + message: 'hello stellar', + }), + }), + ); expect(result).toStrictEqual({ signature: expectedSignature }); }); it('throws when confirmation rejects', async () => { - const { handler, mockAccount } = setupSignMessageHandler(); - jest.mocked(confirmSignMessageRender).mockResolvedValue(false); + const { handler, mockAccount, renderConfirmationDialog } = + setupSignMessageHandler(); + renderConfirmationDialog.mockResolvedValue(false); const request = buildRequest(mockAccount); @@ -99,12 +118,23 @@ describe('SignMessageHandler', () => { UserRejectedRequestError, ); - expect(confirmSignMessageRender).toHaveBeenCalledWith(request, mockAccount); + expect(renderConfirmationDialog).toHaveBeenCalledWith( + expect.objectContaining({ + scope: request.scope, + origin: request.origin, + interfaceKey: ConfirmationInterfaceKey.SignMessage, + renderContext: expect.objectContaining({ + account: mockAccount, + message: 'hello stellar', + }), + }), + ); }); it('rejects invalid requests before calling render', async () => { - const { handler, mockAccount } = setupSignMessageHandler(); - jest.mocked(confirmSignMessageRender).mockResolvedValue(true); + const { handler, mockAccount, renderConfirmationDialog } = + setupSignMessageHandler(); + renderConfirmationDialog.mockResolvedValue(true); await expect( handler.handle({ @@ -116,6 +146,6 @@ describe('SignMessageHandler', () => { }), ).rejects.toThrow(/request\.params\.message/u); - expect(confirmSignMessageRender).not.toHaveBeenCalled(); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts index 24128fa3..83a88e0a 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts @@ -10,8 +10,10 @@ import type { ResolvedActivatedAccountFor } from '../base'; import type { SignMessageRequest, SignMessageResponse } from './api'; import { SignMessageRequestStruct, SignMessageResponseStruct } from './api'; import { WithKeyringRequestActiveAccountResolve } from './base'; -import { render } from '../../ui/confirmation/views/ConfirmSignMessage/render'; -import type { ILogger } from '../../utils'; +import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import type { ConfirmationUXController } from '../../ui/confirmation/controller'; +import { bufferToUint8Array, type ILogger } from '../../utils'; +import { isBase64 } from '../../utils/string'; type SignMessageResolveOpts = { onChainAccount: false; wallet: true }; @@ -20,16 +22,20 @@ export class SignMessageHandler extends WithKeyringRequestActiveAccountResolve< SignMessageResponse, SignMessageResolveOpts > { + readonly #confirmationUIController: ConfirmationUXController; + constructor({ logger, accountService, onChainAccountService, walletService, + confirmationUIController, }: { logger: ILogger; accountService: AccountService; onChainAccountService: OnChainAccountService; walletService: WalletService; + confirmationUIController: ConfirmationUXController; }) { super({ logger, @@ -40,6 +46,7 @@ export class SignMessageHandler extends WithKeyringRequestActiveAccountResolve< responseStruct: SignMessageResponseStruct, resolveAccountOptions: { onChainAccount: false }, }); + this.#confirmationUIController = confirmationUIController; } protected async _handle( @@ -63,6 +70,23 @@ export class SignMessageHandler extends WithKeyringRequestActiveAccountResolve< request: SignMessageRequest, account: StellarKeyringAccount, ): Promise { - return (await render(request, account)) === true; + return ( + (await this.#confirmationUIController.renderConfirmationDialog({ + scope: request.scope, + renderContext: { + account, + message: this.#getUtf8Message(request.request.params.message), + }, + origin: request.origin, + interfaceKey: ConfirmationInterfaceKey.SignMessage, + })) === true + ); + } + + #getUtf8Message(message: string): string { + if (isBase64(message)) { + return bufferToUint8Array(message, 'base64').toString('utf8'); + } + return message; } } diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts index ce2b1877..f9b469f6 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts @@ -1,4 +1,20 @@ -import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; +import { union } from '@metamask/snaps-sdk'; +import type { Infer } from '@metamask/superstruct'; +import { + enums, + record, + type, + string, + nullable, + nonempty, +} from '@metamask/superstruct'; + +import { + KnownCaip19ClassicAssetStruct, + KnownCaip19Sep41AssetStruct, + KnownCaip19Slip44IdStruct, + type KnownCaip19AssetIdOrSlip44Id, +} from '../../api'; export type FeeData = { assetId: KnownCaip19AssetIdOrSlip44Id; @@ -15,8 +31,29 @@ export enum FetchStatus { Error = 'error', } -export type ContextWithPrices = { - tokenPrices: Record; - tokenPricesFetchStatus: FetchStatus; - currency: string; -}; +export const ContextWithPricesStruct = type({ + tokenPrices: record( + union([ + KnownCaip19Sep41AssetStruct, + KnownCaip19ClassicAssetStruct, + KnownCaip19Slip44IdStruct, + ]), + nullable(string()), + ), + tokenPricesFetchStatus: enums(Object.values(FetchStatus)), + currency: nonempty(string()), +}); + +export type ContextWithPrices = Infer; + +export enum ConfirmationInterfaceKey { + ChangeTrustlineOptIn = 'ChangeTrustlineOptIn', + ChangeTrustlineOptOut = 'ChangeTrustlineOptOut', + SendTransaction = 'SendTransaction', + SignMessage = 'SignMessage', + SignTransaction = 'SignTransaction', +} + +export const ConfirmationInterfaceKeyStruct = enums( + Object.values(ConfirmationInterfaceKey), +); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx new file mode 100644 index 00000000..79dac60f --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -0,0 +1,202 @@ +import type { ComponentOrElement, DialogResult } from '@metamask/snaps-sdk'; +import type { Json } from '@metamask/utils'; + +import { + ConfirmationInterfaceKey, + type ContextWithPrices, + FetchStatus, +} from './api'; +import { + formatFeeData, + formatOrigin, + getPreferencesWithFallback, +} from './utils'; +import type { KnownCaip2ChainId } from '../../api'; +import type { ILogger, Locale } from '../../utils'; +import { + createInterface, + createPrefixedLogger, + getSlip44AssetId, + scheduleBackgroundEvent, + showDialog, + updateInterfaceIfExists, +} from '../../utils'; +import { STELLAR_IMAGE } from '../images/icon'; +import { + ConfirmSignMessage, + type ConfirmSignMessageProps, +} from './views/ConfirmSignMessage/ConfirmSignMessage'; +import { + ConfirmSignTransaction, + type ConfirmSignTransactionProps, +} from './views/ConfirmSignTransaction/ConfirmSignTransaction'; +import { BackgroundEventMethod } from '../../handlers/cronjob/api'; + +/** Serializable props bag stored on the interface and merged into each view. */ +type ConfirmationViewProps = Record; + +type ConfirmationRenderOptions = { + loadPrice?: boolean; + scanTxn?: boolean; +}; + +export class ConfirmationUXController { + readonly #logger: ILogger; + + readonly #defaultRenderOptions: ConfirmationRenderOptions = { + loadPrice: false, + scanTxn: false, + }; + + constructor({ logger }: { logger: ILogger }) { + this.#logger = createPrefixedLogger( + logger, + '[💬 ConfirmationUXController]', + ); + } + + async renderConfirmationDialog(params: { + scope: KnownCaip2ChainId; + renderContext: Props; + fee?: string; + interfaceKey: ConfirmationInterfaceKey; + origin?: string; + renderOptions?: ConfirmationRenderOptions; + }): Promise { + try { + const { + interfaceKey, + scope, + renderContext, + origin = 'metamask', + fee, + renderOptions = { + ...this.#defaultRenderOptions, + ...params.renderOptions, + }, + } = params; + + const preferences = await getPreferencesWithFallback(); + + const enablePricing = + renderOptions.loadPrice && preferences.useExternalPricingData; + + const defaultContext = { + // if pricing is disabled, mark as fetched immediately + tokenPricesFetchStatus: enablePricing + ? FetchStatus.Fetching + : FetchStatus.Fetched, + preferences, + locale: preferences.locale as Locale, + networkImage: STELLAR_IMAGE, + origin: formatOrigin(origin), + currency: preferences.currency, + feeData: fee ? formatFeeData(scope, fee) : undefined, + tokenPrices: fee + ? ({ + [getSlip44AssetId(scope)]: null, + } as ContextWithPrices['tokenPrices']) + : {}, + }; + + // 1. Initial context with loading state + const context = { + ...defaultContext, + ...renderContext, + }; + + // 2. Initial render with loading skeleton (always show loading if pricing enabled) + const id = await createInterface( + this.#renderConfirmationView(interfaceKey, context), + {}, + ); + const dialogPromise = showDialog(id); + + // 3. TODO: Perform security scan (always needed for estimated changes simulation) + + // 4. Update interface with scan results after initial render (silently ignores if dismissed) + const updated = await updateInterfaceIfExists( + id, + this.#renderConfirmationView(interfaceKey, context), + context, + ); + + // If interface was dismissed during scan, exit early + if (!updated) { + return dialogPromise; + } + + // 5. Schedule background jobs only after confirming the interface is still alive + if (enablePricing) { + // Trigger immediate price fetch (1 second), then continue every 20 seconds + await scheduleBackgroundEvent({ + method: BackgroundEventMethod.RefreshConfirmationPrices, + duration: 'PT1S', // Start immediately + params: { + scope, + interfaceId: id, + interfaceKey, + }, + }); + } + + // TODO: Schedule security scan background refresh (every 20 seconds) + + // 6. Return the dialog promise immediately (don't await it!) + // Cleanup happens in the background refresh handler when it detects the interface is gone + return dialogPromise; + } catch (error) { + this.#logger.logErrorWithDetails( + 'Error rendering confirmation dialog', + error, + ); + throw error; + } + } + + async updateConfirmation(params: { + interfaceId: string; + updatedContext: ConfirmationViewProps; + interfaceKey: ConfirmationInterfaceKey; + }): Promise { + const { interfaceId, updatedContext, interfaceKey } = params; + await updateInterfaceIfExists( + interfaceId, + this.#renderConfirmationView(interfaceKey, updatedContext), + updatedContext, + ); + } + + /** + * Maps each {@link ConfirmationInterfaceKey} to its view. Casts stay inside this + * switch so callers keep a single `ConfirmationViewProps` shape for storage/refresh. + * + * @param interfaceKey + * @param context + */ + #renderConfirmationView( + interfaceKey: ConfirmationInterfaceKey, + context: ConfirmationViewProps, + ): ComponentOrElement { + switch (interfaceKey) { + case ConfirmationInterfaceKey.ChangeTrustlineOptIn: + throw new Error(`Unsupported interface key: ${interfaceKey}`); + case ConfirmationInterfaceKey.ChangeTrustlineOptOut: + throw new Error(`Unsupported interface key: ${interfaceKey}`); + case ConfirmationInterfaceKey.SendTransaction: + throw new Error(`Unsupported interface key: ${interfaceKey}`); + case ConfirmationInterfaceKey.SignTransaction: + return ( + + ); + case ConfirmationInterfaceKey.SignMessage: + return ; + default: { + const exhaustive: never = interfaceKey; + throw new Error(`Unsupported interface key: ${String(exhaustive)}`); + } + } + } +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx index c7d10139..eabda826 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -28,15 +28,17 @@ import { import type { Locale, LocalizedMessage } from '../../../../utils'; import { i18n, parseClassicAssetCodeIssuer } from '../../../../utils'; import { STELLAR_IMAGE } from '../../../images/icon'; +import type { ContextWithPrices, FeeData } from '../../api'; import { getClassicAssetExplorerUrl, getNetworkName } from '../../utils'; -export type ConfirmSignTransactionProps = { +export type ConfirmSignTransactionProps = ContextWithPrices & { transaction: Transaction; account: StellarKeyringAccount; scope: KnownCaip2ChainId; locale: Locale; networkImage: string | null; origin: string; + feeData: FeeData; }; const AmountRow = ({ amount }: { amount: string }): ComponentOrElement => { diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx index d0174430..725ab4b0 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx @@ -1,12 +1,18 @@ import type { DialogResult } from '@metamask/snaps-sdk'; import { ConfirmSignTransaction } from './ConfirmSignTransaction'; +import type { ConfirmSignTransactionProps } from './ConfirmSignTransaction'; import type { SignTransactionRequest } from '../../../../handlers/keyring'; import type { StellarKeyringAccount } from '../../../../services/account'; import type { Transaction } from '../../../../services/transaction'; -import { createInterface, showDialog } from '../../../../utils'; +import { + createInterface, + getSlip44AssetId, + showDialog, +} from '../../../../utils'; import { STELLAR_IMAGE } from '../../../images/icon'; -import { formatOrigin, getLocale } from '../../utils'; +import { FetchStatus } from '../../api'; +import { formatFeeData, formatOrigin, getLocale } from '../../utils'; /** * Renders the confirmation dialog for a sign transaction request. @@ -24,6 +30,7 @@ export async function render( const { scope, origin } = request; const locale = await getLocale(); + const nativeAssetId = getSlip44AssetId(scope); const id = await createInterface( , {}, ); diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts index 25f2c56b..273cacc8 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts @@ -1,5 +1,4 @@ import type { CaipAssetType } from '@metamask/utils'; -import * as metamaskUtils from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { @@ -102,12 +101,6 @@ describe('getFiatTicker', () => { }); it('returns lowercase asset reference from parser', () => { - jest.spyOn(metamaskUtils, 'parseCaipAssetType').mockReturnValue({ - assetReference: 'EUR', - } as ReturnType); - - expect(getFiatTicker('ignored/swift:0/iso4217:EUR' as CaipAssetType)).toBe( - 'eur', - ); + expect(getFiatTicker('swift:0/iso4217:EUR' as CaipAssetType)).toBe('eur'); }); }); From 8e46b2af36106b48f990aa6f5b5c8b41f849526b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:56:39 +0800 Subject: [PATCH 087/384] fix: lint --- .../stellar-wallet-snap/src/context.ts | 2 +- .../cronjob/refreshConfirmationPrices.ts | 2 -- .../src/ui/confirmation/controller.tsx | 27 ++++++++++++++----- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 24f54e63..9f4396ec 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -2,11 +2,11 @@ import { assert, object } from '@metamask/superstruct'; import { AppConfig } from './config'; import { KeyringHandler, CronjobHandler, UserInputHandler } from './handlers'; +import { AssetsHandler } from './handlers/asset/assets'; import type { ICronjobRequestHandler } from './handlers/cronjob/api'; import { BackgroundEventMethod } from './handlers/cronjob/api'; import { RefreshConfirmationPricesHandler } from './handlers/cronjob/refreshConfirmationPrices'; import { TrackTransactionHandler } from './handlers/cronjob/trackTransaction'; -import { AssetsHandler } from './handlers/asset/assets'; import type { IKeyringRequestHandler } from './handlers/keyring'; import { MultichainMethod, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts index 7d9ca016..d26ad69b 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts @@ -1,5 +1,3 @@ -import type { Json } from '@metamask/utils'; - import { BackgroundEventMethod, RefreshConfirmationPricesJsonRpcRequestStruct, diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 79dac60f..094667ee 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -55,6 +55,18 @@ export class ConfirmationUXController { ); } + /** + * Renders the confirmation dialog. + * + * @param params - The parameters for the render. + * @param params.scope - The scope of the confirmation. + * @param params.renderContext - The context for the render. + * @param params.fee - The fee for the render. + * @param params.interfaceKey - The key of the interface to render. + * @param params.origin - The origin of the confirmation. + * @param params.renderOptions - The options for the render. + * @returns A promise that resolves to the dialog result. + */ async renderConfirmationDialog(params: { scope: KnownCaip2ChainId; renderContext: Props; @@ -154,6 +166,14 @@ export class ConfirmationUXController { } } + /** + * Updates the confirmation dialog with the new context. + * + * @param params - The parameters for the update. + * @param params.interfaceId - The ID of the interface to update. + * @param params.updatedContext - The new context to update the interface with. + * @param params.interfaceKey - The key of the interface to update. + */ async updateConfirmation(params: { interfaceId: string; updatedContext: ConfirmationViewProps; @@ -167,13 +187,6 @@ export class ConfirmationUXController { ); } - /** - * Maps each {@link ConfirmationInterfaceKey} to its view. Casts stay inside this - * switch so callers keep a single `ConfirmationViewProps` shape for storage/refresh. - * - * @param interfaceKey - * @param context - */ #renderConfirmationView( interfaceKey: ConfirmationInterfaceKey, context: ConfirmationViewProps, From d090e9bdac6ec1980adde89a89d277007d5d0d5b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:58:52 +0800 Subject: [PATCH 088/384] chore: remove unuse file --- .../stellar-wallet-snap/src/api/fetch-status.ts | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 merged-packages/stellar-wallet-snap/src/api/fetch-status.ts diff --git a/merged-packages/stellar-wallet-snap/src/api/fetch-status.ts b/merged-packages/stellar-wallet-snap/src/api/fetch-status.ts deleted file mode 100644 index 21ccdfcb..00000000 --- a/merged-packages/stellar-wallet-snap/src/api/fetch-status.ts +++ /dev/null @@ -1,7 +0,0 @@ -export enum FetchStatus { - Initial = 'initial', - Fetching = 'fetching', - Fetched = 'fetched', - // eslint-disable-next-line @typescript-eslint/no-shadow - Error = 'error', -} From aef02df968db788e4af4e881d5f80a7c3975d7ba Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:15:37 +0800 Subject: [PATCH 089/384] chore: address comment --- .../stellar-wallet-snap/src/handlers/cronjob/api.ts | 4 ++-- .../stellar-wallet-snap/src/handlers/cronjob/base.ts | 8 ++++---- .../src/handlers/cronjob/trackTransaction.ts | 2 +- .../src/ui/confirmation/components/Asset.tsx | 2 +- .../src/ui/confirmation/components/AssetText.tsx | 8 ++++---- .../src/ui/confirmation/components/Fee.tsx | 2 +- .../confirmation/views/AccountActivationPrompt/events.tsx | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts index 2499047f..79766a8c 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts @@ -67,11 +67,11 @@ export const TrackTransactionJsonRpcRequestStruct = assign( }), ); -export const CrobJobJsonRpcRequestStruct = object({ +export const CronjobJsonRpcRequestStruct = object({ status: boolean(), }); -export type CrobJobJsonRpcRequest = Infer; +export type CronjobJsonRpcRequest = Infer; export type RefreshConfirmationPricesJsonRpcRequest = Infer< typeof RefreshConfirmationPricesJsonRpcRequestStruct diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/base.ts index 51a88c69..c1374b6e 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/base.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/base.ts @@ -2,13 +2,13 @@ import type { Struct } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; import { BaseHandler } from '../base'; -import type { CrobJobJsonRpcRequest } from './api'; -import { CrobJobJsonRpcRequestStruct } from './api'; +import type { CronjobJsonRpcRequest } from './api'; +import { CronjobJsonRpcRequestStruct } from './api'; import type { ILogger } from '../../utils'; export abstract class CronjobBaseHandler< RequestType extends Json, -> extends BaseHandler { +> extends BaseHandler { constructor({ logger, requestStruct, @@ -19,7 +19,7 @@ export abstract class CronjobBaseHandler< super({ logger, requestStruct, - responseStruct: CrobJobJsonRpcRequestStruct, + responseStruct: CronjobJsonRpcRequestStruct, }); } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts index 8b478c7c..eaa21889 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts @@ -19,7 +19,7 @@ export class TrackTransactionHandler extends CronjobBaseHandler { await scheduleBackgroundEvent({ - method: BackgroundEventMethod.RefreshConfirmationPrices, + method: BackgroundEventMethod.TrackTransaction, params, duration, }); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Asset.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Asset.tsx index 16d9e02d..d02da650 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Asset.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Asset.tsx @@ -51,7 +51,7 @@ export const Asset = (props: AssetProps): ComponentOrElement => { - +
); }; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetText.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetText.tsx index 39dc5388..47897f22 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetText.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetText.tsx @@ -5,7 +5,7 @@ type AssetTextProps = { /** The link to the asset. if provided, the asset text will be a link. */ link?: string; /** The asset text to display. */ - aseset: string; + asset: string; }; /** @@ -16,9 +16,9 @@ type AssetTextProps = { * @returns The rendered asset text element. */ export const AssetText = (props: AssetTextProps): ComponentOrElement => { - const { aseset, link } = props; + const { asset, link } = props; if (link) { - return {aseset}; + return {asset}; } - return ${aseset}; + return ${asset}; }; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Fee.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Fee.tsx index 12ddc518..61b49793 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Fee.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Fee.tsx @@ -5,7 +5,7 @@ import type { import { Box, Text as SnapText } from '@metamask/snaps-sdk/jsx'; import { Asset } from './Asset'; -import { FetchStatus } from '../../../api/fetch-status'; +import { FetchStatus } from '../api'; import { i18n } from '../../../utils/i18n'; import xlmSvg from '../../images/slip44:148.svg'; import type { FeeData } from '../api'; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/events.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/events.tsx index ae4afe59..ac10a844 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/events.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/events.tsx @@ -5,7 +5,7 @@ import type { import { resolveInterface } from '../../../../utils'; /** - * Handles the click event for the cancel button. + * Handles the click event for the close button. * * @param options - The user input handler context from `onUserInput`. * @returns A promise that resolves when the interface has been updated. From ba4d39db84be4ea69ff433103ce4ee7e6e6187b0 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:37:11 +0800 Subject: [PATCH 090/384] fix: lint --- .../src/ui/confirmation/components/Fee.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Fee.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Fee.tsx index 61b49793..556fe9a6 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Fee.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/Fee.tsx @@ -5,9 +5,9 @@ import type { import { Box, Text as SnapText } from '@metamask/snaps-sdk/jsx'; import { Asset } from './Asset'; -import { FetchStatus } from '../api'; import { i18n } from '../../../utils/i18n'; -import xlmSvg from '../../images/slip44:148.svg'; +import { xlmIcon } from '../../images'; +import { FetchStatus } from '../api'; import type { FeeData } from '../api'; type FeesProps = { @@ -38,7 +38,7 @@ export const FeeRow = ({ Date: Wed, 22 Apr 2026 08:06:59 +0800 Subject: [PATCH 091/384] fix: comment --- .../src/ui/confirmation/components/AssetText.tsx | 2 +- .../stellar-wallet-snap/src/ui/confirmation/utils.ts | 4 ++-- .../ConfirmSignTransaction/ConfirmSignTransaction.tsx | 8 ++++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetText.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetText.tsx index 47897f22..26440b46 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetText.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/AssetText.tsx @@ -20,5 +20,5 @@ export const AssetText = (props: AssetTextProps): ComponentOrElement => { if (link) { return {asset}; } - return ${asset}; + return {asset}; }; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts index 625a3c79..4be33ad4 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts @@ -122,8 +122,8 @@ export function getSepAssetExplorerUrl(assetReference: string): string { export function getAccountName( scope: KnownCaip2ChainId, address: string, -): `0x${string}` | CaipAccountId { - return `${scope}:${address}` as `0x${string}` | CaipAccountId; +): CaipAccountId { + return `${scope}:${address}`; } /** diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx index eabda826..34300a5f 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -29,7 +29,11 @@ import type { Locale, LocalizedMessage } from '../../../../utils'; import { i18n, parseClassicAssetCodeIssuer } from '../../../../utils'; import { STELLAR_IMAGE } from '../../../images/icon'; import type { ContextWithPrices, FeeData } from '../../api'; -import { getClassicAssetExplorerUrl, getNetworkName } from '../../utils'; +import { + getAccountName, + getClassicAssetExplorerUrl, + getNetworkName, +} from '../../utils'; export type ConfirmSignTransactionProps = ContextWithPrices & { transaction: Transaction; @@ -129,7 +133,7 @@ export const ConfirmSignTransaction = ({ }: ConfirmSignTransactionProps): ComponentOrElement => { const t = i18n(locale); const { address } = account; - const addressCaip10 = `${scope}:${address}` as `0x${string}` | CaipAccountId; + const addressCaip10 = getAccountName(scope, address); const readableTransaction = new OperationMapper().mapTransaction(transaction); From 87c65c41aafa18ec0a54d8428917f1bcbb61ef01 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:08:31 +0800 Subject: [PATCH 092/384] chore: add cronjob endowment --- merged-packages/stellar-wallet-snap/snap.manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 26a098fc..80ede87d 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -36,6 +36,7 @@ "snap_manageState": {}, "snap_dialog": {}, "snap_getPreferences": {}, + "endowment:cronjob": {}, "endowment:assets": { "scopes": ["stellar:pubnet"] } From 08777fb49ab6a6338ca5d34720e89b7f86903ad9 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:55:37 +0800 Subject: [PATCH 093/384] chore: refine refreshConfirmationPrices --- .../cronjob/refreshConfirmationPrices.ts | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts index d26ad69b..82ed6bb0 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts @@ -1,3 +1,5 @@ +import type { Json } from '@metamask/utils'; + import { BackgroundEventMethod, RefreshConfirmationPricesJsonRpcRequestStruct, @@ -78,18 +80,8 @@ export class RefreshConfirmationPricesHandler extends CronjobBaseHandler(interfaceId); - - // TODO: check if the interfaceContext match the ContextWithPrices - if (!interfaceContext) { - this.logger.info('Interface no longer exists, cleaning up'); - return; - } - - if (!ContextWithPricesStruct.is(interfaceContext)) { - this.logger.warn( - 'Interface context does not match the ContextWithPrices interface, skipping refresh', - ); + await this.#getInterfaceContextIfExists(interfaceId); + if (interfaceContext === null) { return; } @@ -122,9 +114,8 @@ export class RefreshConfirmationPricesHandler extends CronjobBaseHandler(interfaceId); - if (!latestContext) { - this.logger.info('Interface dismissed during price fetch, cleaning up'); + await this.#getInterfaceContextIfExists(interfaceId); + if (latestContext === null) { return; } @@ -152,8 +143,8 @@ export class RefreshConfirmationPricesHandler extends CronjobBaseHandler(interfaceId); - if (currentContext) { + await this.#getInterfaceContextIfExists(interfaceId); + if (currentContext !== null) { // Update the context with the error status const errorContext: ContextWithPrices = { ...currentContext, @@ -183,4 +174,25 @@ export class RefreshConfirmationPricesHandler extends CronjobBaseHandler { + const interfaceContext = + await getInterfaceContextIfExists(interfaceId); + + if (!interfaceContext) { + this.logger.info('Interface no longer exists, cleaning up'); + return null; + } + + if (!ContextWithPricesStruct.is(interfaceContext)) { + this.logger.warn( + 'Interface context does not match the ContextWithPrices interface, skipping refresh', + ); + return null; + } + + return interfaceContext; + } } From f3ed7105559a86e8b666cb06e183df436e626b93 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:00:47 +0800 Subject: [PATCH 094/384] chore: refine ui controller --- .../src/ui/confirmation/controller.tsx | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 094667ee..375662f1 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -61,19 +61,21 @@ export class ConfirmationUXController { * @param params - The parameters for the render. * @param params.scope - The scope of the confirmation. * @param params.renderContext - The context for the render. - * @param params.fee - The fee for the render. * @param params.interfaceKey - The key of the interface to render. - * @param params.origin - The origin of the confirmation. - * @param params.renderOptions - The options for the render. + * @param params.fee - [Optional] The fee for the render. + * @param params.origin - [Optional] The origin of the confirmation. Defaults to 'metamask'. + * @param params.renderOptions - [Optional] The options for the render. Defaults to {@link #defaultRenderOptions}. + * @param params.tokenPrices - [Optional] The token prices for the render {@link ContextWithPrices['tokenPrices']}. * @returns A promise that resolves to the dialog result. */ async renderConfirmationDialog(params: { scope: KnownCaip2ChainId; renderContext: Props; - fee?: string; interfaceKey: ConfirmationInterfaceKey; + fee?: string; origin?: string; renderOptions?: ConfirmationRenderOptions; + tokenPrices?: ContextWithPrices['tokenPrices']; }): Promise { try { const { @@ -93,6 +95,12 @@ export class ConfirmationUXController { const enablePricing = renderOptions.loadPrice && preferences.useExternalPricingData; + const defaultTokenPrices = fee + ? ({ + [getSlip44AssetId(scope)]: null, + } as ContextWithPrices['tokenPrices']) + : {}; + const defaultContext = { // if pricing is disabled, mark as fetched immediately tokenPricesFetchStatus: enablePricing @@ -103,12 +111,12 @@ export class ConfirmationUXController { networkImage: STELLAR_IMAGE, origin: formatOrigin(origin), currency: preferences.currency, - feeData: fee ? formatFeeData(scope, fee) : undefined, - tokenPrices: fee - ? ({ - [getSlip44AssetId(scope)]: null, - } as ContextWithPrices['tokenPrices']) - : {}, + scope, + feeData: fee ? formatFeeData(scope, fee) : {}, + tokenPrices: { + ...defaultTokenPrices, + ...params.tokenPrices, + }, }; // 1. Initial context with loading state @@ -196,8 +204,6 @@ export class ConfirmationUXController { throw new Error(`Unsupported interface key: ${interfaceKey}`); case ConfirmationInterfaceKey.ChangeTrustlineOptOut: throw new Error(`Unsupported interface key: ${interfaceKey}`); - case ConfirmationInterfaceKey.SendTransaction: - throw new Error(`Unsupported interface key: ${interfaceKey}`); case ConfirmationInterfaceKey.SignTransaction: return ( Date: Wed, 22 Apr 2026 15:15:14 +0800 Subject: [PATCH 095/384] fix: lint --- .../src/ui/confirmation/controller.tsx | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 375662f1..4b78cc22 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -92,15 +92,28 @@ export class ConfirmationUXController { const preferences = await getPreferencesWithFallback(); - const enablePricing = - renderOptions.loadPrice && preferences.useExternalPricingData; - const defaultTokenPrices = fee ? ({ [getSlip44AssetId(scope)]: null, } as ContextWithPrices['tokenPrices']) : {}; + const tokenPrices = { + ...defaultTokenPrices, + ...params.tokenPrices, + }; + + /** + * Enazble Price Fetching if: + * - Pricing Loading is enabled + * - External Pricing Preferences is enabled + * - Token Prices mapping is provided + */ + const enablePricing = + renderOptions.loadPrice && + preferences.useExternalPricingData && + tokenPrices !== undefined; + const defaultContext = { // if pricing is disabled, mark as fetched immediately tokenPricesFetchStatus: enablePricing @@ -113,10 +126,7 @@ export class ConfirmationUXController { currency: preferences.currency, scope, feeData: fee ? formatFeeData(scope, fee) : {}, - tokenPrices: { - ...defaultTokenPrices, - ...params.tokenPrices, - }, + tokenPrices, }; // 1. Initial context with loading state @@ -201,9 +211,9 @@ export class ConfirmationUXController { ): ComponentOrElement { switch (interfaceKey) { case ConfirmationInterfaceKey.ChangeTrustlineOptIn: - throw new Error(`Unsupported interface key: ${interfaceKey}`); + throw new Error('ChangeTrustlineOptIn is not supported'); case ConfirmationInterfaceKey.ChangeTrustlineOptOut: - throw new Error(`Unsupported interface key: ${interfaceKey}`); + throw new Error('ChangeTrustlineOptOut is not supported'); case ConfirmationInterfaceKey.SignTransaction: return ( Date: Wed, 22 Apr 2026 15:21:46 +0800 Subject: [PATCH 096/384] fix: lint --- .../src/ui/confirmation/api.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts index f9b469f6..9716b18c 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts @@ -1,3 +1,4 @@ +import type { GetPreferencesResult } from '@metamask/snaps-sdk'; import { union } from '@metamask/snaps-sdk'; import type { Infer } from '@metamask/superstruct'; import { @@ -9,11 +10,14 @@ import { nonempty, } from '@metamask/superstruct'; +import type { + KnownCaip2ChainId, + KnownCaip19AssetIdOrSlip44Id, +} from '../../api'; import { KnownCaip19ClassicAssetStruct, KnownCaip19Sep41AssetStruct, KnownCaip19Slip44IdStruct, - type KnownCaip19AssetIdOrSlip44Id, } from '../../api'; export type FeeData = { @@ -49,7 +53,6 @@ export type ContextWithPrices = Infer; export enum ConfirmationInterfaceKey { ChangeTrustlineOptIn = 'ChangeTrustlineOptIn', ChangeTrustlineOptOut = 'ChangeTrustlineOptOut', - SendTransaction = 'SendTransaction', SignMessage = 'SignMessage', SignTransaction = 'SignTransaction', } @@ -57,3 +60,16 @@ export enum ConfirmationInterfaceKey { export const ConfirmationInterfaceKeyStruct = enums( Object.values(ConfirmationInterfaceKey), ); + +/** + * Cross-cutting confirmation context injected by {@link ConfirmationUXController} + * before the caller's `renderContext` is merged in. + */ +export type ConfirmationBaseProps = Partial & { + preferences: GetPreferencesResult; + locale: string; + scope: KnownCaip2ChainId; + networkImage: string | null; + origin: string; + feeData?: FeeData; +}; From e7653c93e53cadb7c1868ca2bdeaac2ed64c2b0e Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:09:16 +0800 Subject: [PATCH 097/384] feat: add change trust support --- .../src/api/integer.test.ts | 51 +- .../stellar-wallet-snap/src/api/integer.ts | 56 + .../stellar-wallet-snap/src/context.ts | 33 + .../src/handlers/clientRequest/api.test.ts | 233 +++ .../src/handlers/clientRequest/api.ts | 141 ++ .../src/handlers/clientRequest/base.ts | 37 + .../clientRequest/changeTrustOpt.test.ts | 368 +++++ .../handlers/clientRequest/changeTrustOpt.ts | 272 ++++ .../handlers/clientRequest/clientRequest.ts | 66 + .../src/handlers/clientRequest/index.ts | 4 + .../src/handlers/user-input/userInput.ts | 6 + .../stellar-wallet-snap/src/index.ts | 15 +- .../KeyringTransactionBuilder.test.ts | 187 +++ .../transaction/KeyringTransactionBuilder.ts | 169 +++ .../transaction/TransactionBuilder.test.ts | 81 +- .../transaction/TransactionBuilder.ts | 33 +- .../transaction/TransactionService.test.ts | 235 ++- .../transaction/TransactionService.ts | 239 ++- .../transaction/TransactionSimulator.test.ts | 1329 +++++++++++++++++ .../transaction/TransactionSimulator.ts | 408 +++++ .../__mocks__/transaction.fixtures.ts | 1 + .../src/services/transaction/exceptions.ts | 9 + .../src/services/transaction/index.ts | 2 + .../services/transaction/simulation/api.ts | 72 + .../services/transaction/simulation/index.ts | 3 + .../transaction/simulation/simulators.ts | 411 +++++ .../services/transaction/simulation/utils.ts | 133 ++ .../src/services/transaction/utils.ts | 19 + .../src/ui/confirmation/controller.tsx | 16 +- .../ConfirmSignChangeTrustOptIn.tsx | 150 ++ .../ConfirmSignChangeTrustOptIn/events.tsx | 50 + .../ConfirmSignChangeTrustOptOut.tsx | 150 ++ .../ConfirmSignChangeTrustOptOut/events.tsx | 50 + .../src/ui/images/usdt.svg | 1 + .../stellar-wallet-snap/src/utils/snap.ts | 2 +- 35 files changed, 4904 insertions(+), 128 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/base.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/simulation/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/simulation/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/simulation/utils.ts create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/events.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/events.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/images/usdt.svg diff --git a/merged-packages/stellar-wallet-snap/src/api/integer.test.ts b/merged-packages/stellar-wallet-snap/src/api/integer.test.ts index bfb49f67..804d94e4 100644 --- a/merged-packages/stellar-wallet-snap/src/api/integer.test.ts +++ b/merged-packages/stellar-wallet-snap/src/api/integer.test.ts @@ -1,6 +1,10 @@ import { assert, StructError } from '@metamask/superstruct'; -import { PositiveNumberStringStruct } from './integer'; +import { + NonZeroValidAmountStruct, + PositiveNumberStringStruct, + ValidAmountStruct, +} from './integer'; describe('PositiveNumberStringStruct', () => { it('accepts a valid positive integer string', () => { @@ -29,3 +33,48 @@ describe('PositiveNumberStringStruct', () => { ); }); }); + +describe('ValidAmountStruct', () => { + it('accepts a valid amount with up to 7 decimal places', () => { + expect(() => assert('12.3456789', ValidAmountStruct)).not.toThrow(); + }); + + it('accepts max int64 represented in 7-decimal Stellar units', () => { + // MAX_INT64 stroops converted to XLM-style amount. + expect(() => + assert('922337203685.4775807', ValidAmountStruct), + ).not.toThrow(); + }); + + it('rejects an amount above max int64 when converted to stroops', () => { + expect(() => assert('922337203685.4775808', ValidAmountStruct)).toThrow( + StructError, + ); + }); + + it('rejects an amount with more than 7 decimal places', () => { + expect(() => assert('1.00000001', ValidAmountStruct)).toThrow(StructError); + }); + + it('rejects a negative amount', () => { + expect(() => assert('-0.1', ValidAmountStruct)).toThrow(StructError); + }); + + it('rejects non-finite numeric values', () => { + expect(() => assert('Infinity', ValidAmountStruct)).toThrow(StructError); + expect(() => assert('NaN', ValidAmountStruct)).toThrow(StructError); + }); +}); + +describe('NonZeroValidAmountStruct', () => { + it('accepts a valid non-zero amount', () => { + expect(() => assert('0.0000001', NonZeroValidAmountStruct)).not.toThrow(); + }); + + it('rejects zero', () => { + expect(() => assert('0', NonZeroValidAmountStruct)).toThrow(StructError); + expect(() => assert('0.0000000', NonZeroValidAmountStruct)).toThrow( + StructError, + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/api/integer.ts b/merged-packages/stellar-wallet-snap/src/api/integer.ts index 4e047162..6ed6cd38 100644 --- a/merged-packages/stellar-wallet-snap/src/api/integer.ts +++ b/merged-packages/stellar-wallet-snap/src/api/integer.ts @@ -1,6 +1,9 @@ import { nonempty, refine, string, type Infer } from '@metamask/superstruct'; import { BigNumber } from 'bignumber.js'; +import { MAX_INT64, STELLAR_DECIMAL_PLACES } from '../constants'; +import { toSmallestUnit } from '../utils/currency'; + /** * Non-empty string that parses to a finite, non-negative {@link BigNumber} (stroops or human-readable amounts). * Uses `refine` so `assert` / `validate` enforce this; not only `create` with coercion. @@ -24,4 +27,57 @@ export const PositiveNumberStringStruct = refine( }, ); +/** + * Non-empty string that parses to a finite, non-negative {@link BigNumber}. + * The amount is converted to the smallest unit of the asset and validated against the maximum int64. + * Uses `refine` so `assert` / `validate` enforce this; not only `create` with coercion. + */ +export const ValidAmountStruct = refine( + nonempty(string()), + 'valid_amount', + (value: string) => { + try { + const amount = new BigNumber(value); + const decimalPlaces = amount.decimalPlaces(); + if ( + // < 0 + amount.isNegative() || + // > Max value + toSmallestUnit(amount).gt(new BigNumber(MAX_INT64).toString()) || + // Decimal places (max 7) + (decimalPlaces && decimalPlaces > STELLAR_DECIMAL_PLACES) || + // NaN or Infinity + amount.isNaN() || + !amount.isFinite() + ) { + return 'Invalid amount'; + } + return true; + } catch { + return 'Invalid amount'; + } + }, +); + +/** + * Non-empty string that parses to a finite, non-negative {@link BigNumber} and is not zero. + * The amount is converted to the smallest unit of the asset and validated against the maximum int64. + * Uses `refine` so `assert` / `validate` enforce this; not only `create` with coercion. + */ +export const NonZeroValidAmountStruct = refine( + ValidAmountStruct, + 'non_zero_valid_amount', + (value: string) => { + const amount = new BigNumber(value); + if (amount.isZero()) { + return 'Amount cannot be zero'; + } + return true; + }, +); + +export type NonZeroValidAmount = Infer; + +export type ValidAmount = Infer; + export type PositiveNumberString = Infer; diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 9f4396ec..b43ae765 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -3,6 +3,12 @@ import { assert, object } from '@metamask/superstruct'; import { AppConfig } from './config'; import { KeyringHandler, CronjobHandler, UserInputHandler } from './handlers'; import { AssetsHandler } from './handlers/asset/assets'; +import type { IClientRequestHandler } from './handlers/clientRequest'; +import { + ChangeTrustOptHandler, + ClientRequestHandler, + ClientRequestMethod, +} from './handlers/clientRequest'; import type { ICronjobRequestHandler } from './handlers/cronjob/api'; import { BackgroundEventMethod } from './handlers/cronjob/api'; import { RefreshConfirmationPricesHandler } from './handlers/cronjob/refreshConfirmationPrices'; @@ -72,6 +78,7 @@ const transactionService = new TransactionService({ logger, transactionRepository, networkService, + transactionBuilder, cache: new StateCache(state, logger, '__cache__transaction'), }); @@ -161,7 +168,33 @@ const assetsHandler = new AssetsHandler({ priceService, }); +/** ------------------------------ Client Request Handlers ------------------------------ */ +const changeTrustOptHandler = new ChangeTrustOptHandler({ + logger, + accountService, + assetMetadataService, + onChainAccountService, + walletService, + transactionService, + confirmationUIController, +}); + +const clientRequestMethodHandlers: Record< + ClientRequestMethod, + IClientRequestHandler +> = { + [ClientRequestMethod.ChangeTrustOpt]: changeTrustOptHandler, + // TEMP: force cast until we have all handlers, remove this once we have all handlers +} as unknown as Record; + +const clientRequestHandler = new ClientRequestHandler({ + logger, + handlers: clientRequestMethodHandlers, +}); + +/** ------------------------------ Export Handlers ------------------------------ */ export { + clientRequestHandler, cronjobHandler, assetsHandler, keyringHandler, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts new file mode 100644 index 00000000..ea2e22b9 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts @@ -0,0 +1,233 @@ +import { assert, StructError } from '@metamask/superstruct'; + +import { + ChangeTrustOptJsonRpcRequestStruct, + ChangeTrustOptJsonRpcResponseStruct, + JsonRpcRequestWithAccountStruct, +} from './api'; + +const accountId = '11111111-1111-4111-8111-111111111111'; +const scope = 'stellar:testnet'; +const assetId = + 'stellar:testnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + +describe('JsonRpcRequestWithAccountStruct', () => { + it.each([ + { + jsonrpc: '2.0' as const, + id: 1, + method: 'anyMethod', + params: { accountId }, + }, + { + jsonrpc: '2.0' as const, + id: null, + method: 'foo', + params: { accountId, extra: 'allowed' }, + }, + ])( + 'accepts a JSON-RPC request whose params include a valid accountId', + (request) => { + expect(() => + assert(request, JsonRpcRequestWithAccountStruct), + ).not.toThrow(); + }, + ); + + it.each([ + { + jsonrpc: '2.0' as const, + id: 1, + method: 'anyMethod', + params: { accountId: 'not-a-uuid' }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: 'anyMethod', + params: {}, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: 'anyMethod', + }, + ])( + 'rejects a JSON-RPC request without a valid params.accountId', + (request) => { + expect(() => assert(request, JsonRpcRequestWithAccountStruct)).toThrow( + StructError, + ); + }, + ); +}); + +describe('ChangeTrustOptJsonRpcResponseStruct', () => { + it.each([ + { status: true }, + { status: false }, + { status: true, transactionId: 'dGVzdA==' }, + ])('accepts a valid changeTrustOpt JSON-RPC response', (response) => { + expect(() => + assert(response, ChangeTrustOptJsonRpcResponseStruct), + ).not.toThrow(); + }); + + it.each([ + {}, + { status: 'yes' }, + { status: true, transactionId: 'not-base64!!!' }, + ])('rejects an invalid changeTrustOpt JSON-RPC response', (response) => { + expect(() => assert(response, ChangeTrustOptJsonRpcResponseStruct)).toThrow( + StructError, + ); + }); +}); + +describe('ChangeTrustOptJsonRpcRequestStruct', () => { + it.each([ + { + jsonrpc: '2.0' as const, + id: 1, + method: 'changeTrustOpt', + params: { + accountId, + scope, + assetId, + action: 'add', + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: 'changeTrustOpt', + params: { + accountId, + scope, + assetId, + action: 'add', + limit: '1.5', + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: 'changeTrustOpt', + params: { + accountId, + scope, + assetId, + action: 'delete', + limit: '0', + }, + }, + ])('accepts valid changeTrustOpt JSON-RPC requests', (request) => { + expect(() => + assert(request, ChangeTrustOptJsonRpcRequestStruct), + ).not.toThrow(); + }); + + it.each([ + { + jsonrpc: '2.0' as const, + id: 1, + method: 'wrongMethod', + params: { + accountId, + scope, + assetId, + action: 'add', + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: 'changeTrustOpt', + params: { + accountId, + scope: 'stellar:invalid', + assetId, + action: 'add', + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: 'changeTrustOpt', + params: { + accountId, + scope, + assetId: 'stellar:testnet/asset:USDC-INVALID', + action: 'add', + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: 'changeTrustOpt', + params: { + accountId, + scope: 'stellar:pubnet', + assetId, + action: 'add', + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: 'changeTrustOpt', + params: { + accountId, + scope, + assetId, + action: 'delete', + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: 'changeTrustOpt', + params: { + accountId, + scope, + assetId, + action: 'delete', + limit: '1', + }, + }, + ])('rejects invalid changeTrustOpt JSON-RPC requests', (request) => { + expect(() => assert(request, ChangeTrustOptJsonRpcRequestStruct)).toThrow( + StructError, + ); + }); + + it.each([ + { + jsonrpc: '2.0' as const, + id: 1, + method: 'changeTrustOpt', + params: { + accountId, + scope: 'stellar:pubnet', + assetId, + action: 'add', + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: 'changeTrustOpt', + params: { + accountId, + scope: 'stellar:pubnet', + assetId, + action: 'delete', + limit: '0', + }, + }, + ])('rejects requests when assetId chain does not match scope', (request) => { + expect(() => assert(request, ChangeTrustOptJsonRpcRequestStruct)).toThrow( + StructError, + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts new file mode 100644 index 00000000..fdc39401 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts @@ -0,0 +1,141 @@ +import type { Infer } from '@metamask/superstruct'; +import { + enums, + object, + assign, + literal, + optional, + boolean, + string, + type, + union, + refine, +} from '@metamask/superstruct'; +import type { JsonRpcRequest } from '@metamask/utils'; +import { base64, parseCaipAssetType } from '@metamask/utils'; + +import { + JsonRpcRequestStruct, + KnownCaip2ChainIdStruct, + KnownCaip19ClassicAssetStruct, + UuidStruct, + NonZeroValidAmountStruct, +} from '../../api'; + +export enum MultiChainSendErrorCodes { + // eslint-disable-next-line @typescript-eslint/no-shadow + Required = 'Required', + Invalid = 'Invalid', + InsufficientBalance = 'InsufficientBalance', + InsufficientBalanceToCoverFee = 'InsufficientBalanceToCoverFee', +} + +/** + * Enum for the client request method. + */ +export enum ClientRequestMethod { + /** -------------------------------- Stellar Specific -------------------------------- */ + ChangeTrustOpt = 'changeTrustOpt', +} + +/** + * Trustline change intent for {@link ClientRequestMethod.ChangeTrustOpt}. + */ +export enum ChangeTrustOptAction { + Add = 'add', + Delete = 'delete', +} + +/** + * Validation struct for the client request method. + */ +export const ClientRequestMethodStruct = enums( + Object.values(ClientRequestMethod), +); + +export const JsonRpcRequestWithAccountStruct = assign( + JsonRpcRequestStruct, + type({ + params: type({ + accountId: UuidStruct, + }), + }), +); + +export const ChangeTrustOptActionStruct = enums( + Object.values(ChangeTrustOptAction), +); + +const ChangeTrustBaseParamsStruct = object({ + accountId: UuidStruct, + assetId: KnownCaip19ClassicAssetStruct, + scope: KnownCaip2ChainIdStruct, +}); + +const ChangeTrustAddStruct = assign( + ChangeTrustBaseParamsStruct, + object({ + action: literal(ChangeTrustOptAction.Add), + limit: optional(NonZeroValidAmountStruct), + }), +); + +const ChangeTrustRemoveStruct = assign( + ChangeTrustBaseParamsStruct, + object({ + action: literal(ChangeTrustOptAction.Delete), + limit: literal('0'), + }), +); + +/** + * Validation struct for the ChangeTrustOpt JSON-RPC request. + */ +export const ChangeTrustOptJsonRpcRequestStruct = refine( + assign( + JsonRpcRequestStruct, + object({ + method: literal(ClientRequestMethod.ChangeTrustOpt), + params: union([ChangeTrustAddStruct, ChangeTrustRemoveStruct]), + }), + ), + 'change-trust-asset-id-scope-match', + ({ params }) => { + const result = + parseCaipAssetType(params.assetId).chainId === String(params.scope); + if (result) { + return true; + } + return `Asset id ${params.assetId} scope is not match with the request scope ${params.scope}`; + }, +); + +/** + * Validation struct for the ChangeTrustOpt JSON-RPC response. + */ +export const ChangeTrustOptJsonRpcResponseStruct = object({ + status: boolean(), + transactionId: optional(base64(string())), +}); + +/** + * A JSON-RPC request with an account resolve parameter. + */ +export type JsonRpcRequestWithAccount = Infer< + typeof JsonRpcRequestWithAccountStruct +> & + JsonRpcRequest; + +/** + * Type for the ChangeTrustOpt JSON-RPC request. + */ +export type ChangeTrustOptJsonRpcRequest = Infer< + typeof ChangeTrustOptJsonRpcRequestStruct +>; + +/** + * Type for the ChangeTrustOpt JSON-RPC response. + */ +export type ChangeTrustOptJsonRpcResponse = Infer< + typeof ChangeTrustOptJsonRpcResponseStruct +>; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/base.ts new file mode 100644 index 00000000..d62c3ab9 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/base.ts @@ -0,0 +1,37 @@ +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import type { JsonRpcRequestWithAccount } from './api'; +import type { + DefaultResolveAccountOptions, + ResolveAccountOptions, +} from '../base'; +import { WithActiveAccountResolve } from '../base'; + +/** + * Interface for the client request handler. + */ +export type IClientRequestHandler = { + handle: (request: JsonRpcRequest) => Promise; +}; + +/** + * A base class for client request handlers that require an activated account. + */ +export abstract class WithClientRequestActiveAccountResolve< + RequestType extends JsonRpcRequestWithAccount, + ResponseType extends Json, + Opts extends ResolveAccountOptions = DefaultResolveAccountOptions, +> + extends WithActiveAccountResolve + implements IClientRequestHandler +{ + /** + * Get the account ID from the JSON-RPC request. + * + * @param request - The JSON-RPC request to get the account ID from. + * @returns The account ID. + */ + protected getAccountId(request: RequestType): string { + return request.params.accountId; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts new file mode 100644 index 00000000..0506d30c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts @@ -0,0 +1,368 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; +import { BigNumber } from 'bignumber.js'; + +import { + ClientRequestMethod, + ChangeTrustOptAction, + type ChangeTrustOptJsonRpcRequest, +} from './api'; +import { ChangeTrustOptHandler } from './changeTrustOpt'; +import { KnownCaip2ChainId, type KnownCaip19ClassicAssetId } from '../../api'; +import { AccountService } from '../../services/account'; +import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; +import type { StellarAssetMetadata } from '../../services/asset-metadata'; +import { AssetMetadataService } from '../../services/asset-metadata'; +import { + createMockAssetMetadataService, + generateMockStellarAssetMetadata, + USDC_CLASSIC, +} from '../../services/asset-metadata/__mocks__/assets.fixtures'; +import { NetworkService } from '../../services/network'; +import { + OnChainAccount, + OnChainAccountService, +} from '../../services/on-chain-account'; +import { + createMockAccountWithBalances, + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + horizonSource, + mockOnChainAccountService, +} from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; +import { TransactionService } from '../../services/transaction'; +import { createMockTransactionService } from '../../services/transaction/__mocks__/transaction.fixtures'; +import { TrustlineNotFoundException } from '../../services/transaction/exceptions'; +import { KeyringTransactionType } from '../../services/transaction/KeyringTransactionBuilder'; +import { WalletService } from '../../services/wallet'; +import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; +import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import { ConfirmationUXController } from '../../ui/confirmation/controller'; +import { logger } from '../../utils/logger'; + +jest.mock('../../utils/logger'); + +describe('ChangeTrustOptHandler', () => { + const accountId = '11111111-1111-4111-8111-111111111111'; + const scope = KnownCaip2ChainId.Mainnet; + const assetId = USDC_CLASSIC as KnownCaip19ClassicAssetId; + const trustlineAsset = { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + balance: 0, + }; + + const addRequest: ChangeTrustOptJsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.ChangeTrustOpt, + params: { + accountId, + scope, + assetId, + action: ChangeTrustOptAction.Add, + limit: '1.5', + }, + }; + const deleteRequest: ChangeTrustOptJsonRpcRequest = { + jsonrpc: '2.0', + id: 2, + method: ClientRequestMethod.ChangeTrustOpt, + params: { + accountId, + scope, + assetId, + action: ChangeTrustOptAction.Delete, + limit: '0', + }, + }; + + function setup({ withTrustline = false }: { withTrustline?: boolean } = {}) { + const wallet = getTestWallet(); + const account = generateStellarKeyringAccount( + accountId, + wallet.address, + 'entropy-source-1', + 0, + ); + const mockRawAccount = createMockAccountWithBalances(wallet.address, '1', { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 10, + assets: withTrustline ? [trustlineAsset] : [], + }); + const onChainAccount = new OnChainAccount( + mockRawAccount, + scope, + horizonSource(mockRawAccount, scope), + ); + + const { accountService, onChainAccountService, walletService } = + mockOnChainAccountService(); + const resolveAccountSpy = jest + .spyOn(AccountService.prototype, 'resolveAccount') + .mockResolvedValue({ account }); + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue(onChainAccount); + const resolveWalletSpy = jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + + const signTransactionSpy = jest.spyOn(wallet, 'signTransaction'); + + const { transactionService, transactionRepositorySaveSpy } = + createMockTransactionService(); + const getBaseFeeSpy = jest + .spyOn(NetworkService.prototype, 'getBaseFee') + .mockResolvedValue(new BigNumber(100)); + const networkSendSpy = jest + .spyOn(NetworkService.prototype, 'send') + .mockResolvedValue('dGVzdC10eC1pZA=='); + const createValidatedChangeTrustTransaction = jest.spyOn( + TransactionService.prototype, + 'createValidatedChangeTrustTransaction', + ); + const sendTransaction = jest.spyOn( + TransactionService.prototype, + 'sendTransaction', + ); + const savePendingKeyringTransaction = jest.spyOn( + TransactionService.prototype, + 'savePendingKeyringTransaction', + ); + + const { service: assetMetadataService } = createMockAssetMetadataService(); + const assetMetadata = generateMockStellarAssetMetadata()[assetId] as { + symbol: string; + assetId: string; + } as StellarAssetMetadata; + const resolve = jest + .spyOn(AssetMetadataService.prototype, 'resolve') + .mockResolvedValue(assetMetadata); + + const renderConfirmationDialog = jest + .spyOn(ConfirmationUXController.prototype, 'renderConfirmationDialog') + .mockResolvedValue(true); + const confirmationUIController = new ConfirmationUXController({ logger }); + + const handler = new ChangeTrustOptHandler({ + logger, + accountService, + onChainAccountService, + walletService, + transactionService, + assetMetadataService, + confirmationUIController, + }); + + return { + handler, + account, + onChainAccount, + wallet, + assetMetadata, + resolveAccountSpy, + resolveOnChainAccountSpy, + resolveWalletSpy, + getBaseFeeSpy, + networkSendSpy, + createValidatedChangeTrustTransaction, + sendTransaction, + savePendingKeyringTransaction, + transactionRepositorySaveSpy, + resolve, + renderConfirmationDialog, + signTransactionSpy, + }; + } + + beforeEach(() => { + jest.restoreAllMocks(); + }); + + it('handles changeTrust opt-in and saves pending keyring transaction', async () => { + const { + handler, + account, + onChainAccount, + wallet, + assetMetadata, + createValidatedChangeTrustTransaction, + sendTransaction, + savePendingKeyringTransaction, + resolve, + renderConfirmationDialog, + signTransactionSpy, + } = setup(); + + const result = await handler.handle(addRequest); + + expect(result).toStrictEqual({ + status: true, + transactionId: 'dGVzdC10eC1pZA==', + }); + + expect(resolve).toHaveBeenCalledWith(assetId); + expect(createValidatedChangeTrustTransaction).toHaveBeenCalledWith({ + onChainAccount, + assetId, + scope, + limit: '1.5', + }); + expect(renderConfirmationDialog).toHaveBeenCalledWith( + expect.objectContaining({ + scope, + interfaceKey: ConfirmationInterfaceKey.ChangeTrustlineOptIn, + fee: '100', + renderContext: { + account, + assetMetadata, + }, + }), + ); + const signedTransaction = signTransactionSpy.mock.calls[0]?.[0]; + expect(signedTransaction).toBeDefined(); + expect(sendTransaction).toHaveBeenCalledWith({ + wallet, + onChainAccount, + scope, + transaction: signedTransaction, + }); + expect(savePendingKeyringTransaction).toHaveBeenCalledWith({ + type: KeyringTransactionType.ChangeTrustOptIn, + request: { + txId: 'dGVzdC10eC1pZA==', + account, + scope, + asset: { + type: assetId, + symbol: 'USDC', + }, + }, + }); + }); + + it('returns success early for opt-in when trustline already exists', async () => { + const { + handler, + createValidatedChangeTrustTransaction, + resolve, + renderConfirmationDialog, + sendTransaction, + savePendingKeyringTransaction, + signTransactionSpy, + } = setup({ withTrustline: true }); + + const result = await handler.handle(addRequest); + + expect(result).toStrictEqual({ status: true }); + expect(resolve).not.toHaveBeenCalled(); + expect(createValidatedChangeTrustTransaction).not.toHaveBeenCalled(); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + expect(signTransactionSpy).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + expect(savePendingKeyringTransaction).not.toHaveBeenCalled(); + }); + + it('throws TrustlineNotFoundException for opt-out when trustline does not exist', async () => { + const { + handler, + resolve, + createValidatedChangeTrustTransaction, + renderConfirmationDialog, + sendTransaction, + savePendingKeyringTransaction, + } = setup(); + + await expect(handler.handle(deleteRequest)).rejects.toThrow( + TrustlineNotFoundException, + ); + + expect(resolve).not.toHaveBeenCalled(); + expect(createValidatedChangeTrustTransaction).not.toHaveBeenCalled(); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + expect(savePendingKeyringTransaction).not.toHaveBeenCalled(); + }); + + it('handles changeTrust opt-out and enforces delete limit to 0', async () => { + const { + handler, + account, + onChainAccount, + assetMetadata, + createValidatedChangeTrustTransaction, + sendTransaction, + savePendingKeyringTransaction, + renderConfirmationDialog, + networkSendSpy, + } = setup({ withTrustline: true }); + + const result = await handler.handle(deleteRequest); + + expect(result).toStrictEqual({ + status: true, + transactionId: 'dGVzdC10eC1pZA==', + }); + expect(createValidatedChangeTrustTransaction).toHaveBeenCalledWith({ + onChainAccount, + assetId, + scope, + limit: '0', + }); + expect(renderConfirmationDialog).toHaveBeenCalledWith( + expect.objectContaining({ + interfaceKey: ConfirmationInterfaceKey.ChangeTrustlineOptOut, + }), + ); + expect(sendTransaction).toHaveBeenCalled(); + expect(networkSendSpy).toHaveBeenCalledTimes(1); + expect(savePendingKeyringTransaction).toHaveBeenCalledWith({ + type: KeyringTransactionType.ChangeTrustOptOut, + request: { + txId: 'dGVzdC10eC1pZA==', + account, + scope, + asset: { + type: assetId, + symbol: assetMetadata.symbol, + }, + }, + }); + }); + + it('throws UserRejectedRequestError when confirmation is rejected', async () => { + const { + handler, + renderConfirmationDialog, + signTransactionSpy, + sendTransaction, + savePendingKeyringTransaction, + networkSendSpy, + } = setup(); + renderConfirmationDialog.mockResolvedValue(false); + + await expect(handler.handle(addRequest)).rejects.toThrow( + UserRejectedRequestError, + ); + + expect(signTransactionSpy).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + expect(networkSendSpy).not.toHaveBeenCalled(); + expect(savePendingKeyringTransaction).not.toHaveBeenCalled(); + }); + + it('continues successfully when saving pending transaction fails', async () => { + const { handler, transactionRepositorySaveSpy, sendTransaction } = setup(); + transactionRepositorySaveSpy.mockRejectedValueOnce( + new Error('failed save'), + ); + + const result = await handler.handle(addRequest); + + expect(result).toStrictEqual({ + status: true, + transactionId: 'dGVzdC10eC1pZA==', + }); + expect(sendTransaction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts new file mode 100644 index 00000000..f5c1e2a8 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts @@ -0,0 +1,272 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; +import { ensureError } from '@metamask/utils'; + +import type { + ChangeTrustOptJsonRpcRequest, + ChangeTrustOptJsonRpcResponse, +} from './api'; +import { + ChangeTrustOptAction, + ChangeTrustOptJsonRpcRequestStruct, + ChangeTrustOptJsonRpcResponseStruct, +} from './api'; +import type { ResolvedActivatedAccount } from '../base'; +import { WithClientRequestActiveAccountResolve } from './base'; +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip2ChainId, +} from '../../api'; +import type { + AccountService, + StellarKeyringAccount, +} from '../../services/account'; +import type { + AssetMetadataService, + StellarAssetMetadata, +} from '../../services/asset-metadata'; +import type { OnChainAccountService } from '../../services/on-chain-account'; +import { + TrustlineNotFoundException, + KeyringTransactionType, +} from '../../services/transaction'; +import type { TransactionService } from '../../services/transaction'; +import type { WalletService } from '../../services/wallet'; +import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import type { ConfirmationUXController } from '../../ui/confirmation/controller'; +import { createPrefixedLogger, type ILogger } from '../../utils/logger'; + +export class ChangeTrustOptHandler extends WithClientRequestActiveAccountResolve< + ChangeTrustOptJsonRpcRequest, + ChangeTrustOptJsonRpcResponse +> { + readonly #transactionService: TransactionService; + + readonly #assetMetadataService: AssetMetadataService; + + readonly #confirmationUIController: ConfirmationUXController; + + constructor({ + logger, + accountService, + onChainAccountService, + walletService, + transactionService, + assetMetadataService, + confirmationUIController, + }: { + logger: ILogger; + accountService: AccountService; + assetMetadataService: AssetMetadataService; + onChainAccountService: OnChainAccountService; + walletService: WalletService; + transactionService: TransactionService; + confirmationUIController: ConfirmationUXController; + }) { + const prefixedLogger = createPrefixedLogger( + logger, + '[💼 ChangeTrustOptHandler]', + ); + super({ + accountService, + onChainAccountService, + walletService, + logger: prefixedLogger, + requestStruct: ChangeTrustOptJsonRpcRequestStruct, + responseStruct: ChangeTrustOptJsonRpcResponseStruct, + }); + this.#transactionService = transactionService; + this.#assetMetadataService = assetMetadataService; + this.#confirmationUIController = confirmationUIController; + } + + /** + * Handles trustline opt-in/opt-out requests. + * + * @param resolvedAccount - The resolved and activated account. + * @param request - JSON-RPC request containing `scope`, `assetId`, `action`, and optional `limit`. + * @returns A `ChangeTrustOptJsonRpcResponse`: + * - `{ status: true, transactionId }` when the transaction is built, signed, and submitted. + * - `{ status: true }` when preflight detects the trustline already exists for an add request. + * @throws {TrustlineNotFoundException} If a delete request targets a trustline that does not exist. + * @throws {UserRejectedRequestError} If the user rejects the confirmation prompt. + */ + protected async _handle( + resolvedAccount: ResolvedActivatedAccount, + request: ChangeTrustOptJsonRpcRequest, + ): Promise { + const { scope, assetId, action, limit } = request.params; + const { wallet, account, onChainAccount } = resolvedAccount; + + // Quit early if the trustline already exists for add + if ( + action === ChangeTrustOptAction.Add && + onChainAccount.hasAsset(assetId) + ) { + // If the trustline already exists, we return a success response + return { + status: true, + }; + } + + // Quit early if the trustline does not exist for delete + if ( + action === ChangeTrustOptAction.Delete && + !onChainAccount.hasAsset(assetId) + ) { + throw new TrustlineNotFoundException(assetId, onChainAccount.accountId); + } + + // Safeguard to ensure we use the correct limit for delete + const limitForTx = action === ChangeTrustOptAction.Delete ? '0' : limit; + + const assetMetadata = await this.#assetMetadataService.resolve(assetId); + + const transaction = + await this.#transactionService.createValidatedChangeTrustTransaction({ + onChainAccount, + assetId, + scope, + limit: limitForTx, + }); + + const confirmed = await this.#confirmChangeTrustOpt({ + request, + account, + assetMetadata, + fee: transaction.totalFee.toString(), + action, + }); + + if (!confirmed) { + throw ensureError(new UserRejectedRequestError()); + } + + wallet.signTransaction(transaction); + + const transactionId = await this.#transactionService.sendTransaction({ + wallet, + onChainAccount, + scope, + transaction, + }); + + await this.#savePendingTransaction({ + transactionId, + scope, + assetId, + account, + assetMetadata, + action, + }); + + return { + status: true, + transactionId, + }; + } + + async #savePendingTransaction(params: { + transactionId: string; + scope: KnownCaip2ChainId; + assetId: KnownCaip19AssetIdOrSlip44Id; + account: StellarKeyringAccount; + assetMetadata: StellarAssetMetadata; + action: ChangeTrustOptAction; + }): Promise { + try { + const { transactionId, scope, assetId, account, assetMetadata, action } = + params; + await this.#transactionService.savePendingKeyringTransaction({ + type: + action === ChangeTrustOptAction.Add + ? KeyringTransactionType.ChangeTrustOptIn + : KeyringTransactionType.ChangeTrustOptOut, + request: { + txId: transactionId, + account, + scope, + asset: { + type: assetId, + symbol: assetMetadata.symbol, + }, + }, + }); + } catch (error: unknown) { + this.logger.logErrorWithDetails( + 'Failed to save pending transaction', + error, + ); + // we should not throw error here, as we want to continue the flow even if the pending transaction is not saved + } + } + + async #confirmChangeTrustOpt(params: { + request: ChangeTrustOptJsonRpcRequest; + account: StellarKeyringAccount; + assetMetadata: StellarAssetMetadata; + fee: string; + action: ChangeTrustOptAction; + }): Promise { + return params.action === ChangeTrustOptAction.Delete + ? await this.#confirmSignChangeTrustOptOut(params) + : await this.#confirmSignChangeTrustOptIn(params); + } + + async #confirmSignChangeTrustOptIn(params: { + request: ChangeTrustOptJsonRpcRequest; + account: StellarKeyringAccount; + assetMetadata: StellarAssetMetadata; + fee: string; + }): Promise { + return this.#confirmSignChangeTrust({ + ...params, + confirmationInterfaceKey: ConfirmationInterfaceKey.ChangeTrustlineOptIn, + }); + } + + async #confirmSignChangeTrustOptOut(params: { + request: ChangeTrustOptJsonRpcRequest; + account: StellarKeyringAccount; + assetMetadata: StellarAssetMetadata; + fee: string; + }): Promise { + return this.#confirmSignChangeTrust({ + ...params, + confirmationInterfaceKey: ConfirmationInterfaceKey.ChangeTrustlineOptOut, + }); + } + + async #confirmSignChangeTrust(params: { + request: ChangeTrustOptJsonRpcRequest; + account: StellarKeyringAccount; + assetMetadata: StellarAssetMetadata; + fee: string; + confirmationInterfaceKey: + | ConfirmationInterfaceKey.ChangeTrustlineOptIn + | ConfirmationInterfaceKey.ChangeTrustlineOptOut; + }): Promise { + const { + request: { + params: { scope }, + }, + account, + assetMetadata, + fee, + confirmationInterfaceKey, + } = params; + return ( + (await this.#confirmationUIController.renderConfirmationDialog({ + scope, + renderContext: { + account, + assetMetadata, + }, + fee, + interfaceKey: confirmationInterfaceKey, + renderOptions: { + loadPrice: true, + }, + })) === true + ); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts new file mode 100644 index 00000000..4421b4c7 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/clientRequest.ts @@ -0,0 +1,66 @@ +import { MethodNotFoundError } from '@metamask/snaps-sdk'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; +import { ensureError } from '@metamask/utils'; + +import type { ClientRequestMethod } from './api'; +import { ClientRequestMethodStruct } from './api'; +import type { IClientRequestHandler } from './base'; +import { withCatchAndThrowSnapError } from '../../utils'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; + +export class ClientRequestHandler { + readonly #logger: ILogger; + + readonly #handlers: Record; + + constructor({ + logger, + handlers, + }: { + logger: ILogger; + handlers: Record; + }) { + this.#logger = createPrefixedLogger(logger, '[👋 ClientRequestHandler]'); + this.#handlers = handlers; + } + + /** + * Handles JSON-RPC requests originating exclusively from the client - as defined in [SIP-31](https://github.com/MetaMask/SIPs/blob/main/SIPS/sip-31.md) - + * by routing them to the appropriate use case, based on the method. Some methods need to be implemented + * as part of the [Unified Non-EVM Send](https://www.notion.so/metamask-consensys/Unified-Non-EVM-Send-248f86d67d6880278445f9ad75478471) specification. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + * @throws {MethodNotFoundError} If the method is not found. + * @throws {InvalidParamsError} If the params are invalid. + */ + async handle(request: JsonRpcRequest): Promise { + const result = + (await withCatchAndThrowSnapError(async () => { + return this.#handleClientRequest(request); + }, this.#logger)) ?? null; + + return result; + } + + /** + * Handles a client request by routing it to the appropriate use case, based on the method. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @returns The response to the JSON-RPC request. + */ + async #handleClientRequest(request: JsonRpcRequest): Promise { + const { method } = request; + + const [validateError, validatedMethod] = + ClientRequestMethodStruct.validate(method); + if (validateError !== undefined) { + throw ensureError(new MethodNotFoundError()); + } + + const handler = this.#handlers[validatedMethod]; + + return handler.handle(request); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/index.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/index.ts new file mode 100644 index 00000000..2f949a4c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/index.ts @@ -0,0 +1,4 @@ +export * from './changeTrustOpt'; +export * from './clientRequest'; +export * from './api'; +export type { IClientRequestHandler } from './base'; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts index 2f85c365..4b9b602d 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts @@ -1,6 +1,9 @@ import type { InterfaceContext, UserInputEvent } from '@metamask/snaps-sdk'; import type { UserInputUiEventHandler } from './api'; +import { createEventHandlers as createAccountActivationPromptEvents } from '../../ui/confirmation/views/AccountActivationPrompt/events'; +import { createEventHandlers as createSignChangeTrustOptInEvents } from '../../ui/confirmation/views/ConfirmSignChangeTrustOptIn/events'; +import { createEventHandlers as createSignChangeTrustOptOutEvents } from '../../ui/confirmation/views/ConfirmSignChangeTrustOptOut/events'; import { createEventHandlers as createSignMessageEvents } from '../../ui/confirmation/views/ConfirmSignMessage/events'; import { createEventHandlers as createSignTransactionEvents } from '../../ui/confirmation/views/ConfirmSignTransaction/events'; import { @@ -43,6 +46,9 @@ export class UserInputHandler { const uiEventHandlers: Record = { ...createSignMessageEvents(), ...createSignTransactionEvents(), + ...createSignChangeTrustOptInEvents(), + ...createSignChangeTrustOptOutEvents(), + ...createAccountActivationPromptEvents(), }; /** diff --git a/merged-packages/stellar-wallet-snap/src/index.ts b/merged-packages/stellar-wallet-snap/src/index.ts index f9d01ff0..7baa6725 100644 --- a/merged-packages/stellar-wallet-snap/src/index.ts +++ b/merged-packages/stellar-wallet-snap/src/index.ts @@ -2,11 +2,12 @@ import type { OnUserInputHandler, OnKeyringRequestHandler, OnRpcRequestHandler, - OnCronjobHandler, - OnAssetHistoricalPriceHandler, OnAssetsConversionHandler, + OnAssetHistoricalPriceHandler, OnAssetsLookupHandler, OnAssetsMarketDataHandler, + OnClientRequestHandler, + OnCronjobHandler, } from '@metamask/snaps-sdk'; import { MethodNotFoundError } from '@metamask/snaps-sdk'; import type { JsonRpcRequest } from '@metamask/utils'; @@ -16,8 +17,9 @@ import { signMessageHandler, userInputHandler, signTransactionHandler, - cronjobHandler, assetsHandler, + clientRequestHandler, + cronjobHandler, } from './context'; export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ( @@ -41,6 +43,9 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ export const onUserInput: OnUserInputHandler = async (params) => userInputHandler.handle(params); +export const onClientRequest: OnClientRequestHandler = async ({ request }) => + clientRequestHandler.handle(request); + export const onCronjob: OnCronjobHandler = async ({ request }) => cronjobHandler.handle(request); @@ -56,6 +61,10 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { return signTransactionHandler.handle( request.params as unknown as JsonRpcRequest, ); + case 'stellar_changeTrustOpt': + return clientRequestHandler.handle( + request.params as unknown as JsonRpcRequest, + ); default: throw new MethodNotFoundError() as Error; } diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.test.ts new file mode 100644 index 00000000..0336d9e9 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.test.ts @@ -0,0 +1,187 @@ +import { TransactionStatus, TransactionType } from '@metamask/keyring-api'; + +import { KeyringTransactionBuilderException } from './exceptions'; +import { + KeyringTransactionBuilder, + KeyringTransactionType, +} from './KeyringTransactionBuilder'; +import { KnownCaip2ChainId } from '../../api'; +import type { StellarKeyringAccount } from '../account/api'; + +describe('KeyringTransactionBuilder', () => { + const mockNow = new Date('2026-01-15T00:00:00.000Z').getTime(); + const fixedTimestamp = Math.floor(mockNow / 1000); + + const account = { + id: 'account-id-1', + address: 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + } as StellarKeyringAccount; + const scope = KnownCaip2ChainId.Mainnet; + const nativeAsset = { + type: 'stellar:pubnet/slip44:148' as const, + symbol: 'XLM', + }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(mockNow); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('creates a send transaction with expected keyring fields', () => { + const builder = new KeyringTransactionBuilder(); + + const transaction = builder.createTransaction({ + type: KeyringTransactionType.Send, + request: { + txId: 'tx-send-1', + account, + scope, + toAddress: 'GBQ67YZIDIMGS4UE2VXW4BBLRW6QJJQ6D6L5AXR5TBKX2L6IY3LCLTTR', + amount: '1230000', + asset: nativeAsset, + }, + }); + + expect(transaction).toStrictEqual({ + type: TransactionType.Send, + id: 'tx-send-1', + from: [ + { + address: account.address, + asset: { + unit: 'XLM', + type: 'stellar:pubnet/slip44:148', + amount: '1230000', + fungible: true, + }, + }, + ], + to: [ + { + address: 'GBQ67YZIDIMGS4UE2VXW4BBLRW6QJJQ6D6L5AXR5TBKX2L6IY3LCLTTR', + asset: { + unit: 'XLM', + type: 'stellar:pubnet/slip44:148', + amount: '1230000', + fungible: true, + }, + }, + ], + events: [ + { status: TransactionStatus.Unconfirmed, timestamp: fixedTimestamp }, + ], + chain: scope, + status: TransactionStatus.Unconfirmed, + account: account.id, + timestamp: fixedTimestamp, + fees: [], + }); + }); + + it('creates changeTrust opt-in transaction with default unconfirmed status', () => { + const builder = new KeyringTransactionBuilder(); + + const transaction = builder.createTransaction({ + type: KeyringTransactionType.ChangeTrustOptIn, + request: { + txId: 'tx-opt-in-1', + account, + scope, + asset: { + type: 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + symbol: 'USDC', + }, + }, + }); + + expect(transaction.type).toBe(TransactionType.Unknown); + expect(transaction.id).toBe('tx-opt-in-1'); + expect(transaction.status).toBe(TransactionStatus.Unconfirmed); + expect(transaction.events).toStrictEqual([ + { status: TransactionStatus.Unconfirmed, timestamp: fixedTimestamp }, + ]); + expect(transaction.from).toStrictEqual([ + { + address: account.address, + asset: { + unit: 'USDC', + type: 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + amount: '0', + fungible: true, + }, + }, + ]); + expect(transaction.to).toStrictEqual([ + { + address: account.address, + asset: { + unit: 'USDC', + type: 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + amount: '0', + fungible: true, + }, + }, + ]); + }); + + it('creates changeTrust opt-out transaction with caller-provided status', () => { + const builder = new KeyringTransactionBuilder(); + + const transaction = builder.createTransaction({ + type: KeyringTransactionType.ChangeTrustOptOut, + request: { + txId: 'tx-opt-out-1', + account, + scope, + status: TransactionStatus.Confirmed, + asset: { + type: 'stellar:pubnet/asset:USDT-GCEZWKPH6X7R2SDYB7V4LGAU5N5LE6L4P7J6LQEXAMPLE1234567890', + symbol: 'USDT', + }, + }, + }); + + expect(transaction.type).toBe(TransactionType.Unknown); + expect(transaction.status).toBe(TransactionStatus.Confirmed); + expect(transaction.events).toStrictEqual([ + { status: TransactionStatus.Confirmed, timestamp: fixedTimestamp }, + ]); + expect(transaction.from).toStrictEqual([ + { + address: account.address, + asset: { + unit: 'USDT', + type: 'stellar:pubnet/asset:USDT-GCEZWKPH6X7R2SDYB7V4LGAU5N5LE6L4P7J6LQEXAMPLE1234567890', + amount: '0', + fungible: true, + }, + }, + ]); + expect(transaction.to).toStrictEqual([ + { + address: account.address, + asset: { + unit: 'USDT', + type: 'stellar:pubnet/asset:USDT-GCEZWKPH6X7R2SDYB7V4LGAU5N5LE6L4P7J6LQEXAMPLE1234567890', + amount: '0', + fungible: true, + }, + }, + ]); + }); + + it('throws KeyringTransactionBuilderException for unsupported type', () => { + const builder = new KeyringTransactionBuilder(); + + expect(() => + builder.createTransaction({ + type: 'unsupported-type' as never, + request: {} as never, + }), + ).toThrow(KeyringTransactionBuilderException); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.ts new file mode 100644 index 00000000..3ffeb399 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.ts @@ -0,0 +1,169 @@ +import { + TransactionStatus, + TransactionType, + type Transaction as KeyringTransaction, +} from '@metamask/keyring-api'; + +import { KeyringTransactionBuilderException } from './exceptions'; +import type { KnownCaip2ChainId } from '../../api'; +import type { KnownCaip19AssetIdOrSlip44Id } from '../../api/asset'; +import type { StellarKeyringAccount } from '../account/api'; + +export enum KeyringTransactionType { + ChangeTrustOptIn = 'changeTrustOptIn', + ChangeTrustOptOut = 'changeTrustOptOut', + Send = 'send', +} + +export type SendTransactionRequest = { + txId: string; + account: StellarKeyringAccount; + scope: KnownCaip2ChainId; + toAddress: string; + amount: string; + asset: { + type: KnownCaip19AssetIdOrSlip44Id; + symbol: string; + }; + status?: TransactionStatus; +}; + +export type ChangeTrustTransactionRequest = { + txId: string; + account: StellarKeyringAccount; + scope: KnownCaip2ChainId; + asset: { + type: KnownCaip19AssetIdOrSlip44Id; + symbol: string; + }; + status?: TransactionStatus; +}; + +export type KeyringTransactionRequest = + | { + type: KeyringTransactionType.ChangeTrustOptIn; + request: ChangeTrustTransactionRequest; + } + | { + type: KeyringTransactionType.ChangeTrustOptOut; + request: ChangeTrustTransactionRequest; + } + | { + type: KeyringTransactionType.Send; + request: SendTransactionRequest; + }; + +export class KeyringTransactionBuilder { + createTransaction(request: KeyringTransactionRequest): KeyringTransaction { + switch (request.type) { + case KeyringTransactionType.ChangeTrustOptOut: + case KeyringTransactionType.ChangeTrustOptIn: + return this.#createChangeTrustTransaction( + request.request, + request.type, + ); + case KeyringTransactionType.Send: + return this.#createSendTransaction(request.request); + default: + throw new KeyringTransactionBuilderException( + `Invalid transaction type`, + ); + } + } + + #createChangeTrustTransaction( + request: ChangeTrustTransactionRequest, + _type: + | KeyringTransactionType.ChangeTrustOptIn + | KeyringTransactionType.ChangeTrustOptOut, + ): KeyringTransaction { + const timestamp = this.#getCreateTime(); + const { txId, account, scope, asset } = request; + + return { + // TODO: Add the correct type + type: TransactionType.Unknown, + id: txId, + from: [ + { + address: account.address, + asset: { + unit: asset.symbol, + type: asset.type, + amount: '0', + fungible: true, + }, + }, + ], + to: [ + { + address: account.address, + asset: { + unit: asset.symbol, + type: asset.type, + amount: '0', + fungible: true, + }, + }, + ], + events: [ + { + status: request.status ?? TransactionStatus.Unconfirmed, + timestamp, + }, + ], + chain: scope, + status: request.status ?? TransactionStatus.Unconfirmed, + account: account.id, + timestamp, + fees: [], + }; + } + + #createSendTransaction(request: SendTransactionRequest): KeyringTransaction { + const timestamp = this.#getCreateTime(); + const { txId, account, scope, toAddress, amount, asset } = request; + + return { + type: TransactionType.Send, + id: txId, + from: [ + { + address: account.address, + asset: { + unit: asset.symbol, + type: asset.type, + amount, + fungible: true, + }, + }, + ], + to: [ + { + address: toAddress, + asset: { + unit: asset.symbol, + type: asset.type, + amount, + fungible: true, + }, + }, + ], + events: [ + { + status: TransactionStatus.Unconfirmed, + timestamp, + }, + ], + chain: scope, + status: TransactionStatus.Unconfirmed, + account: account.id, + timestamp, + fees: [], + }; + } + + #getCreateTime() { + return Math.floor(Date.now() / 1000); // seconds since epoch + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts index 7f4bda9d..a958aa9d 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts @@ -20,6 +20,7 @@ import { logger } from '../../utils/logger'; import { createMockAccountWithBalances, DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + horizonSource, } from '../on-chain-account/__mocks__/onChainAccount.fixtures'; import { OnChainAccount } from '../on-chain-account/OnChainAccount'; import { getTestWallet } from '../wallet/__mocks__/wallet.fixtures'; @@ -37,14 +38,12 @@ describe('TransactionBuilder', () => { transactionBuilder = new TransactionBuilder({ logger }); testAsset = `stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN`; testWalletWithSigner = getTestWallet(); - testOnChainAccount = new OnChainAccount( - createMockAccountWithBalances( - testWalletWithSigner.address, - '1', - DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, - ), - KnownCaip2ChainId.Mainnet, + const acc = createMockAccountWithBalances( + testWalletWithSigner.address, + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, ); + testOnChainAccount = new OnChainAccount(acc, KnownCaip2ChainId.Mainnet); }); const getAccountSpies = () => ({ @@ -89,6 +88,17 @@ describe('TransactionBuilder', () => { }); }).toThrow(TransactionBuilderException); }); + + it('throws a TransactionBuilderException when assetId scope does not match request scope', () => { + expect(() => + transactionBuilder.changeTrust({ + baseFee: '100', + scope: KnownCaip2ChainId.Testnet, + assetId: testAsset, + onChainAccount: testOnChainAccount, + }), + ).toThrow(TransactionBuilderException); + }); }); describe('rebuildTxnWithNewSeq', () => { @@ -100,16 +110,15 @@ describe('TransactionBuilder', () => { onChainAccount: testOnChainAccount, }); + const seqAcc = createMockAccountWithBalances( + getTestWallet().address, + '100', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); const rebuiltTransaction = transactionBuilder.rebuildTxnWithNewSeq({ transaction, - sequenceNumber: new OnChainAccount( - createMockAccountWithBalances( - getTestWallet().address, - '100', - DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, - ), - KnownCaip2ChainId.Mainnet, - ).sequenceNumber, + sequenceNumber: new OnChainAccount(seqAcc, KnownCaip2ChainId.Mainnet) + .sequenceNumber, }); expect(rebuiltTransaction).toBeInstanceOf(Transaction); @@ -155,13 +164,19 @@ describe('TransactionBuilder', () => { expect(signedRaw.signatures.length).toBeGreaterThan(0); - const sameSourceOnChainAccount = new OnChainAccount( - createMockAccountWithBalances(testWalletWithSigner.address, '42', { + const sameSeqAcc = createMockAccountWithBalances( + testWalletWithSigner.address, + '42', + { nativeBalance: 10, subentryCount: 0, assets: [], - }), + }, + ); + const sameSourceOnChainAccount = new OnChainAccount( + sameSeqAcc, KnownCaip2ChainId.Mainnet, + horizonSource(sameSeqAcc, KnownCaip2ChainId.Mainnet), ); const rebuilt = transactionBuilder.rebuildTxnWithNewSeq({ @@ -235,6 +250,23 @@ describe('TransactionBuilder', () => { }); }).toThrow(InvalidAssetForCreateAccountException); }); + + it('throws a TransactionBuilderException when assetId scope does not match request scope', () => { + const testDestination = getTestWallet(); + expect(() => + transactionBuilder.transfer({ + onChainAccount: testOnChainAccount, + scope: KnownCaip2ChainId.Testnet, + assetId: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + amount: new BigNumber(100), + destination: { + address: testDestination.address, + isActivated: true, + }, + baseFee: new BigNumber(100), + }), + ).toThrow(TransactionBuilderException); + }); }); describe('deserialize', () => { @@ -297,5 +329,18 @@ describe('TransactionBuilder', () => { }); }).toThrow(TransactionBuilderException); }); + + it('throws a TransactionBuilderException when assetId scope does not match request scope', () => { + const testDestination = getTestWallet(); + expect(() => + transactionBuilder.sep41Transfer({ + scope: KnownCaip2ChainId.Testnet, + onChainAccount: testOnChainAccount, + assetId: sep41AssetId, + destination: testDestination.address, + amount: new BigNumber(100000000), + }), + ).toThrow(TransactionBuilderException); + }); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts index 39d6d2d1..10d214ab 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts @@ -15,7 +15,7 @@ import { TransactionBuilderException, } from './exceptions'; import { Transaction } from './Transaction'; -import { caip19ToStellarAsset } from './utils'; +import { assertAssetScopeMatch, caip19ToStellarAsset } from './utils'; import type { KnownCaip2ChainId, KnownCaip19AssetIdOrSlip44Id, @@ -31,6 +31,7 @@ import { isSep41Id, isSlip44Id, normalizeAmount, + rethrowIfInstanceElseThrow, } from '../../utils'; import { caip2ChainIdToNetwork } from '../network/utils'; import type { OnChainAccount } from '../on-chain-account/OnChainAccount'; @@ -54,7 +55,7 @@ export class TransactionBuilder { * @param params.scope - The CAIP-2 chain ID. * @param params.assetId - CAIP-19 asset id for the classic token asset. * @param params.onChainAccount - Source account (sequence and account id for the transaction). - * @param params.deleteTrustline - [optional] Whether to delete the trustline. Defaults to false. + * @param params.limit - [optional] limit of the trustline. * @returns An unsigned transaction ready for signing. * @throws {TransactionBuilderException} If building fails. */ @@ -63,21 +64,22 @@ export class TransactionBuilder { scope, assetId, onChainAccount, - deleteTrustline = false, + limit, }: { baseFee: string; scope: KnownCaip2ChainId; assetId: KnownCaip19ClassicAssetId; onChainAccount: OnChainAccount; - deleteTrustline?: boolean; + limit?: string; }): Transaction { try { + assertAssetScopeMatch(assetId, scope); + const operationOpt: OperationOptions.ChangeTrust = { asset: caip19ToStellarAsset(assetId), }; - // Remove trustline by setting the limit to 0 - if (deleteTrustline) { - operationOpt.limit = '0'; + if (limit !== undefined) { + operationOpt.limit = limit; } return this.#buildTransaction({ onChainAccount, @@ -118,6 +120,7 @@ export class TransactionBuilder { }): Transaction { try { const { scope, onChainAccount, assetId, destination, amount } = params; + assertAssetScopeMatch(assetId, scope); // If it is a SEP-41 asset, the asset reference is the token address const { assetReference: tokenAddress } = parseCaipAssetType(assetId); @@ -236,6 +239,8 @@ export class TransactionBuilder { const { address: toAddress, isActivated } = destination; try { + assertAssetScopeMatch(assetId, scope); + if (isSep41Id(assetId)) { return this.sep41Transfer({ scope, @@ -353,6 +358,9 @@ export class TransactionBuilder { ); if (!Number.isFinite(fee) || fee <= 0) { + this.#logger.warn( + `Invalid fee amount, fallback to use fix base fee value ${BASE_FEE}`, + ); fee = BASE_FEE; } @@ -379,17 +387,18 @@ export class TransactionBuilder { tx.operations().forEach((op) => builder.addOperation(op)); } else { throw new TransactionBuilderException( - 'Transaction is not a compatible transaction', + 'Failed to clone the transaction, it is not a compatible transaction', ); } return new Transaction(builder.build()); } catch (error: unknown) { - if (error instanceof TransactionBuilderException) { - throw error; - } this.#logger.logErrorWithDetails('Failed to rebuild transaction', error); - throw new TransactionBuilderException('Failed to rebuild transaction'); + return rethrowIfInstanceElseThrow( + error, + [TransactionBuilderException], + new TransactionBuilderException('Failed to rebuild transaction'), + ); } } diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts index c0f5acba..07adadc1 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts @@ -1,17 +1,31 @@ import { TransactionStatus, TransactionType } from '@metamask/keyring-api'; +import { hexToBytes } from '@metamask/utils'; +import { KeyringTransactionType } from './KeyringTransactionBuilder'; +import { TransactionBuilder } from './TransactionBuilder'; +import type { KnownCaip19ClassicAssetId } from '../../api'; import { KnownCaip2ChainId } from '../../api'; import { getSlip44AssetId } from '../../utils'; import { createMockTransactionService } from './__mocks__/transaction.fixtures'; import { generateMockStellarKeyringAccounts } from '../account/__mocks__/account.fixtures'; import type { StellarKeyringAccount } from '../account/api'; +import { NetworkService, TransactionRetryableException } from '../network'; +import { TransactionScopeNotMatchException } from './exceptions'; +import { OnChainAccount } from '../on-chain-account'; +import { + createMockAccountWithBalances, + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + horizonSource, +} from '../on-chain-account/__mocks__/onChainAccount.fixtures'; +import { getTestWallet } from '../wallet/__mocks__/wallet.fixtures'; +import type { Wallet } from '../wallet/Wallet'; jest.mock('../../utils/logger'); jest.mock('../../utils/snap'); describe('TransactionService', () => { - describe('createPendingSendTransaction', () => { - it('creates a pending send transaction', async () => { + describe('savePendingKeyringTransaction', () => { + it('creates and saves a pending send transaction', async () => { const { transactionService, transactionRepositorySaveSpy } = createMockTransactionService(); const [fromAccount, toAccount] = generateMockStellarKeyringAccounts( @@ -19,19 +33,21 @@ describe('TransactionService', () => { 'test-entropy', ) as [StellarKeyringAccount, StellarKeyringAccount]; - const transaction = await transactionService.createPendingSendTransaction( - { - txId: 'test-tx-id', - account: fromAccount, - scope: KnownCaip2ChainId.Mainnet, - toAddress: toAccount.address, - amount: '10000000', - asset: { - type: getSlip44AssetId(KnownCaip2ChainId.Mainnet), - symbol: 'XLM', + const transaction = + await transactionService.savePendingKeyringTransaction({ + type: KeyringTransactionType.Send, + request: { + txId: 'test-tx-id', + account: fromAccount, + scope: KnownCaip2ChainId.Mainnet, + toAddress: toAccount.address, + amount: '10000000', + asset: { + type: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + symbol: 'XLM', + }, }, - }, - ); + }); const expectedTransaction = { type: TransactionType.Send, @@ -77,4 +93,195 @@ describe('TransactionService', () => { ); }); }); + + describe('sendTransaction', () => { + const seed = hexToBytes( + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ); + let testWalletWithSigner: Wallet; + let testOnChainAccount: OnChainAccount; + let testAsset: KnownCaip19ClassicAssetId; + let scope: KnownCaip2ChainId; + + const getNetworkServiceSpies = () => ({ + getAccountSpy: jest.spyOn(NetworkService.prototype, 'getAccount'), + }); + + const getTransactionBuilderSpies = () => ({ + rebuildTxnWithNewSeqSpy: jest.spyOn( + TransactionBuilder.prototype, + 'rebuildTxnWithNewSeq', + ), + }); + + beforeEach(() => { + testWalletWithSigner = getTestWallet({ seed }); + const acc = createMockAccountWithBalances( + testWalletWithSigner.address, + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + testOnChainAccount = new OnChainAccount( + acc, + KnownCaip2ChainId.Mainnet, + horizonSource(acc, KnownCaip2ChainId.Mainnet), + ); + testAsset = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + scope = KnownCaip2ChainId.Mainnet; + }); + + it('returns hash when send succeeds on first attempt', async () => { + const { transactionService, transactionBuilder } = + createMockTransactionService(); + const sendSpy = jest.spyOn(NetworkService.prototype, 'send'); + sendSpy.mockResolvedValue('abc123hash'); + + const testTransaction = transactionBuilder.changeTrust({ + baseFee: '100', + scope, + assetId: testAsset, + onChainAccount: testOnChainAccount, + }); + testWalletWithSigner.signTransaction(testTransaction); + + const result = await transactionService.sendTransaction({ + wallet: testWalletWithSigner, + onChainAccount: testOnChainAccount, + scope, + transaction: testTransaction, + }); + + expect(result).toBe('abc123hash'); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledWith({ + transaction: testTransaction, + scope, + pollTransaction: false, + }); + }); + + it('throws TransactionScopeNotMatchException when transaction scope does not match params.scope', async () => { + const { transactionService, transactionBuilder } = + createMockTransactionService(); + const testnetUsdc: KnownCaip19ClassicAssetId = + 'stellar:testnet/asset:USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + + const testTransaction = transactionBuilder.changeTrust({ + baseFee: '100', + scope: KnownCaip2ChainId.Testnet, + assetId: testnetUsdc, + onChainAccount: testOnChainAccount, + }); + testWalletWithSigner.signTransaction(testTransaction); + + await expect( + transactionService.sendTransaction({ + wallet: testWalletWithSigner, + onChainAccount: testOnChainAccount, + scope: KnownCaip2ChainId.Mainnet, + transaction: testTransaction, + }), + ).rejects.toThrow(TransactionScopeNotMatchException); + }); + + it('resigns and retries once when send fails with txBadSeq', async () => { + const { transactionService, transactionBuilder } = + createMockTransactionService(); + const sendSpy = jest.spyOn(NetworkService.prototype, 'send'); + sendSpy + .mockRejectedValueOnce( + new TransactionRetryableException(scope, 'txBadSeq'), + ) + .mockResolvedValueOnce('retry-hash'); + + const { getAccountSpy } = getNetworkServiceSpies(); + getAccountSpy.mockResolvedValue(testOnChainAccount); + + const testTransaction = transactionBuilder.changeTrust({ + baseFee: '100', + scope, + assetId: testAsset, + onChainAccount: testOnChainAccount, + }); + testWalletWithSigner.signTransaction(testTransaction); + + const resignedTx = transactionBuilder.changeTrust({ + baseFee: '100', + scope, + assetId: testAsset, + onChainAccount: testOnChainAccount, + }); + const { rebuildTxnWithNewSeqSpy } = getTransactionBuilderSpies(); + rebuildTxnWithNewSeqSpy.mockReturnValue(resignedTx); + + const result = await transactionService.sendTransaction({ + wallet: testWalletWithSigner, + onChainAccount: testOnChainAccount, + scope, + transaction: testTransaction, + }); + + expect(result).toBe('retry-hash'); + expect(sendSpy).toHaveBeenCalledTimes(2); + const secondSendArgs = sendSpy.mock.calls[1]; + expect(secondSendArgs).toBeDefined(); + expect(secondSendArgs?.[0].transaction).toStrictEqual(resignedTx); + expect(rebuildTxnWithNewSeqSpy).toHaveBeenCalledWith({ + transaction: testTransaction, + sequenceNumber: testOnChainAccount.sequenceNumber, + }); + }); + + it('rethrows txBadSeq when transaction source is not the wallet account', async () => { + const { transactionService, transactionBuilder } = + createMockTransactionService(); + const sendSpy = jest.spyOn(NetworkService.prototype, 'send'); + sendSpy.mockRejectedValue( + new TransactionRetryableException(scope, 'txBadSeq'), + ); + + const otherSeed = hexToBytes( + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + ); + const sourceWallet = getTestWallet({ seed: otherSeed }); + const sourceAcc = createMockAccountWithBalances( + sourceWallet.address, + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const sourceOnChainAccount = new OnChainAccount( + sourceAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(sourceAcc, KnownCaip2ChainId.Mainnet), + ); + const wrongWallet = getTestWallet({ seed }); + + const testTransaction = transactionBuilder.changeTrust({ + baseFee: '100', + scope, + assetId: testAsset, + onChainAccount: sourceOnChainAccount, + }); + sourceWallet.signTransaction(testTransaction); + + const { getAccountSpy, rebuildTxnWithNewSeqSpy } = { + ...getNetworkServiceSpies(), + ...getTransactionBuilderSpies(), + }; + + await expect( + transactionService.sendTransaction({ + wallet: wrongWallet, + onChainAccount: testOnChainAccount, + scope, + transaction: testTransaction, + }), + ).rejects.toThrow(TransactionRetryableException); + + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(getAccountSpy).not.toHaveBeenCalled(); + expect(rebuildTxnWithNewSeqSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index d8e492c6..a06fb620 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -1,20 +1,28 @@ import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; -import { TransactionStatus, TransactionType } from '@metamask/keyring-api'; import type { Transaction } from './Transaction'; +import type { TransactionBuilder } from './TransactionBuilder'; import type { TransactionRepository } from './TransactionRepository'; -import type { - KnownCaip19AssetIdOrSlip44Id, - KnownCaip2ChainId, -} from '../../api'; +import type { KnownCaip19ClassicAssetId, KnownCaip2ChainId } from '../../api'; import { BASE_FEE_CACHE_TTL_MILLISECONDS } from '../../constants'; +import type { Serializable } from '../../utils'; import type { ILogger } from '../../utils/logger'; import { createPrefixedLogger } from '../../utils/logger'; -import type { Serializable } from '../../utils/serialization'; import type { StellarKeyringAccount } from '../account/api'; +import type { NetworkService } from '../network'; +import { TransactionRetryableException } from '../network/exceptions'; +import type { OnChainAccount } from '../on-chain-account/OnChainAccount'; +import type { Wallet } from '../wallet'; +import { + SupportedOperations, + TransactionSimulator, + type TransactionSimulatorOptions, +} from './TransactionSimulator'; +import { assertTransactionScope } from './utils'; import type { ICache } from '../cache'; import { useCache } from '../cache'; -import type { NetworkService } from '../network'; +import type { KeyringTransactionRequest } from './KeyringTransactionBuilder'; +import { KeyringTransactionBuilder } from './KeyringTransactionBuilder'; export class TransactionService { readonly #logger: ILogger; @@ -23,29 +31,38 @@ export class TransactionService { readonly #networkService: NetworkService; + readonly #transactionBuilder: TransactionBuilder; + + readonly #keyringTransactionBuilder: KeyringTransactionBuilder; + readonly #cache: ICache; constructor({ logger, transactionRepository, networkService, + transactionBuilder, cache, }: { logger: ILogger; transactionRepository: TransactionRepository; networkService: NetworkService; + transactionBuilder: TransactionBuilder; cache: ICache; }) { this.#logger = createPrefixedLogger(logger, '[🧾 TransactionService]'); this.#transactionRepository = transactionRepository; this.#networkService = networkService; + this.#transactionBuilder = transactionBuilder; + this.#keyringTransactionBuilder = new KeyringTransactionBuilder(); this.#cache = cache; } /** * Gets the base fee for a transaction. + * Results are cached for {@link BASE_FEE_CACHE_TTL_MILLISECONDS}. * - * @param scope - The CAIP-2 chain id. + * @param scope - The CAIP-2 chain ID. * @returns A promise that resolves to the base fee. */ async getBaseFee(scope: KnownCaip2ChainId): Promise { @@ -60,78 +77,54 @@ export class TransactionService { } /** - * Creates a pending send transaction. + * Creates a validated change trust transaction. + * + * @param params - The parameters for the transaction. + * @param params.onChainAccount - The on-chain account. + * @param params.scope - The CAIP-2 chain ID. + * @param params.assetId - The CAIP-19 classic asset ID. + * @param params.limit - The limit for the trustline, 0 for delete trustline. * - * @param params - The parameters for the pending send transaction. - * @param params.txId - Stable id for this activity row (e.g. client correlation id). - * @param params.account - Keyring account that initiated the send (`from`). - * @param params.scope - CAIP-2 chain for `chain` on the keyring transaction. - * @param params.toAddress - Destination Stellar address (`G…`). - * @param params.amount - Amount in the asset’s smallest units (string). - * @param params.asset - Display / CAIP metadata for `from` and `to` asset rows. - * @param params.asset.type - CAIP-19 (or slip44) asset id. - * @param params.asset.symbol - Human-readable unit label (e.g. `XLM`). - * @returns A promise that resolves to the pending send transaction. + * @returns A promise that resolves to the validated transaction. */ - async createPendingSendTransaction({ - txId, - account, - scope, - toAddress, - amount, - asset, - }: { - txId: string; - account: StellarKeyringAccount; + async createValidatedChangeTrustTransaction(params: { + onChainAccount: OnChainAccount; scope: KnownCaip2ChainId; - toAddress: string; - amount: string; - asset: { - type: KnownCaip19AssetIdOrSlip44Id; - symbol: string; - }; - }): Promise { - const timestamp = Math.floor(Date.now() / 1000); - - const transaction: KeyringTransaction = { - type: TransactionType.Send, - id: txId, - from: [ - { - address: account.address, - asset: { - unit: asset.symbol, - type: asset.type, - amount, - fungible: true, - }, - }, - ], - to: [ - { - address: toAddress, - asset: { - unit: asset.symbol, - type: asset.type, - amount, - fungible: true, - }, - }, - ], - events: [ - { - status: TransactionStatus.Unconfirmed, - timestamp, - }, - ], - chain: scope, - status: TransactionStatus.Unconfirmed, - account: account.id, - timestamp, - fees: [], - }; - - this.#logger.debug('Creating pending send transaction', { + assetId: KnownCaip19ClassicAssetId; + limit?: string; + }): Promise { + const { onChainAccount, scope, assetId, limit } = params; + + const baseFee = await this.getBaseFee(scope); + + const transaction = this.#transactionBuilder.changeTrust({ + onChainAccount, + assetId, + scope, + baseFee: baseFee.toString(), + limit, + }); + + this.validateTransaction(transaction, onChainAccount, { + expectedOPTypes: [SupportedOperations.ChangeTrust], + }); + + return transaction; + } + + /** + * Create and save a pending keyring transaction. + * + * @param request - The request {@link KeyringTransactionRequest} to create the pending transaction for. + * @returns A promise that resolves to the pending transaction. + */ + async savePendingKeyringTransaction( + request: KeyringTransactionRequest, + ): Promise { + const transaction = + this.#keyringTransactionBuilder.createTransaction(request); + + this.#logger.debug('Creating pending transaction', { transaction, }); @@ -158,6 +151,98 @@ export class TransactionService { return transaction; } + /** + * Runs local fee/balance/operation simulation for a transaction against the given ledger snapshot. + * Delegates to {@link TransactionSimulator.simulate}; throws the same validation exceptions. + * + * @param transaction - The transaction to validate. + * @param onChainAccount - The on-chain account to validate against. + * @param options - Optional options for the transaction validation {@link TransactionSimulatorOptions}. + * @throws {TransactionScopeNotMatchException} When {@link OnChainAccount.scope} does not match {@link Transaction.scope}. + * @throws {TransactionValidationException} When the transaction cannot be validated. + */ + validateTransaction( + transaction: Transaction, + onChainAccount: OnChainAccount, + options?: TransactionSimulatorOptions, + ): void { + const simulator = new TransactionSimulator(); + simulator.simulate(transaction, onChainAccount, options); + } + + /** + * Submits a signed transaction. + * When the transaction fails with `txBadSeq`, reloads the account sequence, rebuilds, re-signs once, and retries + * **only when** the transaction source matches the resolved {@link OnChainAccount}'s `accountId` (this account consumes sequence). + * If the source is another account, `txBadSeq` is rethrown: sequence must be fixed on their side and the envelope re-signed. + * + * @param params - Options object. + * @param params.wallet - Wallet used to sign; for automatic retry, must be the transaction source account. + * @param params.onChainAccount - On-chain account for the signing account (sequence bump on `txBadSeq` retry). + * @param params.scope - The CAIP-2 chain ID. + * @param params.transaction - The signed transaction (same envelope used as the rebuild template on retry). + * @param params.pollTransaction - If true, wait for RPC terminal status after submit. + * @returns A promise that resolves to the transaction hash. + * @throws {TransactionScopeNotMatchException} When `scope` does not match {@link Transaction.scope} (from {@link assertTransactionScope} before submit). + * @throws {TransactionRetryableException} When RPC returns `txBadSeq` for the signing account (one rebuild+retry is attempted when the tx source matches `onChainAccount`). + * @throws {TransactionSendException} When submission fails for other RPC reasons. + * @throws {TransactionPollException} When `pollTransaction` is true and polling does not end in SUCCESS. + */ + async sendTransaction(params: { + wallet: Wallet; + onChainAccount: OnChainAccount; + scope: KnownCaip2ChainId; + transaction: Transaction; + pollTransaction?: boolean; + }): Promise { + const { + wallet, + onChainAccount, + scope, + transaction: templateTransaction, + } = params; + + assertTransactionScope(templateTransaction, scope); + + const pollTransaction = params.pollTransaction ?? false; + + const sendOnce = async (transaction: Transaction): Promise => + this.#networkService.send({ + transaction, + scope, + pollTransaction, + }); + + try { + return await sendOnce(templateTransaction); + } catch (error: unknown) { + if (error instanceof TransactionRetryableException) { + const txSource = templateTransaction.sourceAccount; + if (txSource !== onChainAccount.accountId) { + this.#logger.warn( + 'transaction failed with txBadSeq but transaction source does not match wallet; cannot bump sequence.', + ); + throw error; + } + + const freshAccount = await this.#networkService.getAccount( + onChainAccount.accountId, + scope, + ); + + const newTransaction = this.#transactionBuilder.rebuildTxnWithNewSeq({ + transaction: templateTransaction, + sequenceNumber: freshAccount.sequenceNumber, + }); + + wallet.signTransaction(newTransaction); + + return await sendOnce(newTransaction); + } + throw error; + } + } + /** * Finds all transactions for the given accounts. * diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts new file mode 100644 index 00000000..bb5f8766 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts @@ -0,0 +1,1329 @@ +import type { Operation } from '@stellar/stellar-sdk'; +import { + Account, + Asset, + Keypair, + nativeToScVal, + Networks, + Operation as StellarOperation, + TransactionBuilder, +} from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import { + InsufficientBalanceException, + InsufficientBalanceToCoverBaseReserveException, + InsufficientBalanceToCoverFeeException, + InvalidAmountForCreateAccountException, + InvalidInvokeContractStructureException, + RemoveTrustlineWithNonZeroBalanceException, + TransactionScopeNotMatchException, + TransactionValidationException, + TrustlineNotAuthorizedException, + TrustlineNotFoundException, + UnsupportedOperationTypeException, + UpdateTrustlineException, +} from './exceptions'; +import { Transaction } from './Transaction'; +import { + SupportedOperations, + TransactionSimulator, +} from './TransactionSimulator'; +import { KnownCaip2ChainId } from '../../api'; +import { caip2ChainIdToNetwork } from '../network/utils'; +import { + createMockAccountWithBalances, + horizonSource, + type MockAccountWithBalancesData, +} from '../on-chain-account/__mocks__/onChainAccount.fixtures'; +import { OnChainAccount } from '../on-chain-account/OnChainAccount'; +import { + buildMockClassicTransaction, + buildMockInvokeHostFunctionTransaction, + type BuildMockTransactionOptions, +} from './__mocks__/transaction.fixtures'; +import { getTestWallet } from '../wallet/__mocks__/wallet.fixtures'; + +const SEP41_ASSET_MAINNET = + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as const; + +const SEP41_CONTRACT_MAINNET = + 'CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as const; + +const USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + +/** Source account used for Soroban `invokeHostFunction` simulator tests (mainnet). */ +const SOROBAN_INVOKE_SOURCE = + 'GDRZ4B4X2GCM3IINPEBUYQXTO2GJX6YDTV5OMLC7TKTGL33WNEKLUSKF'; + +const MOCK_USDC_ASSET = { code: 'USDC', issuer: USDC_ISSUER } as const; + +const SWAP_TEST_CONTRACT_ID = + 'CASUP2OPFVEHCWGP2XLBXOV7DQIQIT42AQISG4MXAZGNLVFFN63X7WRT'; + +const MAX_TRUST_LIMIT = '922337203685.4775807'; + +/** + * Default {@link buildMockClassicTransaction} / Soroban mock options for mainnet envelopes + * in this file (matches prior `buildEnvelopeTransaction` fee and time bounds). + * + * @param source - The source account id (`G…`). + * @param sequence - The source sequence number. + * @param overrides - The overrides for the transaction options. + * @param overrides.baseFeePerOperation - The base fee per operation. + * @param overrides.timeout - The timeout. + * @returns The transaction options. + */ +function mainnetSimulatorTxOptions( + source: string, + sequence: string, + overrides?: Partial< + Pick + >, +): BuildMockTransactionOptions { + return { + networkPassphrase: Networks.PUBLIC, + source: { accountId: source, sequence }, + baseFeePerOperation: overrides?.baseFeePerOperation ?? '100', + timeout: overrides?.timeout ?? 30, + }; +} + +/** + * Builds a mock transaction with a single Soroban `invokeHostFunction` operation. + * + * @returns A {@link Transaction} wrapper. + */ +function buildSoleNonSep41InvokeTx(): Transaction { + return buildMockInvokeHostFunctionTransaction( + 'swap', + [ + 'GDRZ4B4X2GCM3IINPEBUYQXTO2GJX6YDTV5OMLC7TKTGL33WNEKLUSKF', + 'GDRZ4B4X2GCM3IINPEBUYQXTO2GJX6YDTV5OMLC7TKTGL33WNEKLUSKF', + ], + { + ...mainnetSimulatorTxOptions(SOROBAN_INVOKE_SOURCE, '1'), + contractId: SWAP_TEST_CONTRACT_ID, + argNativeToScValOptions: [{ type: 'address' }, { type: 'address' }], + }, + ); +} + +/** + * Builds a wrapped transaction with one SEP-41 `transfer(from, to, amount)` invoke (for simulator tests). + * + * @param params - Transfer build parameters. + * @param params.source - Transaction source account id (`G…`). + * @param params.sequence - Source sequence string. + * @param params.contractId - Token contract id (`C…`). + * @param params.from - `transfer` `from` address. + * @param params.to - `transfer` `to` address. + * @param params.amountSmallestUnits - Amount in token smallest units (integer string). + * @param params.feeStroops - Optional fee in stroops. + * @param params.scope - Optional CAIP-2 chain id (defaults to mainnet). + * @returns A {@link Transaction} wrapper. + */ +function buildSep41TransferTransaction(params: { + source: string; + sequence: string; + contractId: string; + from: string; + to: string; + amountSmallestUnits: string; + feeStroops?: string; + scope?: KnownCaip2ChainId; +}): Transaction { + const scope = params.scope ?? KnownCaip2ChainId.Mainnet; + return buildMockInvokeHostFunctionTransaction( + 'transfer', + [params.from, params.to, params.amountSmallestUnits], + { + source: { accountId: params.source, sequence: params.sequence }, + baseFeePerOperation: params.feeStroops ?? '100', + networkPassphrase: caip2ChainIdToNetwork(scope), + contractId: params.contractId, + timeout: 30, + argNativeToScValOptions: [ + { type: 'address' }, + { type: 'address' }, + { type: 'i128' }, + ], + }, + ); +} + +/** + * Builds a wrapped classic transaction for cases not covered by {@link buildMockClassicTransaction} + * (empty envelope, `accountMerge`, or Soroban `invokeContractFunction` mixed with classic ops). + * + * @param source - Transaction source account public key. + * @param sequence - Current sequence number string for the source account. + * @param addOperations - Callback that adds one or more operations to the builder. + * @param options - Optional builder settings. + * @param options.feeStroops - Total fee in stroops (string for SDK). Defaults to `100`. + * @param options.scope - The CAIP-2 chain ID. Defaults to `KnownCaip2ChainId.Mainnet`. + * @returns A {@link Transaction} wrapper around the built Stellar envelope. + */ +function buildEnvelopeTransaction( + source: string, + sequence: string, + addOperations: (tb: TransactionBuilder) => TransactionBuilder, + options?: { feeStroops?: string; scope?: KnownCaip2ChainId }, +): Transaction { + const account = new Account(source, sequence); + + const raw = addOperations( + new TransactionBuilder(account, { + fee: options?.feeStroops ?? '100', + networkPassphrase: caip2ChainIdToNetwork( + options?.scope ?? KnownCaip2ChainId.Mainnet, + ), + }), + ) + .setTimeout(30) + .build(); + return new Transaction(raw); +} + +/** + * Builds a preloaded destination account with a USDC trustline for {@link TransactionSimulator.simulate}. + * + * @param destPublicKey - Payment destination Stellar account id (G…). + * @returns Horizon-shaped loaded account for preload. + */ +function destOnChainAccount(destPublicKey: string): OnChainAccount { + return onChainFromMockBalances(destPublicKey, '1', { + nativeBalance: 50, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + balance: 0, + }, + ], + }); +} + +/** + * Destination with a USDC trustline that exists but is not authorized (`is_authorized` false). + * + * @param destPublicKey - Payment destination Stellar account id (G…). + * @returns Horizon-shaped loaded account for preload. + */ +function destOnChainAccountUnauthorized(destPublicKey: string): OnChainAccount { + return onChainFromMockBalances(destPublicKey, '1', { + nativeBalance: 50, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + balance: 0, + isAuthorized: false, + }, + ], + }); +} + +/** + * Builds {@link OnChainAccount} from {@link createMockAccountWithBalances} with a serializable binding from mock Horizon data. + * + * @param accountId - Stellar public key (`G…`). + * @param sequence - Account sequence string. + * @param data - Native balance, subentries, and optional trustline mocks. + * @param scope - CAIP-2 chain (defaults to mainnet). + * @returns Hydrated on-chain account for simulator tests. + */ +function onChainFromMockBalances( + accountId: string, + sequence: string, + data: MockAccountWithBalancesData, + scope: KnownCaip2ChainId = KnownCaip2ChainId.Mainnet, +): OnChainAccount { + const acc = createMockAccountWithBalances(accountId, sequence, data); + return new OnChainAccount(acc, scope, horizonSource(acc, scope)); +} + +describe('TransactionSimulator', () => { + const simulator = new TransactionSimulator(); + + describe('preflight validation', () => { + it('throws when account scope does not match transaction network', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '10', + }, + }, + ], + { + ...mainnetSimulatorTxOptions(wallet.address, '1'), + networkPassphrase: Networks.TESTNET, + }, + ); + + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + TransactionScopeNotMatchException, + ); + }); + + it('throws when the envelope has no operations', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const tx = buildEnvelopeTransaction(wallet.address, '1', (tb) => tb); + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + TransactionValidationException, + ); + }); + + it('throws when an operation has an unsupported type (unsupported in preflight)', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const tx = buildEnvelopeTransaction(wallet.address, '1', (tb) => + tb.addOperation( + StellarOperation.accountMerge({ + source: wallet.address, + destination: wallet.address, + }), + ), + ); + + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + UnsupportedOperationTypeException, + ); + }); + + it('rejects invokeHostFunction combined with other operations', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildEnvelopeTransaction(wallet.address, '1', (tb) => + tb + .addOperation( + StellarOperation.payment({ + source: wallet.address, + destination: dest, + asset: Asset.native(), + amount: '1', + }), + ) + .addOperation( + StellarOperation.invokeContractFunction({ + contract: SWAP_TEST_CONTRACT_ID, + function: 'swap', + args: [ + nativeToScVal( + 'GDRZ4B4X2GCM3IINPEBUYQXTO2GJX6YDTV5OMLC7TKTGL33WNEKLUSKF', + { + type: 'address', + }, + ), + nativeToScVal( + 'GDRZ4B4X2GCM3IINPEBUYQXTO2GJX6YDTV5OMLC7TKTGL33WNEKLUSKF', + { + type: 'address', + }, + ), + ], + }), + ), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(InvalidInvokeContractStructureException); + }); + + it('throws when expectedOPTypes omits an operation type on a mixed classic envelope', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '10', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + expectedOPTypes: [SupportedOperations.Payment], + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(TransactionValidationException); + }); + }); + + describe('payment', () => { + it('succeeds for native payment when destination is preloaded', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '10', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + const stack = simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }); + expect(stack).toHaveLength(2); + }); + + it('throws when destination account is not in the simulation set', () => { + const walletKey = Keypair.random().publicKey(); + const external = Keypair.random().publicKey(); + const loaded = onChainFromMockBalances(walletKey, '1', { + nativeBalance: 100, + subentryCount: 0, + assets: [], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: walletKey, + destination: external, + asset: 'native', + amount: '1', + }, + }, + ], + mainnetSimulatorTxOptions(walletKey, '1'), + ); + expect(() => simulator.simulate(tx, loaded)).toThrow( + TransactionValidationException, + ); + }); + + it('throws when source spendable native is below payment amount', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 1.01, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '1', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(InsufficientBalanceException); + }); + + it('throws when source has no trustline for a credit asset payment', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: MOCK_USDC_ASSET, + amount: '1', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(TrustlineNotFoundException); + }); + + it('throws when source trustline is not authorized (is_authorized)', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + balance: 100, + isAuthorized: false, + }, + ], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: MOCK_USDC_ASSET, + amount: '1', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(TrustlineNotAuthorizedException); + }); + + it('throws when destination trustline is not authorized (is_authorized)', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + balance: 100, + }, + ], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: MOCK_USDC_ASSET, + amount: '1', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccountUnauthorized(dest)], + }), + ).toThrow(TrustlineNotAuthorizedException); + }); + }); + + describe('createAccount', () => { + it('succeeds when funder has enough XLM and destination is absent from state', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'createAccount', + params: { + source: wallet.address, + destination: dest, + startingBalance: '2', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(simulator.simulate(tx, onChainAccount)).toHaveLength(2); + }); + + it('throws when starting balance is below minimum (1 XLM)', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'createAccount', + params: { + source: wallet.address, + destination: dest, + startingBalance: '0.5', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + InvalidAmountForCreateAccountException, + ); + }); + + it('throws when destination already exists in simulation state', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'createAccount', + params: { + source: wallet.address, + destination: dest, + startingBalance: '2', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(TransactionValidationException); + }); + }); + + describe('changeTrust', () => { + it('succeeds when adding a new trustline and spendable covers base reserve', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(simulator.simulate(tx, onChainAccount)).toHaveLength(2); + }); + + it('throws when adding a trustline but spendable native is below one base reserve', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 1.1, + subentryCount: 0, + assets: [], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + InsufficientBalanceToCoverBaseReserveException, + ); + }); + + it('succeeds when removing an existing trustline with zero balance', () => { + const issuer = Keypair.random().publicKey(); + const removable = { code: 'REM', issuer } as const; + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'REM', + assetIssuer: issuer, + balance: 0, + }, + ], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: removable, + limit: '0', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(simulator.simulate(tx, onChainAccount)).toHaveLength(2); + }); + + it('throws when removing a trustline that does not exist', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: '0', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + TrustlineNotFoundException, + ); + }); + + it('throws when removing a trustline with non-zero balance', () => { + const issuer = Keypair.random().publicKey(); + const removable = { code: 'REM', issuer } as const; + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'REM', + assetIssuer: issuer, + balance: 10, + }, + ], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: removable, + limit: '0', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + RemoveTrustlineWithNonZeroBalanceException, + ); + }); + + it('throws when lowering limit below current asset balance', () => { + const issuer = Keypair.random().publicKey(); + const line = { code: 'REM', issuer } as const; + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'REM', + assetIssuer: issuer, + balance: 10, + }, + ], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: line, + limit: '5', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + UpdateTrustlineException, + ); + }); + }); + + describe('invokeHostFunction', () => { + it('succeeds for sole invoke (fee debit + one op snapshot, no classic balance effects)', () => { + const sorobanTx = buildSoleNonSep41InvokeTx(); + const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }); + + expect(simulator.simulate(sorobanTx, loaded)).toHaveLength(2); + }); + + it('throws when invoke effective source account is not in simulation state', () => { + const sorobanTx = buildSoleNonSep41InvokeTx(); + const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }); + const otherSource = Keypair.random().publicKey(); + const [invokeOp] = sorobanTx.transactionOperations; + jest + .spyOn(sorobanTx, 'transactionOperations', 'get') + .mockReturnValue([{ ...invokeOp, source: otherSource } as Operation]); + + expect(() => simulator.simulate(sorobanTx, loaded)).toThrow( + TransactionValidationException, + ); + }); + + it('passes preloadedTokenBalance when invoke is SEP-41 transfer and balance covers amount', () => { + const dest = Keypair.random().publicKey(); + const sorobanTx = buildSep41TransferTransaction({ + source: SOROBAN_INVOKE_SOURCE, + sequence: '1', + contractId: SEP41_CONTRACT_MAINNET, + from: SOROBAN_INVOKE_SOURCE, + to: dest, + amountSmallestUnits: '1', + }); + const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }); + + expect( + simulator.simulate(sorobanTx, loaded, { + preloadedTokenBalance: { + [SOROBAN_INVOKE_SOURCE]: { + [SEP41_ASSET_MAINNET]: new BigNumber(1_000_000), + }, + }, + }), + ).toHaveLength(2); + }); + + it('throws InsufficientBalanceException when SEP-41 transfer amount exceeds preloaded balance', () => { + const dest = Keypair.random().publicKey(); + const sorobanTx = buildSep41TransferTransaction({ + source: SOROBAN_INVOKE_SOURCE, + sequence: '1', + contractId: SEP41_CONTRACT_MAINNET, + from: SOROBAN_INVOKE_SOURCE, + to: dest, + amountSmallestUnits: '10', + }); + const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }); + + expect(() => + simulator.simulate(sorobanTx, loaded, { + preloadedTokenBalance: { + [SOROBAN_INVOKE_SOURCE]: { + [SEP41_ASSET_MAINNET]: new BigNumber(5), + }, + }, + }), + ).toThrow(InsufficientBalanceException); + }); + + it('throws when preloadedTokenBalance does not match SEP-41 transfer sender or contract', () => { + const dest = Keypair.random().publicKey(); + const sorobanTx = buildSep41TransferTransaction({ + source: SOROBAN_INVOKE_SOURCE, + sequence: '1', + contractId: SEP41_CONTRACT_MAINNET, + from: SOROBAN_INVOKE_SOURCE, + to: dest, + amountSmallestUnits: '1', + }); + const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }); + const other = Keypair.random().publicKey(); + + expect(() => + simulator.simulate(sorobanTx, loaded, { + preloadedTokenBalance: { + [other]: { [SEP41_ASSET_MAINNET]: new BigNumber(100) }, + }, + }), + ).toThrow(TransactionValidationException); + }); + + it('throws when SEP-41 transfer has no preloaded entry for sender and contract', () => { + const dest = Keypair.random().publicKey(); + const sorobanTx = buildSep41TransferTransaction({ + source: SOROBAN_INVOKE_SOURCE, + sequence: '1', + contractId: SEP41_CONTRACT_MAINNET, + from: SOROBAN_INVOKE_SOURCE, + to: dest, + amountSmallestUnits: '1', + }); + const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }); + + expect(() => simulator.simulate(sorobanTx, loaded)).toThrow( + TransactionValidationException, + ); + }); + + it('ignores preloadedTokenBalance when invoke is not a SEP-41 transfer', () => { + const sorobanTx = buildSoleNonSep41InvokeTx(); + const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }); + + expect( + simulator.simulate(sorobanTx, loaded, { + preloadedTokenBalance: { + [SOROBAN_INVOKE_SOURCE]: { + [SEP41_ASSET_MAINNET]: new BigNumber(1_000_000), + }, + }, + }), + ).toHaveLength(2); + }); + }); + + describe('mixed multi-operation flows', () => { + it('allows createAccount then native payment to the new account', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'createAccount', + params: { + source: wallet.address, + destination: dest, + startingBalance: '2', + }, + }, + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '5', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(simulator.simulate(tx, onChainAccount)).toHaveLength(3); + }); + + it('allows changeTrust add then native payment when destination is preloaded', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '10', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + const stack = simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }); + expect(stack).toHaveLength(3); + }); + + it('throws when payment uses credit asset before changeTrust add in the same tx', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: MOCK_USDC_ASSET, + amount: '1', + }, + }, + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(TrustlineNotFoundException); + }); + + it('allows mixed changeTrust and payment when expectedOPTypes lists both', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '10', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect( + simulator.simulate(tx, onChainAccount, { + expectedOPTypes: [ + SupportedOperations.ChangeTrust, + SupportedOperations.Payment, + ], + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toHaveLength(3); + }); + + it('allows adding one trustline and removing another in the same transaction', () => { + const issuerToRemove = Keypair.random().publicKey(); + const removable = { code: 'REM', issuer: issuerToRemove } as const; + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'REM', + assetIssuer: issuerToRemove, + balance: 0, + }, + ], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: removable, + limit: '0', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(simulator.simulate(tx, onChainAccount)).toHaveLength(3); + }); + + it('allows createAccount then native payment then changeTrust add', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'createAccount', + params: { + source: wallet.address, + destination: dest, + startingBalance: '2', + }, + }, + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '5', + }, + }, + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect( + simulator.simulate(tx, onChainAccount, { + expectedOPTypes: [ + SupportedOperations.CreateAccount, + SupportedOperations.Payment, + SupportedOperations.ChangeTrust, + ], + }), + ).toHaveLength(4); + }); + + describe('sponsored trustlines and fee accounting', () => { + it('rejects at fee when spendable is too low even if later ops remove sponsored trustlines', () => { + const issuerA = Keypair.random().publicKey(); + const issuerB = Keypair.random().publicKey(); + const sourceKey = Keypair.random().publicKey(); + const dest = Keypair.random().publicKey(); + + const loaded = onChainFromMockBalances(sourceKey, '1', { + nativeBalance: 1.5, + subentryCount: 2, + sponsoredCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'AAA', + assetIssuer: issuerA, + balance: 0, + }, + { + assetType: 'credit_alphanum4', + assetCode: 'BBB', + assetIssuer: issuerB, + balance: 0, + }, + ], + }); + + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: sourceKey, + asset: { code: 'AAA', issuer: issuerA }, + limit: '0', + }, + }, + { + type: 'changeTrust', + params: { + source: sourceKey, + asset: { code: 'BBB', issuer: issuerB }, + limit: '0', + }, + }, + { + type: 'payment', + params: { + source: sourceKey, + destination: dest, + asset: 'native', + amount: '0.4', + }, + }, + ], + mainnetSimulatorTxOptions(sourceKey, '1', { + baseFeePerOperation: '300', + }), + ); + + expect(() => + simulator.simulate(tx, loaded, { + expectedOPTypes: [ + SupportedOperations.ChangeTrust, + SupportedOperations.Payment, + ], + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(InsufficientBalanceToCoverFeeException); + }); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts new file mode 100644 index 00000000..5f88ce86 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts @@ -0,0 +1,408 @@ +import type { Operation } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import { + InsufficientBalanceToCoverFeeException, + InvalidInvokeContractStructureException, + TransactionValidationException, + UnsupportedOperationTypeException, +} from './exceptions'; +import type { + AccountState, + SimulationState, + TrustlineState, + OperationSimulator, + Sep41TokenBalanceMapKey, +} from './simulation'; +import { + ChangeTrustOPSimulator, + CreateAccountOPSimulator, + InvokeHostFunctionOPSimulator, + PaymentOPSimulator, + getSpendableNative, + getAccount, + toSep41TokenBalanceMapKey, +} from './simulation'; +import type { Transaction } from './Transaction'; +import { + assertTransactionScope, + assertTransactionSourceAccount, +} from './utils'; +import type { + KnownCaip19ClassicAssetId, + KnownCaip19Sep41AssetId, + KnownCaip2ChainId, +} from '../../api'; +import { entries } from '../../utils/array'; +import type { OnChainAccount } from '../on-chain-account/OnChainAccount'; + +/** + * Supported operation types in MetaMask. + */ +type SupportedOPType = + | Operation.Payment + | Operation.CreateAccount + | Operation.ChangeTrust + | Operation.InvokeHostFunction; + +/** + * Stellar operation kinds used when validating or simulating transactions. + */ +export enum SupportedOperations { + Payment = 'payment', + CreateAccount = 'createAccount', + ChangeTrust = 'changeTrust', + InvokeHostFunction = 'invokeHostFunction', +} + +/** + * Optional settings for {@link TransactionSimulator.simulate}. + */ +export type TransactionSimulatorOptions = { + expectedOPTypes?: SupportedOperations[]; + /** + * Extra accounts merged into simulation (e.g. payment destinations). Ignored when simulation path does not apply. + */ + preloadedAccounts?: OnChainAccount[]; + /** + * Per-account token balances (account id → SEP-41 asset id → smallest units). Flattened internally with {@link toSep41TokenBalanceMapKey}. + * Used only when the sole invoke is a SEP-41 `transfer`; spend is read from the invoke, not from this map. + */ + preloadedTokenBalance?: Record< + string, + Record + >; +}; + +export class TransactionSimulator { + readonly #operationSimulator: Record; + + constructor() { + this.#operationSimulator = { + payment: new PaymentOPSimulator(), + createAccount: new CreateAccountOPSimulator(), + changeTrust: new ChangeTrustOPSimulator(), + invokeHostFunction: new InvokeHostFunctionOPSimulator(), + }; + } + + /** + * Inspects envelope operations, optionally enforces expected operation types and Soroban rules, + * then runs ordered simulation for supported ops; classic ops update balances / trustlines. + * Soroban `invokeHostFunction` is only allowed as a single-op tx and is a no-op for state. + * All involved accounts must be known from the wallet snapshot (or {@link TransactionSimulatorOptions.preloadedAccounts}). + * + * @param transaction - Wrapped Stellar transaction. + * @param account - Loaded signing account (Horizon-shaped raw for balances). + * @param options - Optional `expectedOPTypes`, `preloadedAccounts`, and `preloadedTokenBalance` (invoke-only SEP-41 `transfer` balance check after fee debit). + * @returns Stack of simulation states: fee snapshot first, then one entry per operation after apply. + * @throws {TransactionScopeNotMatchException} If the transaction scope does not match the account scope. + * @throws {TransactionValidationException} When the transaction cannot be simulated (wallet not source/fee source, unsupported op, unknown accounts for payments, etc.). + */ + simulate( + transaction: Transaction, + account: OnChainAccount, + options?: TransactionSimulatorOptions, + ): SimulationState[] { + const ops = transaction.transactionOperations; + + // Allow to quit early if any operation not valid (not supported or not expected) + this.#preflightValidation(ops, account, transaction, options); + + return this.#run({ + operations: ops, + transaction, + initialState: this.#buildInitialState(account, options), + }); + } + + #run(params: { + operations: SupportedOPType[]; + transaction: Transaction; + initialState: SimulationState; + }): SimulationState[] { + const { operations, initialState, transaction } = params; + + const txSource = transaction.sourceAccount; + const feeSource = transaction.feeSourceAccount; + const fee = transaction.totalFee; + const { scope } = transaction; + + // it is required to validate if the current balance is enough to cover the fee, + // even though later operations may "free" the base reserve. + // e.g + // a user has 1.5 xlm, 2 trustlines, 1 of those trustline is sponsored, + // when he executes a transaction to remove all trustlines and send 0.4 XLM to another + // account, it will still fail the fee validation. + const feeState = this.#validateAndApplyFeeState({ + state: this.#cloneSimulationState(initialState), + feeSource, + fee, + }); + + return operations.reduce( + (stack, op, opIndex) => { + const beforeState = stack[stack.length - 1]; + if (beforeState === undefined) { + throw new TransactionValidationException( + 'Simulation failed: missing state snapshot', + ); + } + + // Each stack entry is an independent snapshot: we clone before apply. + const state = this.#cloneSimulationState(beforeState); + this.#validateOP({ + op, + opIndex, + state, + txSource, + scope, + operations, + }); + this.#applyOP({ op, state, txSource, scope, opIndex }); + stack.push(state); + return stack; + }, + [feeState], + ); + } + + #preflightValidation( + ops: Operation[], + account: OnChainAccount, + transaction: Transaction, + options?: TransactionSimulatorOptions, + ): asserts ops is SupportedOPType[] { + const { expectedOPTypes = [] } = options ?? {}; + + // Ensure the transaction scope matches the account scope. + assertTransactionScope(transaction, account.scope); + // Envelope must involve this wallet as source or fee source (API XDR or in-app builds). + // TODO: we may need to relax it in future when we support fee payment by other account. + assertTransactionSourceAccount(transaction, account.accountId); + + const expectedOPTypeSet = new Set(expectedOPTypes); + const supportedOPTypeSet = new Set( + Object.values(SupportedOperations), + ); + + this.#assertOPLength(ops); + this.#assertInvokeHostFunctionSoleOP(transaction); + + for (const op of ops) { + this.#assertSupportedOP(op, supportedOPTypeSet); + this.#assertExpectedOP(op, expectedOPTypeSet); + } + } + + #buildInitialState( + sourceAccount: OnChainAccount, + options?: TransactionSimulatorOptions, + ): SimulationState { + const { preloadedAccounts, preloadedTokenBalance } = options ?? {}; + const sourceAccountState = this.#buildAccountState(sourceAccount); + const accounts = new Map([[sourceAccount.accountId, sourceAccountState]]); + + if (preloadedAccounts !== undefined && preloadedAccounts.length > 0) { + for (const account of preloadedAccounts) { + if (!accounts.has(account.accountId)) { + accounts.set(account.accountId, this.#buildAccountState(account)); + } + } + } + + let simulationPreloadedTokenBalance: SimulationState['preloadedTokenBalance']; + if (preloadedTokenBalance !== undefined) { + const preloadedTokenBalanceMap = new Map< + Sep41TokenBalanceMapKey, + BigNumber + >(); + + entries(preloadedTokenBalance).forEach(([accountId, balancesByAsset]) => { + entries(balancesByAsset).forEach(([assetId, balance]) => { + preloadedTokenBalanceMap.set( + toSep41TokenBalanceMapKey(accountId, assetId), + balance, + ); + }); + }); + + simulationPreloadedTokenBalance = + preloadedTokenBalanceMap.size > 0 + ? preloadedTokenBalanceMap + : undefined; + } + + return { + accounts, + preloadedTokenBalance: simulationPreloadedTokenBalance, + }; + } + + #buildAccountState(account: OnChainAccount): AccountState { + const trustlines = new Map(); + + for (const assetId of account.classicTrustlineAssetIds) { + const row = account.getAsset(assetId); + if (row === undefined) { + continue; + } + const { limit } = row; + if (limit === undefined) { + continue; + } + trustlines.set(assetId, { + balance: row.balance, + limit, + authorized: row.authorized !== false, + sponsored: row.sponsored === true, + }); + } + + return { + nativeRawBalance: account.nativeRawBalance, + subentryCount: account.subentryCount, + numSponsoring: account.numSponsoring, + numSponsored: account.numSponsored, + trustlines, + }; + } + + #assertInvokeHostFunctionSoleOP(transaction: Transaction): void { + if (transaction.hasInvokeHostFunction && transaction.operationCount !== 1) { + throw new InvalidInvokeContractStructureException(); + } + } + + #assertSupportedOP( + op: Operation, + supportedOPTypeSet: Set, + ): asserts op is SupportedOPType { + if (!supportedOPTypeSet.has(op.type)) { + throw new UnsupportedOperationTypeException(op.type); + } + } + + #assertExpectedOP(op: Operation, types: Set): void { + if (types.size > 0 && !types.has(op.type)) { + throw new TransactionValidationException( + `Unexpected operation type ${op.type}, expected one of: ${Array.from(types).join(', ')}`, + ); + } + } + + #assertOPLength( + ops: Operation[], + ): asserts ops is [Operation, ...Operation[]] { + if (ops.length === 0) { + throw new TransactionValidationException( + `Transaction must have at least one operation`, + ); + } + } + + #validateAndApplyFeeState(params: { + state: SimulationState; + feeSource: string; + fee: BigNumber; + }): SimulationState { + // Assume the state is cloned beforehand + const { state, feeSource, fee } = params; + // it is possible that the transaction fee source is different than the wallet user, + // if the transaction is passed from external, we dont support it yet, + // hence `getAccount` will throw an error. + const feePayer = getAccount(state, feeSource); + + const spendable = getSpendableNative(feePayer); + if (spendable.isLessThan(fee)) { + throw new InsufficientBalanceToCoverFeeException( + spendable.toString(), + fee.toString(), + ); + } + + // assign new native raw balance to the fee payer in the cloned state + feePayer.nativeRawBalance = feePayer.nativeRawBalance.minus(fee); + return state; + } + + #validateOP(params: { + op: SupportedOPType; + opIndex: number; + state: SimulationState; + txSource: string; + scope: KnownCaip2ChainId; + operations: readonly Operation[]; + }): void { + const { op, opIndex, state, txSource, scope, operations } = params; + + this.#operationSimulator[op.type].validate( + { + state, + txSource, + scope, + opIndex, + }, + op, + operations, + ); + } + + #applyOP(params: { + op: SupportedOPType; + state: SimulationState; + txSource: string; + scope: KnownCaip2ChainId; + opIndex: number; + }): SimulationState { + const { op, state, txSource, scope, opIndex } = params; + // the state will pass by reference, so the changes will be reflected in the original state + this.#operationSimulator[op.type].apply( + { state, txSource, scope, opIndex }, + op, + ); + // return the state after the operation is applied + return state; + } + + #cloneSimulationState(state: SimulationState): SimulationState { + const accounts = new Map(); + for (const [accountId, accountState] of state.accounts) { + accounts.set(accountId, this.#cloneAccountState(accountState)); + } + const tokenBalances = state.preloadedTokenBalance; + let preloadedTokenBalance: SimulationState['preloadedTokenBalance']; + if (tokenBalances !== undefined && tokenBalances.size > 0) { + const cloned = new Map(); + for (const [key, balance] of tokenBalances) { + cloned.set(key, new BigNumber(balance.toString())); + } + preloadedTokenBalance = cloned; + } + return { + accounts, + preloadedTokenBalance, + }; + } + + #cloneAccountState(accountState: AccountState): AccountState { + const trustlines = new Map(); + const { nativeRawBalance, subentryCount, numSponsoring, numSponsored } = + accountState; + for (const [assetId, trustline] of accountState.trustlines) { + trustlines.set(assetId, { + balance: new BigNumber(trustline.balance.toString()), + limit: new BigNumber(trustline.limit.toString()), + authorized: trustline.authorized, + sponsored: trustline.sponsored, + }); + } + return { + nativeRawBalance: new BigNumber(nativeRawBalance.toString()), + subentryCount, + numSponsoring, + numSponsored, + trustlines, + }; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts index 87a06ad7..2ecddd54 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts @@ -39,6 +39,7 @@ export const createMockTransactionService = () => { ), cache: createMemoryCache().cache, networkService, + transactionBuilder, }); const transactionRepositorySaveSpy = jest.spyOn( diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts index 4f016814..8a8ab9fc 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts @@ -178,3 +178,12 @@ export class InvalidInvokeContractStructureException extends TransactionValidati super(`Invoke host function transaction must have exactly one operation`); } } +/** + * Thrown when the keyring transaction builder fails to create a transaction. + */ +export class KeyringTransactionBuilderException extends Error { + constructor(message: string) { + super(message); + this.name = 'KeyringTransactionBuilderException'; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/index.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/index.ts index 406766c0..3778432a 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/index.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/index.ts @@ -4,3 +4,5 @@ export * from './Transaction'; export * from './TransactionBuilder'; export * from './TransactionRepository'; export * from './TransactionService'; +export * from './TransactionSimulator'; +export * from './KeyringTransactionBuilder'; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/api.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/api.ts new file mode 100644 index 00000000..30455ec0 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/api.ts @@ -0,0 +1,72 @@ +import type { Operation } from '@stellar/stellar-sdk'; + +import type { + KnownCaip19ClassicAssetId, + KnownCaip19Sep41AssetId, + KnownCaip2ChainId, +} from '../../../api'; + +export type Sep41TokenBalanceMapKey = `${string}-${KnownCaip19Sep41AssetId}`; + +/** + * Trustline row for simulation. `sponsored` mirrors Horizon: non-empty `balance.sponsor` means reserve is sponsored. + */ +export type TrustlineState = { + balance: BigNumber; + limit: BigNumber; + /** + * Horizon `is_authorized`: when false, the account cannot send or receive this credit asset + * (issuer auth required / revoked). + */ + authorized: boolean; + /** When true, this line's reserve is counted in the account's `numSponsored` (not self-paid). */ + sponsored: boolean; +}; + +/** + * Per-account view used for ordered (stack-based) simulation of classic operations. + * Amounts are in stroops / smallest units; trustline limit and balance match Horizon semantics. + */ +export type AccountState = { + nativeRawBalance: BigNumber; + subentryCount: number; + numSponsoring: number; + numSponsored: number; + trustlines: Map; +}; + +/** + * Global simulation: keyed by account id (G… only; muxed ids resolved to base account). + */ +export type SimulationState = { + /** + * Map of account id to account state. + */ + accounts: Map; + /** + * Optional map for preloaded SEP-41 token balances. + */ + preloadedTokenBalance?: Map; +}; + +/** + * Context for validating one classic operation against the current simulation snapshot. + */ +export type Context = { + state: SimulationState; + txSource: string; + scope: KnownCaip2ChainId; + opIndex: number; +}; + +/** + * Validates and applies a single supported classic operation against {@link SimulationState}. + */ +export type OperationSimulator = { + validate( + ctx: Context, + op: Operation, + allOperations?: readonly Operation[], + ): void; + apply(ctx: Context, op: Operation): void; +}; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/index.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/index.ts new file mode 100644 index 00000000..25c07818 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/index.ts @@ -0,0 +1,3 @@ +export type * from './api'; +export * from './simulators'; +export * from './utils'; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts new file mode 100644 index 00000000..ffff9f65 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts @@ -0,0 +1,411 @@ +import type { Operation } from '@stellar/stellar-sdk'; +import { Asset } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import type { OperationSimulator, Context, AccountState } from './api'; +import { + getAccount, + effectiveSource, + getSpendableNative, + tryParseSep41TransferInvoke, + toSep41TokenBalanceMapKey, +} from './utils'; +import type { + KnownCaip19ClassicAssetId, + KnownCaip19Slip44Id, + KnownCaip2ChainId, +} from '../../../api'; +import { BASE_RESERVE_STROOPS, MAX_INT64 } from '../../../constants'; +import { + getSlip44AssetId, + isSlip44Id, + toCaip19ClassicAssetId, + toSmallestUnit, +} from '../../../utils'; +import { + InsufficientBalanceException, + InsufficientBalanceToCoverBaseReserveException, + InvalidAmountForCreateAccountException, + InvalidTrustlineException, + RemoveTrustlineWithNonZeroBalanceException, + TransactionValidationException, + TrustlineNotAuthorizedException, + TrustlineNotFoundException, + UpdateTrustlineException, +} from '../exceptions'; + +export class PaymentOPSimulator implements OperationSimulator { + validate(ctx: Context, op: Operation.Payment): void { + const payment = op; + const { opIndex } = ctx; + const { assetId, payAmt, source, dest, sourceId, destId } = + this.#getContextData(ctx, op); + + if (payment.amount === undefined || payment.amount === null) { + throw new TransactionValidationException( + `Payment operation at index ${opIndex} has no amount`, + ); + } + + // verify if the asset is a native asset and if the source has enough balance + if (isSlip44Id(assetId)) { + const spendable = getSpendableNative(source); + if (spendable.isLessThan(payAmt)) { + throw new InsufficientBalanceException( + spendable.toString(), + payAmt.toString(), + ); + } + const newDestNative = dest.nativeRawBalance.plus(payAmt); + if (newDestNative.isGreaterThan(new BigNumber(MAX_INT64))) { + throw new TransactionValidationException( + 'Payment would exceed maximum int64 balance for destination', + ); + } + return; + } + + // verify if the trustline exists and if the source has enough balance + const line = source.trustlines.get(assetId); + if (line === undefined) { + throw new TrustlineNotFoundException(assetId, sourceId); + } + + if (!line.authorized) { + throw new TrustlineNotAuthorizedException(assetId, sourceId); + } + + if (line.balance.isLessThan(payAmt)) { + throw new InsufficientBalanceException( + line.balance.toString(), + payAmt.toString(), + ); + } + + // verify if the destination has trustline and the receiving amount not exceed the trustline limit + const destLine = dest.trustlines.get(assetId); + if (destLine === undefined) { + throw new TrustlineNotFoundException(assetId, destId); + } + + if (!destLine.authorized) { + throw new TrustlineNotAuthorizedException(assetId, destId); + } + + const newBalance = destLine.balance.plus(payAmt); + if (newBalance.isGreaterThan(destLine.limit)) { + throw new TransactionValidationException( + `Payment would exceed trustline limit for asset ${assetId} on destination`, + ); + } + } + + apply(ctx: Context, op: Operation.Payment): void { + const { assetId, payAmt, source, dest } = this.#getContextData(ctx, op); + + if (isSlip44Id(assetId)) { + source.nativeRawBalance = source.nativeRawBalance.minus(payAmt); + dest.nativeRawBalance = dest.nativeRawBalance.plus(payAmt); + return; + } + + const srcLine = source.trustlines.get(assetId); + const destLine = dest.trustlines.get(assetId); + if (srcLine !== undefined) { + srcLine.balance = srcLine.balance.minus(payAmt); + } + if (destLine !== undefined) { + destLine.balance = destLine.balance.plus(payAmt); + } + } + + #getContextData( + ctx: Context, + op: Operation.Payment, + ): { + sourceId: string; + destId: string; + payAmt: BigNumber; + assetId: KnownCaip19ClassicAssetId | KnownCaip19Slip44Id; + source: AccountState; + dest: AccountState; + } { + const { txSource, scope, state } = ctx; + const payment = op; + const sourceId = effectiveSource(payment, txSource); + const destId = this.#paymentDestinationAccountId(payment); + const payAmt = toSmallestUnit(new BigNumber(payment.amount)); + const assetId = this.#paymentAssetToId(payment, scope); + const source = getAccount(state, sourceId); + const dest = getAccount(state, destId); + return { sourceId, destId, payAmt, assetId, source, dest }; + } + + #paymentAssetToId( + op: Operation.Payment, + scope: KnownCaip2ChainId, + ): KnownCaip19ClassicAssetId | KnownCaip19Slip44Id { + const { asset } = op; + if (asset instanceof Asset) { + if (asset.isNative()) { + return getSlip44AssetId(scope); + } + return toCaip19ClassicAssetId(scope, asset.getCode(), asset.getIssuer()); + } + throw new TransactionValidationException( + 'Only native or alphanum Asset payments are supported for sequential validation', + ); + } + + #paymentDestinationAccountId(op: Operation.Payment): string { + const { destination } = op; + if (typeof destination === 'string') { + return destination; + } + throw new TransactionValidationException( + 'Unsupported payment destination type', + ); + } +} + +export class CreateAccountOPSimulator implements OperationSimulator { + validate(ctx: Context, op: Operation.CreateAccount): void { + const { state, opIndex } = ctx; + if (typeof op.destination !== 'string' || op.destination.length === 0) { + throw new TransactionValidationException( + `CreateAccount at index ${opIndex} has no destination`, + ); + } + const { source, destId, startingBalance } = this.#getContextData(ctx, op); + + // Minimum starting balance is 1 XLM if we are not sponsoring the account + const minCreate = toSmallestUnit(new BigNumber(1)); + + if (startingBalance.isLessThan(minCreate)) { + throw new InvalidAmountForCreateAccountException( + startingBalance.toString(), + ); + } + + const spendable = getSpendableNative(source); + if (spendable.isLessThan(startingBalance)) { + throw new InsufficientBalanceException( + spendable.toString(), + startingBalance.toString(), + ); + } + + const existing = state.accounts.get(destId); + if (existing !== undefined) { + throw new TransactionValidationException( + `CreateAccount destination already exists in simulation: ${destId}`, + ); + } + } + + apply(ctx: Context, op: Operation.CreateAccount): void { + const { state } = ctx; + const { source, destId, startingBalance } = this.#getContextData(ctx, op); + + source.nativeRawBalance = source.nativeRawBalance.minus(startingBalance); + + state.accounts.set(destId, { + nativeRawBalance: startingBalance, + subentryCount: 0, + numSponsoring: 0, + numSponsored: 0, + trustlines: new Map(), + }); + } + + #getContextData( + ctx: Context, + op: Operation.CreateAccount, + ): { source: AccountState; destId: string; startingBalance: BigNumber } { + const { txSource, state } = ctx; + const funderId = effectiveSource(op, txSource); + const destId = op.destination; + const startingBalance = toSmallestUnit(new BigNumber(op.startingBalance)); + const source = getAccount(state, funderId); + return { source, destId, startingBalance }; + } +} + +export class ChangeTrustOPSimulator implements OperationSimulator { + validate(ctx: Context, op: Operation.ChangeTrust): void { + const { opIndex } = ctx; + if ( + op.limit === undefined || + op.limit === null || + op.line === undefined || + op.line === null + ) { + throw new InvalidTrustlineException( + `ChangeTrust at index ${opIndex} is incomplete`, + ); + } + + const { source, sourceId, assetId, trustlineLimit } = this.#getContextData( + ctx, + op, + ); + + const sourceTrustline = source.trustlines.get(assetId); + const isRemove = trustlineLimit.isZero(); + + // if it is removing an existing trustline, verify if the trustline exists and if the balance is zero + if (isRemove) { + if (sourceTrustline === undefined) { + throw new TrustlineNotFoundException(assetId, sourceId); + } + if (sourceTrustline.balance.isGreaterThan(0)) { + throw new RemoveTrustlineWithNonZeroBalanceException( + `Cannot remove trustline for ${assetId}: balance must be zero`, + ); + } + return; + } + + // if it is adding a new trustline, verify if the source has enough balance to cover the base reserve + if (sourceTrustline === undefined) { + const spendable = getSpendableNative(source); + const reserve = new BigNumber(BASE_RESERVE_STROOPS); + if (spendable.isLessThan(reserve)) { + throw new InsufficientBalanceToCoverBaseReserveException( + spendable.toString(), + reserve.toString(), + ); + } + return; + } + + // if it is updating an existing trustline, verify if the limit is lower than the current balance + if (trustlineLimit.isLessThan(sourceTrustline.balance)) { + throw new UpdateTrustlineException( + `ChangeTrust limit cannot be below current balance for ${assetId}`, + ); + } + } + + apply(ctx: Context, op: Operation.ChangeTrust): void { + const { source, assetId, trustlineLimit } = this.#getContextData(ctx, op); + + const sourceTrustline = source.trustlines.get(assetId); + const isRemove = trustlineLimit.isZero(); + + // if it is removing an existing trustline, we need to update the source account subentry and numSponsored for spendable balance calculation: + // - decrease the subentry count by 1 + // - decrease the numSponsored by 1 if the trustline is sponsored + if (isRemove) { + // Safe guard + if (sourceTrustline !== undefined) { + source.subentryCount = Math.max(0, source.subentryCount - 1); + if (sourceTrustline.sponsored) { + source.numSponsored = Math.max(0, source.numSponsored - 1); + } + source.trustlines.delete(assetId); + } + return; + } + + // if it is adding a new trustline, we need to update the source account subentry for spendable balance calculation: + // - increase the subentry count by 1 + if (sourceTrustline === undefined) { + source.trustlines.set(assetId, { + balance: new BigNumber(0), + limit: trustlineLimit, + // assume we always authorize the trustline for source account. + authorized: true, + // assume we only support enable the trustline for source account, but not sponsor to other accounts + sponsored: false, + }); + source.subentryCount += 1; + return; + } + + sourceTrustline.limit = trustlineLimit; + } + + #getContextData( + ctx: Context, + op: Operation.ChangeTrust, + ): { + source: AccountState; + sourceId: string; + assetId: KnownCaip19ClassicAssetId; + trustlineLimit: BigNumber; + } { + const { txSource, state, scope } = ctx; + const sourceId = effectiveSource(op, txSource); + const source = getAccount(state, sourceId); + const asset = op.line; + if (!(asset instanceof Asset)) { + throw new InvalidTrustlineException( + `ChangeTrust line must be Stellar SAC Asset or Stellar Classic Asset, ${asset.constructor.name} is not supported`, + ); + } + + const assetId = toCaip19ClassicAssetId( + scope, + asset.getCode(), + asset.getIssuer(), + ); + + // Operation limit is in human-readable form; convert to stroops like Horizon balances. + const limit = new BigNumber(op.limit); + const trustlineLimit = limit.isZero() + ? new BigNumber(0) + : toSmallestUnit(limit); + + return { source, sourceId, assetId, trustlineLimit }; + } +} + +export class InvokeHostFunctionOPSimulator implements OperationSimulator { + validate(ctx: Context, op: Operation.InvokeHostFunction): void { + const { txSource, state, scope } = ctx; + const sourceId = effectiveSource(op, txSource); + // Contract transaction should always be sourced from the user wallet account + // `getAccount` will throw if the source account is not found in the simulation state, + // hence, it should protect if the actual source account is not same as user wallet account + getAccount(state, sourceId); + + // handle the SEP-41 transfer operation + const parsed = tryParseSep41TransferInvoke(op, scope); + if (parsed === null) { + // Not a SEP-41 `transfer`; skip contract-token balance validation (other invokes ignore preloaded map). + return; + } + + const { fromAccountId, assetId, amount } = parsed; + + // safe guard to prevent the from account is different from the source account. + if (fromAccountId !== sourceId) { + throw new TransactionValidationException( + 'SEP-41 transfer requires the sender account to be the same as the source account', + ); + } + + const sep41TokenBalanceMap = state.preloadedTokenBalance; + const onChainBalance = sep41TokenBalanceMap?.get( + toSep41TokenBalanceMapKey(sourceId, assetId), + ); + if (onChainBalance === undefined) { + throw new TransactionValidationException( + 'SEP-41 transfer requires a preloaded token balance for the sender and contract', + ); + } + + if (onChainBalance.isLessThan(amount)) { + throw new InsufficientBalanceException( + onChainBalance.toString(), + amount.toString(), + ); + } + } + + apply(_ctx: Context, _op: Operation.InvokeHostFunction): void { + // InvokeHostFunction is a single operation transaction, + // hence we don't need to apply any balance or trustline effects for Soroban invoke during simulation. + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/utils.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/utils.ts new file mode 100644 index 00000000..c9a8a257 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/utils.ts @@ -0,0 +1,133 @@ +import type { Operation } from '@stellar/stellar-sdk'; +import { Address, scValToNative } from '@stellar/stellar-sdk'; + +import { TransactionValidationException } from '../exceptions'; +import type { + AccountState, + Sep41TokenBalanceMapKey, + SimulationState, +} from './api'; +import type { KnownCaip19Sep41AssetId, KnownCaip2ChainId } from '../../../api'; +import { toCaip19Sep41AssetId } from '../../../utils'; +import { parseScValToNative } from '../../network/utils'; +import { calculateSpendableBalance } from '../../on-chain-account/utils'; +/** + * Gets the effective source account ID from the operation. + * Returns the source account ID from the operation if it is set, otherwise returns the transaction source. + * + * @param op - The operation. + * @param txSource - The transaction source. + * @returns Effective source account public key. + */ +export function effectiveSource(op: Operation, txSource: string): string { + return op.source ?? txSource; +} + +/** + * Calculates the spendable native balance for an account. + * + * @param account - The account state. + * @returns Spendable native balance in stroops. + */ +export function getSpendableNative(account: AccountState): BigNumber { + return calculateSpendableBalance({ + nativeBalance: account.nativeRawBalance, + subentryCount: account.subentryCount, + numSponsoring: account.numSponsoring, + numSponsored: account.numSponsored, + }); +} + +/** + * Gets the account state from the simulation state. + * + * @param state - The simulation state. + * @param accountId - The account ID. + * @returns Mutable account state for the given id. + */ +export function getAccount( + state: SimulationState, + accountId: string, +): AccountState { + const account = state.accounts.get(accountId); + if (account === undefined) { + throw new TransactionValidationException( + `Account not loaded: ${accountId}`, + ); + } + return account; +} + +export type ParsedSep41TransferInvoke = { + /** + * Canonical SEP-41 CAIP-19 id for the **invoked contract** — not a separate XDR field. + * Same encoding as {@link TransactionBuilder.sep41Transfer}: `toCaip19Sep41AssetId(scope, contractId)`. + */ + assetId: KnownCaip19Sep41AssetId; + fromAccountId: string; + amount: BigNumber; +}; + +/** + * When the op is a single-contract `transfer(from, to, amount)` (SEP-41 token shape), + * reads **contract address** from the invoke target and **derives** the CAIP-19 asset id with `scope` + * (the envelope never embeds a CAIP string — only `C…` like `Contract.call`). + * {@link TransactionBuilder.sep41Transfer} is the same function that is used to build the transaction. + * + * @param op - Parsed `invokeHostFunction` operation. + * @param scope - CAIP-2 chain id (must match the envelope network when matching preload keys). + * @returns Parsed transfer metadata, or `null` if the shape does not match. + */ +export function tryParseSep41TransferInvoke( + op: Operation.InvokeHostFunction, + scope: KnownCaip2ChainId, +): ParsedSep41TransferInvoke | null { + const { func } = op; + if (!func || func.switch().name !== 'hostFunctionTypeInvokeContract') { + return null; + } + const ic = func.invokeContract(); + // if it is not a transfer function, we can skip parsing the transfer metadata + if (ic.functionName().toString() !== 'transfer') { + return null; + } + + const args = ic.args(); + if (args.length !== 3 || args[0] === undefined || args[2] === undefined) { + throw new TransactionValidationException( + 'Invalid transfer function arguments', + ); + } + // First argument is the from address + const fromArg = args[0]; + // Third argument is the amount + const amountArg = args[2]; + + const contractAddr = Address.fromScAddress(ic.contractAddress()).toString(); + + const fromNative = scValToNative(fromArg); + const amountNative = scValToNative(amountArg); + if (typeof fromNative !== 'string' || !fromNative.startsWith('G')) { + throw new TransactionValidationException('Invalid from address'); + } + + return { + assetId: toCaip19Sep41AssetId(scope, contractAddr), + fromAccountId: fromNative, + amount: parseScValToNative(amountNative), + }; +} + +/** + * Map key for {@link SimulationState.preloadedTokenBalance}: `accountId` and SEP-41 `assetId` (order matters). + * + * @param accountId - Stellar account id of the token holder (`G…`). + * @param assetId - SEP-41 CAIP-19 asset id for the token contract. + * @returns Opaque composite map key. + */ +export function toSep41TokenBalanceMapKey( + accountId: string, + assetId: KnownCaip19Sep41AssetId, +): Sep41TokenBalanceMapKey { + return `${accountId}-${assetId}`; +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts index f18e4d70..11a7d540 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts @@ -96,3 +96,22 @@ export function assertAccountInvolvesTransaction( 'Transaction does not involve this wallet account', ); } + +/** + * Ensures a CAIP asset identifier belongs to the caller-provided scope. + * + * @param assetId - CAIP-19 or slip44 asset id. + * @param expectedScope - CAIP-2 chain ID expected by caller. + * @throws {TransactionValidationException} When the asset chain id differs from `expectedScope`. + */ +export function assertAssetScopeMatch( + assetId: KnownCaip19AssetIdOrSlip44Id, + expectedScope: KnownCaip2ChainId, +): void { + const { chainId } = parseCaipAssetType(assetId); + if (chainId !== String(expectedScope)) { + throw new TransactionValidationException( + `Asset ${assetId} scope does not match expected scope ${expectedScope}`, + ); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 4b78cc22..cc53ac3a 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -22,6 +22,10 @@ import { updateInterfaceIfExists, } from '../../utils'; import { STELLAR_IMAGE } from '../images/icon'; +import type { ConfirmSignChangeTrustOptInProps } from './views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn'; +import { ConfirmSignChangeTrustOptIn } from './views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn'; +import type { ConfirmSignChangeTrustOptOutProps } from './views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut'; +import { ConfirmSignChangeTrustOptOut } from './views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut'; import { ConfirmSignMessage, type ConfirmSignMessageProps, @@ -211,9 +215,17 @@ export class ConfirmationUXController { ): ComponentOrElement { switch (interfaceKey) { case ConfirmationInterfaceKey.ChangeTrustlineOptIn: - throw new Error('ChangeTrustlineOptIn is not supported'); + return ( + + ); case ConfirmationInterfaceKey.ChangeTrustlineOptOut: - throw new Error('ChangeTrustlineOptOut is not supported'); + return ( + + ); case ConfirmationInterfaceKey.SignTransaction: return ( { + const t = i18n(locale); + const { address } = account; + return ( + + + + {null} + + {t('confirmation.signChangeTrustOptIn.title', { + asset: assetMetadata.symbol, + })} + + + {/* TODO: Replace with the asset icon, dummy for testing */} + + + {null} + {null} + + +
+ {origin ? ( + + + + {t('confirmation.origin')} + + + + + + {origin} + + ) : null} + {/* From */} + + + {t('confirmation.account')} + +
+ + + + {t('confirmation.asset')} + + + {/* TODO: Replace with the asset icon, dummy for testing */} + + + + + + {t('confirmation.network')} + + + + {getNetworkName(scope)} + + + {null} + {/* Fee Breakdown */} + +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/events.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/events.tsx new file mode 100644 index 00000000..9d2ff861 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/events.tsx @@ -0,0 +1,50 @@ +import type { + UserInputUiEventHandler, + UserInputUiEventHandlerContext, +} from '../../../../handlers/user-input/api'; +import { resolveInterface } from '../../../../utils'; + +/** + * Handles the click event for the cancel button. + * + * @param options - The user input handler context from `onUserInput`. + * @returns A promise that resolves when the interface has been updated. + */ +async function onCancelButtonClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id } = options; + await resolveInterface(id, false); +} + +/** + * Handles the click event for the confirm button. + * + * @param options - The user input handler context from `onUserInput`. + * @returns A promise that resolves when the interface has been updated. + */ +async function onConfirmButtonClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id } = options; + await resolveInterface(id, true); +} + +export enum ConfirmSignChangeTrustOptInFormNames { + Cancel = 'confirm-sign-change-trust-opt-in-cancel', + Confirm = 'confirm-sign-change-trust-opt-in-confirm', +} + +/** + * Create event handlers bound to a SnapClient instance. + * + * @returns Object containing event handlers. + */ +export function createEventHandlers(): Record { + return { + [ConfirmSignChangeTrustOptInFormNames.Cancel]: async (options) => + onCancelButtonClick(options), + [ConfirmSignChangeTrustOptInFormNames.Confirm]: async (options) => + onConfirmButtonClick(options), + }; +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx new file mode 100644 index 00000000..03c80e43 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx @@ -0,0 +1,150 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { + Address, + Box, + Button, + Container, + Footer, + Heading, + Icon, + Image, + Section, + Text as SnapText, + Tooltip, +} from '@metamask/snaps-sdk/jsx'; +import { parseCaipAssetType } from '@metamask/utils'; + +import { ConfirmSignChangeTrustOptOutFormNames } from './events'; +import type { StellarKeyringAccount } from '../../../../services/account'; +import type { StellarAssetMetadata } from '../../../../services/asset-metadata'; +import { i18n } from '../../../../utils'; +import { STELLAR_IMAGE } from '../../../images/icon'; +import usdtSvg from '../../../images/usdt.svg'; +import type { + ConfirmationBaseProps, + ContextWithPrices, + FeeData, +} from '../../api'; +import { FetchStatus } from '../../api'; +import { Asset, AssetIcon, FeeRow } from '../../components'; +import { + getAccountName, + getClassicAssetExplorerUrl, + getNetworkName, +} from '../../utils'; + +export type ConfirmSignChangeTrustOptOutProps = ConfirmationBaseProps & + ContextWithPrices & { + account: StellarKeyringAccount; + assetMetadata: StellarAssetMetadata; + feeData: FeeData; + }; + +export const ConfirmSignChangeTrustOptOut = ({ + account, + scope, + assetMetadata, + locale, + networkImage, + feeData, + tokenPrices, + origin, + preferences, + tokenPricesFetchStatus = FetchStatus.Initial, +}: ConfirmSignChangeTrustOptOutProps): ComponentOrElement => { + const t = i18n(locale); + const { address } = account; + return ( + + + + {null} + + {t('confirmation.signChangeTrustOptOut.title', { + asset: assetMetadata.symbol, + })} + + + {/* TODO: Replace with the asset icon, dummy for testing */} + + + {null} + {null} + + +
+ {origin ? ( + + + + {t('confirmation.origin')} + + + + + + {origin} + + ) : null} + {/* From */} + + + {t('confirmation.account')} + +
+ + + + {t('confirmation.asset')} + + + {/* TODO: Replace with the asset icon, dummy for testing */} + + + + + + {t('confirmation.network')} + + + + {getNetworkName(scope)} + + + {null} + {/* Fee Breakdown */} + +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/events.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/events.tsx new file mode 100644 index 00000000..21317059 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/events.tsx @@ -0,0 +1,50 @@ +import type { + UserInputUiEventHandler, + UserInputUiEventHandlerContext, +} from '../../../../handlers/user-input/api'; +import { resolveInterface } from '../../../../utils'; + +/** + * Handles the click event for the cancel button. + * + * @param options - The user input handler context from `onUserInput`. + * @returns A promise that resolves when the interface has been updated. + */ +async function onCancelButtonClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id } = options; + await resolveInterface(id, false); +} + +/** + * Handles the click event for the confirm button. + * + * @param options - The user input handler context from `onUserInput`. + * @returns A promise that resolves when the interface has been updated. + */ +async function onConfirmButtonClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id } = options; + await resolveInterface(id, true); +} + +export enum ConfirmSignChangeTrustOptOutFormNames { + Cancel = 'confirm-sign-change-trust-opt-out-cancel', + Confirm = 'confirm-sign-change-trust-opt-out-confirm', +} + +/** + * Create event handlers bound to a SnapClient instance. + * + * @returns Object containing event handlers. + */ +export function createEventHandlers(): Record { + return { + [ConfirmSignChangeTrustOptOutFormNames.Cancel]: async (options) => + onCancelButtonClick(options), + [ConfirmSignChangeTrustOptOutFormNames.Confirm]: async (options) => + onConfirmButtonClick(options), + }; +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/images/usdt.svg b/merged-packages/stellar-wallet-snap/src/ui/images/usdt.svg new file mode 100644 index 00000000..9648c2fe --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/images/usdt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/merged-packages/stellar-wallet-snap/src/utils/snap.ts b/merged-packages/stellar-wallet-snap/src/utils/snap.ts index 890756f9..29920d25 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/snap.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/snap.ts @@ -21,7 +21,7 @@ import { type Serializable, serialize, deserialize } from './serialization'; */ export function getSnapProvider(): SnapsProvider { // snap is a global variable provided by the Snap SDK - return snap; + return snap as unknown as SnapsProvider; } /** From 59a7282b8fcf2a636777508d95bde0f78f053030 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:10:03 +0800 Subject: [PATCH 098/384] fix: lint --- .../src/services/transaction/KeyringTransactionBuilder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.ts index 3ffeb399..8c9419f7 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.ts @@ -163,7 +163,7 @@ export class KeyringTransactionBuilder { }; } - #getCreateTime() { + #getCreateTime(): number { return Math.floor(Date.now() / 1000); // seconds since epoch } } From 47622fa8f3fdae1d8ad2f2af30c0ac47d9054567 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Wed, 22 Apr 2026 17:25:29 +0200 Subject: [PATCH 099/384] feat: show fees/assets in sign-tx modal via ConfirmationUXController --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../stellar-wallet-snap/src/context.ts | 1 + .../handlers/keyring/signTransaction.test.ts | 272 ++++++++++++++++++ .../src/handlers/keyring/signTransaction.ts | 38 ++- .../src/services/transaction/utils.ts | 76 +++++ .../src/ui/confirmation/controller.tsx | 45 ++- .../src/ui/confirmation/utils.ts | 54 ++++ .../ConfirmSignMessage/ConfirmSignMessage.tsx | 18 +- .../views/ConfirmSignMessage/render.test.tsx | 175 ----------- .../views/ConfirmSignMessage/render.tsx | 64 ----- .../ConfirmSignTransaction.tsx | 147 ++++++---- .../ConfirmSignTransaction/render.test.tsx | 167 ----------- .../views/ConfirmSignTransaction/render.tsx | 56 ---- 13 files changed, 575 insertions(+), 540 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx delete mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.tsx delete mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.test.tsx delete mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 09a963af..06ff83f9 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "7FGSiokz1rpbF9awWaD5gb/E6XZGxGhm7yGIG75NLY8=", + "shasum": "7Ka72Qi4qL2Pudnz5k4XAG/Ci8EMvem1bAO0CkKkRFY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 9f4396ec..7b9cba09 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -99,6 +99,7 @@ const signTransactionHandler = new SignTransactionHandler({ walletService, transactionBuilder, transactionService, + confirmationUIController, }); const signMessageHandler = new SignMessageHandler({ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts new file mode 100644 index 00000000..6ef5ac07 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts @@ -0,0 +1,272 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; +import { Keypair, Networks } from '@stellar/stellar-sdk'; + +import { MultichainMethod, type SignTransactionRequest } from './api'; +import { SignTransactionHandler } from './signTransaction'; +import { KnownCaip2ChainId } from '../../api'; +import type { StellarKeyringAccount } from '../../services/account'; +import { AccountService } from '../../services/account'; +import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; +import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; +import type { TransactionBuilder } from '../../services/transaction'; +import { + TransactionService, + OperationMapper, +} from '../../services/transaction'; +import { + buildMockClassicTransaction, + createMockTransactionService, +} from '../../services/transaction/__mocks__/transaction.fixtures'; +import { WalletService, Wallet } from '../../services/wallet'; +import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import type { ConfirmationUXController } from '../../ui/confirmation/controller'; +import { logger } from '../../utils/logger'; + +jest.mock('../../utils/logger'); + +describe('SignTransactionHandler', () => { + const keyringRequestId = '22222222-2222-4222-8222-222222222222'; + + /** + * Builds a {@link SignTransactionHandler} with mocked account/wallet resolution + * and a stubbed `ConfirmationUXController`. + * + * @returns Handler instance and the test doubles needed by each spec. + */ + function setupSignTransactionHandler(): { + handler: SignTransactionHandler; + mockAccount: StellarKeyringAccount; + wallet: Wallet; + walletKeypair: Keypair; + renderConfirmationDialog: jest.Mock; + transactionBuilder: TransactionBuilder; + transactionService: TransactionService; + } { + const walletKeypair = Keypair.random(); + const wallet = new Wallet(walletKeypair); + + const mockAccount = generateStellarKeyringAccount( + globalThis.crypto.randomUUID(), + wallet.address, + 'entropy-source-1', + 0, + ); + + const { accountService, onChainAccountService, walletService } = + mockOnChainAccountService(); + + jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ + account: mockAccount, + }); + + jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + + const { transactionBuilder, transactionService } = + createMockTransactionService(); + + // Default: pass-through fee (no Soroban simulation needed for classic tx). + jest + .spyOn(TransactionService.prototype, 'computingFee') + .mockImplementation(async (transaction) => transaction); + + const renderConfirmationDialog = jest.fn(); + const confirmationUIController = { + renderConfirmationDialog, + } as Pick< + ConfirmationUXController, + 'renderConfirmationDialog' + > as unknown as ConfirmationUXController; + + const handler = new SignTransactionHandler({ + logger, + accountService, + onChainAccountService, + walletService, + transactionBuilder, + transactionService, + confirmationUIController, + }); + + return { + handler, + mockAccount, + wallet, + walletKeypair, + renderConfirmationDialog, + transactionBuilder, + transactionService, + }; + } + + /** + * Builds a single-payment transaction whose source is the wallet account so it + * passes {@link assertAccountInvolvesTransaction}. + * + * @param walletAddress - The wallet's Stellar public key (`G…`). + * @returns The mock transaction. + */ + function buildPaymentTxFromWallet(walletAddress: string) { + return buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: Keypair.random().publicKey(), + asset: 'native', + amount: '10', + }, + }, + ], + { + networkPassphrase: Networks.TESTNET, + source: { accountId: walletAddress, sequence: '1' }, + }, + ); + } + + const buildRequest = (transactionXdr: string): SignTransactionRequest => ({ + id: keyringRequestId, + origin: 'https://example.com', + scope: KnownCaip2ChainId.Testnet, + account: '00000000-0000-4000-8000-000000000001', + request: { + method: MultichainMethod.SignTransaction, + params: { transaction: transactionXdr }, + }, + }); + + it('renders confirmation with fee, native price slot, and signs when accepted', async () => { + const { + handler, + mockAccount, + wallet, + renderConfirmationDialog, + transactionBuilder, + } = setupSignTransactionHandler(); + + const transaction = buildPaymentTxFromWallet(wallet.address); + const xdr = transaction.getRaw().toXDR(); + + jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); + const signSpy = jest.spyOn(wallet, 'signTransaction'); + + renderConfirmationDialog.mockResolvedValue(true); + + const request = buildRequest(xdr); + const result = await handler.handle(request); + + expect(renderConfirmationDialog).toHaveBeenCalledTimes(1); + const callArgs = renderConfirmationDialog.mock.calls[0]?.[0]; + expect(callArgs).toMatchObject({ + scope: KnownCaip2ChainId.Testnet, + origin: 'https://example.com', + interfaceKey: ConfirmationInterfaceKey.SignTransaction, + fee: transaction.totalFee.toFixed(0), + renderOptions: { loadPrice: true }, + }); + expect(callArgs.renderContext.account).toStrictEqual(mockAccount); + expect(callArgs.renderContext.readableTransaction).toStrictEqual( + new OperationMapper().mapTransaction(transaction), + ); + + // Hard-coded so a parser regression actually fails the test. + expect(callArgs.tokenPrices).toStrictEqual({ + 'stellar:testnet/slip44:148': null, + }); + + expect(signSpy).toHaveBeenCalledWith(transaction); + expect(typeof result).toBe('object'); + expect((result as { signature: string }).signature).toStrictEqual( + transaction.getRaw().toXDR(), + ); + }); + + it('seeds tokenPrices with classic-asset CAIP-19 ids alongside the native fee asset', async () => { + const { handler, wallet, renderConfirmationDialog, transactionBuilder } = + setupSignTransactionHandler(); + + const issuer = Keypair.random().publicKey(); + const transaction = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: Keypair.random().publicKey(), + asset: { code: 'USDC', issuer }, + amount: '5', + }, + }, + { + type: 'changeTrust', + params: { + asset: { code: 'USDC', issuer }, + limit: '1000', + }, + }, + ], + { + networkPassphrase: Networks.TESTNET, + source: { accountId: wallet.address, sequence: '1' }, + }, + ); + const xdr = transaction.getRaw().toXDR(); + jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); + renderConfirmationDialog.mockResolvedValue(true); + + await handler.handle(buildRequest(xdr)); + + const callArgs = renderConfirmationDialog.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + const { tokenPrices } = callArgs; + + // Classic asset CAIP-19 keyed for cron price refresh. + expect(tokenPrices).toHaveProperty( + `stellar:testnet/asset:USDC-${issuer}`, + null, + ); + // Same USDC trustline op should not duplicate the entry. + expect(Object.keys(tokenPrices)).toHaveLength(1); + }); + + it('throws UserRejectedRequestError when confirmation rejects', async () => { + const { handler, wallet, renderConfirmationDialog, transactionBuilder } = + setupSignTransactionHandler(); + + const transaction = buildPaymentTxFromWallet(wallet.address); + const xdr = transaction.getRaw().toXDR(); + + jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); + const signSpy = jest.spyOn(wallet, 'signTransaction'); + + renderConfirmationDialog.mockResolvedValue(false); + + await expect(handler.handle(buildRequest(xdr))).rejects.toThrow( + UserRejectedRequestError, + ); + expect(signSpy).not.toHaveBeenCalled(); + }); + + it('rejects invalid requests before resolving the account', async () => { + const { handler, renderConfirmationDialog } = setupSignTransactionHandler(); + + const resolveAccountSpy = jest.spyOn( + AccountService.prototype, + 'resolveAccount', + ); + + await expect( + handler.handle({ + ...buildRequest(''), + request: { + method: MultichainMethod.SignTransaction, + params: { transaction: '' }, + }, + }), + ).rejects.toThrow(/transaction/u); + + expect(resolveAccountSpy).not.toHaveBeenCalled(); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts index fab095de..83149e18 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts @@ -18,12 +18,16 @@ import type { Transaction, TransactionService, } from '../../services/transaction'; +import { OperationMapper } from '../../services/transaction'; import { assertTransactionScope, assertAccountInvolvesTransaction, + collectTransactionAssetCaipIds, } from '../../services/transaction/utils'; import type { WalletService } from '../../services/wallet'; -import { render } from '../../ui/confirmation/views/ConfirmSignTransaction/render'; +import type { ContextWithPrices } from '../../ui/confirmation/api'; +import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import type { ConfirmationUXController } from '../../ui/confirmation/controller'; import type { ILogger } from '../../utils'; export class SignTransactionHandler extends WithKeyringRequestActiveAccountResolve< @@ -34,6 +38,8 @@ export class SignTransactionHandler extends WithKeyringRequestActiveAccountResol readonly #transactionService: TransactionService; + readonly #confirmationUIController: ConfirmationUXController; + constructor({ logger, accountService, @@ -41,6 +47,7 @@ export class SignTransactionHandler extends WithKeyringRequestActiveAccountResol walletService, transactionBuilder, transactionService, + confirmationUIController, }: { logger: ILogger; accountService: AccountService; @@ -48,6 +55,7 @@ export class SignTransactionHandler extends WithKeyringRequestActiveAccountResol transactionService: TransactionService; walletService: WalletService; transactionBuilder: TransactionBuilder; + confirmationUIController: ConfirmationUXController; }) { super({ logger, @@ -60,6 +68,7 @@ export class SignTransactionHandler extends WithKeyringRequestActiveAccountResol }); this.#transactionBuilder = transactionBuilder; this.#transactionService = transactionService; + this.#confirmationUIController = confirmationUIController; } protected async _handle( @@ -105,6 +114,31 @@ export class SignTransactionHandler extends WithKeyringRequestActiveAccountResol transaction: Transaction, account: StellarKeyringAccount, ): Promise { - return (await render(request, transaction, account)) === true; + const readableTransaction = new OperationMapper().mapTransaction( + transaction, + ); + + // Seed every asset id we render so the cron refresh updates prices for all of them. + // The `as` cast bypasses superstruct typing that requires every union key. + const tokenPrices = Object.fromEntries( + collectTransactionAssetCaipIds(request.scope, readableTransaction).map( + (assetId) => [assetId, null] as const, + ), + ) as ContextWithPrices['tokenPrices']; + + return ( + (await this.#confirmationUIController.renderConfirmationDialog({ + scope: request.scope, + origin: request.origin, + interfaceKey: ConfirmationInterfaceKey.SignTransaction, + fee: readableTransaction.feeStroops, + renderContext: { + readableTransaction, + account, + }, + renderOptions: { loadPrice: true }, + tokenPrices, + })) === true + ); } } diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts index f18e4d70..9cb0b47f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts @@ -5,15 +5,21 @@ import { TransactionScopeNotMatchException, TransactionValidationException, } from './exceptions'; +import type { + ReadableOperationField, + ReadableTransactionJson, +} from './OperationMapper'; import type { Transaction } from './Transaction'; import type { KnownCaip19AssetIdOrSlip44Id, KnownCaip2ChainId, } from '../../api'; import { + getSlip44AssetId, isClassicAssetId, isSlip44Id, parseClassicAssetCodeIssuer, + toCaip19ClassicAssetId, } from '../../utils'; /** @@ -96,3 +102,73 @@ export function assertAccountInvolvesTransaction( 'Transaction does not involve this wallet account', ); } + +/** + * Maps an `OperationMapper` asset reference to its CAIP-19 id. + * + * @param scope - CAIP-2 chain of the transaction. + * @param assetReference - Either `'native'` or a classic `CODE-ISSUER` / `CODE:ISSUER` string. + * @returns The CAIP-19 id, or `null` when the reference cannot be parsed + * (e.g. liquidity pool ids that arrive on `setTrustLineFlags` / `revokeSponsorship`). + */ +export function parseOperationAssetReference( + scope: KnownCaip2ChainId, + assetReference: string, +): KnownCaip19AssetIdOrSlip44Id | null { + if (assetReference === 'native') { + return getSlip44AssetId(scope); + } + try { + const { assetCode, assetIssuer } = + parseClassicAssetCodeIssuer(assetReference); + return toCaip19ClassicAssetId(scope, assetCode, assetIssuer); + } catch { + return null; + } +} + +/** + * Pulls the asset reference string out of an `OperationMapper` row when it carries one. + * + * @param param - One field on a {@link ReadableOperationJson}. + * @returns The reference string, or `null` for rows that don't represent an asset. + */ +function getAssetReferenceFromField( + param: ReadableOperationField, +): string | null { + if (param.type === 'assetWithAmount' && Array.isArray(param.value)) { + const [reference] = param.value as [string, string]; + return reference; + } + if (param.type === 'asset' && typeof param.value === 'string') { + return param.value; + } + return null; +} + +/** + * Collects the unique CAIP-19 ids referenced by a transaction's operations. + * + * @param scope - CAIP-2 chain of the transaction. + * @param readable - Transaction summary produced by `OperationMapper`. + * @returns Deduplicated CAIP-19 ids; references that can't be resolved are skipped. + */ +export function collectTransactionAssetCaipIds( + scope: KnownCaip2ChainId, + readable: ReadableTransactionJson, +): KnownCaip19AssetIdOrSlip44Id[] { + const ids = new Set(); + for (const operation of readable.operations) { + for (const param of operation.params) { + const reference = getAssetReferenceFromField(param); + if (reference === null) { + continue; + } + const assetId = parseOperationAssetReference(scope, reference); + if (assetId !== null) { + ids.add(assetId); + } + } + } + return [...ids]; +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 4b78cc22..901a0eb1 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -40,6 +40,34 @@ type ConfirmationRenderOptions = { scanTxn?: boolean; }; +/** Common params accepted by every {@link ConfirmationUXController.renderConfirmationDialog} call. */ +type RenderConfirmationDialogCommon = { + scope: KnownCaip2ChainId; + renderContext: Props; + origin?: string; + renderOptions?: ConfirmationRenderOptions; + tokenPrices?: ContextWithPrices['tokenPrices']; +}; + +/** + * Discriminated union: confirmations that have a fee (sign transaction) + * MUST provide one; fee-less confirmations (sign message, etc.) MUST NOT. + * Prevents callers from forgetting `fee` for SignTransaction (would yield + * `feeData: {}` and crash the view at `feeData.assetId`). + */ +type RenderConfirmationDialogParams = + | (RenderConfirmationDialogCommon & { + interfaceKey: ConfirmationInterfaceKey.SignTransaction; + fee: string; + }) + | (RenderConfirmationDialogCommon & { + interfaceKey: Exclude< + ConfirmationInterfaceKey, + ConfirmationInterfaceKey.SignTransaction + >; + fee?: never; + }); + export class ConfirmationUXController { readonly #logger: ILogger; @@ -61,22 +89,17 @@ export class ConfirmationUXController { * @param params - The parameters for the render. * @param params.scope - The scope of the confirmation. * @param params.renderContext - The context for the render. - * @param params.interfaceKey - The key of the interface to render. - * @param params.fee - [Optional] The fee for the render. + * @param params.interfaceKey - The key of the interface to render. When this is + * {@link ConfirmationInterfaceKey.SignTransaction}, `fee` is required. + * @param params.fee - Fee in stroops, REQUIRED for SignTransaction, forbidden otherwise. * @param params.origin - [Optional] The origin of the confirmation. Defaults to 'metamask'. * @param params.renderOptions - [Optional] The options for the render. Defaults to {@link #defaultRenderOptions}. * @param params.tokenPrices - [Optional] The token prices for the render {@link ContextWithPrices['tokenPrices']}. * @returns A promise that resolves to the dialog result. */ - async renderConfirmationDialog(params: { - scope: KnownCaip2ChainId; - renderContext: Props; - interfaceKey: ConfirmationInterfaceKey; - fee?: string; - origin?: string; - renderOptions?: ConfirmationRenderOptions; - tokenPrices?: ContextWithPrices['tokenPrices']; - }): Promise { + async renderConfirmationDialog( + params: RenderConfirmationDialogParams, + ): Promise { try { const { interfaceKey, diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts index 4be33ad4..54bd004f 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts @@ -3,15 +3,19 @@ import type { CaipAccountId } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import type { FeeData } from './api'; +import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; import { KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; import { getNativeAssetMetadata } from '../../services/asset-metadata/utils'; +import { parseOperationAssetReference } from '../../services/transaction/utils'; import type { Locale } from '../../utils'; import { FALLBACK_LANGUAGE, getPreferences, normalizeAmount, + parseClassicAssetCodeIssuer, } from '../../utils'; +import { xlmIcon } from '../images'; const NetworkName = { [KnownCaip2ChainId.Mainnet]: 'Mainnet', @@ -147,3 +151,53 @@ export function formatFeeData( amount: amountInLumen.toString(), }; } + +/** + * Display-friendly resolution of a Stellar operation `asset` reference. + * Used by the confirmation UI to render assets and to look up prices. + */ +export type ResolvedAssetDisplay = { + /** CAIP-19 id used to key into the prices map. */ + assetId: KnownCaip19AssetIdOrSlip44Id; + /** Short ticker (e.g. `XLM`, `USD`). */ + symbol: string; + /** Bundled icon when known (native XLM only today). */ + iconUrl?: string; + /** Explorer link for classic assets. */ + link?: string; +}; + +/** + * Resolves an `OperationMapper` asset reference into the data required to display it. + * + * @param scope - CAIP-2 chain of the transaction. + * @param assetReference - Either `'native'` or a classic `CODE-ISSUER` / `CODE:ISSUER` string. + * @returns The resolved display data, or `null` when the reference cannot be parsed + * (e.g. liquidity pool ids that arrive on `setTrustLineFlags` / `revokeSponsorship`). + */ +export function resolveAssetDisplay( + scope: KnownCaip2ChainId, + assetReference: string, +): ResolvedAssetDisplay | null { + const assetId = parseOperationAssetReference(scope, assetReference); + if (assetId === null) { + return null; + } + if (assetReference === 'native') { + const native = getNativeAssetMetadata(scope); + return { + assetId, + symbol: native.symbol, + // Use the bundled SVG instead of the remote token-icon URL + iconUrl: xlmIcon, + }; + } + // Safe: parseOperationAssetReference returned non-null for a non-native ref, + // so the reference is a parseable classic CODE-ISSUER pair. + const { assetCode } = parseClassicAssetCodeIssuer(assetReference); + return { + assetId, + symbol: assetCode, + link: getClassicAssetExplorerUrl(assetReference), + }; +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx index eb7a6349..459fe6f2 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/ConfirmSignMessage.tsx @@ -12,23 +12,21 @@ import { Text as SnapText, Tooltip, } from '@metamask/snaps-sdk/jsx'; -import type { CaipAccountId } from '@metamask/utils'; import { ConfirmSignMessageFormNames } from './events'; -import type { KnownCaip2ChainId } from '../../../../api'; import type { StellarKeyringAccount } from '../../../../services/account'; import type { Locale } from '../../../../utils'; import { i18n } from '../../../../utils'; import { STELLAR_IMAGE } from '../../../images/icon'; -import { getNetworkName } from '../../utils'; +import type { ConfirmationBaseProps } from '../../api'; +import { getAccountName, getNetworkName } from '../../utils'; -export type ConfirmSignMessageProps = { +export type ConfirmSignMessageProps = Pick< + ConfirmationBaseProps, + 'scope' | 'locale' | 'networkImage' | 'origin' +> & { message: string; account: StellarKeyringAccount; - scope: KnownCaip2ChainId; - locale: Locale; - networkImage: string | null; - origin: string; }; export const ConfirmSignMessage = ({ @@ -39,9 +37,9 @@ export const ConfirmSignMessage = ({ networkImage, origin, }: ConfirmSignMessageProps): ComponentOrElement => { - const translate = i18n(locale); + const translate = i18n(locale as Locale); const { address } = account; - const addressCaip10 = `${scope}:${address}` as `0x${string}` | CaipAccountId; + const addressCaip10 = getAccountName(scope, address); return ( diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx deleted file mode 100644 index 6d300d04..00000000 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.test.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import type { GetPreferencesResult } from '@metamask/snaps-sdk'; -import { bytesToBase64, stringToBytes } from '@metamask/utils'; - -import { render } from './render'; -import { KnownCaip2ChainId } from '../../../../api'; -import { MultichainMethod } from '../../../../handlers/keyring'; -import type { SignMessageRequest } from '../../../../handlers/keyring'; -import type { StellarKeyringAccount } from '../../../../services/account'; -import { generateMockStellarKeyringAccounts } from '../../../../services/account/__mocks__/account.fixtures'; -import * as snapUtils from '../../../../utils/snap'; - -/** - * Helper function to convert string to base64. - * - * @param str - The string to convert. - * @returns Base64 encoded string. - */ -function toBase64(str: string): string { - return bytesToBase64(stringToBytes(str)); -} - -describe('ConfirmSignMessage render', () => { - const mockAccount = generateMockStellarKeyringAccounts( - 1, - 'entropy-source-1', - )[0] as StellarKeyringAccount; - const mockPreferences: GetPreferencesResult = { - locale: 'en', - currency: 'usd', - hideBalances: false, - useSecurityAlerts: true, - useExternalPricingData: true, - simulateOnChainActions: true, - useTokenDetection: true, - batchCheckBalances: true, - displayNftMedia: true, - useNftDetection: true, - showTestnets: false, - }; - - const createSnapSpies = () => { - const createInterfaceSpy = jest.spyOn(snapUtils, 'createInterface'); - const showDialogSpy = jest.spyOn(snapUtils, 'showDialog'); - const getPreferencesSpy = jest.spyOn(snapUtils, 'getPreferences'); - - createInterfaceSpy.mockResolvedValue('interface-id-123'); - showDialogSpy.mockResolvedValue(true); - getPreferencesSpy.mockResolvedValue(mockPreferences); - - return { - createInterfaceSpy, - showDialogSpy, - getPreferencesSpy, - }; - }; - - it('renders the confirmation dialog with correct props', async () => { - const { createInterfaceSpy, showDialogSpy, getPreferencesSpy } = - createSnapSpies(); - const testOrigin = 'https://example.com'; - const testMessage = 'Hello, Stellar!'; - - const request: SignMessageRequest = { - id: '00000000-0000-4000-8000-000000000001', - origin: testOrigin, - account: mockAccount.id, - scope: KnownCaip2ChainId.Mainnet, - request: { - method: MultichainMethod.SignMessage, - params: { - message: toBase64(testMessage), - }, - }, - }; - - await render(request, mockAccount); - - // Verify createInterface and showDialog were called correctly - expect(createInterfaceSpy).toHaveBeenCalledTimes(1); - expect(showDialogSpy).toHaveBeenCalledWith('interface-id-123'); - - // Verify the message was decoded correctly (we can't easily check the full JSX tree) - // So we verify the render function was called with correct inputs - expect(getPreferencesSpy).toHaveBeenCalled(); - }); - - it('uses fallback locale when preferences fail to load', async () => { - const { createInterfaceSpy, getPreferencesSpy } = createSnapSpies(); - getPreferencesSpy.mockRejectedValue(new Error('Failed to load')); - - const request: SignMessageRequest = { - id: '00000000-0000-4000-8000-000000000003', - origin: 'https://test.com', - account: mockAccount.id, - scope: KnownCaip2ChainId.Mainnet, - request: { - method: MultichainMethod.SignMessage, - params: { - message: toBase64('Test'), - }, - }, - }; - - await render(request, mockAccount); - - // Should still create interface even when preferences fail - expect(createInterfaceSpy).toHaveBeenCalledTimes(1); - expect(getPreferencesSpy).toHaveBeenCalled(); - }); - - it('handles missing origin gracefully', async () => { - const { createInterfaceSpy } = createSnapSpies(); - const request: SignMessageRequest = { - id: '00000000-0000-4000-8000-000000000004', - origin: undefined as any, - account: mockAccount.id, - scope: KnownCaip2ChainId.Mainnet, - request: { - method: MultichainMethod.SignMessage, - params: { - message: toBase64('Test message'), - }, - }, - }; - - await render(request, mockAccount); - - // Should create interface even with missing origin (formatOrigin handles it) - expect(createInterfaceSpy).toHaveBeenCalledTimes(1); - }); - - it('returns the dialog promise', async () => { - const expectedResult = true; - const { showDialogSpy } = createSnapSpies(); - showDialogSpy.mockResolvedValue(expectedResult); - - const request: SignMessageRequest = { - id: '00000000-0000-4000-8000-000000000006', - origin: 'https://test.com', - account: mockAccount.id, - scope: KnownCaip2ChainId.Mainnet, - request: { - method: MultichainMethod.SignMessage, - params: { - message: toBase64('Test'), - }, - }, - }; - - const result = await render(request, mockAccount); - - expect(result).toBe(expectedResult); - }); - - it('passes STELLAR_IMAGE as network image', async () => { - const { createInterfaceSpy } = createSnapSpies(); - const request: SignMessageRequest = { - id: '00000000-0000-4000-8000-000000000007', - origin: 'https://test.com', - account: mockAccount.id, - scope: KnownCaip2ChainId.Mainnet, - request: { - method: MultichainMethod.SignMessage, - params: { - message: toBase64('Test'), - }, - }, - }; - - await render(request, mockAccount); - - // Verify interface was created with TRX image - expect(createInterfaceSpy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.tsx deleted file mode 100644 index 060acb3d..00000000 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignMessage/render.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type { DialogResult } from '@metamask/snaps-sdk'; - -import { ConfirmSignMessage } from './ConfirmSignMessage'; -import type { SignMessageRequest } from '../../../../handlers/keyring'; -import type { StellarKeyringAccount } from '../../../../services/account'; -import { - bufferToUint8Array, - createInterface, - showDialog, -} from '../../../../utils'; -import { isBase64 } from '../../../../utils/string'; -import { STELLAR_IMAGE } from '../../../images/icon'; -import { formatOrigin, getLocale } from '../../utils'; - -/** - * Decodes a message to UTF-8. - * - * @param message - The message to decode. - * @returns The decoded message. - */ -function getUtf8Message(message: string): string { - if (isBase64(message)) { - return bufferToUint8Array(message, 'base64').toString('utf8'); - } - return message; -} - -/** - * Renders the confirmation dialog for a sign message request. - * - * @param request - The keyring request to confirm. - * @param account - The account that the request is for. - * @returns The confirmation dialog result. - */ -export async function render( - request: SignMessageRequest, - account: StellarKeyringAccount, -): Promise { - const { - request: { - params: { message }, - }, - scope, - origin, - } = request; - - const locale = await getLocale(); - - const id = await createInterface( - , - {}, - ); - - const dialogPromise = showDialog(id); - - return dialogPromise; -} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx index 8525cb05..2e6c4a1e 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -11,69 +11,81 @@ import { Section, Text as SnapText, Tooltip, - Link, Divider, } from '@metamask/snaps-sdk/jsx'; -import type { Json, CaipAccountId } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; import { isNullOrUndefined } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { ConfirmSignTransactionFormNames } from './events'; import type { KnownCaip2ChainId } from '../../../../api'; import type { StellarKeyringAccount } from '../../../../services/account'; -import { - OperationMapper, - type Transaction, -} from '../../../../services/transaction'; +import type { ReadableTransactionJson } from '../../../../services/transaction'; import type { Locale, LocalizedMessage } from '../../../../utils'; -import { i18n, parseClassicAssetCodeIssuer } from '../../../../utils'; +import { i18n } from '../../../../utils'; import { STELLAR_IMAGE } from '../../../images/icon'; -import type { ContextWithPrices, FeeData } from '../../api'; +import type { ConfirmationBaseProps, FeeData } from '../../api'; +import { FetchStatus } from '../../api'; +import { Asset } from '../../components/Asset'; +import { FeeRow } from '../../components/Fee'; import { getAccountName, - getClassicAssetExplorerUrl, getNetworkName, + resolveAssetDisplay, } from '../../utils'; -export type ConfirmSignTransactionProps = ContextWithPrices & { - transaction: Transaction; - account: StellarKeyringAccount; - scope: KnownCaip2ChainId; - locale: Locale; - networkImage: string | null; - origin: string; +export type ConfirmSignTransactionProps = Omit< + ConfirmationBaseProps, + 'feeData' +> & { feeData: FeeData; + readableTransaction: ReadableTransactionJson; + account: StellarKeyringAccount; }; const AmountRow = ({ amount }: { amount: string }): ComponentOrElement => { return {new BigNumber(amount).toString()}; }; -const AssetRow = ({ - asset, +const AssetParam = ({ + scope, + assetReference, amount, + preferences, + price, + priceLoading, }: { - asset: string; + scope: KnownCaip2ChainId; + assetReference: string; amount?: string; + preferences?: ConfirmationBaseProps['preferences']; + price?: string | null; + priceLoading?: boolean; }): ComponentOrElement => { - let assetRow; - if (asset === 'native') { - assetRow = {'Native'}; - } else { - const { assetCode } = parseClassicAssetCodeIssuer(asset); - assetRow = ( - ${assetCode} + const resolved = resolveAssetDisplay(scope, assetReference); + if (!resolved) { + // Liquidity pool ids and other non-classic references fall back to the raw string. + if (amount === undefined) { + return {assetReference}; + } + return ( + + {new BigNumber(amount).toString()} + {assetReference} + ); } - if (amount === undefined) { - return assetRow; - } return ( - - {new BigNumber(amount).toString()} - {assetRow} - + ); }; @@ -84,33 +96,53 @@ const AddressRow = ({ address: string; scope: KnownCaip2ChainId; }): ComponentOrElement => { - const addressCaip10 = `${scope}:${address}` as `0x${string}` | CaipAccountId; - return
; + return ( +
+ ); }; const RenderReadableParamValue = (params: { type: string; value: Json; scope: KnownCaip2ChainId; + preferences?: ConfirmationBaseProps['preferences']; + tokenPrices?: ConfirmationBaseProps['tokenPrices']; + priceLoading?: boolean; }): ComponentOrElement | null => { - const { type, value, scope } = params; + const { type, value, scope, preferences, tokenPrices, priceLoading } = params; if (isNullOrUndefined(value)) { return null; } switch (type) { - case 'assetWithAmount': - if (Array.isArray(value)) { - return ( - - ); + case 'assetWithAmount': { + if (!Array.isArray(value)) { + return null; } - return null; + const [assetReference, amount] = value as [string, string]; + const resolved = resolveAssetDisplay(scope, assetReference); + const price = resolved ? (tokenPrices?.[resolved.assetId] ?? null) : null; + return ( + + ); + } + case 'asset': + return ; case 'address': return ; case 'amount': return ; - case 'asset': - return ; case 'json': return {JSON.stringify(value, null, 2)}; default: @@ -125,18 +157,22 @@ const RenderReadableParamValue = (params: { }; export const ConfirmSignTransaction = ({ - transaction, + readableTransaction, account, scope, locale, networkImage, origin, + preferences, + feeData, + tokenPrices, + tokenPricesFetchStatus = FetchStatus.Initial, }: ConfirmSignTransactionProps): ComponentOrElement => { - const t = i18n(locale); + const t = i18n(locale as Locale); const { address } = account; const addressCaip10 = getAccountName(scope, address); - - const readableTransaction = new OperationMapper().mapTransaction(transaction); + const priceLoading = tokenPricesFetchStatus === FetchStatus.Fetching; + const feePrice = tokenPrices?.[feeData.assetId] ?? null; return ( @@ -181,12 +217,12 @@ export const ConfirmSignTransaction = ({ {getNetworkName(scope)} - - - {t('confirmation.transactionFee')} - - {readableTransaction.feeStroops} stroops - + {[readableTransaction.memo].filter(Boolean).map((memo) => ( @@ -237,6 +273,9 @@ export const ConfirmSignTransaction = ({ type={param.type} value={param.value} scope={scope} + preferences={preferences} + tokenPrices={tokenPrices} + priceLoading={priceLoading} /> ); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.test.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.test.tsx deleted file mode 100644 index 93f66450..00000000 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import type { GetPreferencesResult } from '@metamask/snaps-sdk'; -import { Keypair } from '@stellar/stellar-sdk'; - -import { render } from './render'; -import { KnownCaip2ChainId } from '../../../../api'; -import { MultichainMethod } from '../../../../handlers/keyring'; -import type { SignTransactionRequest } from '../../../../handlers/keyring'; -import type { StellarKeyringAccount } from '../../../../services/account'; -import { generateMockStellarKeyringAccounts } from '../../../../services/account/__mocks__/account.fixtures'; -import type { Transaction } from '../../../../services/transaction'; -import { buildMockClassicTransaction } from '../../../../services/transaction/__mocks__/transaction.fixtures'; -import * as snapUtils from '../../../../utils/snap'; - -describe('ConfirmSignTransaction render', () => { - const mockAccount = generateMockStellarKeyringAccounts( - 1, - 'entropy-source-1', - )[0] as StellarKeyringAccount; - const mockPreferences: GetPreferencesResult = { - locale: 'en', - currency: 'usd', - hideBalances: false, - useSecurityAlerts: true, - useExternalPricingData: true, - simulateOnChainActions: true, - useTokenDetection: true, - batchCheckBalances: true, - displayNftMedia: true, - useNftDetection: true, - showTestnets: false, - }; - - const destination = Keypair.random().publicKey(); - - const createSnapSpies = () => { - const createInterfaceSpy = jest.spyOn(snapUtils, 'createInterface'); - const showDialogSpy = jest.spyOn(snapUtils, 'showDialog'); - const getPreferencesSpy = jest.spyOn(snapUtils, 'getPreferences'); - - createInterfaceSpy.mockResolvedValue('interface-id-123'); - showDialogSpy.mockResolvedValue(true); - getPreferencesSpy.mockResolvedValue(mockPreferences); - - return { createInterfaceSpy, showDialogSpy, getPreferencesSpy }; - }; - - const createRequest = ( - overrides: Partial = {}, - ): SignTransactionRequest => ({ - id: '00000000-0000-4000-8000-000000000001', - origin: 'https://example.com', - account: mockAccount.id, - scope: KnownCaip2ChainId.Testnet, - request: { - method: MultichainMethod.SignTransaction, - params: { transaction: 'dummy-xdr' }, - }, - ...overrides, - }); - - const buildSinglePaymentTx = (): Transaction => - buildMockClassicTransaction([ - { - type: 'payment', - params: { destination, asset: 'native', amount: '100' }, - }, - ]); - - it('renders the confirmation dialog and returns the dialog result', async () => { - const { createInterfaceSpy, showDialogSpy, getPreferencesSpy } = - createSnapSpies(); - - const transaction = buildSinglePaymentTx(); - const result = await render(createRequest(), transaction, mockAccount); - - expect(getPreferencesSpy).toHaveBeenCalled(); - expect(createInterfaceSpy).toHaveBeenCalledTimes(1); - expect(showDialogSpy).toHaveBeenCalledWith('interface-id-123'); - expect(result).toBe(true); - }); - - it('renders with multiple operations', async () => { - const { createInterfaceSpy } = createSnapSpies(); - - const transaction = buildMockClassicTransaction([ - { - type: 'payment', - params: { destination, asset: 'native', amount: '50' }, - }, - { - type: 'createAccount', - params: { destination, startingBalance: '10' }, - }, - { - type: 'changeTrust', - params: { - asset: { code: 'USD', issuer: destination }, - limit: '1000', - }, - }, - ]); - - await render(createRequest(), transaction, mockAccount); - - expect(createInterfaceSpy).toHaveBeenCalledTimes(1); - }); - - it('renders with an operation that has an explicit source', async () => { - const { createInterfaceSpy } = createSnapSpies(); - const opSource = Keypair.random().publicKey(); - - const transaction = buildMockClassicTransaction([ - { - type: 'payment', - params: { - destination, - asset: 'native', - amount: '10', - source: opSource, - }, - }, - ]); - - await render(createRequest(), transaction, mockAccount); - - expect(createInterfaceSpy).toHaveBeenCalledTimes(1); - }); - - it('uses fallback locale when preferences fail to load', async () => { - const { createInterfaceSpy, getPreferencesSpy } = createSnapSpies(); - getPreferencesSpy.mockRejectedValue(new Error('Failed to load')); - - const transaction = buildSinglePaymentTx(); - await render(createRequest(), transaction, mockAccount); - - expect(createInterfaceSpy).toHaveBeenCalledTimes(1); - expect(getPreferencesSpy).toHaveBeenCalled(); - }); - - it('handles missing origin gracefully', async () => { - const { createInterfaceSpy } = createSnapSpies(); - - const transaction = buildSinglePaymentTx(); - await render( - createRequest({ origin: undefined as any }), - transaction, - mockAccount, - ); - - expect(createInterfaceSpy).toHaveBeenCalledTimes(1); - }); - - it('renders with setOptions operation (conditional params)', async () => { - const { createInterfaceSpy } = createSnapSpies(); - - const transaction = buildMockClassicTransaction([ - { - type: 'setOptions', - params: { setFlags: 1, clearFlags: 2 }, - }, - ]); - - await render(createRequest(), transaction, mockAccount); - - expect(createInterfaceSpy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx deleted file mode 100644 index 725ab4b0..00000000 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/render.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import type { DialogResult } from '@metamask/snaps-sdk'; - -import { ConfirmSignTransaction } from './ConfirmSignTransaction'; -import type { ConfirmSignTransactionProps } from './ConfirmSignTransaction'; -import type { SignTransactionRequest } from '../../../../handlers/keyring'; -import type { StellarKeyringAccount } from '../../../../services/account'; -import type { Transaction } from '../../../../services/transaction'; -import { - createInterface, - getSlip44AssetId, - showDialog, -} from '../../../../utils'; -import { STELLAR_IMAGE } from '../../../images/icon'; -import { FetchStatus } from '../../api'; -import { formatFeeData, formatOrigin, getLocale } from '../../utils'; - -/** - * Renders the confirmation dialog for a sign transaction request. - * - * @param request - The keyring request to confirm. - * @param transaction - The transaction to show in the confirmation UI. - * @param account - The account that the request is for. - * @returns The confirmation dialog result. - */ -export async function render( - request: SignTransactionRequest, - transaction: Transaction, - account: StellarKeyringAccount, -): Promise { - const { scope, origin } = request; - - const locale = await getLocale(); - const nativeAssetId = getSlip44AssetId(scope); - - const id = await createInterface( - , - {}, - ); - - const dialogPromise = showDialog(id); - - return dialogPromise; -} From 81ce35a3a5203bda631e4e520e4b6f1749951c0d Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Wed, 22 Apr 2026 17:58:34 +0200 Subject: [PATCH 100/384] fix: add stable keys to operation/param Box maps in sign-tx view --- merged-packages/stellar-wallet-snap/snap.manifest.json | 2 +- .../ConfirmSignTransaction/ConfirmSignTransaction.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 06ff83f9..8c19fda9 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "7Ka72Qi4qL2Pudnz5k4XAG/Ci8EMvem1bAO0CkKkRFY=", + "shasum": "1pjIylHgZaviCLIglGyGW9c+ojNQ8EH0L5kjCAOFI24=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx index 2e6c4a1e..f0b99afc 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -235,7 +235,11 @@ export const ConfirmSignTransaction = ({
{readableTransaction.operations.map((operationJson, index) => ( - + {t( `confirmation.transaction.${operationJson.type.toLowerCase()}` as LocalizedMessage, @@ -261,6 +265,7 @@ export const ConfirmSignTransaction = ({ param.value.length > 40); return ( From b99bbe331856bd52b3085de20a7fc90984853244 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:25:02 +0800 Subject: [PATCH 101/384] fix: transaction memo --- .../services/transaction/Transaction.test.ts | 57 +++++++++++++++++++ .../src/services/transaction/Transaction.ts | 37 +++++++++--- 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts index 55b126ab..efe0d164 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts @@ -3,6 +3,7 @@ import { Asset, FeeBumpTransaction, Keypair, + Memo, Networks, Operation, TransactionBuilder as StellarTransactionBuilder, @@ -81,4 +82,60 @@ describe('Transaction', () => { new BigNumber(inner.fee).toFixed(0), ); }); + + it.each([ + { + memo: Memo.text('english'), + expected: 'english', + }, + { + memo: Memo.text('🧾 éclair'), + expected: '🧾 éclair', + }, + { + memo: Memo.id('12321'), + expected: '12321', + }, + { + memo: Memo.hash( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ), + expected: + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + }, + { + memo: Memo.return( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ), + expected: + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + }, + { + memo: Memo.none(), + expected: null, + }, + ])( + 'decodes memo values correctly', + ({ memo, expected }: { memo: Memo; expected: string | null }) => { + const source = Keypair.random(); + const dest = Keypair.random().publicKey(); + const inner = new StellarTransactionBuilder( + new Account(source.publicKey(), '1'), + { fee: '100', networkPassphrase: Networks.TESTNET }, + ) + .addOperation( + Operation.payment({ + destination: dest, + asset: Asset.native(), + amount: '1', + }), + ) + .addMemo(memo) + .setTimeout(60) + .build(); + + const wrapped = new Transaction(inner); + expect(wrapped.getMemo()).toStrictEqual(expected); + }, + ); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts index 1e64cdf9..be24e111 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts @@ -1,6 +1,7 @@ import type { Transaction as StellarTransaction, Operation, + Memo, } from '@stellar/stellar-sdk'; import { FeeBumpTransaction } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; @@ -35,16 +36,38 @@ export class Transaction { } } - getMemo(encode: 'hex' | 'base64' | 'utf8' = 'utf8'): string | null { + getMemo(): string | null { const raw = this.getRaw(); + let memo: Memo | null = null; + if (raw instanceof FeeBumpTransaction) { - return raw.innerTransaction.memo?.value - ? bufferToUint8Array(raw.innerTransaction.memo.value).toString(encode) - : null; + memo = raw.innerTransaction.memo; + } else { + memo = raw.memo; + } + + if (memo) { + switch (memo.type) { + case 'hash': + case 'return': + // Hash and return memo value is always hex, so encoded to hex + return memo?.value + ? bufferToUint8Array(memo?.value).toString('hex') + : null; + case 'id': + // ID memo value is always a uint64, so encoded to string + return memo?.value ? memo?.value.toString() : null; + case 'text': + // Text memo value is always a ASCII string, so encoded to utf8 + return memo?.value + ? bufferToUint8Array(memo?.value).toString('utf8') + : null; + case 'none': + default: + return null; + } } - return raw.memo?.value - ? bufferToUint8Array(raw.memo.value).toString(encode) - : null; + return null; } /** From 5df1df6fe5790e6aca4008b611cd58ffc5c5d123 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:30:18 +0800 Subject: [PATCH 102/384] fix: nit --- .../services/transaction/Transaction.test.ts | 38 ++++++++++++ .../src/services/transaction/Transaction.ts | 59 +++++++++++-------- 2 files changed, 72 insertions(+), 25 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts index efe0d164..5e741f7e 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts @@ -83,11 +83,45 @@ describe('Transaction', () => { ); }); + it('reads memo from inner transaction for a fee-bump envelope', () => { + const source = Keypair.random(); + const feeSource = Keypair.random(); + const dest = Keypair.random().publicKey(); + + const inner = new StellarTransactionBuilder( + new Account(source.publicKey(), '1'), + { fee: '100', networkPassphrase: Networks.TESTNET }, + ) + .addOperation( + Operation.payment({ + destination: dest, + asset: Asset.native(), + amount: '1', + }), + ) + .addMemo(Memo.text('inner-memo')) + .setTimeout(60) + .build(); + + const feeBump = StellarTransactionBuilder.buildFeeBumpTransaction( + feeSource, + String(Number(inner.fee) * 2), + inner, + Networks.TESTNET, + ); + + expect(new Transaction(feeBump).getMemo()).toBe('inner-memo'); + }); + it.each([ { memo: Memo.text('english'), expected: 'english', }, + { + memo: Memo.text(''), + expected: '', + }, { memo: Memo.text('🧾 éclair'), expected: '🧾 éclair', @@ -96,6 +130,10 @@ describe('Transaction', () => { memo: Memo.id('12321'), expected: '12321', }, + { + memo: Memo.id('0'), + expected: '0', + }, { memo: Memo.hash( 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts index be24e111..2267f607 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts @@ -1,7 +1,6 @@ import type { Transaction as StellarTransaction, Operation, - Memo, } from '@stellar/stellar-sdk'; import { FeeBumpTransaction } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; @@ -36,38 +35,48 @@ export class Transaction { } } + /** + * Memo for confirmations: lowercase hex for hash/return (32 raw bytes), decimal string for + * id, UTF-8 for text (up to 28 on-chain bytes), or null when the memo is `none` or missing. + * + * @returns Encoded memo string or null. + */ getMemo(): string | null { const raw = this.getRaw(); - let memo: Memo | null = null; + const memo = + raw instanceof FeeBumpTransaction ? raw.innerTransaction.memo : raw.memo; - if (raw instanceof FeeBumpTransaction) { - memo = raw.innerTransaction.memo; - } else { - memo = raw.memo; + if (!memo) { + return null; } - if (memo) { - switch (memo.type) { - case 'hash': - case 'return': - // Hash and return memo value is always hex, so encoded to hex - return memo?.value - ? bufferToUint8Array(memo?.value).toString('hex') - : null; - case 'id': - // ID memo value is always a uint64, so encoded to string - return memo?.value ? memo?.value.toString() : null; - case 'text': - // Text memo value is always a ASCII string, so encoded to utf8 - return memo?.value - ? bufferToUint8Array(memo?.value).toString('utf8') - : null; - case 'none': - default: + switch (memo.type) { + case 'hash': + case 'return': { + const { value } = memo; + if (value === undefined || value === null) { + return null; + } + return bufferToUint8Array(value).toString('hex'); + } + case 'id': { + const { value } = memo; + if (value === undefined || value === null) { + return null; + } + return typeof value === 'string' ? value : String(value); + } + case 'text': { + const { value } = memo; + if (value === undefined || value === null) { return null; + } + return bufferToUint8Array(value).toString('utf8'); } + case 'none': + default: + return null; } - return null; } /** From 979659e105bc18226b1e965fe1dccbc97de737e9 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:43:44 +0800 Subject: [PATCH 103/384] fix: test --- merged-packages/stellar-wallet-snap/jest.config.js | 1 + .../src/handlers/clientRequest/changeTrustOpt.test.ts | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/jest.config.js b/merged-packages/stellar-wallet-snap/jest.config.js index adad808c..859b3a37 100644 --- a/merged-packages/stellar-wallet-snap/jest.config.js +++ b/merged-packages/stellar-wallet-snap/jest.config.js @@ -48,6 +48,7 @@ const config = { '\\.svg$': 'jest-transform-stub', }, resetMocks: true, + restoreMocks: true, testMatch: ['**/src/**/?(*.)+(spec|test).[tj]s?(x)'], setupFilesAfterEnv: ['/jest.setup.ts'], }; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts index 0506d30c..c2a48831 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts @@ -175,10 +175,6 @@ describe('ChangeTrustOptHandler', () => { }; } - beforeEach(() => { - jest.restoreAllMocks(); - }); - it('handles changeTrust opt-in and saves pending keyring transaction', async () => { const { handler, From e42c4bac60f3f152bde07d1824191373638a9ff5 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:12:29 +0800 Subject: [PATCH 104/384] chore: adjust test --- .../src/handlers/clientRequest/api.test.ts | 12 ------- .../src/handlers/clientRequest/api.ts | 11 +------ .../clientRequest/changeTrustOpt.test.ts | 23 +++++++++++--- .../handlers/clientRequest/changeTrustOpt.ts | 24 +++++++------- .../transaction/TransactionService.test.ts | 31 ++++++++++++++++--- .../transaction/TransactionService.ts | 22 +++++++++++-- 6 files changed, 78 insertions(+), 45 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts index ea2e22b9..2a6e8227 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts @@ -118,7 +118,6 @@ describe('ChangeTrustOptJsonRpcRequestStruct', () => { scope, assetId, action: 'delete', - limit: '0', }, }, ])('accepts valid changeTrustOpt JSON-RPC requests', (request) => { @@ -172,17 +171,6 @@ describe('ChangeTrustOptJsonRpcRequestStruct', () => { action: 'add', }, }, - { - jsonrpc: '2.0' as const, - id: 1, - method: 'changeTrustOpt', - params: { - accountId, - scope, - assetId, - action: 'delete', - }, - }, { jsonrpc: '2.0' as const, id: 1, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts index fdc39401..a56ed83e 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts @@ -22,14 +22,6 @@ import { NonZeroValidAmountStruct, } from '../../api'; -export enum MultiChainSendErrorCodes { - // eslint-disable-next-line @typescript-eslint/no-shadow - Required = 'Required', - Invalid = 'Invalid', - InsufficientBalance = 'InsufficientBalance', - InsufficientBalanceToCoverFee = 'InsufficientBalanceToCoverFee', -} - /** * Enum for the client request method. */ @@ -84,7 +76,6 @@ const ChangeTrustRemoveStruct = assign( ChangeTrustBaseParamsStruct, object({ action: literal(ChangeTrustOptAction.Delete), - limit: literal('0'), }), ); @@ -106,7 +97,7 @@ export const ChangeTrustOptJsonRpcRequestStruct = refine( if (result) { return true; } - return `Asset id ${params.assetId} scope is not match with the request scope ${params.scope}`; + return `The chain implied by asset id ${params.assetId} does not match request scope ${params.scope}`; }, ); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts index c2a48831..9c04bbfd 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts @@ -1,3 +1,4 @@ +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import { UserRejectedRequestError } from '@metamask/snaps-sdk'; import { BigNumber } from 'bignumber.js'; @@ -39,8 +40,16 @@ import { ConfirmationUXController } from '../../ui/confirmation/controller'; import { logger } from '../../utils/logger'; jest.mock('../../utils/logger'); +jest.mock('@metamask/keyring-snap-sdk', () => ({ + emitSnapKeyringEvent: jest.fn(), +})); describe('ChangeTrustOptHandler', () => { + beforeEach(() => { + jest.mocked(emitSnapKeyringEvent).mockReset(); + jest.mocked(emitSnapKeyringEvent).mockResolvedValue(undefined); + }); + const accountId = '11111111-1111-4111-8111-111111111111'; const scope = KnownCaip2ChainId.Mainnet; const assetId = USDC_CLASSIC as KnownCaip19ClassicAssetId; @@ -72,7 +81,6 @@ describe('ChangeTrustOptHandler', () => { scope, assetId, action: ChangeTrustOptAction.Delete, - limit: '0', }, }; @@ -109,8 +117,11 @@ describe('ChangeTrustOptHandler', () => { const signTransactionSpy = jest.spyOn(wallet, 'signTransaction'); - const { transactionService, transactionRepositorySaveSpy } = - createMockTransactionService(); + const { + transactionService, + transactionRepositorySaveSpy, + transactionRepositorySaveManySpy, + } = createMockTransactionService(); const getBaseFeeSpy = jest .spyOn(NetworkService.prototype, 'getBaseFee') .mockResolvedValue(new BigNumber(100)); @@ -169,6 +180,7 @@ describe('ChangeTrustOptHandler', () => { sendTransaction, savePendingKeyringTransaction, transactionRepositorySaveSpy, + transactionRepositorySaveManySpy, resolve, renderConfirmationDialog, signTransactionSpy, @@ -348,8 +360,9 @@ describe('ChangeTrustOptHandler', () => { }); it('continues successfully when saving pending transaction fails', async () => { - const { handler, transactionRepositorySaveSpy, sendTransaction } = setup(); - transactionRepositorySaveSpy.mockRejectedValueOnce( + const { handler, transactionRepositorySaveManySpy, sendTransaction } = + setup(); + transactionRepositorySaveManySpy.mockRejectedValueOnce( new Error('failed save'), ); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts index f5c1e2a8..b78d5baf 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts @@ -86,7 +86,7 @@ export class ChangeTrustOptHandler extends WithClientRequestActiveAccountResolve * @param request - JSON-RPC request containing `scope`, `assetId`, `action`, and optional `limit`. * @returns A `ChangeTrustOptJsonRpcResponse`: * - `{ status: true, transactionId }` when the transaction is built, signed, and submitted. - * - `{ status: true }` when preflight detects the trustline already exists for an add request. + * - `{ status: true }` when preflight finds an existing classic trustline with limit greater than zero for an add request. * @throws {TrustlineNotFoundException} If a delete request targets a trustline that does not exist. * @throws {UserRejectedRequestError} If the user rejects the confirmation prompt. */ @@ -94,18 +94,17 @@ export class ChangeTrustOptHandler extends WithClientRequestActiveAccountResolve resolvedAccount: ResolvedActivatedAccount, request: ChangeTrustOptJsonRpcRequest, ): Promise { - const { scope, assetId, action, limit } = request.params; + const { scope, assetId, action } = request.params; const { wallet, account, onChainAccount } = resolvedAccount; - // Quit early if the trustline already exists for add - if ( - action === ChangeTrustOptAction.Add && - onChainAccount.hasAsset(assetId) - ) { - // If the trustline already exists, we return a success response - return { - status: true, - }; + // Quit early if add is redundant (classic line already present with limit > 0) + if (action === ChangeTrustOptAction.Add) { + const asset = onChainAccount.getAsset(assetId); + if (asset?.limit?.gt(0)) { + return { + status: true, + }; + } } // Quit early if the trustline does not exist for delete @@ -117,7 +116,8 @@ export class ChangeTrustOptHandler extends WithClientRequestActiveAccountResolve } // Safeguard to ensure we use the correct limit for delete - const limitForTx = action === ChangeTrustOptAction.Delete ? '0' : limit; + const limitForTx = + action === ChangeTrustOptAction.Delete ? '0' : request.params.limit; const assetMetadata = await this.#assetMetadataService.resolve(assetId); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts index 07adadc1..710b5067 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts @@ -1,11 +1,16 @@ -import { TransactionStatus, TransactionType } from '@metamask/keyring-api'; +import { + KeyringEvent, + TransactionStatus, + TransactionType, +} from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import { hexToBytes } from '@metamask/utils'; import { KeyringTransactionType } from './KeyringTransactionBuilder'; import { TransactionBuilder } from './TransactionBuilder'; import type { KnownCaip19ClassicAssetId } from '../../api'; import { KnownCaip2ChainId } from '../../api'; -import { getSlip44AssetId } from '../../utils'; +import { getSlip44AssetId, getSnapProvider } from '../../utils'; import { createMockTransactionService } from './__mocks__/transaction.fixtures'; import { generateMockStellarKeyringAccounts } from '../account/__mocks__/account.fixtures'; import type { StellarKeyringAccount } from '../account/api'; @@ -22,11 +27,19 @@ import type { Wallet } from '../wallet/Wallet'; jest.mock('../../utils/logger'); jest.mock('../../utils/snap'); +jest.mock('@metamask/keyring-snap-sdk', () => ({ + emitSnapKeyringEvent: jest.fn(), +})); describe('TransactionService', () => { + beforeEach(() => { + jest.mocked(emitSnapKeyringEvent).mockReset(); + jest.mocked(emitSnapKeyringEvent).mockResolvedValue(undefined); + }); + describe('savePendingKeyringTransaction', () => { it('creates and saves a pending send transaction', async () => { - const { transactionService, transactionRepositorySaveSpy } = + const { transactionService, transactionRepositorySaveManySpy } = createMockTransactionService(); const [fromAccount, toAccount] = generateMockStellarKeyringAccounts( 2, @@ -88,8 +101,18 @@ describe('TransactionService', () => { }; expect(transaction).toStrictEqual(expectedTransaction); - expect(transactionRepositorySaveSpy).toHaveBeenCalledWith( + expect(transactionRepositorySaveManySpy).toHaveBeenCalledWith([ expectedTransaction, + ]); + expect(emitSnapKeyringEvent).toHaveBeenCalledTimes(1); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + getSnapProvider(), + KeyringEvent.AccountTransactionsUpdated, + { + transactions: { + [fromAccount.id]: [expectedTransaction], + }, + }, ); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index a06fb620..51b41da7 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -1,4 +1,9 @@ -import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; +import { + KeyringEvent, + type Transaction as KeyringTransaction, +} from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { groupBy } from 'lodash'; import type { Transaction } from './Transaction'; import type { TransactionBuilder } from './TransactionBuilder'; @@ -268,7 +273,9 @@ export class TransactionService { * @returns A promise that resolves when the transaction is saved. */ async save(transaction: KeyringTransaction): Promise { - await this.#transactionRepository.save(transaction); + // use saveMany here to leverage the state lock, + // hence the update of the state and the transaction event emission will be in sequence + await this.saveMany([transaction]); } /** @@ -279,6 +286,7 @@ export class TransactionService { */ async saveMany(transactions: KeyringTransaction[]): Promise { await this.#transactionRepository.saveMany(transactions); + await this.#emitAccountTransactionsUpdated(transactions); } /** @@ -296,4 +304,14 @@ export class TransactionService { 'TransactionService.synchronize: transaction history sync not implemented yet', ); } + + async #emitAccountTransactionsUpdated( + transactions: KeyringTransaction[], + ): Promise { + const transactionsByAccountId = groupBy(transactions, 'account'); + + await emitSnapKeyringEvent(snap, KeyringEvent.AccountTransactionsUpdated, { + transactions: transactionsByAccountId, + }); + } } From 72f9ff45d0f0be539f0f3d0333fafb086d27b3e8 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:40:16 +0800 Subject: [PATCH 105/384] fix: comment --- .../stellar-wallet-snap/src/context.ts | 3 +- .../handlers/clientRequest/changeTrustOpt.ts | 52 +++++++++++++++---- .../transaction/TransactionBuilder.ts | 2 +- .../transaction/TransactionService.ts | 12 +++-- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index b43ae765..da2d2545 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -184,8 +184,7 @@ const clientRequestMethodHandlers: Record< IClientRequestHandler > = { [ClientRequestMethod.ChangeTrustOpt]: changeTrustOptHandler, - // TEMP: force cast until we have all handlers, remove this once we have all handlers -} as unknown as Record; +}; const clientRequestHandler = new ClientRequestHandler({ logger, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts index b78d5baf..7acc018d 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts @@ -24,12 +24,19 @@ import type { AssetMetadataService, StellarAssetMetadata, } from '../../services/asset-metadata'; -import type { OnChainAccountService } from '../../services/on-chain-account'; +import type { + OnChainAccount, + OnChainAccountService, +} from '../../services/on-chain-account'; import { TrustlineNotFoundException, KeyringTransactionType, + RemoveTrustlineWithNonZeroBalanceException, +} from '../../services/transaction'; +import type { + Transaction, + TransactionService, } from '../../services/transaction'; -import type { TransactionService } from '../../services/transaction'; import type { WalletService } from '../../services/wallet'; import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; import type { ConfirmationUXController } from '../../ui/confirmation/controller'; @@ -121,13 +128,11 @@ export class ChangeTrustOptHandler extends WithClientRequestActiveAccountResolve const assetMetadata = await this.#assetMetadataService.resolve(assetId); - const transaction = - await this.#transactionService.createValidatedChangeTrustTransaction({ - onChainAccount, - assetId, - scope, - limit: limitForTx, - }); + const transaction = await this.#createTransaction({ + request, + onChainAccount, + limit: limitForTx, + }); const confirmed = await this.#confirmChangeTrustOpt({ request, @@ -269,4 +274,33 @@ export class ChangeTrustOptHandler extends WithClientRequestActiveAccountResolve })) === true ); } + + async #createTransaction(params: { + request: ChangeTrustOptJsonRpcRequest; + onChainAccount: OnChainAccount; + limit?: string; + }): Promise { + const { + request: { + params: { scope, assetId }, + }, + onChainAccount, + limit, + } = params; + + try { + return this.#transactionService.createValidatedChangeTrustTransaction({ + onChainAccount, + assetId, + scope, + limit, + }); + } catch (error: unknown) { + if (error instanceof RemoveTrustlineWithNonZeroBalanceException) { + // TODO: Display a alert for showing user balance and error message (TBC) + throw error; + } + throw error; + } + } } diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts index 10d214ab..febf67f7 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts @@ -359,7 +359,7 @@ export class TransactionBuilder { if (!Number.isFinite(fee) || fee <= 0) { this.#logger.warn( - `Invalid fee amount, fallback to use fix base fee value ${BASE_FEE}`, + `Invalid fee amount, fallback to use fixed base fee value ${BASE_FEE}`, ); fee = BASE_FEE; } diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index 51b41da7..1dd305ac 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -10,7 +10,7 @@ import type { TransactionBuilder } from './TransactionBuilder'; import type { TransactionRepository } from './TransactionRepository'; import type { KnownCaip19ClassicAssetId, KnownCaip2ChainId } from '../../api'; import { BASE_FEE_CACHE_TTL_MILLISECONDS } from '../../constants'; -import type { Serializable } from '../../utils'; +import { getSnapProvider, type Serializable } from '../../utils'; import type { ILogger } from '../../utils/logger'; import { createPrefixedLogger } from '../../utils/logger'; import type { StellarKeyringAccount } from '../account/api'; @@ -310,8 +310,12 @@ export class TransactionService { ): Promise { const transactionsByAccountId = groupBy(transactions, 'account'); - await emitSnapKeyringEvent(snap, KeyringEvent.AccountTransactionsUpdated, { - transactions: transactionsByAccountId, - }); + await emitSnapKeyringEvent( + getSnapProvider(), + KeyringEvent.AccountTransactionsUpdated, + { + transactions: transactionsByAccountId, + }, + ); } } From 7e376775f1bcdb0d001d64fdca6449eb964714a2 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Thu, 23 Apr 2026 11:54:40 +0200 Subject: [PATCH 106/384] fix: address review feedback on sign-tx confirmation --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../transaction/OperationMapper.test.ts | 34 +++++++++---------- .../services/transaction/OperationMapper.ts | 26 +++++++++++++- .../src/ui/confirmation/utils.ts | 2 ++ .../ConfirmSignTransaction.tsx | 5 ++- 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 8c19fda9..eddde23d 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "1pjIylHgZaviCLIglGyGW9c+ojNQ8EH0L5kjCAOFI24=", + "shasum": "VwPCt/flfhcOfZV3UGt/6tvA7+FJJM5wCMrlxDfMxBs=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts index 1d53cb07..18eac8b0 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.test.ts @@ -83,7 +83,7 @@ describe('OperationMapper', () => { { key: 'asset', type: 'assetWithAmount', - value: ['native', '10.0000000'], + value: ['native', '10'], }, ], }); @@ -96,7 +96,7 @@ describe('OperationMapper', () => { classic: true, params: [ { key: 'line', value: `USD:${issuer}`, type: 'text' }, - { key: 'limit', value: '1000.0000000', type: 'amount' }, + { key: 'limit', value: '1000', type: 'amount' }, ], }); }); @@ -143,7 +143,7 @@ describe('OperationMapper', () => { expect(op?.explicitSource).toBeNull(); expect(op?.params).toStrictEqual([ { key: 'destination', value: dest, type: 'address' }, - { key: 'startingBalance', value: '5.0000000', type: 'amount' }, + { key: 'startingBalance', value: '5', type: 'amount' }, ]); }); @@ -255,13 +255,13 @@ describe('OperationMapper', () => { expect(op?.params).toStrictEqual([ { key: 'sendAsset', - value: ['native', '50.0000000'], + value: ['native', '50'], type: 'assetWithAmount', }, { key: 'destination', value: dest, type: 'address' }, { key: 'destAsset', - value: [`USD:${issuer}`, '100.0000000'], + value: [`USD:${issuer}`, '100'], type: 'assetWithAmount', }, { key: 'path', value: [`EUR:${issuer}`], type: 'json' }, @@ -288,13 +288,13 @@ describe('OperationMapper', () => { expect(op?.params).toStrictEqual([ { key: 'sendAsset', - value: ['native', '25.0000000'], + value: ['native', '25'], type: 'assetWithAmount', }, { key: 'destination', value: dest, type: 'address' }, { key: 'destAsset', - value: [`EUR:${issuer}`, '20.0000000'], + value: [`EUR:${issuer}`, '20'], type: 'assetWithAmount', }, { key: 'path', value: [], type: 'json' }, @@ -318,7 +318,7 @@ describe('OperationMapper', () => { expect(op?.params).toStrictEqual([ { key: 'selling', - value: ['native', '10.0000000'], + value: ['native', '10'], type: 'assetWithAmount', }, { key: 'buying', value: `USD:${issuer}`, type: 'asset' }, @@ -344,7 +344,7 @@ describe('OperationMapper', () => { expect(op?.params).toStrictEqual([ { key: 'buying', - value: [`BTC:${issuer}`, '5.0000000'], + value: [`BTC:${issuer}`, '5'], type: 'assetWithAmount', }, { key: 'selling', value: 'native', type: 'asset' }, @@ -369,7 +369,7 @@ describe('OperationMapper', () => { expect(op?.params).toStrictEqual([ { key: 'selling', - value: ['native', '100.0000000'], + value: ['native', '100'], type: 'assetWithAmount', }, { key: 'buying', value: `EUR:${issuer}`, type: 'asset' }, @@ -442,7 +442,7 @@ describe('OperationMapper', () => { expect(op?.type).toBe('createClaimableBalance'); expect(op?.params[0]).toStrictEqual({ key: 'asset', - value: ['native', '50.0000000'], + value: ['native', '50'], type: 'assetWithAmount', }); expect(op?.params[1]?.key).toBe('claimants'); @@ -509,7 +509,7 @@ describe('OperationMapper', () => { expect(op?.params).toStrictEqual([ { key: 'asset', - value: [`USD:${issuer}`, '100.0000000'], + value: [`USD:${issuer}`, '100'], type: 'assetWithAmount', }, { key: 'from', value: from, type: 'address' }, @@ -566,8 +566,8 @@ describe('OperationMapper', () => { expect(op?.type).toBe('liquidityPoolDeposit'); expect(op?.params).toStrictEqual([ { key: 'liquidityPoolId', value: poolId, type: 'text' }, - { key: 'maxAmountA', value: '100.0000000', type: 'amount' }, - { key: 'maxAmountB', value: '200.0000000', type: 'amount' }, + { key: 'maxAmountA', value: '100', type: 'amount' }, + { key: 'maxAmountB', value: '200', type: 'amount' }, { key: 'minPrice', value: '0.5', type: 'price' }, { key: 'maxPrice', value: '2', type: 'price' }, ]); @@ -589,9 +589,9 @@ describe('OperationMapper', () => { expect(op?.type).toBe('liquidityPoolWithdraw'); expect(op?.params).toStrictEqual([ { key: 'liquidityPoolId', value: poolId, type: 'text' }, - { key: 'amount', value: '50.0000000', type: 'amount' }, - { key: 'minAmountA', value: '20.0000000', type: 'amount' }, - { key: 'minAmountB', value: '25.0000000', type: 'amount' }, + { key: 'amount', value: '50', type: 'amount' }, + { key: 'minAmountA', value: '20', type: 'amount' }, + { key: 'minAmountB', value: '25', type: 'amount' }, ]); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts index caf2d4e0..d027b647 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts @@ -1,6 +1,7 @@ import type { Json } from '@metamask/utils'; import type { Asset, Operation } from '@stellar/stellar-sdk'; import { LiquidityPoolAsset, LiquidityPoolId, xdr } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; import type { Transaction } from './Transaction'; import type { KnownCaip2ChainId } from '../../api'; @@ -664,7 +665,30 @@ export class OperationMapper { value: Json, type: ReadableFieldType, ): ReadableOperationField { - return { key, value, type }; + let normalizedValue: Json = value; + if (type === 'amount') { + normalizedValue = OperationMapper.#normalizeStellarAmount(value); + } else if (type === 'assetWithAmount' && Array.isArray(value)) { + const [asset, amount] = value as [Json, Json]; + normalizedValue = [ + asset, + OperationMapper.#normalizeStellarAmount(amount), + ]; + } + return { key, value: normalizedValue, type }; + } + + /** + * Strips trailing zeros from Stellar amount strings; passes other values through. + * + * @param value - Field value as produced by the Stellar SDK operation. + * @returns Normalized amount string, or the original value when not numeric. + */ + static #normalizeStellarAmount(value: Json): Json { + if (typeof value === 'string' && /^-?\d+(\.\d+)?$/u.test(value)) { + return new BigNumber(value).toString(); + } + return value; } #formatTrustLine(line: Asset | LiquidityPoolAsset): string { diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts index 54bd004f..3209a35f 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts @@ -195,6 +195,8 @@ export function resolveAssetDisplay( // Safe: parseOperationAssetReference returned non-null for a non-native ref, // so the reference is a parseable classic CODE-ISSUER pair. const { assetCode } = parseClassicAssetCodeIssuer(assetReference); + // TODO: resolve classic-asset iconUrl via AssetMetadataService once + // integrated, instead of letting fall back to question-mark. return { assetId, symbol: assetCode, diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx index f0b99afc..f213c128 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -15,7 +15,6 @@ import { } from '@metamask/snaps-sdk/jsx'; import type { Json } from '@metamask/utils'; import { isNullOrUndefined } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; import { ConfirmSignTransactionFormNames } from './events'; import type { KnownCaip2ChainId } from '../../../../api'; @@ -44,7 +43,7 @@ export type ConfirmSignTransactionProps = Omit< }; const AmountRow = ({ amount }: { amount: string }): ComponentOrElement => { - return {new BigNumber(amount).toString()}; + return {amount}; }; const AssetParam = ({ @@ -70,7 +69,7 @@ const AssetParam = ({ } return ( - {new BigNumber(amount).toString()} + {amount} {assetReference} ); From 50edd677d5f8b3caba2be60cc0595cb72a7087af Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Thu, 23 Apr 2026 17:47:40 +0200 Subject: [PATCH 107/384] feat: add SEP-43 SignMessage/SignTransaction dapp handlers --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../stellar-wallet-snap/src/context.ts | 23 ++ .../src/handlers/sep43/api.ts | 143 ++++++++++++ .../src/handlers/sep43/base.ts | 219 ++++++++++++++++++ .../src/handlers/sep43/exceptions.ts | 144 ++++++++++++ .../src/handlers/sep43/index.ts | 5 + .../src/handlers/sep43/signMessage.test.ts | 88 +++++++ .../src/handlers/sep43/signMessage.ts | 107 +++++++++ .../handlers/sep43/signTransaction.test.ts | 68 ++++++ .../src/handlers/sep43/signTransaction.ts | 149 ++++++++++++ .../stellar-wallet-snap/src/index.ts | 36 ++- 11 files changed, 972 insertions(+), 12 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/base.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/exceptions.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.ts diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index eddde23d..a0a94359 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "VwPCt/flfhcOfZV3UGt/6tvA7+FJJM5wCMrlxDfMxBs=", + "shasum": "26Q/bDok4/+dUQVEql0ZHDEZSDUpRZulFsKxsr0CZ00=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 7b9cba09..3ce04b90 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -13,6 +13,10 @@ import { SignMessageHandler, SignTransactionHandler, } from './handlers/keyring'; +import { + Sep43SignMessageHandler, + Sep43SignTransactionHandler, +} from './handlers/sep43'; import { AccountService, AccountsRepository } from './services/account'; import type { AccountBalanceState } from './services/account-balance'; import { @@ -162,6 +166,23 @@ const assetsHandler = new AssetsHandler({ priceService, }); +/** ------------------------------ SEP-43 Handlers (dapp-facing) ------------------------------ */ +const sep43SignMessageHandler = new Sep43SignMessageHandler({ + logger, + accountService, + walletService, + confirmationUIController, +}); + +const sep43SignTransactionHandler = new Sep43SignTransactionHandler({ + logger, + accountService, + walletService, + transactionBuilder, + transactionService, + confirmationUIController, +}); + export { cronjobHandler, assetsHandler, @@ -169,5 +190,7 @@ export { userInputHandler, signTransactionHandler, signMessageHandler, + sep43SignMessageHandler, + sep43SignTransactionHandler, confirmationUIController, }; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/api.ts new file mode 100644 index 00000000..fa94683b --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/sep43/api.ts @@ -0,0 +1,143 @@ +import { + array, + enums, + literal, + nonempty, + number, + object, + optional, + string, + union, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { base64 } from '@metamask/utils'; + +import { StellarAddressStruct } from '../../api/address'; +import { KnownCaip2ChainIdStruct } from '../../api/network'; +import { UuidStruct } from '../../api/uuid'; +import { XdrStruct } from '../../api/xdr'; + +/** + * SEP-43 method names exposed via `onRpcRequest`. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md + */ +export enum Sep43Method { + SignMessage = 'SignMessage', + SignTransaction = 'SignTransaction', +} + +export const Sep43MethodStruct = enums(Object.values(Sep43Method)); + +/** + * Optional bag accepted by both SEP-43 methods. + * + * `submit` and `submitUrl` are intentionally omitted from the schema: + * the snap signs only and rejects any caller asking for submission. + */ +export const Sep43OptsStruct = object({ + networkPassphrase: optional(nonempty(string())), + address: optional(StellarAddressStruct), +}); + +export type Sep43Opts = Infer; + +/** + * SEP-43 SignMessage params. + */ +export const Sep43SignMessageParamsStruct = object({ + message: nonempty(base64(string())), + opts: optional(Sep43OptsStruct), +}); + +export type Sep43SignMessageParams = Infer; + +/** + * SEP-43 SignTransaction params. + */ +export const Sep43SignTransactionParamsStruct = object({ + xdr: XdrStruct, + opts: optional(Sep43OptsStruct), +}); + +export type Sep43SignTransactionParams = Infer< + typeof Sep43SignTransactionParamsStruct +>; + +/** + * Wrapper request as it arrives at `onRpcRequest`. + * + * `account` is the keyring account UUID resolved by the multichain middleware + * from the dapp's session-connected accounts (CAIP-25 caveat). + */ +const Sep43RequestWrapper = { + scope: KnownCaip2ChainIdStruct, + account: UuidStruct, + origin: nonempty(string()), + id: union([string(), number(), literal(null)] as const), +}; + +export const Sep43SignMessageRequestStruct = object({ + ...Sep43RequestWrapper, + request: object({ + method: literal(Sep43Method.SignMessage), + params: Sep43SignMessageParamsStruct, + }), +}); + +export type Sep43SignMessageRequest = Infer< + typeof Sep43SignMessageRequestStruct +>; + +export const Sep43SignTransactionRequestStruct = object({ + ...Sep43RequestWrapper, + request: object({ + method: literal(Sep43Method.SignTransaction), + params: Sep43SignTransactionParamsStruct, + }), +}); + +export type Sep43SignTransactionRequest = Infer< + typeof Sep43SignTransactionRequestStruct +>; + +/** + * Shape of the SEP-43 error envelope that may sit alongside the success fields. + */ +export const Sep43ErrorEnvelopeStruct = object({ + message: nonempty(string()), + code: number(), + ext: optional(array(string())), +}); + +export type Sep43ErrorEnvelope = Infer; + +/** + * SEP-43 SignMessage response. + * + * `signedMessage` is base64-encoded (matches the rest of the codebase / SEP-53 byte signing). + */ +export const Sep43SignMessageResponseStruct = object({ + signedMessage: nonempty(base64(string())), + signerAddress: StellarAddressStruct, + error: optional(Sep43ErrorEnvelopeStruct), +}); + +export type Sep43SignMessageResponse = Infer< + typeof Sep43SignMessageResponseStruct +>; + +/** + * SEP-43 SignTransaction response. + * + * `signedTxXdr` is the signed transaction envelope as base64 XDR. + */ +export const Sep43SignTransactionResponseStruct = object({ + signedTxXdr: XdrStruct, + signerAddress: StellarAddressStruct, + error: optional(Sep43ErrorEnvelopeStruct), +}); + +export type Sep43SignTransactionResponse = Infer< + typeof Sep43SignTransactionResponseStruct +>; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/base.ts new file mode 100644 index 00000000..ab056e75 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/sep43/base.ts @@ -0,0 +1,219 @@ +import type { Struct } from '@metamask/superstruct'; +import { Networks } from '@stellar/stellar-sdk'; + +import type { Sep43Opts } from './api'; +import { Sep43Error, Sep43ErrorCode, toSep43Error } from './exceptions'; +import type { KnownCaip2ChainId } from '../../api'; +import { KnownCaip2ChainId as Caip2 } from '../../api'; +import type { + AccountService, + StellarKeyringAccount, +} from '../../services/account'; +import type { Wallet, WalletService } from '../../services/wallet'; +import type { ILogger } from '../../utils'; +import { createPrefixedLogger } from '../../utils'; +import { validateRequest } from '../../utils/requestResponse'; + +/** Mainnet is the only network the snap currently signs for. */ +const SUPPORTED_PASSPHRASE: string = Networks.PUBLIC; + +/** Mapping from supported scope to the matching Stellar SDK passphrase. */ +const SUPPORTED_SCOPE_PASSPHRASE: Record = { + [Caip2.Mainnet]: String(Networks.PUBLIC), + [Caip2.Testnet]: String(Networks.TESTNET), +}; + +/** + * Base class shared by SEP-43 SignMessage and SignTransaction handlers. + * + * Provides common cross-cutting concerns: validates `opts.networkPassphrase` + * (mainnet only), validates `scope` is mainnet (re-affirms what the middleware + * already did), forbids `submit` / `submitUrl` (snap is sign-only), resolves + * the keyring account by `opts.address` when provided (otherwise falls back to + * the wrapper's `account` UUID), and wraps thrown errors into the SEP-43 + * `error` envelope so the dapp always receives a well-formed payload. + * + * Subclasses implement {@link execute} which performs the wallet signing and + * returns the success-shaped fields. They never throw to the dapp directly. + */ +export abstract class BaseSep43Handler< + Request extends { + scope: KnownCaip2ChainId; + account: string; + request: { params: { opts?: Sep43Opts } }; + }, + Response extends { signerAddress: string; error?: unknown }, +> { + protected readonly logger: ILogger; + + protected readonly accountService: AccountService; + + protected readonly walletService: WalletService; + + protected readonly requestStruct: Struct; + + constructor({ + logger, + accountService, + walletService, + loggerPrefix, + requestStruct, + }: { + logger: ILogger; + accountService: AccountService; + walletService: WalletService; + loggerPrefix: string; + requestStruct: Struct; + }) { + this.logger = createPrefixedLogger(logger, loggerPrefix); + this.accountService = accountService; + this.walletService = walletService; + this.requestStruct = requestStruct; + } + + /** + * Top-level entry point. Runs the full pipeline (validate → check + * network/opts → resolve account → execute) inside a single try/catch so + * every failure (including struct validation) is serialized into the + * SEP-43 `error` envelope. The dapp never sees a thrown JSON-RPC error. + * + * @param rawRequest - The unvalidated SEP-43 request as it arrives from the dapp. + * @returns The SEP-43 response with either the success fields or `error` populated. + */ + async handle(rawRequest: unknown): Promise { + let signerAddress = ''; + try { + const request = validateRequest(rawRequest, this.requestStruct); + + this.assertSupportedNetwork(request); + this.assertNoSubmit(request.request.params.opts); + + const { account, wallet } = await this.resolveAccount(request); + signerAddress = account.address; + + return await this.execute(request, { account, wallet }); + } catch (error: unknown) { + const sep43 = toSep43Error(error); + this.logger.logErrorWithDetails('SEP-43 request failed', sep43); + return this.toErrorResponse(signerAddress, sep43); + } + } + + /** + * Subclass hook: do the actual signing. + * + * @param request - The validated request. + * @param resolved - The resolved keyring account and signing wallet. + * @returns The success-shaped response (no `error` field). + */ + protected abstract execute( + request: Request, + resolved: { account: StellarKeyringAccount; wallet: Wallet }, + ): Promise; + + /** + * Subclass hook: shape an error-only response when everything fails. + * + * @param signerAddress - The resolved address (or empty string when unknown). + * @param error - The classified SEP-43 error. + * @returns The error response in the subclass's response shape. + */ + protected abstract toErrorResponse( + signerAddress: string, + error: Sep43Error, + ): Response; + + /** + * Resolves the signing account. + * Prefers `opts.address` when provided; otherwise uses the wrapper's `account` UUID. + * When both are present, the resolved address must match. + * + * @param request - The SEP-43 request. + * @returns The resolved keyring account and signing wallet. + */ + protected async resolveAccount( + request: Request, + ): Promise<{ account: StellarKeyringAccount; wallet: Wallet }> { + const { account: accountId, scope } = request; + const optsAddress = request.request.params.opts?.address; + + const { account } = optsAddress + ? await this.accountService.resolveAccount({ + scope, + accountAddress: optsAddress, + }) + : await this.accountService.resolveAccount({ accountId }); + + if (optsAddress && account.id !== accountId) { + // The dapp picked an address that doesn't match the session-selected account. + throw new Sep43Error({ + code: Sep43ErrorCode.InvalidRequest, + ext: [ + `opts.address ${optsAddress} does not match the session-selected account.`, + ], + }); + } + + const wallet = await this.walletService.resolveWallet(account); + return { account, wallet }; + } + + /** + * Throws when the dapp asks for a network we don't support. + * Today: mainnet only. The snap rejects any other `opts.networkPassphrase` + * and any non-mainnet `scope`. + * + * @param request - The SEP-43 request. + */ + protected assertSupportedNetwork(request: Request): void { + if (request.scope !== Caip2.Mainnet) { + throw new Sep43Error({ + code: Sep43ErrorCode.InvalidRequest, + ext: [`Only mainnet is supported, received scope ${request.scope}.`], + }); + } + + const requestedPassphrase = request.request.params.opts?.networkPassphrase; + if ( + requestedPassphrase !== undefined && + requestedPassphrase !== SUPPORTED_PASSPHRASE + ) { + throw new Sep43Error({ + code: Sep43ErrorCode.InvalidRequest, + ext: [ + `Only Stellar mainnet is supported by this wallet. Received passphrase: ${requestedPassphrase}.`, + ], + }); + } + + // Defensive: scope and passphrase must agree when both are set. + const scopePassphrase = SUPPORTED_SCOPE_PASSPHRASE[request.scope]; + if ( + requestedPassphrase !== undefined && + requestedPassphrase !== scopePassphrase + ) { + throw new Sep43Error({ + code: Sep43ErrorCode.InvalidRequest, + ext: [ + `opts.networkPassphrase does not match scope (${request.scope}).`, + ], + }); + } + } + + /** + * Throws when the dapp set `submit` or `submitUrl`. The snap is sign-only. + * + * @param opts - The SEP-43 opts bag (may be undefined). + */ + protected assertNoSubmit(opts: Sep43Opts | undefined): void { + // Use property access to detect even runtime-injected fields the struct stripped. + const raw = opts as undefined | Record; + if (raw?.submit !== undefined || raw?.submitUrl !== undefined) { + throw new Sep43Error({ + code: Sep43ErrorCode.InvalidRequest, + ext: ['This wallet does not submit transactions; use sign only.'], + }); + } + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/exceptions.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/exceptions.ts new file mode 100644 index 00000000..48d8cb3c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/sep43/exceptions.ts @@ -0,0 +1,144 @@ +import { + InvalidParamsError, + UserRejectedRequestError, +} from '@metamask/snaps-sdk'; +import { StructError } from '@metamask/superstruct'; +import { ensureError } from '@metamask/utils'; + +import { AccountServiceException } from '../../services/account/exceptions'; +import { + AccountLoadException, + AccountNotActivatedException, + AssetDataFetchException, + BaseFeeFetchException, + NetworkServiceException, + SimulationException, + TransactionPollException, + TransactionRetryableException, + TransactionSendException, +} from '../../services/network/exceptions'; +import { + TransactionScopeNotMatchException, + TransactionValidationException, +} from '../../services/transaction/exceptions'; + +/** + * SEP-43 error codes. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md + */ +export enum Sep43ErrorCode { + /** Internal wallet error (JS runtime, programmer error, etc.). */ + Internal = -1, + /** External service (Horizon, RPC, …) returned an error. */ + ExternalService = -2, + /** Client app request is invalid (bad params, malformed XDR, unsupported option). */ + InvalidRequest = -3, + /** User declined the confirmation. */ + UserRejected = -4, +} + +/** + * Generic SEP-43 message that's user-safe to forward to the dapp. + * + * Per-code default messages used when callers throw without a specific message. + * The `ext` array carries optional richer context (e.g. underlying error message). + */ +export const SEP43_DEFAULT_MESSAGE: Record = { + [Sep43ErrorCode.Internal]: + 'The wallet encountered an internal error. Please try again or contact the wallet if the problem persists.', + [Sep43ErrorCode.ExternalService]: + 'An error occurred with an external service. Please try again.', + [Sep43ErrorCode.InvalidRequest]: + 'Request is invalid. Please check the details and try again.', + [Sep43ErrorCode.UserRejected]: 'The user rejected this request.', +}; + +/** + * Structured SEP-43 error envelope returned to the dapp on failure. + */ +export class Sep43Error extends Error { + readonly code: Sep43ErrorCode; + + readonly ext: string[] | undefined; + + constructor(params: { + code: Sep43ErrorCode; + message?: string; + ext?: string[]; + }) { + super(params.message ?? SEP43_DEFAULT_MESSAGE[params.code]); + this.name = 'Sep43Error'; + this.code = params.code; + this.ext = params.ext; + } + + /** + * Serializes to the SEP-43 `error` shape. + * + * @returns The serialized error envelope (`message`, `code`, optional `ext`). + */ + toEnvelope(): { message: string; code: number; ext?: string[] } { + return { + message: this.message, + code: this.code, + ...(this.ext === undefined ? {} : { ext: this.ext }), + }; + } +} + +/** + * Maps any thrown error to a {@link Sep43Error}, classifying by known internal types. + * Pass-through for `Sep43Error`; everything else falls back to {@link Sep43ErrorCode.Internal}. + * + * @param error - The thrown value. + * @returns A {@link Sep43Error} ready to serialize back to the dapp. + */ +export function toSep43Error(error: unknown): Sep43Error { + if (error instanceof Sep43Error) { + return error; + } + + const wrapped = ensureError(error); + + if (wrapped instanceof UserRejectedRequestError) { + return new Sep43Error({ code: Sep43ErrorCode.UserRejected }); + } + + if ( + // `validateRequest` rewraps StructError as InvalidParamsError before it + // reaches us, so we accept both shapes here. + wrapped instanceof InvalidParamsError || + wrapped instanceof StructError || + // Catches AccountNotFoundException + DerivedAccountAddressMismatchException + // (both extend AccountServiceException) — typically caused by a bad + // `opts.address` from the dapp. + wrapped instanceof AccountServiceException || + wrapped instanceof TransactionValidationException || + wrapped instanceof TransactionScopeNotMatchException + ) { + return new Sep43Error({ + code: Sep43ErrorCode.InvalidRequest, + ext: [wrapped.message], + }); + } + + if ( + wrapped instanceof AccountNotActivatedException || + wrapped instanceof AccountLoadException || + wrapped instanceof AssetDataFetchException || + wrapped instanceof BaseFeeFetchException || + wrapped instanceof SimulationException || + wrapped instanceof TransactionPollException || + wrapped instanceof TransactionRetryableException || + wrapped instanceof TransactionSendException || + wrapped instanceof NetworkServiceException + ) { + return new Sep43Error({ + code: Sep43ErrorCode.ExternalService, + ext: [wrapped.message], + }); + } + + return new Sep43Error({ code: Sep43ErrorCode.Internal }); +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/index.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/index.ts new file mode 100644 index 00000000..49c4898d --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/sep43/index.ts @@ -0,0 +1,5 @@ +export * from './api'; +export * from './base'; +export * from './exceptions'; +export * from './signMessage'; +export * from './signTransaction'; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts new file mode 100644 index 00000000..8c593561 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts @@ -0,0 +1,88 @@ +/* eslint-disable @typescript-eslint/no-unused-vars, jest/no-disabled-tests */ +import { Sep43Method, type Sep43SignMessageRequest } from './api'; +import { Sep43SignMessageHandler } from './signMessage'; +import { KnownCaip2ChainId } from '../../api'; +import { AccountService } from '../../services/account'; +import { generateMockStellarKeyringAccounts } from '../../services/account/__mocks__/account.fixtures'; +import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; +import { WalletService } from '../../services/wallet'; +import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; +import type { ConfirmationUXController } from '../../ui/confirmation/controller'; +import { logger } from '../../utils/logger'; + +jest.mock('../../utils/logger'); + +describe.skip('Sep43SignMessageHandler', () => { + /** + * Builds a `Sep43SignMessageHandler` with mocked account / wallet resolution + * and a stubbed `ConfirmationUXController`. + * + * @returns Handler instance and the test doubles needed by each spec. + */ + function setupHandler() { + const wallet = getTestWallet(); + const [mockAccount] = generateMockStellarKeyringAccounts( + 1, + 'entropy-source-1', + ); + if (!mockAccount) { + throw new Error('mockAccount is undefined'); + } + + const { accountService, walletService } = mockOnChainAccountService(); + + jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ + account: { ...mockAccount, address: wallet.address }, + }); + + jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + + const renderConfirmationDialog = jest.fn(); + const confirmationUIController = { + renderConfirmationDialog, + } as Pick< + ConfirmationUXController, + 'renderConfirmationDialog' + > as unknown as ConfirmationUXController; + + const handler = new Sep43SignMessageHandler({ + logger, + accountService, + walletService, + confirmationUIController, + }); + + return { handler, mockAccount, wallet, renderConfirmationDialog }; + } + + const buildRequest = ( + overrides: Partial = {}, + ): Sep43SignMessageRequest => ({ + id: '11111111-1111-4111-8111-111111111111', + origin: 'https://example.com', + scope: KnownCaip2ChainId.Mainnet, + account: '00000000-0000-4000-8000-000000000001', + request: { + method: Sep43Method.SignMessage, + params: { + message: btoa('hello stellar'), + }, + }, + ...overrides, + }); + + // TODO: implement specs + it.todo('returns signedMessage and signerAddress on confirm'); + it.todo('returns error -4 when user rejects'); + it.todo('returns error -3 when scope is testnet'); + it.todo( + 'returns error -3 when opts.networkPassphrase is not the mainnet passphrase', + ); + it.todo('returns error -3 when opts.submit or opts.submitUrl is provided'); + it.todo( + 'returns error -3 when opts.address does not match the wrapper account', + ); + it.todo('returns error -3 when message is not valid base64'); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.ts new file mode 100644 index 00000000..57254182 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.ts @@ -0,0 +1,107 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; + +import type { Sep43SignMessageRequest, Sep43SignMessageResponse } from './api'; +import { Sep43SignMessageRequestStruct } from './api'; +import { BaseSep43Handler } from './base'; +import type { Sep43Error } from './exceptions'; +import type { + AccountService, + StellarKeyringAccount, +} from '../../services/account'; +import type { Wallet, WalletService } from '../../services/wallet'; +import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import type { ConfirmationUXController } from '../../ui/confirmation/controller'; +import type { ILogger } from '../../utils'; +import { bufferToUint8Array } from '../../utils'; +import { isBase64 } from '../../utils/string'; + +/** + * SEP-43 `SignMessage` handler. + * + * Reuses the existing sign-message confirmation view via {@link ConfirmationUXController}. + * Returns the SEP-43 response shape (`signedMessage`, `signerAddress`, optional `error`) + * and never throws to the dapp — failures are wrapped in the `error` envelope by the base. + */ +export class Sep43SignMessageHandler extends BaseSep43Handler< + Sep43SignMessageRequest, + Sep43SignMessageResponse +> { + readonly #confirmationUIController: ConfirmationUXController; + + constructor({ + logger, + accountService, + walletService, + confirmationUIController, + }: { + logger: ILogger; + accountService: AccountService; + walletService: WalletService; + confirmationUIController: ConfirmationUXController; + }) { + super({ + logger, + accountService, + walletService, + loggerPrefix: '[✉️ Sep43SignMessageHandler]', + requestStruct: Sep43SignMessageRequestStruct, + }); + this.#confirmationUIController = confirmationUIController; + } + + protected async execute( + request: Sep43SignMessageRequest, + resolved: { account: StellarKeyringAccount; wallet: Wallet }, + ): Promise { + const { account, wallet } = resolved; + const { message } = request.request.params; + + if (!(await this.#confirm(request, account, message))) { + throw new UserRejectedRequestError() as unknown as Error; + } + + const signedMessage = await wallet.signMessage(message); + + return { + signedMessage, + signerAddress: account.address, + }; + } + + protected toErrorResponse( + signerAddress: string, + error: Sep43Error, + ): Sep43SignMessageResponse { + return { + // SEP-43 schema requires the field even on error; keep it empty when unknown. + signedMessage: '', + signerAddress, + error: error.toEnvelope(), + }; + } + + async #confirm( + request: Sep43SignMessageRequest, + account: StellarKeyringAccount, + message: string, + ): Promise { + return ( + (await this.#confirmationUIController.renderConfirmationDialog({ + scope: request.scope, + renderContext: { + account, + message: this.#getUtf8Message(message), + }, + origin: request.origin, + interfaceKey: ConfirmationInterfaceKey.SignMessage, + })) === true + ); + } + + #getUtf8Message(message: string): string { + if (isBase64(message)) { + return bufferToUint8Array(message, 'base64').toString('utf8'); + } + return message; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts new file mode 100644 index 00000000..eaf836ce --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts @@ -0,0 +1,68 @@ +/* eslint-disable @typescript-eslint/no-unused-vars, jest/no-disabled-tests */ +import { Sep43Method, type Sep43SignTransactionRequest } from './api'; +import { Sep43SignTransactionHandler } from './signTransaction'; +import { KnownCaip2ChainId } from '../../api'; +import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; +import { createMockTransactionService } from '../../services/transaction/__mocks__/transaction.fixtures'; +import { logger } from '../../utils/logger'; + +jest.mock('../../utils/logger'); + +describe.skip('Sep43SignTransactionHandler', () => { + /** + * Builds a `Sep43SignTransactionHandler` with mocked services. + * + * @returns Handler instance and the test doubles needed by each spec. + */ + function setupHandler() { + const { transactionBuilder, transactionService } = + createMockTransactionService(); + const { accountService, walletService } = mockOnChainAccountService(); + + return { + transactionBuilder, + transactionService, + accountService, + walletService, + logger, + }; + } + + const buildRequest = ( + overrides: Partial = {}, + ): Sep43SignTransactionRequest => ({ + id: '22222222-2222-4222-8222-222222222222', + origin: 'https://example.com', + scope: KnownCaip2ChainId.Mainnet, + account: '00000000-0000-4000-8000-000000000001', + request: { + method: Sep43Method.SignTransaction, + params: { + // Replace with a valid mainnet XDR built via buildMockClassicTransaction + // before implementing specs. + xdr: 'placeholder', + }, + }, + ...overrides, + }); + + // TODO: implement specs + it.todo('returns signedTxXdr and signerAddress on confirm'); + it.todo('returns error -4 when user rejects'); + it.todo('returns error -3 when XDR is invalid'); + it.todo( + 'returns error -3 when XDR network does not match scope (testnet XDR on mainnet scope)', + ); + it.todo( + 'returns error -3 when wallet account does not participate in the transaction', + ); + it.todo('returns error -3 when scope is testnet'); + it.todo( + 'returns error -3 when opts.networkPassphrase is not the mainnet passphrase', + ); + it.todo('returns error -3 when opts.submit or opts.submitUrl is provided'); + it.todo( + 'returns error -3 when opts.address does not match the wrapper account', + ); + it.todo('returns error -2 when fee simulation fails (Soroban)'); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.ts new file mode 100644 index 00000000..14573ce9 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.ts @@ -0,0 +1,149 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; + +import type { + Sep43SignTransactionRequest, + Sep43SignTransactionResponse, +} from './api'; +import { Sep43SignTransactionRequestStruct } from './api'; +import { BaseSep43Handler } from './base'; +import type { Sep43Error } from './exceptions'; +import type { + AccountService, + StellarKeyringAccount, +} from '../../services/account'; +import type { + Transaction, + TransactionBuilder, + TransactionService, +} from '../../services/transaction'; +import { OperationMapper } from '../../services/transaction'; +import { + assertAccountInvolvesTransaction, + assertTransactionScope, + collectTransactionAssetCaipIds, +} from '../../services/transaction/utils'; +import type { Wallet, WalletService } from '../../services/wallet'; +import type { ContextWithPrices } from '../../ui/confirmation/api'; +import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import type { ConfirmationUXController } from '../../ui/confirmation/controller'; +import type { ILogger } from '../../utils'; + +/** + * SEP-43 `SignTransaction` handler. + * + * Reuses the existing sign-transaction confirmation view via {@link ConfirmationUXController}. + * Returns the SEP-43 response shape (`signedTxXdr`, `signerAddress`, optional `error`) + * and never throws to the dapp — failures are wrapped in the `error` envelope by the base. + */ +export class Sep43SignTransactionHandler extends BaseSep43Handler< + Sep43SignTransactionRequest, + Sep43SignTransactionResponse +> { + readonly #transactionBuilder: TransactionBuilder; + + readonly #transactionService: TransactionService; + + readonly #confirmationUIController: ConfirmationUXController; + + constructor({ + logger, + accountService, + walletService, + transactionBuilder, + transactionService, + confirmationUIController, + }: { + logger: ILogger; + accountService: AccountService; + walletService: WalletService; + transactionBuilder: TransactionBuilder; + transactionService: TransactionService; + confirmationUIController: ConfirmationUXController; + }) { + super({ + logger, + accountService, + walletService, + loggerPrefix: '[📝 Sep43SignTransactionHandler]', + requestStruct: Sep43SignTransactionRequestStruct, + }); + this.#transactionBuilder = transactionBuilder; + this.#transactionService = transactionService; + this.#confirmationUIController = confirmationUIController; + } + + protected async execute( + request: Sep43SignTransactionRequest, + resolved: { account: StellarKeyringAccount; wallet: Wallet }, + ): Promise { + const { account, wallet } = resolved; + const { scope } = request; + const { xdr } = request.request.params; + + // Deserializing validates that the transaction is well-formed and scope-compatible. + const transaction = this.#transactionBuilder.deserialize({ xdr, scope }); + + assertTransactionScope(transaction, scope); + assertAccountInvolvesTransaction(transaction, wallet.address); + + const transactionWithFee = + await this.#transactionService.computingFee(transaction); + + if (!(await this.#confirm(request, transactionWithFee, account))) { + throw new UserRejectedRequestError() as unknown as Error; + } + + wallet.signTransaction(transactionWithFee); + const signedTxXdr = transactionWithFee.getRaw().toXDR(); + + return { + signedTxXdr, + signerAddress: account.address, + }; + } + + protected toErrorResponse( + signerAddress: string, + error: Sep43Error, + ): Sep43SignTransactionResponse { + return { + // SEP-43 schema requires the field even on error; keep it empty when unknown. + signedTxXdr: '', + signerAddress, + error: error.toEnvelope(), + }; + } + + async #confirm( + request: Sep43SignTransactionRequest, + transaction: Transaction, + account: StellarKeyringAccount, + ): Promise { + const readableTransaction = new OperationMapper().mapTransaction( + transaction, + ); + + // Seed every asset id we render so the cron refresh updates prices for all of them. + // The `as` cast bypasses superstruct typing that requires every union key. + const tokenPrices = Object.fromEntries( + collectTransactionAssetCaipIds(request.scope, readableTransaction).map( + (assetId) => [assetId, null] as const, + ), + ) as ContextWithPrices['tokenPrices']; + + return ( + (await this.#confirmationUIController.renderConfirmationDialog({ + scope: request.scope, + origin: request.origin, + interfaceKey: ConfirmationInterfaceKey.SignTransaction, + fee: readableTransaction.feeStroops, + renderContext: { + readableTransaction, + account, + }, + renderOptions: { loadPrice: true }, + tokenPrices, + })) === true + ); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/index.ts b/merged-packages/stellar-wallet-snap/src/index.ts index f9d01ff0..ff0af41c 100644 --- a/merged-packages/stellar-wallet-snap/src/index.ts +++ b/merged-packages/stellar-wallet-snap/src/index.ts @@ -18,7 +18,10 @@ import { signTransactionHandler, cronjobHandler, assetsHandler, + sep43SignMessageHandler, + sep43SignTransactionHandler, } from './context'; +import { Sep43Method } from './handlers/sep43'; export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ( args, @@ -47,16 +50,27 @@ export const onCronjob: OnCronjobHandler = async ({ request }) => export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { const { method } = request; - switch (method) { - case 'stellar_signMessage': - return signMessageHandler.handle( - request.params as unknown as JsonRpcRequest, - ); - case 'stellar_signTransaction': - return signTransactionHandler.handle( - request.params as unknown as JsonRpcRequest, - ); - default: - throw new MethodNotFoundError() as Error; + // SEP-43 dapp-facing methods. Both handlers always resolve to the SEP-43 + // response shape (success or error envelope) — they never throw to the dapp. + // @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md + if (method === String(Sep43Method.SignMessage)) { + return sep43SignMessageHandler.handle(request.params); } + if (method === String(Sep43Method.SignTransaction)) { + return sep43SignTransactionHandler.handle(request.params); + } + + // TODO: deprecate the legacy `stellar_*` methods once dapps migrate to SEP-43. + if (method === 'stellar_signMessage') { + return signMessageHandler.handle( + request.params as unknown as JsonRpcRequest, + ); + } + if (method === 'stellar_signTransaction') { + return signTransactionHandler.handle( + request.params as unknown as JsonRpcRequest, + ); + } + + throw new MethodNotFoundError() as Error; }; From a7aa6b79a8ef96d5cc522f42df150e5584022a45 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Thu, 23 Apr 2026 18:01:42 +0200 Subject: [PATCH 108/384] feat(snap): add SEP-43 tests + dapp card --- .../src/handlers/sep43/signMessage.test.ts | 175 ++++++++-- .../handlers/sep43/signTransaction.test.ts | 321 ++++++++++++++++-- 2 files changed, 435 insertions(+), 61 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts index 8c593561..591ac499 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts @@ -1,9 +1,12 @@ -/* eslint-disable @typescript-eslint/no-unused-vars, jest/no-disabled-tests */ +import { Networks } from '@stellar/stellar-sdk'; + import { Sep43Method, type Sep43SignMessageRequest } from './api'; +import { Sep43ErrorCode } from './exceptions'; import { Sep43SignMessageHandler } from './signMessage'; import { KnownCaip2ChainId } from '../../api'; import { AccountService } from '../../services/account'; -import { generateMockStellarKeyringAccounts } from '../../services/account/__mocks__/account.fixtures'; +import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; +import { AccountNotFoundException } from '../../services/account/exceptions'; import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; import { WalletService } from '../../services/wallet'; import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; @@ -12,7 +15,7 @@ import { logger } from '../../utils/logger'; jest.mock('../../utils/logger'); -describe.skip('Sep43SignMessageHandler', () => { +describe('Sep43SignMessageHandler', () => { /** * Builds a `Sep43SignMessageHandler` with mocked account / wallet resolution * and a stubbed `ConfirmationUXController`. @@ -21,19 +24,19 @@ describe.skip('Sep43SignMessageHandler', () => { */ function setupHandler() { const wallet = getTestWallet(); - const [mockAccount] = generateMockStellarKeyringAccounts( - 1, + const accountId = globalThis.crypto.randomUUID(); + const mockAccount = generateStellarKeyringAccount( + accountId, + wallet.address, 'entropy-source-1', + 0, ); - if (!mockAccount) { - throw new Error('mockAccount is undefined'); - } const { accountService, walletService } = mockOnChainAccountService(); - jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ - account: { ...mockAccount, address: wallet.address }, - }); + const resolveAccountSpy = jest + .spyOn(AccountService.prototype, 'resolveAccount') + .mockResolvedValue({ account: mockAccount }); jest .spyOn(WalletService.prototype, 'resolveWallet') @@ -54,35 +57,153 @@ describe.skip('Sep43SignMessageHandler', () => { confirmationUIController, }); - return { handler, mockAccount, wallet, renderConfirmationDialog }; + return { + handler, + mockAccount, + wallet, + renderConfirmationDialog, + resolveAccountSpy, + }; } const buildRequest = ( - overrides: Partial = {}, + accountId: string, + overrides: Partial = {}, ): Sep43SignMessageRequest => ({ id: '11111111-1111-4111-8111-111111111111', origin: 'https://example.com', scope: KnownCaip2ChainId.Mainnet, - account: '00000000-0000-4000-8000-000000000001', + account: accountId, request: { method: Sep43Method.SignMessage, params: { message: btoa('hello stellar'), + ...overrides, }, }, - ...overrides, }); - // TODO: implement specs - it.todo('returns signedMessage and signerAddress on confirm'); - it.todo('returns error -4 when user rejects'); - it.todo('returns error -3 when scope is testnet'); - it.todo( - 'returns error -3 when opts.networkPassphrase is not the mainnet passphrase', - ); - it.todo('returns error -3 when opts.submit or opts.submitUrl is provided'); - it.todo( - 'returns error -3 when opts.address does not match the wrapper account', - ); - it.todo('returns error -3 when message is not valid base64'); + it('returns signedMessage and signerAddress on confirm', async () => { + const { handler, mockAccount, wallet, renderConfirmationDialog } = + setupHandler(); + renderConfirmationDialog.mockResolvedValue(true); + + const result = await handler.handle(buildRequest(mockAccount.id)); + + const expected = await wallet.signMessage(btoa('hello stellar')); + expect(result).toStrictEqual({ + signedMessage: expected, + signerAddress: wallet.address, + }); + }); + + it('returns error -4 when user rejects', async () => { + const { handler, mockAccount, wallet, renderConfirmationDialog } = + setupHandler(); + renderConfirmationDialog.mockResolvedValue(false); + + const result = await handler.handle(buildRequest(mockAccount.id)); + + expect(result.signedMessage).toBe(''); + expect(result.signerAddress).toBe(wallet.address); + expect(result.error?.code).toBe(Sep43ErrorCode.UserRejected); + }); + + it('returns error -3 when scope is testnet', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const result = await handler.handle({ + ...buildRequest(mockAccount.id), + scope: KnownCaip2ChainId.Testnet, + }); + + expect(result.signedMessage).toBe(''); + expect(result.signerAddress).toBe(''); + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -3 when opts.networkPassphrase is not the mainnet passphrase', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const result = await handler.handle( + buildRequest(mockAccount.id, { + opts: { networkPassphrase: Networks.TESTNET }, + }), + ); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(result.error?.ext?.[0]).toContain('mainnet'); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it.each([ + ['opts.submit', { submit: true }], + ['opts.submitUrl', { submitUrl: 'https://horizon.stellar.org' }], + ])('returns error -3 when %s is provided', async (_label, forbiddenOpts) => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const base = buildRequest(mockAccount.id); + // Inject the forbidden opt bypassing the struct type so we can assert the + // handler rejects it at runtime with -3 InvalidRequest. + (base.request.params as unknown as { opts: Record }).opts = + forbiddenOpts; + + const result = await handler.handle(base); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -3 when opts.address cannot be resolved', async () => { + const { handler, mockAccount, resolveAccountSpy } = setupHandler(); + const unknownAddress = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB'; + resolveAccountSpy.mockRejectedValueOnce( + new AccountNotFoundException(unknownAddress), + ); + + const result = await handler.handle( + buildRequest(mockAccount.id, { opts: { address: unknownAddress } }), + ); + + expect(result.signedMessage).toBe(''); + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + }); + + it('returns error -3 when opts.address resolves to a different account than the wrapper UUID', async () => { + const { + handler, + mockAccount, + renderConfirmationDialog, + resolveAccountSpy, + } = setupHandler(); + const otherAccount = generateStellarKeyringAccount( + globalThis.crypto.randomUUID(), + mockAccount.address, + 'entropy-source-1', + 1, + ); + resolveAccountSpy.mockResolvedValueOnce({ account: otherAccount }); + + const result = await handler.handle( + buildRequest(mockAccount.id, { + opts: { address: otherAccount.address }, + }), + ); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -3 when message is not valid base64', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const result = await handler.handle( + buildRequest(mockAccount.id, { message: 'not valid base64 !!!' }), + ); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts index eaf836ce..e027e8a6 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts @@ -1,68 +1,321 @@ -/* eslint-disable @typescript-eslint/no-unused-vars, jest/no-disabled-tests */ +import { Keypair, Networks } from '@stellar/stellar-sdk'; + import { Sep43Method, type Sep43SignTransactionRequest } from './api'; +import { Sep43ErrorCode } from './exceptions'; import { Sep43SignTransactionHandler } from './signTransaction'; import { KnownCaip2ChainId } from '../../api'; +import { AccountService } from '../../services/account'; +import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; +import { SimulationException } from '../../services/network/exceptions'; import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; -import { createMockTransactionService } from '../../services/transaction/__mocks__/transaction.fixtures'; +import type { Transaction } from '../../services/transaction'; +import { TransactionService } from '../../services/transaction'; +import { + buildMockClassicTransaction, + createMockTransactionService, +} from '../../services/transaction/__mocks__/transaction.fixtures'; +import { WalletService } from '../../services/wallet'; +import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; +import type { ConfirmationUXController } from '../../ui/confirmation/controller'; import { logger } from '../../utils/logger'; jest.mock('../../utils/logger'); -describe.skip('Sep43SignTransactionHandler', () => { +describe('Sep43SignTransactionHandler', () => { /** - * Builds a `Sep43SignTransactionHandler` with mocked services. + * Builds a `Sep43SignTransactionHandler` with mocked services + account/wallet + * resolution, plus a stubbed `ConfirmationUXController`. * * @returns Handler instance and the test doubles needed by each spec. */ function setupHandler() { + const wallet = getTestWallet(); + const mockAccount = generateStellarKeyringAccount( + globalThis.crypto.randomUUID(), + wallet.address, + 'entropy-source-1', + 0, + ); + const { transactionBuilder, transactionService } = createMockTransactionService(); const { accountService, walletService } = mockOnChainAccountService(); + const resolveAccountSpy = jest + .spyOn(AccountService.prototype, 'resolveAccount') + .mockResolvedValue({ account: mockAccount }); + + jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + + // Default: pass-through fee (no Soroban simulation needed for classic tx). + jest + .spyOn(TransactionService.prototype, 'computingFee') + .mockImplementation(async (tx) => tx); + + const renderConfirmationDialog = jest.fn(); + const confirmationUIController = { + renderConfirmationDialog, + } as Pick< + ConfirmationUXController, + 'renderConfirmationDialog' + > as unknown as ConfirmationUXController; + + const handler = new Sep43SignTransactionHandler({ + logger, + accountService, + walletService, + transactionBuilder, + transactionService, + confirmationUIController, + }); + return { + handler, + mockAccount, + wallet, transactionBuilder, transactionService, - accountService, - walletService, - logger, + renderConfirmationDialog, + resolveAccountSpy, }; } + /** + * Builds a mainnet payment transaction whose source is the wallet so it + * passes `assertAccountInvolvesTransaction`. + * + * @param walletAddress - Wallet's Stellar public key (`G…`). + * @returns Mock transaction built with `Networks.PUBLIC`. + */ + function buildMainnetPaymentFromWallet(walletAddress: string): Transaction { + return buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: Keypair.random().publicKey(), + asset: 'native', + amount: '1', + }, + }, + ], + { + networkPassphrase: Networks.PUBLIC, + source: { accountId: walletAddress, sequence: '1' }, + }, + ); + } + const buildRequest = ( - overrides: Partial = {}, + accountId: string, + xdr: string, + overrides: Partial = {}, ): Sep43SignTransactionRequest => ({ id: '22222222-2222-4222-8222-222222222222', origin: 'https://example.com', scope: KnownCaip2ChainId.Mainnet, - account: '00000000-0000-4000-8000-000000000001', + account: accountId, request: { method: Sep43Method.SignTransaction, - params: { - // Replace with a valid mainnet XDR built via buildMockClassicTransaction - // before implementing specs. - xdr: 'placeholder', - }, + params: { xdr, ...overrides }, }, - ...overrides, }); - // TODO: implement specs - it.todo('returns signedTxXdr and signerAddress on confirm'); - it.todo('returns error -4 when user rejects'); - it.todo('returns error -3 when XDR is invalid'); - it.todo( - 'returns error -3 when XDR network does not match scope (testnet XDR on mainnet scope)', - ); - it.todo( - 'returns error -3 when wallet account does not participate in the transaction', - ); - it.todo('returns error -3 when scope is testnet'); - it.todo( - 'returns error -3 when opts.networkPassphrase is not the mainnet passphrase', - ); - it.todo('returns error -3 when opts.submit or opts.submitUrl is provided'); - it.todo( - 'returns error -3 when opts.address does not match the wrapper account', - ); - it.todo('returns error -2 when fee simulation fails (Soroban)'); + it('returns signedTxXdr and signerAddress on confirm', async () => { + const { + handler, + mockAccount, + wallet, + transactionBuilder, + renderConfirmationDialog, + } = setupHandler(); + + const transaction = buildMainnetPaymentFromWallet(wallet.address); + const xdr = transaction.getRaw().toXDR(); + jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); + const signSpy = jest.spyOn(wallet, 'signTransaction'); + renderConfirmationDialog.mockResolvedValue(true); + + const result = await handler.handle(buildRequest(mockAccount.id, xdr)); + + expect(signSpy).toHaveBeenCalledWith(transaction); + expect(result.signedTxXdr).toStrictEqual(transaction.getRaw().toXDR()); + expect(result.signerAddress).toBe(wallet.address); + expect(result.error).toBeUndefined(); + }); + + it('returns error -4 when user rejects', async () => { + const { + handler, + mockAccount, + wallet, + transactionBuilder, + renderConfirmationDialog, + } = setupHandler(); + + const transaction = buildMainnetPaymentFromWallet(wallet.address); + const xdr = transaction.getRaw().toXDR(); + jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); + const signSpy = jest.spyOn(wallet, 'signTransaction'); + renderConfirmationDialog.mockResolvedValue(false); + + const result = await handler.handle(buildRequest(mockAccount.id, xdr)); + + expect(signSpy).not.toHaveBeenCalled(); + expect(result.signedTxXdr).toBe(''); + expect(result.signerAddress).toBe(wallet.address); + expect(result.error?.code).toBe(Sep43ErrorCode.UserRejected); + }); + + it('returns error -3 when XDR is invalid', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const result = await handler.handle( + buildRequest(mockAccount.id, 'not-an-xdr'), + ); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -3 when the transaction scope does not match the request scope', async () => { + const { + handler, + mockAccount, + wallet, + transactionBuilder, + renderConfirmationDialog, + } = setupHandler(); + + // Build a TESTNET transaction but request signing on MAINNET scope. + const testnetTx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: Keypair.random().publicKey(), + asset: 'native', + amount: '1', + }, + }, + ], + { + networkPassphrase: Networks.TESTNET, + source: { accountId: wallet.address, sequence: '1' }, + }, + ); + jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(testnetTx); + + const result = await handler.handle( + buildRequest(mockAccount.id, testnetTx.getRaw().toXDR()), + ); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -3 when the wallet does not participate in the transaction', async () => { + const { + handler, + mockAccount, + transactionBuilder, + renderConfirmationDialog, + } = setupHandler(); + + const strangerTx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: Keypair.random().publicKey(), + asset: 'native', + amount: '1', + }, + }, + ], + { + networkPassphrase: Networks.PUBLIC, + source: { + accountId: Keypair.random().publicKey(), + sequence: '1', + }, + }, + ); + jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(strangerTx); + + const result = await handler.handle( + buildRequest(mockAccount.id, strangerTx.getRaw().toXDR()), + ); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -3 when scope is testnet', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const result = await handler.handle({ + ...buildRequest(mockAccount.id, 'AAAA'), + scope: KnownCaip2ChainId.Testnet, + }); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -3 when opts.networkPassphrase is not the mainnet passphrase', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const result = await handler.handle( + buildRequest(mockAccount.id, 'AAAA', { + opts: { networkPassphrase: Networks.TESTNET }, + }), + ); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it.each([ + ['opts.submit', { submit: true }], + ['opts.submitUrl', { submitUrl: 'https://horizon.stellar.org' }], + ])('returns error -3 when %s is provided', async (_label, forbiddenOpts) => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const base = buildRequest(mockAccount.id, 'AAAA'); + (base.request.params as unknown as { opts: Record }).opts = + forbiddenOpts; + + const result = await handler.handle(base); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -2 when fee simulation fails', async () => { + const { + handler, + mockAccount, + wallet, + transactionBuilder, + transactionService, + renderConfirmationDialog, + } = setupHandler(); + + const transaction = buildMainnetPaymentFromWallet(wallet.address); + jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); + jest + .spyOn(transactionService, 'computingFee') + .mockRejectedValueOnce(new SimulationException('contract not found')); + + const result = await handler.handle( + buildRequest(mockAccount.id, transaction.getRaw().toXDR()), + ); + + expect(result.error?.code).toBe(Sep43ErrorCode.ExternalService); + expect(result.error?.ext?.[0]).toContain('Failed to simulate transaction'); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); }); From c26bf1f43a755231840eda6de5c3c86bcb8fc959 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Thu, 23 Apr 2026 18:46:10 +0200 Subject: [PATCH 109/384] fix: address Copilot review on SEP-43 handlers --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/sep43/api.ts | 17 +++++--- .../src/handlers/sep43/base.ts | 42 +++++++------------ .../src/handlers/sep43/signMessage.test.ts | 27 ++++++++---- .../handlers/sep43/signTransaction.test.ts | 31 ++++++++++---- .../stellar-wallet-snap/src/index.ts | 11 +++-- 6 files changed, 78 insertions(+), 52 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index a0a94359..1391b5e3 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "26Q/bDok4/+dUQVEql0ZHDEZSDUpRZulFsKxsr0CZ00=", + "shasum": "3lXjnAEgms2CIAT6rFRWPdpFMmtbtHP+975khMgbDs0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/api.ts index fa94683b..d1fac164 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/sep43/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/sep43/api.ts @@ -115,11 +115,13 @@ export type Sep43ErrorEnvelope = Infer; /** * SEP-43 SignMessage response. * - * `signedMessage` is base64-encoded (matches the rest of the codebase / SEP-53 byte signing). + * `signedMessage` is base64-encoded on success; empty string on error. + * `signerAddress` is the signer's G-address on success, or empty when + * account resolution failed before we could determine the address. */ export const Sep43SignMessageResponseStruct = object({ - signedMessage: nonempty(base64(string())), - signerAddress: StellarAddressStruct, + signedMessage: union([nonempty(base64(string())), literal('')]), + signerAddress: union([StellarAddressStruct, literal('')]), error: optional(Sep43ErrorEnvelopeStruct), }); @@ -130,11 +132,14 @@ export type Sep43SignMessageResponse = Infer< /** * SEP-43 SignTransaction response. * - * `signedTxXdr` is the signed transaction envelope as base64 XDR. + * `signedTxXdr` is the signed transaction envelope as base64 XDR on success; + * empty string on error. + * `signerAddress` is the signer's G-address on success, or empty when + * account resolution failed before we could determine the address. */ export const Sep43SignTransactionResponseStruct = object({ - signedTxXdr: XdrStruct, - signerAddress: StellarAddressStruct, + signedTxXdr: union([XdrStruct, literal('')]), + signerAddress: union([StellarAddressStruct, literal('')]), error: optional(Sep43ErrorEnvelopeStruct), }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/base.ts index ab056e75..a7ef90f4 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/sep43/base.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/sep43/base.ts @@ -17,12 +17,6 @@ import { validateRequest } from '../../utils/requestResponse'; /** Mainnet is the only network the snap currently signs for. */ const SUPPORTED_PASSPHRASE: string = Networks.PUBLIC; -/** Mapping from supported scope to the matching Stellar SDK passphrase. */ -const SUPPORTED_SCOPE_PASSPHRASE: Record = { - [Caip2.Mainnet]: String(Networks.PUBLIC), - [Caip2.Testnet]: String(Networks.TESTNET), -}; - /** * Base class shared by SEP-43 SignMessage and SignTransaction handlers. * @@ -72,26 +66,32 @@ export abstract class BaseSep43Handler< } /** - * Top-level entry point. Runs the full pipeline (validate → check - * network/opts → resolve account → execute) inside a single try/catch so - * every failure (including struct validation) is serialized into the + * Top-level entry point. Runs the full pipeline (validate → override origin + * → check network/opts → resolve account → execute) inside a single try/catch + * so every failure (including struct validation) is serialized into the * SEP-43 `error` envelope. The dapp never sees a thrown JSON-RPC error. * * @param rawRequest - The unvalidated SEP-43 request as it arrives from the dapp. + * @param trustedOrigin - The verified origin provided by MetaMask's `onRpcRequest` + * handler. Overrides the dapp-supplied `params.origin` so the confirmation UI + * cannot be spoofed by a malicious dapp. * @returns The SEP-43 response with either the success fields or `error` populated. */ - async handle(rawRequest: unknown): Promise { + async handle(rawRequest: unknown, trustedOrigin: string): Promise { let signerAddress = ''; try { const request = validateRequest(rawRequest, this.requestStruct); - this.assertSupportedNetwork(request); - this.assertNoSubmit(request.request.params.opts); + // Override the dapp-supplied origin with the MM-verified one. + const verifiedRequest = { ...request, origin: trustedOrigin }; + + this.assertSupportedNetwork(verifiedRequest); + this.assertNoSubmit(verifiedRequest.request.params.opts); - const { account, wallet } = await this.resolveAccount(request); + const { account, wallet } = await this.resolveAccount(verifiedRequest); signerAddress = account.address; - return await this.execute(request, { account, wallet }); + return await this.execute(verifiedRequest, { account, wallet }); } catch (error: unknown) { const sep43 = toSep43Error(error); this.logger.logErrorWithDetails('SEP-43 request failed', sep43); @@ -185,20 +185,6 @@ export abstract class BaseSep43Handler< ], }); } - - // Defensive: scope and passphrase must agree when both are set. - const scopePassphrase = SUPPORTED_SCOPE_PASSPHRASE[request.scope]; - if ( - requestedPassphrase !== undefined && - requestedPassphrase !== scopePassphrase - ) { - throw new Sep43Error({ - code: Sep43ErrorCode.InvalidRequest, - ext: [ - `opts.networkPassphrase does not match scope (${request.scope}).`, - ], - }); - } } /** diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts index 591ac499..45449c62 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts @@ -15,6 +15,9 @@ import { logger } from '../../utils/logger'; jest.mock('../../utils/logger'); +/** Simulates the verified origin MetaMask passes to `onRpcRequest`. */ +const TRUSTED_ORIGIN = 'https://example.com'; + describe('Sep43SignMessageHandler', () => { /** * Builds a `Sep43SignMessageHandler` with mocked account / wallet resolution @@ -88,7 +91,10 @@ describe('Sep43SignMessageHandler', () => { setupHandler(); renderConfirmationDialog.mockResolvedValue(true); - const result = await handler.handle(buildRequest(mockAccount.id)); + const result = await handler.handle( + buildRequest(mockAccount.id), + TRUSTED_ORIGIN, + ); const expected = await wallet.signMessage(btoa('hello stellar')); expect(result).toStrictEqual({ @@ -102,7 +108,10 @@ describe('Sep43SignMessageHandler', () => { setupHandler(); renderConfirmationDialog.mockResolvedValue(false); - const result = await handler.handle(buildRequest(mockAccount.id)); + const result = await handler.handle( + buildRequest(mockAccount.id), + TRUSTED_ORIGIN, + ); expect(result.signedMessage).toBe(''); expect(result.signerAddress).toBe(wallet.address); @@ -112,10 +121,10 @@ describe('Sep43SignMessageHandler', () => { it('returns error -3 when scope is testnet', async () => { const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); - const result = await handler.handle({ - ...buildRequest(mockAccount.id), - scope: KnownCaip2ChainId.Testnet, - }); + const result = await handler.handle( + { ...buildRequest(mockAccount.id), scope: KnownCaip2ChainId.Testnet }, + TRUSTED_ORIGIN, + ); expect(result.signedMessage).toBe(''); expect(result.signerAddress).toBe(''); @@ -130,6 +139,7 @@ describe('Sep43SignMessageHandler', () => { buildRequest(mockAccount.id, { opts: { networkPassphrase: Networks.TESTNET }, }), + TRUSTED_ORIGIN, ); expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); @@ -149,7 +159,7 @@ describe('Sep43SignMessageHandler', () => { (base.request.params as unknown as { opts: Record }).opts = forbiddenOpts; - const result = await handler.handle(base); + const result = await handler.handle(base, TRUSTED_ORIGIN); expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); expect(renderConfirmationDialog).not.toHaveBeenCalled(); @@ -165,6 +175,7 @@ describe('Sep43SignMessageHandler', () => { const result = await handler.handle( buildRequest(mockAccount.id, { opts: { address: unknownAddress } }), + TRUSTED_ORIGIN, ); expect(result.signedMessage).toBe(''); @@ -190,6 +201,7 @@ describe('Sep43SignMessageHandler', () => { buildRequest(mockAccount.id, { opts: { address: otherAccount.address }, }), + TRUSTED_ORIGIN, ); expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); @@ -201,6 +213,7 @@ describe('Sep43SignMessageHandler', () => { const result = await handler.handle( buildRequest(mockAccount.id, { message: 'not valid base64 !!!' }), + TRUSTED_ORIGIN, ); expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts index e027e8a6..85659042 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts @@ -21,6 +21,9 @@ import { logger } from '../../utils/logger'; jest.mock('../../utils/logger'); +/** Simulates the verified origin MetaMask passes to `onRpcRequest`. */ +const TRUSTED_ORIGIN = 'https://example.com'; + describe('Sep43SignTransactionHandler', () => { /** * Builds a `Sep43SignTransactionHandler` with mocked services + account/wallet @@ -138,7 +141,10 @@ describe('Sep43SignTransactionHandler', () => { const signSpy = jest.spyOn(wallet, 'signTransaction'); renderConfirmationDialog.mockResolvedValue(true); - const result = await handler.handle(buildRequest(mockAccount.id, xdr)); + const result = await handler.handle( + buildRequest(mockAccount.id, xdr), + TRUSTED_ORIGIN, + ); expect(signSpy).toHaveBeenCalledWith(transaction); expect(result.signedTxXdr).toStrictEqual(transaction.getRaw().toXDR()); @@ -161,7 +167,10 @@ describe('Sep43SignTransactionHandler', () => { const signSpy = jest.spyOn(wallet, 'signTransaction'); renderConfirmationDialog.mockResolvedValue(false); - const result = await handler.handle(buildRequest(mockAccount.id, xdr)); + const result = await handler.handle( + buildRequest(mockAccount.id, xdr), + TRUSTED_ORIGIN, + ); expect(signSpy).not.toHaveBeenCalled(); expect(result.signedTxXdr).toBe(''); @@ -174,6 +183,7 @@ describe('Sep43SignTransactionHandler', () => { const result = await handler.handle( buildRequest(mockAccount.id, 'not-an-xdr'), + TRUSTED_ORIGIN, ); expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); @@ -210,6 +220,7 @@ describe('Sep43SignTransactionHandler', () => { const result = await handler.handle( buildRequest(mockAccount.id, testnetTx.getRaw().toXDR()), + TRUSTED_ORIGIN, ); expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); @@ -247,6 +258,7 @@ describe('Sep43SignTransactionHandler', () => { const result = await handler.handle( buildRequest(mockAccount.id, strangerTx.getRaw().toXDR()), + TRUSTED_ORIGIN, ); expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); @@ -256,10 +268,13 @@ describe('Sep43SignTransactionHandler', () => { it('returns error -3 when scope is testnet', async () => { const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); - const result = await handler.handle({ - ...buildRequest(mockAccount.id, 'AAAA'), - scope: KnownCaip2ChainId.Testnet, - }); + const result = await handler.handle( + { + ...buildRequest(mockAccount.id, 'AAAA'), + scope: KnownCaip2ChainId.Testnet, + }, + TRUSTED_ORIGIN, + ); expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); expect(renderConfirmationDialog).not.toHaveBeenCalled(); @@ -272,6 +287,7 @@ describe('Sep43SignTransactionHandler', () => { buildRequest(mockAccount.id, 'AAAA', { opts: { networkPassphrase: Networks.TESTNET }, }), + TRUSTED_ORIGIN, ); expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); @@ -288,7 +304,7 @@ describe('Sep43SignTransactionHandler', () => { (base.request.params as unknown as { opts: Record }).opts = forbiddenOpts; - const result = await handler.handle(base); + const result = await handler.handle(base, TRUSTED_ORIGIN); expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); expect(renderConfirmationDialog).not.toHaveBeenCalled(); @@ -312,6 +328,7 @@ describe('Sep43SignTransactionHandler', () => { const result = await handler.handle( buildRequest(mockAccount.id, transaction.getRaw().toXDR()), + TRUSTED_ORIGIN, ); expect(result.error?.code).toBe(Sep43ErrorCode.ExternalService); diff --git a/merged-packages/stellar-wallet-snap/src/index.ts b/merged-packages/stellar-wallet-snap/src/index.ts index ff0af41c..7c84fa8e 100644 --- a/merged-packages/stellar-wallet-snap/src/index.ts +++ b/merged-packages/stellar-wallet-snap/src/index.ts @@ -47,17 +47,22 @@ export const onUserInput: OnUserInputHandler = async (params) => export const onCronjob: OnCronjobHandler = async ({ request }) => cronjobHandler.handle(request); -export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { +export const onRpcRequest: OnRpcRequestHandler = async ({ + origin, + request, +}) => { const { method } = request; // SEP-43 dapp-facing methods. Both handlers always resolve to the SEP-43 // response shape (success or error envelope) — they never throw to the dapp. + // `origin` comes from MetaMask (verified); we override the dapp-supplied + // `params.origin` so the confirmation UI cannot be phished. // @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md if (method === String(Sep43Method.SignMessage)) { - return sep43SignMessageHandler.handle(request.params); + return sep43SignMessageHandler.handle(request.params, origin); } if (method === String(Sep43Method.SignTransaction)) { - return sep43SignTransactionHandler.handle(request.params); + return sep43SignTransactionHandler.handle(request.params, origin); } // TODO: deprecate the legacy `stellar_*` methods once dapps migrate to SEP-43. From b576e2c7a24d8e80e29c3c89e9816ba312e11c05 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Fri, 24 Apr 2026 19:28:35 +0200 Subject: [PATCH 110/384] refactor: route SEP-43 signing through keyring handlers --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../stellar-wallet-snap/src/context.ts | 25 +- .../src/handlers/keyring/api.test.ts | 129 +++++-- .../src/handlers/keyring/api.ts | 57 ++- .../src/handlers/keyring/base.ts | 263 ++++++++++++- .../src/handlers/keyring/exceptions.ts | 144 +++++++ .../src/handlers/keyring/keyring.test.ts | 95 ++++- .../src/handlers/keyring/signMessage.test.ts | 263 +++++++++---- .../src/handlers/keyring/signMessage.ts | 83 ++-- .../handlers/keyring/signTransaction.test.ts | 354 +++++++++++------- .../src/handlers/keyring/signTransaction.ts | 76 ++-- .../src/handlers/sep43/api.ts | 148 -------- .../src/handlers/sep43/base.ts | 205 ---------- .../src/handlers/sep43/exceptions.ts | 144 ------- .../src/handlers/sep43/index.ts | 5 - .../src/handlers/sep43/signMessage.test.ts | 222 ----------- .../src/handlers/sep43/signMessage.ts | 107 ------ .../handlers/sep43/signTransaction.test.ts | 338 ----------------- .../src/handlers/sep43/signTransaction.ts | 149 -------- .../stellar-wallet-snap/src/index.ts | 85 +++-- .../stellar-wallet-snap/src/permissions.ts | 8 +- 21 files changed, 1198 insertions(+), 1704 deletions(-) delete mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/api.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/base.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/exceptions.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/index.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.ts diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 1391b5e3..d3b73662 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "3lXjnAEgms2CIAT6rFRWPdpFMmtbtHP+975khMgbDs0=", + "shasum": "JehlWHYUIoODRixjEn3Q7IUvZAItnrzPExXlg6u99sc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 3ce04b90..5a185468 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -13,10 +13,6 @@ import { SignMessageHandler, SignTransactionHandler, } from './handlers/keyring'; -import { - Sep43SignMessageHandler, - Sep43SignTransactionHandler, -} from './handlers/sep43'; import { AccountService, AccountsRepository } from './services/account'; import type { AccountBalanceState } from './services/account-balance'; import { @@ -99,8 +95,8 @@ const assetMetadataService = new AssetMetadataService({ const signTransactionHandler = new SignTransactionHandler({ logger, accountService, - onChainAccountService, walletService, + onChainAccountService, transactionBuilder, transactionService, confirmationUIController, @@ -166,23 +162,6 @@ const assetsHandler = new AssetsHandler({ priceService, }); -/** ------------------------------ SEP-43 Handlers (dapp-facing) ------------------------------ */ -const sep43SignMessageHandler = new Sep43SignMessageHandler({ - logger, - accountService, - walletService, - confirmationUIController, -}); - -const sep43SignTransactionHandler = new Sep43SignTransactionHandler({ - logger, - accountService, - walletService, - transactionBuilder, - transactionService, - confirmationUIController, -}); - export { cronjobHandler, assetsHandler, @@ -190,7 +169,5 @@ export { userInputHandler, signTransactionHandler, signMessageHandler, - sep43SignMessageHandler, - sep43SignTransactionHandler, confirmationUIController, }; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts index d64db251..f7973f60 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts @@ -185,7 +185,7 @@ describe('SignMessageRequestStruct', () => { account: account.id, request: { method: MultichainMethod.SignMessage, - params: { message: 'Hello, world!' }, + params: { message: btoa('Hello, world!') }, }, }; @@ -195,12 +195,34 @@ describe('SignMessageRequestStruct', () => { ).not.toThrow(); }); + it('accepts an SEP-43 opts bag with address and networkPassphrase', () => { + expect(() => + assert( + { + ...validSignMessageRequest, + request: { + method: MultichainMethod.SignMessage, + params: { + message: btoa('Hello, world!'), + opts: { + address: account.address, + networkPassphrase: + 'Public Global Stellar Network ; September 2015', + }, + }, + }, + }, + SignMessageRequestStruct, + ), + ).not.toThrow(); + }); + it.each([ { ...validSignMessageRequest, request: { method: MultichainMethod.SignTransaction, - params: { message: 'Hello' }, + params: { message: btoa('Hello') }, }, }, { @@ -210,6 +232,13 @@ describe('SignMessageRequestStruct', () => { params: { message: '' }, }, }, + { + ...validSignMessageRequest, + request: { + method: MultichainMethod.SignMessage, + params: { message: 'not valid base64 !!!' }, + }, + }, { ...validSignMessageRequest, account: 'not-a-uuid', @@ -230,20 +259,39 @@ describe('SignMessageRequestStruct', () => { }); describe('SignMessageResponseStruct', () => { - it('accepts a nonempty base64 signature', () => { + it('accepts a successful signMessage envelope', () => { expect(() => - assert({ signature: btoa('signed') }, SignMessageResponseStruct), + assert( + { + signedMessage: btoa('signed'), + signerAddress: account.address, + }, + SignMessageResponseStruct, + ), ).not.toThrow(); }); - it.each([{ signature: '' }, { signature: 'not!!!valid-base64' }])( - 'rejects an invalid signMessage response', - (response) => { - expect(() => assert(response, SignMessageResponseStruct)).toThrow( - StructError, - ); - }, - ); + it('accepts an error envelope with empty success fields', () => { + expect(() => + assert( + { + signedMessage: '', + signerAddress: '', + error: { message: 'rejected', code: -4 }, + }, + SignMessageResponseStruct, + ), + ).not.toThrow(); + }); + + it.each([ + { signedMessage: 'not!!!valid-base64', signerAddress: account.address }, + { signedMessage: btoa('signed'), signerAddress: 'invalid-address' }, + ])('rejects an invalid signMessage response', (response) => { + expect(() => assert(response, SignMessageResponseStruct)).toThrow( + StructError, + ); + }); }); describe('SignTransactionRequestStruct', () => { @@ -254,7 +302,7 @@ describe('SignTransactionRequestStruct', () => { account: account.id, request: { method: MultichainMethod.SignTransaction, - params: { transaction: xdr }, + params: { xdr }, }, }; @@ -264,19 +312,34 @@ describe('SignTransactionRequestStruct', () => { ).not.toThrow(); }); + it('accepts an SEP-43 opts bag with address', () => { + expect(() => + assert( + { + ...validSignTransactionRequest, + request: { + method: MultichainMethod.SignTransaction, + params: { xdr, opts: { address: account.address } }, + }, + }, + SignTransactionRequestStruct, + ), + ).not.toThrow(); + }); + it.each([ { ...validSignTransactionRequest, request: { method: MultichainMethod.SignMessage, - params: { transaction: xdr }, + params: { xdr }, }, }, { ...validSignTransactionRequest, request: { method: MultichainMethod.SignTransaction, - params: { transaction: 'not-valid-xdr' }, + params: { xdr: 'not-valid-xdr' }, }, }, { @@ -291,20 +354,36 @@ describe('SignTransactionRequestStruct', () => { }); describe('SignTransactionResponseStruct', () => { - it('accepts a signature that is valid transaction envelope XDR', () => { + it('accepts a successful signTransaction envelope', () => { expect(() => - assert({ signature: xdr }, SignTransactionResponseStruct), + assert( + { signedTxXdr: xdr, signerAddress: account.address }, + SignTransactionResponseStruct, + ), ).not.toThrow(); }); - it.each([{ signature: '' }, { signature: 'AAA=' }])( - 'rejects an invalid signTransaction response', - (response) => { - expect(() => assert(response, SignTransactionResponseStruct)).toThrow( - StructError, - ); - }, - ); + it('accepts an error envelope with empty success fields', () => { + expect(() => + assert( + { + signedTxXdr: '', + signerAddress: '', + error: { message: 'invalid', code: -3 }, + }, + SignTransactionResponseStruct, + ), + ).not.toThrow(); + }); + + it.each([ + { signedTxXdr: 'AAA=', signerAddress: account.address }, + { signedTxXdr: xdr, signerAddress: 'invalid-address' }, + ])('rejects an invalid signTransaction response', (response) => { + expect(() => assert(response, SignTransactionResponseStruct)).toThrow( + StructError, + ); + }); }); describe('ListAccountTransactionsRequestStruct', () => { diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts index 18a3e261..553ac65a 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts @@ -26,7 +26,6 @@ import { } from '../../api'; import { StellarAddressStruct } from '../../api/address'; import { KnownCaip2ChainIdStruct } from '../../api/network'; -import { Utf8StringStruct } from '../../api/string'; import { UuidStruct } from '../../api/uuid'; import { XdrStruct } from '../../api/xdr'; @@ -88,8 +87,37 @@ export const DiscoverAccountsStruct = object({ groupIndex: min(integer(), 0), }); +/** + * Optional bag accepted by both SEP-43 sign methods. + * + * `submit` and `submitUrl` are intentionally omitted from the schema: + * the snap signs only and rejects any caller asking for submission. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md + */ +export const Sep43OptsStruct = object({ + networkPassphrase: optional(nonempty(string())), + address: optional(StellarAddressStruct), +}); + +export type Sep43Opts = Infer; + +/** + * Shape of the SEP-43 error envelope returned alongside the success fields. + */ +export const Sep43ErrorEnvelopeStruct = object({ + message: nonempty(string()), + code: number(), + ext: optional(array(string())), +}); + +export type Sep43ErrorEnvelope = Infer; + /** * Validation struct for the signMessage request. + * + * Params follow the SEP-43 `SignMessage` shape: a base64-encoded message and + * the optional `opts` bag (`address`, `networkPassphrase`). */ export const SignMessageRequestStruct = assign( KeyringRequestStruct, @@ -97,7 +125,8 @@ export const SignMessageRequestStruct = assign( request: object({ method: literal(MultichainMethod.SignMessage), params: object({ - message: nonempty(union([base64(string()), Utf8StringStruct])), + message: nonempty(base64(string())), + opts: optional(Sep43OptsStruct), }), }), scope: KnownCaip2ChainIdStruct, @@ -107,13 +136,23 @@ export const SignMessageRequestStruct = assign( /** * Validation struct for the signMessage response. + * + * `signedMessage` is base64-encoded on success; empty string on error. + * `signerAddress` is the signer's G-address on success, or empty when + * account resolution failed before we could determine the address. */ export const SignMessageResponseStruct = object({ - signature: nonempty(base64(string())), + signedMessage: union([nonempty(base64(string())), literal('')]), + signerAddress: union([StellarAddressStruct, literal('')]), + error: optional(Sep43ErrorEnvelopeStruct), }); /** * Validation struct for the signTransaction request. + * + * Params follow the SEP-43 `SignTransaction` shape: a base64-encoded + * transaction envelope XDR and the optional `opts` bag (`address`, + * `networkPassphrase`). */ export const SignTransactionRequestStruct = assign( KeyringRequestStruct, @@ -121,7 +160,8 @@ export const SignTransactionRequestStruct = assign( request: object({ method: literal(MultichainMethod.SignTransaction), params: object({ - transaction: XdrStruct, + xdr: XdrStruct, + opts: optional(Sep43OptsStruct), }), }), scope: KnownCaip2ChainIdStruct, @@ -142,9 +182,16 @@ export const ListAccountTransactionsRequestStruct = object({ /** * Validation struct for the signTransaction response. + * + * `signedTxXdr` is the signed transaction envelope as base64 XDR on success; + * empty string on error. + * `signerAddress` is the signer's G-address on success, or empty when + * account resolution failed before we could determine the address. */ export const SignTransactionResponseStruct = object({ - signature: XdrStruct, + signedTxXdr: union([XdrStruct, literal('')]), + signerAddress: union([StellarAddressStruct, literal('')]), + error: optional(Sep43ErrorEnvelopeStruct), }); /** diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts index df94a7d1..2d4db474 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts @@ -1,10 +1,22 @@ +import type { Struct } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; +import { Networks } from '@stellar/stellar-sdk'; +import type { Sep43ErrorEnvelope, Sep43Opts } from './api'; +import { Sep43Error, Sep43ErrorCode, toSep43Error } from './exceptions'; +import type { KnownCaip2ChainId } from '../../api'; +import { KnownCaip2ChainId as Caip2 } from '../../api'; import type { - DefaultResolveAccountOptions, - ResolveAccountOptions, -} from '../base'; -import { WithActiveAccountResolve } from '../base'; + AccountService, + StellarKeyringAccount, +} from '../../services/account'; +import { AccountNotActivatedException } from '../../services/network'; +import type { OnChainAccountService } from '../../services/on-chain-account'; +import type { Wallet, WalletService } from '../../services/wallet'; +import { render as renderAccountActivationPrompt } from '../../ui/confirmation/views/AccountActivationPrompt/render'; +import type { ILogger } from '../../utils'; +import { createPrefixedLogger } from '../../utils'; +import { validateRequest, validateResponse } from '../../utils/requestResponse'; /** * Interface for the client request handler. @@ -13,24 +25,239 @@ export type IKeyringRequestHandler = { handle: (request: Json) => Promise; }; +/** Mainnet is the only network the snap currently signs for. */ +const SUPPORTED_PASSPHRASE: string = Networks.PUBLIC; + /** - * A base class for keyring request handlers that require an activated account. + * Base class shared by the SEP-43 SignMessage and SignTransaction keyring + * handlers. + * + * Provides common cross-cutting concerns: validates `opts.networkPassphrase` + * (mainnet only), validates `scope` is mainnet, forbids `submit` / `submitUrl` + * (snap is sign-only), resolves the keyring account by `opts.address` when + * provided (otherwise falls back to the wrapper's `account` UUID), and wraps + * thrown errors into the SEP-43 `error` envelope so the dapp always receives a + * well-formed payload. + * + * After the keyring account is resolved, {@link OnChainAccountService} is used + * the same way as other activated-account flows: an unfunded ledger account + * triggers the account-activation UI, then a SEP-43 `error` (not a JSON-RPC + * error) is returned. + * + * `request.origin` is the dapp or wallet caller origin already validated by + * MetaMask before the snap runs; the confirmation UI uses it for display only. + * + * Subclasses implement {@link execute} which performs the wallet signing and + * returns the success-shaped fields. They never throw to the dapp directly. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md */ -export abstract class WithKeyringRequestActiveAccountResolve< - RequestType extends { account: string }, - ResponseType extends Json, - Opts extends ResolveAccountOptions = DefaultResolveAccountOptions, -> - extends WithActiveAccountResolve - implements IKeyringRequestHandler -{ +export abstract class BaseSep43KeyringHandler< + Request extends { + scope: KnownCaip2ChainId; + account: string; + origin: string; + request: { params: { opts?: Sep43Opts } }; + }, + Response extends Json & { + signerAddress: string; + error?: Sep43ErrorEnvelope; + }, +> implements IKeyringRequestHandler { + protected readonly logger: ILogger; + + protected readonly accountService: AccountService; + + protected readonly walletService: WalletService; + + protected readonly onChainAccountService: OnChainAccountService; + + protected readonly requestStruct: Struct; + + protected readonly responseStruct: Struct; + + constructor({ + logger, + accountService, + walletService, + onChainAccountService, + loggerPrefix, + requestStruct, + responseStruct, + }: { + logger: ILogger; + accountService: AccountService; + walletService: WalletService; + onChainAccountService: OnChainAccountService; + loggerPrefix: string; + requestStruct: Struct; + responseStruct: Struct; + }) { + this.logger = createPrefixedLogger(logger, loggerPrefix); + this.accountService = accountService; + this.walletService = walletService; + this.onChainAccountService = onChainAccountService; + this.requestStruct = requestStruct; + this.responseStruct = responseStruct; + } + + /** + * Top-level entry point. Runs the full pipeline (validate → check + * network/opts → resolve account → execute) inside a single try/catch so + * every failure (including struct validation) is serialized into the SEP-43 + * `error` envelope. The dapp never sees a thrown JSON-RPC error. + * + * @param rawRequest - The unvalidated keyring request as forwarded by + * `KeyringHandler.submitRequest` (or the dev `stellar_*` RPC aliases). The + * wrapper's `origin` is the caller origin MetaMask attached; treat it as + * system-trusted for labeling in confirmation UI, not as a crypto capability. + * @returns The SEP-43 response with either the success fields or `error` + * populated. + */ + async handle(rawRequest: Json): Promise { + let signerAddress = ''; + try { + const request = validateRequest(rawRequest, this.requestStruct); + + this.assertSupportedNetwork(request); + this.assertNoSubmit(request.request.params.opts); + + const { account, wallet } = await this.resolveAccount(request); + signerAddress = account.address; + + await this.assertAccountActivatedOnChain(request, account); + + const result = await this.execute(request, { account, wallet }); + validateResponse(result, this.responseStruct); + return result; + } catch (error: unknown) { + if (error instanceof AccountNotActivatedException) { + await renderAccountActivationPrompt(error.address); + } + const sep43 = toSep43Error(error); + this.logger.logErrorWithDetails('SEP-43 request failed', sep43); + return this.toErrorResponse(signerAddress, sep43); + } + } + + /** + * Subclass hook: do the actual signing. + * + * @param request - The validated request. + * @param resolved - The resolved keyring account and signing wallet. + * @returns The success-shaped response (no `error` field). + */ + protected abstract execute( + request: Request, + resolved: { account: StellarKeyringAccount; wallet: Wallet }, + ): Promise; + + /** + * Subclass hook: shape an error-only response when everything fails. + * + * @param signerAddress - The resolved address (or empty string when unknown). + * @param error - The classified SEP-43 error. + * @returns The error response in the subclass's response shape. + */ + protected abstract toErrorResponse( + signerAddress: string, + error: Sep43Error, + ): Response; + + /** + * Resolves the signing account. + * Prefers `opts.address` when provided; otherwise uses the wrapper's + * `account` UUID. When both are present, the resolved address must match. + * + * @param request - The keyring request. + * @returns The resolved keyring account and signing wallet. + */ + protected async resolveAccount( + request: Request, + ): Promise<{ account: StellarKeyringAccount; wallet: Wallet }> { + const { account: accountId, scope } = request; + const optsAddress = request.request.params.opts?.address; + + const { account } = optsAddress + ? await this.accountService.resolveAccount({ + scope, + accountAddress: optsAddress, + }) + : await this.accountService.resolveAccount({ accountId }); + + if (optsAddress && account.id !== accountId) { + throw new Sep43Error({ + code: Sep43ErrorCode.InvalidRequest, + ext: [ + `opts.address ${optsAddress} does not match the session-selected account.`, + ], + }); + } + + const wallet = await this.walletService.resolveWallet(account); + return { account, wallet }; + } + + /** + * Throws when the dapp asks for a network we don't support. + * Today: mainnet only. The snap rejects any other `opts.networkPassphrase` + * and any non-mainnet `scope`. + * + * @param request - The keyring request. + */ + protected assertSupportedNetwork(request: Request): void { + if (request.scope !== Caip2.Mainnet) { + throw new Sep43Error({ + code: Sep43ErrorCode.InvalidRequest, + ext: [`Only mainnet is supported, received scope ${request.scope}.`], + }); + } + + const requestedPassphrase = request.request.params.opts?.networkPassphrase; + if ( + requestedPassphrase !== undefined && + requestedPassphrase !== SUPPORTED_PASSPHRASE + ) { + throw new Sep43Error({ + code: Sep43ErrorCode.InvalidRequest, + ext: [ + `Only Stellar mainnet is supported by this wallet. Received passphrase: ${requestedPassphrase}.`, + ], + }); + } + } + + /** + * Throws when the dapp set `submit` or `submitUrl`. The snap is sign-only. + * + * @param opts - The SEP-43 opts bag (may be undefined). + */ + protected assertNoSubmit(opts: Sep43Opts | undefined): void { + // Use property access to detect even runtime-injected fields the struct stripped. + const raw = opts as undefined | Record; + if (raw?.submit !== undefined || raw?.submitUrl !== undefined) { + throw new Sep43Error({ + code: Sep43ErrorCode.InvalidRequest, + ext: ['This wallet does not submit transactions; use sign only.'], + }); + } + } + /** - * Get the account ID from the JSON-RPC request. + * Ensures the account exists on the Stellar network (funded) before signing. + * Aligns with the `WithActiveAccountResolve` path in `handlers/base.ts` for + * non-SEP-43 client routes. * - * @param request - The JSON-RPC request to get the account ID from. - * @returns The account ID. + * @param request - The validated keyring request (used for `scope`). + * @param account - The resolved keyring account. */ - protected getAccountId(request: RequestType): string { - return request.account; + protected async assertAccountActivatedOnChain( + request: Request, + account: StellarKeyringAccount, + ): Promise { + await this.onChainAccountService.resolveOnChainAccount( + account.address, + request.scope, + ); } } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts index 5e71c484..b5e8dc13 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts @@ -1,5 +1,28 @@ +import { + InvalidParamsError, + UserRejectedRequestError, +} from '@metamask/snaps-sdk'; +import { StructError } from '@metamask/superstruct'; +import { ensureError } from '@metamask/utils'; + import type { ResolveAccountAddressJsonRpcRequest } from './api'; import type { KnownCaip2ChainId } from '../../api/network'; +import { AccountServiceException } from '../../services/account/exceptions'; +import { + AccountLoadException, + AccountNotActivatedException, + AssetDataFetchException, + BaseFeeFetchException, + NetworkServiceException, + SimulationException, + TransactionPollException, + TransactionRetryableException, + TransactionSendException, +} from '../../services/network/exceptions'; +import { + TransactionScopeNotMatchException, + TransactionValidationException, +} from '../../services/transaction/exceptions'; export class KeyringException extends Error { constructor(message: string) { @@ -8,6 +31,127 @@ export class KeyringException extends Error { } } +/** + * SEP-43 error codes. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md + */ +export enum Sep43ErrorCode { + /** Internal wallet error (JS runtime, programmer error, etc.). */ + Internal = -1, + /** External service (Horizon, RPC, …) returned an error. */ + ExternalService = -2, + /** Client app request is invalid (bad params, malformed XDR, unsupported option). */ + InvalidRequest = -3, + /** User declined the confirmation. */ + UserRejected = -4, +} + +/** + * Generic SEP-43 message that's user-safe to forward to the dapp. + * + * Per-code default messages used when callers throw without a specific message. + * The `ext` array carries optional richer context (e.g. underlying error message). + */ +export const SEP43_DEFAULT_MESSAGE: Record = { + [Sep43ErrorCode.Internal]: + 'The wallet encountered an internal error. Please try again or contact the wallet if the problem persists.', + [Sep43ErrorCode.ExternalService]: + 'An error occurred with an external service. Please try again.', + [Sep43ErrorCode.InvalidRequest]: + 'Request is invalid. Please check the details and try again.', + [Sep43ErrorCode.UserRejected]: 'The user rejected this request.', +}; + +/** + * Structured SEP-43 error envelope returned to the dapp on failure. + */ +export class Sep43Error extends Error { + readonly code: Sep43ErrorCode; + + readonly ext: string[] | undefined; + + constructor(params: { + code: Sep43ErrorCode; + message?: string; + ext?: string[]; + }) { + super(params.message ?? SEP43_DEFAULT_MESSAGE[params.code]); + this.name = 'Sep43Error'; + this.code = params.code; + this.ext = params.ext; + } + + /** + * Serializes to the SEP-43 `error` shape. + * + * @returns The serialized error envelope (`message`, `code`, optional `ext`). + */ + toEnvelope(): { message: string; code: number; ext?: string[] } { + return { + message: this.message, + code: this.code, + ...(this.ext === undefined ? {} : { ext: this.ext }), + }; + } +} + +/** + * Maps any thrown error to a {@link Sep43Error}, classifying by known internal types. + * Pass-through for `Sep43Error`; everything else falls back to {@link Sep43ErrorCode.Internal}. + * + * @param error - The thrown value. + * @returns A {@link Sep43Error} ready to serialize back to the dapp. + */ +export function toSep43Error(error: unknown): Sep43Error { + if (error instanceof Sep43Error) { + return error; + } + + const wrapped = ensureError(error); + + if (wrapped instanceof UserRejectedRequestError) { + return new Sep43Error({ code: Sep43ErrorCode.UserRejected }); + } + + if ( + // `validateRequest` rewraps StructError as InvalidParamsError before it + // reaches us, so we accept both shapes here. + wrapped instanceof InvalidParamsError || + wrapped instanceof StructError || + // Catches AccountNotFoundException + DerivedAccountAddressMismatchException + // (both extend AccountServiceException) — typically caused by a bad + // `opts.address` from the dapp. + wrapped instanceof AccountServiceException || + wrapped instanceof TransactionValidationException || + wrapped instanceof TransactionScopeNotMatchException + ) { + return new Sep43Error({ + code: Sep43ErrorCode.InvalidRequest, + ext: [wrapped.message], + }); + } + + if ( + wrapped instanceof AccountNotActivatedException || + wrapped instanceof AccountLoadException || + wrapped instanceof AssetDataFetchException || + wrapped instanceof BaseFeeFetchException || + wrapped instanceof SimulationException || + wrapped instanceof TransactionPollException || + wrapped instanceof TransactionRetryableException || + wrapped instanceof TransactionSendException || + wrapped instanceof NetworkServiceException + ) { + return new Sep43Error({ + code: Sep43ErrorCode.ExternalService, + ext: [wrapped.message], + }); + } + + return new Sep43Error({ code: Sep43ErrorCode.Internal }); +} + export class KeyringListAccountsException extends KeyringException { constructor() { super(`Failed to list accounts`); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 46b93bce..c1e0cc2b 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -9,9 +9,15 @@ import { handleKeyringRequest, } from '@metamask/keyring-snap-sdk'; import { InvalidParamsError, type JsonRpcRequest } from '@metamask/snaps-sdk'; +import { create } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { MultichainMethod } from './api'; +import { + MultichainMethod, + SignMessageResponseStruct, + SignTransactionResponseStruct, +} from './api'; import type { IKeyringRequestHandler } from './base'; import { KeyringCreateAccountException, @@ -688,10 +694,11 @@ describe('KeyringHandler', () => { it('submits a sign message request', async () => { const expectedResult = { - signature: bufferToUint8Array( + signedMessage: bufferToUint8Array( 'Stellar Signed Message: Hello, world!', 'utf8', ).toString('base64'), + signerAddress: mockAccount.address, }; jest @@ -703,7 +710,11 @@ describe('KeyringHandler', () => { origin: 'metamask', request: { method: MultichainMethod.SignMessage, - params: { message: 'Hello, world!' }, + params: { + message: bufferToUint8Array('Hello, world!', 'utf8').toString( + 'base64', + ), + }, }, scope: KnownCaip2ChainId.Mainnet, account: mockAccountId, @@ -726,10 +737,8 @@ describe('KeyringHandler', () => { const xdr = `AAAAAgAAAADjngeX0YTNoQ15A0xC83aMm/sDnXrmLF+apmXvdmkUugAAAGQAC3gAAAAAQQAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAOZfkjSFZ31vI/Nx28cC6iAFWLWcPIvJhM2NVoxmfgVTAAAAAAAAAAAAmJaAAAAAAAAAAAA=`; const expectedResult = { - signature: bufferToUint8Array( - `Stellar Signed transaction: ${xdr}`, - 'utf8', - ).toString('base64'), + signedTxXdr: xdr, + signerAddress: mockAccount.address, }; jest @@ -741,7 +750,7 @@ describe('KeyringHandler', () => { origin: 'metamask', request: { method: MultichainMethod.SignTransaction, - params: { transaction: xdr }, + params: { xdr }, }, scope: KnownCaip2ChainId.Mainnet, account: mockAccountId, @@ -777,5 +786,75 @@ describe('KeyringHandler', () => { expect(mockSignMessageHandler.handle).not.toHaveBeenCalled(); expect(mockSignTransactionHandler.handle).not.toHaveBeenCalled(); }); + + it('exposes a submitRequest result that satisfies the SEP-43 response struct', async () => { + const expectedWithError = { + signedMessage: '', + signerAddress: + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + error: { message: 'x', code: -3, ext: ['y'] }, + }; + jest + .mocked(mockSignMessageHandler.handle) + .mockResolvedValue(expectedWithError); + + const signMessagePayload = { + id: keyringRequestId, + origin: 'metamask', + request: { + method: MultichainMethod.SignMessage, + params: { + message: bufferToUint8Array('Hello, world!', 'utf8').toString( + 'base64', + ), + }, + }, + scope: KnownCaip2ChainId.Mainnet, + account: mockAccountId, + }; + + const response = await keyringHandler.submitRequest(signMessagePayload); + expect(response).toMatchObject({ pending: false }); + expect(() => + create( + (response as { pending: false; result: Json }).result, + SignMessageResponseStruct, + ), + ).not.toThrow(); + }); + + it('exposes a sign-tx submitRequest result that satisfies the SEP-43 response struct', async () => { + const xdr = `AAAAAgAAAADjngeX0YTNoQ15A0xC83aMm/sDnXrmLF+apmXvdmkUugAAAGQAC3gAAAAAQQAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAOZfkjSFZ31vI/Nx28cC6iAFWLWcPIvJhM2NVoxmfgVTAAAAAAAAAAAAmJaAAAAAAAAAAAA=`; + const expectedWithError = { + signedTxXdr: '', + signerAddress: mockAccount.address, + error: { message: 'x', code: -1 }, + }; + jest + .mocked(mockSignTransactionHandler.handle) + .mockResolvedValue(expectedWithError); + + const signTransactionPayload = { + id: keyringRequestId, + origin: 'metamask', + request: { + method: MultichainMethod.SignTransaction, + params: { xdr }, + }, + scope: KnownCaip2ChainId.Mainnet, + account: mockAccountId, + }; + + const response = await keyringHandler.submitRequest( + signTransactionPayload, + ); + expect(response).toMatchObject({ pending: false }); + expect(() => + create( + (response as { pending: false; result: Json }).result, + SignTransactionResponseStruct, + ), + ).not.toThrow(); + }); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts index d6381e49..0abd6739 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts @@ -1,63 +1,56 @@ -import { UserRejectedRequestError } from '@metamask/snaps-sdk'; +import { Networks } from '@stellar/stellar-sdk'; import { MultichainMethod, type SignMessageRequest } from './api'; +import { Sep43ErrorCode } from './exceptions'; import { SignMessageHandler } from './signMessage'; import { KnownCaip2ChainId } from '../../api'; -import type { StellarKeyringAccount } from '../../services/account'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; +import { AccountNotFoundException } from '../../services/account/exceptions'; +import { AccountNotActivatedException } from '../../services/network'; +import { OnChainAccountService } from '../../services/on-chain-account'; import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; +import type { OnChainAccount } from '../../services/on-chain-account/OnChainAccount'; import { WalletService } from '../../services/wallet'; import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; -import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; import type { ConfirmationUXController } from '../../ui/confirmation/controller'; import { logger } from '../../utils/logger'; jest.mock('../../utils/logger'); +/* eslint-disable @typescript-eslint/naming-convention -- Jest ESM interop */ +jest.mock('../../ui/confirmation/views/AccountActivationPrompt/render', () => ({ + __esModule: true, + render: jest.fn().mockResolvedValue(undefined), +})); +/* eslint-enable @typescript-eslint/naming-convention */ describe('SignMessageHandler', () => { - const keyringRequestId = '11111111-1111-4111-8111-111111111111'; - - const encodedMessage = btoa('hello stellar'); - - const buildRequest = ( - account: StellarKeyringAccount, - ): SignMessageRequest => ({ - id: keyringRequestId, - origin: 'https://example.com', - scope: KnownCaip2ChainId.Mainnet, - account: account.id, - request: { - method: MultichainMethod.SignMessage, - params: { message: encodedMessage }, - }, - }); - /** - * Builds a {@link SignMessageHandler} with mocked account/wallet resolution. + * Builds a {@link SignMessageHandler} with mocked account / wallet + * resolution and a stubbed `ConfirmationUXController`. * - * @returns Handler instance, resolved keyring account, and test wallet. + * @returns Handler instance and the test doubles needed by each spec. */ - function setupSignMessageHandler(): { - handler: SignMessageHandler; - mockAccount: StellarKeyringAccount; - wallet: ReturnType; - renderConfirmationDialog: jest.Mock; - } { + function setupHandler() { const wallet = getTestWallet(); + const accountId = globalThis.crypto.randomUUID(); const mockAccount = generateStellarKeyringAccount( - globalThis.crypto.randomUUID(), + accountId, wallet.address, 'entropy-source-1', 0, ); - const { accountService, onChainAccountService, walletService } = + const { accountService, walletService, onChainAccountService } = mockOnChainAccountService(); - jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ - account: mockAccount, - }); + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue({ assetIds: [] } as unknown as OnChainAccount); + + const resolveAccountSpy = jest + .spyOn(AccountService.prototype, 'resolveAccount') + .mockResolvedValue({ account: mockAccount }); jest .spyOn(WalletService.prototype, 'resolveWallet') @@ -74,78 +67,184 @@ describe('SignMessageHandler', () => { const handler = new SignMessageHandler({ logger, accountService, - onChainAccountService, walletService, + onChainAccountService, confirmationUIController, }); - return { handler, mockAccount, wallet, renderConfirmationDialog }; + return { + handler, + mockAccount, + wallet, + renderConfirmationDialog, + resolveAccountSpy, + resolveOnChainAccountSpy, + }; } - it('returns signature when confirmation accepts', async () => { + const buildRequest = ( + accountId: string, + overrides: Partial = {}, + ): SignMessageRequest => ({ + id: '11111111-1111-4111-8111-111111111111', + origin: 'https://example.com', + scope: KnownCaip2ChainId.Mainnet, + account: accountId, + request: { + method: MultichainMethod.SignMessage, + params: { + message: btoa('hello stellar'), + ...overrides, + }, + }, + }); + + it('returns signedMessage and signerAddress on confirm', async () => { const { handler, mockAccount, wallet, renderConfirmationDialog } = - setupSignMessageHandler(); + setupHandler(); renderConfirmationDialog.mockResolvedValue(true); - const request = buildRequest(mockAccount); - const result = await handler.handle(request); - - const expectedSignature = await wallet.signMessage(encodedMessage); - - expect(renderConfirmationDialog).toHaveBeenCalledTimes(1); - expect(renderConfirmationDialog).toHaveBeenCalledWith( - expect.objectContaining({ - scope: request.scope, - origin: request.origin, - interfaceKey: ConfirmationInterfaceKey.SignMessage, - renderContext: expect.objectContaining({ - account: mockAccount, - message: 'hello stellar', - }), + const result = await handler.handle(buildRequest(mockAccount.id)); + + const expected = await wallet.signMessage(btoa('hello stellar')); + expect(result).toStrictEqual({ + signedMessage: expected, + signerAddress: wallet.address, + }); + }); + + it('returns error -4 when user rejects', async () => { + const { handler, mockAccount, wallet, renderConfirmationDialog } = + setupHandler(); + renderConfirmationDialog.mockResolvedValue(false); + + const result = await handler.handle(buildRequest(mockAccount.id)); + + expect(result.signedMessage).toBe(''); + expect(result.signerAddress).toBe(wallet.address); + expect(result.error?.code).toBe(Sep43ErrorCode.UserRejected); + }); + + it('returns error -3 when scope is testnet', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const result = await handler.handle({ + ...buildRequest(mockAccount.id), + scope: KnownCaip2ChainId.Testnet, + }); + + expect(result.signedMessage).toBe(''); + expect(result.signerAddress).toBe(''); + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -3 when opts.networkPassphrase is not the mainnet passphrase', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const result = await handler.handle( + buildRequest(mockAccount.id, { + opts: { networkPassphrase: Networks.TESTNET }, }), ); - expect(result).toStrictEqual({ signature: expectedSignature }); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(result.error?.ext?.[0]).toContain('mainnet'); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); - it('throws when confirmation rejects', async () => { - const { handler, mockAccount, renderConfirmationDialog } = - setupSignMessageHandler(); - renderConfirmationDialog.mockResolvedValue(false); + it.each([ + ['opts.submit', { submit: true }], + ['opts.submitUrl', { submitUrl: 'https://horizon.stellar.org' }], + ])('returns error -3 when %s is provided', async (_label, forbiddenOpts) => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const base = buildRequest(mockAccount.id); + // Inject the forbidden opt bypassing the struct type so we can assert the + // handler rejects it at runtime with -3 InvalidRequest. + (base.request.params as unknown as { opts: Record }).opts = + forbiddenOpts; + + const result = await handler.handle(base); - const request = buildRequest(mockAccount); + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); - await expect(handler.handle(request)).rejects.toThrow( - UserRejectedRequestError, + it('returns error -3 when opts.address cannot be resolved', async () => { + const { handler, mockAccount, resolveAccountSpy } = setupHandler(); + const unknownAddress = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB'; + resolveAccountSpy.mockRejectedValueOnce( + new AccountNotFoundException(unknownAddress), ); - expect(renderConfirmationDialog).toHaveBeenCalledWith( - expect.objectContaining({ - scope: request.scope, - origin: request.origin, - interfaceKey: ConfirmationInterfaceKey.SignMessage, - renderContext: expect.objectContaining({ - account: mockAccount, - message: 'hello stellar', - }), - }), + const result = await handler.handle( + buildRequest(mockAccount.id, { opts: { address: unknownAddress } }), ); + + expect(result.signedMessage).toBe(''); + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); }); - it('rejects invalid requests before calling render', async () => { - const { handler, mockAccount, renderConfirmationDialog } = - setupSignMessageHandler(); - renderConfirmationDialog.mockResolvedValue(true); + it('returns error -3 when opts.address resolves to a different account than the wrapper UUID', async () => { + const { + handler, + mockAccount, + renderConfirmationDialog, + resolveAccountSpy, + } = setupHandler(); + const otherAccount = generateStellarKeyringAccount( + globalThis.crypto.randomUUID(), + mockAccount.address, + 'entropy-source-1', + 1, + ); + resolveAccountSpy.mockResolvedValueOnce({ account: otherAccount }); - await expect( - handler.handle({ - ...buildRequest(mockAccount), - request: { - method: MultichainMethod.SignMessage, - params: { message: '' }, - }, + const result = await handler.handle( + buildRequest(mockAccount.id, { + opts: { address: otherAccount.address }, }), - ).rejects.toThrow(/request\.params\.message/u); + ); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -3 when message is not valid base64', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const result = await handler.handle( + buildRequest(mockAccount.id, { message: 'not valid base64 !!!' }), + ); + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('shows the account activation prompt and returns ExternalService when the account is not funded', async () => { + const { render: renderAccountActivationPrompt } = + await import('../../ui/confirmation/views/AccountActivationPrompt/render'); + const { + handler, + mockAccount, + renderConfirmationDialog, + resolveOnChainAccountSpy, + } = setupHandler(); + resolveOnChainAccountSpy.mockRejectedValueOnce( + new AccountNotActivatedException( + mockAccount.address, + KnownCaip2ChainId.Mainnet, + ), + ); + + const result = await handler.handle(buildRequest(mockAccount.id)); + + expect(jest.mocked(renderAccountActivationPrompt)).toHaveBeenCalledWith( + mockAccount.address, + ); + expect(result.error?.code).toBe(Sep43ErrorCode.ExternalService); expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts index 83a88e0a..a0942630 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts @@ -1,81 +1,103 @@ import { UserRejectedRequestError } from '@metamask/snaps-sdk'; +import type { SignMessageRequest, SignMessageResponse } from './api'; +import { SignMessageRequestStruct, SignMessageResponseStruct } from './api'; +import { BaseSep43KeyringHandler } from './base'; +import type { Sep43Error } from './exceptions'; import type { AccountService, StellarKeyringAccount, } from '../../services/account'; import type { OnChainAccountService } from '../../services/on-chain-account'; -import type { WalletService } from '../../services/wallet'; -import type { ResolvedActivatedAccountFor } from '../base'; -import type { SignMessageRequest, SignMessageResponse } from './api'; -import { SignMessageRequestStruct, SignMessageResponseStruct } from './api'; -import { WithKeyringRequestActiveAccountResolve } from './base'; +import type { Wallet, WalletService } from '../../services/wallet'; import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; import type { ConfirmationUXController } from '../../ui/confirmation/controller'; -import { bufferToUint8Array, type ILogger } from '../../utils'; -import { isBase64 } from '../../utils/string'; +import type { ILogger } from '../../utils'; +import { bufferToUint8Array } from '../../utils'; -type SignMessageResolveOpts = { onChainAccount: false; wallet: true }; - -export class SignMessageHandler extends WithKeyringRequestActiveAccountResolve< +/** + * SEP-43 `signMessage` keyring handler. + * + * Reuses the existing sign-message confirmation view via + * {@link ConfirmationUXController}. Returns the SEP-43 response shape + * (`signedMessage`, `signerAddress`, optional `error`) and never throws to the + * dapp — failures are wrapped in the `error` envelope by the base. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md + */ +export class SignMessageHandler extends BaseSep43KeyringHandler< SignMessageRequest, - SignMessageResponse, - SignMessageResolveOpts + SignMessageResponse > { readonly #confirmationUIController: ConfirmationUXController; constructor({ logger, accountService, - onChainAccountService, walletService, + onChainAccountService, confirmationUIController, }: { logger: ILogger; accountService: AccountService; - onChainAccountService: OnChainAccountService; walletService: WalletService; + onChainAccountService: OnChainAccountService; confirmationUIController: ConfirmationUXController; }) { super({ logger, accountService, - onChainAccountService, walletService, + onChainAccountService, + loggerPrefix: '[✉️ SignMessageHandler]', requestStruct: SignMessageRequestStruct, responseStruct: SignMessageResponseStruct, - resolveAccountOptions: { onChainAccount: false }, }); this.#confirmationUIController = confirmationUIController; } - protected async _handle( - resolved: ResolvedActivatedAccountFor, + protected async execute( request: SignMessageRequest, + resolved: { account: StellarKeyringAccount; wallet: Wallet }, ): Promise { - const { wallet, account } = resolved; + const { account, wallet } = resolved; + const { message } = request.request.params; - if (!(await this.#confirmation(request, account))) { + if (!(await this.#confirm(request, account, message))) { throw new UserRejectedRequestError() as unknown as Error; } - const { message } = request.request.params; + const signedMessage = await wallet.signMessage(message); - const signature = await wallet.signMessage(message); + return { + signedMessage, + signerAddress: account.address, + }; + } - return { signature }; + protected toErrorResponse( + signerAddress: string, + error: Sep43Error, + ): SignMessageResponse { + return { + // SEP-43 schema requires the field even on error; keep it empty when unknown. + signedMessage: '', + signerAddress, + error: error.toEnvelope(), + }; } - async #confirmation( + async #confirm( request: SignMessageRequest, account: StellarKeyringAccount, + message: string, ): Promise { return ( (await this.#confirmationUIController.renderConfirmationDialog({ scope: request.scope, renderContext: { account, - message: this.#getUtf8Message(request.request.params.message), + message: this.#getUtf8Message(message), }, origin: request.origin, interfaceKey: ConfirmationInterfaceKey.SignMessage, @@ -83,10 +105,13 @@ export class SignMessageHandler extends WithKeyringRequestActiveAccountResolve< ); } + /** + * Decodes the SEP-43 base64 message for display in the confirmation dialog. + * + * @param message - Base64-encoded bytes (validated by {@link SignMessageRequestStruct}). + * @returns The same content interpreted as UTF-8 text for the UI. + */ #getUtf8Message(message: string): string { - if (isBase64(message)) { - return bufferToUint8Array(message, 'base64').toString('utf8'); - } - return message; + return bufferToUint8Array(message, 'base64').toString('utf8'); } } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts index 6ef5ac07..eb7acc7e 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts @@ -1,50 +1,44 @@ -import { UserRejectedRequestError } from '@metamask/snaps-sdk'; import { Keypair, Networks } from '@stellar/stellar-sdk'; import { MultichainMethod, type SignTransactionRequest } from './api'; +import { Sep43ErrorCode } from './exceptions'; import { SignTransactionHandler } from './signTransaction'; import { KnownCaip2ChainId } from '../../api'; -import type { StellarKeyringAccount } from '../../services/account'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; +import { AccountNotActivatedException } from '../../services/network'; +import { SimulationException } from '../../services/network/exceptions'; +import { OnChainAccountService } from '../../services/on-chain-account'; import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; -import type { TransactionBuilder } from '../../services/transaction'; -import { - TransactionService, - OperationMapper, -} from '../../services/transaction'; +import type { OnChainAccount } from '../../services/on-chain-account/OnChainAccount'; +import type { Transaction } from '../../services/transaction'; +import { TransactionService } from '../../services/transaction'; import { buildMockClassicTransaction, createMockTransactionService, } from '../../services/transaction/__mocks__/transaction.fixtures'; -import { WalletService, Wallet } from '../../services/wallet'; -import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import { WalletService } from '../../services/wallet'; +import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; import type { ConfirmationUXController } from '../../ui/confirmation/controller'; import { logger } from '../../utils/logger'; jest.mock('../../utils/logger'); +/* eslint-disable @typescript-eslint/naming-convention -- Jest ESM interop */ +jest.mock('../../ui/confirmation/views/AccountActivationPrompt/render', () => ({ + __esModule: true, + render: jest.fn().mockResolvedValue(undefined), +})); +/* eslint-enable @typescript-eslint/naming-convention */ describe('SignTransactionHandler', () => { - const keyringRequestId = '22222222-2222-4222-8222-222222222222'; - /** - * Builds a {@link SignTransactionHandler} with mocked account/wallet resolution - * and a stubbed `ConfirmationUXController`. + * Builds a {@link SignTransactionHandler} with mocked services + account/wallet + * resolution, plus a stubbed `ConfirmationUXController`. * * @returns Handler instance and the test doubles needed by each spec. */ - function setupSignTransactionHandler(): { - handler: SignTransactionHandler; - mockAccount: StellarKeyringAccount; - wallet: Wallet; - walletKeypair: Keypair; - renderConfirmationDialog: jest.Mock; - transactionBuilder: TransactionBuilder; - transactionService: TransactionService; - } { - const walletKeypair = Keypair.random(); - const wallet = new Wallet(walletKeypair); - + function setupHandler() { + const wallet = getTestWallet(); const mockAccount = generateStellarKeyringAccount( globalThis.crypto.randomUUID(), wallet.address, @@ -52,24 +46,27 @@ describe('SignTransactionHandler', () => { 0, ); - const { accountService, onChainAccountService, walletService } = + const { transactionBuilder, transactionService } = + createMockTransactionService(); + const { accountService, walletService, onChainAccountService } = mockOnChainAccountService(); - jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ - account: mockAccount, - }); + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue({ assetIds: [] } as unknown as OnChainAccount); + + const resolveAccountSpy = jest + .spyOn(AccountService.prototype, 'resolveAccount') + .mockResolvedValue({ account: mockAccount }); jest .spyOn(WalletService.prototype, 'resolveWallet') .mockResolvedValue(wallet); - const { transactionBuilder, transactionService } = - createMockTransactionService(); - // Default: pass-through fee (no Soroban simulation needed for classic tx). jest .spyOn(TransactionService.prototype, 'computingFee') - .mockImplementation(async (transaction) => transaction); + .mockImplementation(async (tx) => tx); const renderConfirmationDialog = jest.fn(); const confirmationUIController = { @@ -82,8 +79,8 @@ describe('SignTransactionHandler', () => { const handler = new SignTransactionHandler({ logger, accountService, - onChainAccountService, walletService, + onChainAccountService, transactionBuilder, transactionService, confirmationUIController, @@ -93,21 +90,22 @@ describe('SignTransactionHandler', () => { handler, mockAccount, wallet, - walletKeypair, - renderConfirmationDialog, transactionBuilder, transactionService, + renderConfirmationDialog, + resolveAccountSpy, + resolveOnChainAccountSpy, }; } /** - * Builds a single-payment transaction whose source is the wallet account so it - * passes {@link assertAccountInvolvesTransaction}. + * Builds a mainnet payment transaction whose source is the wallet so it + * passes `assertAccountInvolvesTransaction`. * - * @param walletAddress - The wallet's Stellar public key (`G…`). - * @returns The mock transaction. + * @param walletAddress - Wallet's Stellar public key (`G…`). + * @returns Mock transaction built with `Networks.PUBLIC`. */ - function buildPaymentTxFromWallet(walletAddress: string) { + function buildMainnetPaymentFromWallet(walletAddress: string): Transaction { return buildMockClassicTransaction( [ { @@ -115,158 +113,258 @@ describe('SignTransactionHandler', () => { params: { destination: Keypair.random().publicKey(), asset: 'native', - amount: '10', + amount: '1', }, }, ], { - networkPassphrase: Networks.TESTNET, + networkPassphrase: Networks.PUBLIC, source: { accountId: walletAddress, sequence: '1' }, }, ); } - const buildRequest = (transactionXdr: string): SignTransactionRequest => ({ - id: keyringRequestId, + const buildRequest = ( + accountId: string, + xdr: string, + overrides: Partial = {}, + ): SignTransactionRequest => ({ + id: '22222222-2222-4222-8222-222222222222', origin: 'https://example.com', - scope: KnownCaip2ChainId.Testnet, - account: '00000000-0000-4000-8000-000000000001', + scope: KnownCaip2ChainId.Mainnet, + account: accountId, request: { method: MultichainMethod.SignTransaction, - params: { transaction: transactionXdr }, + params: { xdr, ...overrides }, }, }); - it('renders confirmation with fee, native price slot, and signs when accepted', async () => { + it('returns signedTxXdr and signerAddress on confirm', async () => { const { handler, mockAccount, wallet, - renderConfirmationDialog, transactionBuilder, - } = setupSignTransactionHandler(); + renderConfirmationDialog, + } = setupHandler(); - const transaction = buildPaymentTxFromWallet(wallet.address); + const transaction = buildMainnetPaymentFromWallet(wallet.address); const xdr = transaction.getRaw().toXDR(); - jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); const signSpy = jest.spyOn(wallet, 'signTransaction'); - renderConfirmationDialog.mockResolvedValue(true); - const request = buildRequest(xdr); - const result = await handler.handle(request); + const result = await handler.handle(buildRequest(mockAccount.id, xdr)); - expect(renderConfirmationDialog).toHaveBeenCalledTimes(1); - const callArgs = renderConfirmationDialog.mock.calls[0]?.[0]; - expect(callArgs).toMatchObject({ - scope: KnownCaip2ChainId.Testnet, - origin: 'https://example.com', - interfaceKey: ConfirmationInterfaceKey.SignTransaction, - fee: transaction.totalFee.toFixed(0), - renderOptions: { loadPrice: true }, - }); - expect(callArgs.renderContext.account).toStrictEqual(mockAccount); - expect(callArgs.renderContext.readableTransaction).toStrictEqual( - new OperationMapper().mapTransaction(transaction), - ); + expect(signSpy).toHaveBeenCalledWith(transaction); + expect(result.signedTxXdr).toStrictEqual(transaction.getRaw().toXDR()); + expect(result.signerAddress).toBe(wallet.address); + expect(result.error).toBeUndefined(); + }); - // Hard-coded so a parser regression actually fails the test. - expect(callArgs.tokenPrices).toStrictEqual({ - 'stellar:testnet/slip44:148': null, - }); + it('returns error -4 when user rejects', async () => { + const { + handler, + mockAccount, + wallet, + transactionBuilder, + renderConfirmationDialog, + } = setupHandler(); - expect(signSpy).toHaveBeenCalledWith(transaction); - expect(typeof result).toBe('object'); - expect((result as { signature: string }).signature).toStrictEqual( - transaction.getRaw().toXDR(), + const transaction = buildMainnetPaymentFromWallet(wallet.address); + const xdr = transaction.getRaw().toXDR(); + jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); + const signSpy = jest.spyOn(wallet, 'signTransaction'); + renderConfirmationDialog.mockResolvedValue(false); + + const result = await handler.handle(buildRequest(mockAccount.id, xdr)); + + expect(signSpy).not.toHaveBeenCalled(); + expect(result.signedTxXdr).toBe(''); + expect(result.signerAddress).toBe(wallet.address); + expect(result.error?.code).toBe(Sep43ErrorCode.UserRejected); + }); + + it('returns error -3 when XDR is invalid', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const result = await handler.handle( + buildRequest(mockAccount.id, 'not-an-xdr'), ); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); - it('seeds tokenPrices with classic-asset CAIP-19 ids alongside the native fee asset', async () => { - const { handler, wallet, renderConfirmationDialog, transactionBuilder } = - setupSignTransactionHandler(); + it('returns error -3 when the transaction scope does not match the request scope', async () => { + const { + handler, + mockAccount, + wallet, + transactionBuilder, + renderConfirmationDialog, + } = setupHandler(); - const issuer = Keypair.random().publicKey(); - const transaction = buildMockClassicTransaction( + // Build a TESTNET transaction but request signing on MAINNET scope. + const testnetTx = buildMockClassicTransaction( [ { type: 'payment', params: { destination: Keypair.random().publicKey(), - asset: { code: 'USDC', issuer }, - amount: '5', + asset: 'native', + amount: '1', }, }, + ], + { + networkPassphrase: Networks.TESTNET, + source: { accountId: wallet.address, sequence: '1' }, + }, + ); + jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(testnetTx); + + const result = await handler.handle( + buildRequest(mockAccount.id, testnetTx.getRaw().toXDR()), + ); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -3 when the wallet does not participate in the transaction', async () => { + const { + handler, + mockAccount, + transactionBuilder, + renderConfirmationDialog, + } = setupHandler(); + + const strangerTx = buildMockClassicTransaction( + [ { - type: 'changeTrust', + type: 'payment', params: { - asset: { code: 'USDC', issuer }, - limit: '1000', + destination: Keypair.random().publicKey(), + asset: 'native', + amount: '1', }, }, ], { - networkPassphrase: Networks.TESTNET, - source: { accountId: wallet.address, sequence: '1' }, + networkPassphrase: Networks.PUBLIC, + source: { + accountId: Keypair.random().publicKey(), + sequence: '1', + }, }, ); - const xdr = transaction.getRaw().toXDR(); - jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); - renderConfirmationDialog.mockResolvedValue(true); + jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(strangerTx); - await handler.handle(buildRequest(xdr)); + const result = await handler.handle( + buildRequest(mockAccount.id, strangerTx.getRaw().toXDR()), + ); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); - const callArgs = renderConfirmationDialog.mock.calls[0]?.[0]; - expect(callArgs).toBeDefined(); - const { tokenPrices } = callArgs; + it('returns error -3 when scope is testnet', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); - // Classic asset CAIP-19 keyed for cron price refresh. - expect(tokenPrices).toHaveProperty( - `stellar:testnet/asset:USDC-${issuer}`, - null, + const result = await handler.handle({ + ...buildRequest(mockAccount.id, 'AAAA'), + scope: KnownCaip2ChainId.Testnet, + }); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -3 when opts.networkPassphrase is not the mainnet passphrase', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const result = await handler.handle( + buildRequest(mockAccount.id, 'AAAA', { + opts: { networkPassphrase: Networks.TESTNET }, + }), ); - // Same USDC trustline op should not duplicate the entry. - expect(Object.keys(tokenPrices)).toHaveLength(1); + + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); - it('throws UserRejectedRequestError when confirmation rejects', async () => { - const { handler, wallet, renderConfirmationDialog, transactionBuilder } = - setupSignTransactionHandler(); + it.each([ + ['opts.submit', { submit: true }], + ['opts.submitUrl', { submitUrl: 'https://horizon.stellar.org' }], + ])('returns error -3 when %s is provided', async (_label, forbiddenOpts) => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); - const transaction = buildPaymentTxFromWallet(wallet.address); - const xdr = transaction.getRaw().toXDR(); + const base = buildRequest(mockAccount.id, 'AAAA'); + (base.request.params as unknown as { opts: Record }).opts = + forbiddenOpts; - jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); - const signSpy = jest.spyOn(wallet, 'signTransaction'); + const result = await handler.handle(base); - renderConfirmationDialog.mockResolvedValue(false); + expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -2 when fee simulation fails', async () => { + const { + handler, + mockAccount, + wallet, + transactionBuilder, + transactionService, + renderConfirmationDialog, + } = setupHandler(); + + const transaction = buildMainnetPaymentFromWallet(wallet.address); + jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); + jest + .spyOn(transactionService, 'computingFee') + .mockRejectedValueOnce(new SimulationException('contract not found')); - await expect(handler.handle(buildRequest(xdr))).rejects.toThrow( - UserRejectedRequestError, + const result = await handler.handle( + buildRequest(mockAccount.id, transaction.getRaw().toXDR()), ); - expect(signSpy).not.toHaveBeenCalled(); + + expect(result.error?.code).toBe(Sep43ErrorCode.ExternalService); + expect(result.error?.ext?.[0]).toContain('Failed to simulate transaction'); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); - it('rejects invalid requests before resolving the account', async () => { - const { handler, renderConfirmationDialog } = setupSignTransactionHandler(); + it('shows the account activation prompt and returns ExternalService when the account is not funded', async () => { + const { render: renderAccountActivationPrompt } = + await import('../../ui/confirmation/views/AccountActivationPrompt/render'); + const { + handler, + mockAccount, + wallet, + transactionBuilder, + renderConfirmationDialog, + resolveOnChainAccountSpy, + } = setupHandler(); + + const transaction = buildMainnetPaymentFromWallet(wallet.address); + jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); - const resolveAccountSpy = jest.spyOn( - AccountService.prototype, - 'resolveAccount', + resolveOnChainAccountSpy.mockRejectedValueOnce( + new AccountNotActivatedException( + mockAccount.address, + KnownCaip2ChainId.Mainnet, + ), ); - await expect( - handler.handle({ - ...buildRequest(''), - request: { - method: MultichainMethod.SignTransaction, - params: { transaction: '' }, - }, - }), - ).rejects.toThrow(/transaction/u); + const result = await handler.handle( + buildRequest(mockAccount.id, transaction.getRaw().toXDR()), + ); - expect(resolveAccountSpy).not.toHaveBeenCalled(); + expect(jest.mocked(renderAccountActivationPrompt)).toHaveBeenCalledWith( + mockAccount.address, + ); + expect(result.error?.code).toBe(Sep43ErrorCode.ExternalService); expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts index 83149e18..a1859de4 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts @@ -1,36 +1,45 @@ import { UserRejectedRequestError } from '@metamask/snaps-sdk'; -import { ensureError } from '@metamask/utils'; -import type { - AccountService, - StellarKeyringAccount, -} from '../../services/account'; -import type { OnChainAccountService } from '../../services/on-chain-account'; -import type { ResolvedActivatedAccount } from '../base'; import type { SignTransactionRequest, SignTransactionResponse } from './api'; import { SignTransactionRequestStruct, SignTransactionResponseStruct, } from './api'; -import { WithKeyringRequestActiveAccountResolve } from './base'; +import { BaseSep43KeyringHandler } from './base'; +import type { Sep43Error } from './exceptions'; +import type { + AccountService, + StellarKeyringAccount, +} from '../../services/account'; +import type { OnChainAccountService } from '../../services/on-chain-account'; import type { - TransactionBuilder, Transaction, + TransactionBuilder, TransactionService, } from '../../services/transaction'; import { OperationMapper } from '../../services/transaction'; import { - assertTransactionScope, assertAccountInvolvesTransaction, + assertTransactionScope, collectTransactionAssetCaipIds, } from '../../services/transaction/utils'; -import type { WalletService } from '../../services/wallet'; +import type { Wallet, WalletService } from '../../services/wallet'; import type { ContextWithPrices } from '../../ui/confirmation/api'; import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; import type { ConfirmationUXController } from '../../ui/confirmation/controller'; import type { ILogger } from '../../utils'; -export class SignTransactionHandler extends WithKeyringRequestActiveAccountResolve< +/** + * SEP-43 `signTransaction` keyring handler. + * + * Reuses the existing sign-transaction confirmation view via + * {@link ConfirmationUXController}. Returns the SEP-43 response shape + * (`signedTxXdr`, `signerAddress`, optional `error`) and never throws to the + * dapp — failures are wrapped in the `error` envelope by the base. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md + */ +export class SignTransactionHandler extends BaseSep43KeyringHandler< SignTransactionRequest, SignTransactionResponse > { @@ -43,49 +52,46 @@ export class SignTransactionHandler extends WithKeyringRequestActiveAccountResol constructor({ logger, accountService, - onChainAccountService, walletService, + onChainAccountService, transactionBuilder, transactionService, confirmationUIController, }: { logger: ILogger; accountService: AccountService; - onChainAccountService: OnChainAccountService; - transactionService: TransactionService; walletService: WalletService; + onChainAccountService: OnChainAccountService; transactionBuilder: TransactionBuilder; + transactionService: TransactionService; confirmationUIController: ConfirmationUXController; }) { super({ logger, accountService, - onChainAccountService, walletService, + onChainAccountService, + loggerPrefix: '[📝 SignTransactionHandler]', requestStruct: SignTransactionRequestStruct, responseStruct: SignTransactionResponseStruct, - resolveAccountOptions: { onChainAccount: false }, }); this.#transactionBuilder = transactionBuilder; this.#transactionService = transactionService; this.#confirmationUIController = confirmationUIController; } - protected async _handle( - resolved: ResolvedActivatedAccount, + protected async execute( request: SignTransactionRequest, + resolved: { account: StellarKeyringAccount; wallet: Wallet }, ): Promise { - const { wallet, account } = resolved; + const { account, wallet } = resolved; const { scope } = request; - const { transaction: transactionBase64Xdr } = request.request.params; + const { xdr } = request.request.params; // Deserializing validates that the transaction is well-formed and scope-compatible. // We intentionally skip balance and operation-level checks here; // callers must validate those before requesting a signature. - const transaction = this.#transactionBuilder.deserialize({ - xdr: transactionBase64Xdr, - scope, - }); + const transaction = this.#transactionBuilder.deserialize({ xdr, scope }); // verify the transaction scope matches the requested scope assertTransactionScope(transaction, scope); @@ -99,14 +105,28 @@ export class SignTransactionHandler extends WithKeyringRequestActiveAccountResol await this.#transactionService.computingFee(transaction); if (!(await this.#confirmation(request, transactionWithFee, account))) { - throw ensureError(new UserRejectedRequestError()); + throw new UserRejectedRequestError() as unknown as Error; } wallet.signTransaction(transactionWithFee); + const signedTxXdr = transactionWithFee.getRaw().toXDR(); - const signature = transactionWithFee.getRaw().toXDR(); + return { + signedTxXdr, + signerAddress: account.address, + }; + } - return { signature }; + protected toErrorResponse( + signerAddress: string, + error: Sep43Error, + ): SignTransactionResponse { + return { + // SEP-43 schema requires the field even on error; keep it empty when unknown. + signedTxXdr: '', + signerAddress, + error: error.toEnvelope(), + }; } async #confirmation( diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/api.ts deleted file mode 100644 index d1fac164..00000000 --- a/merged-packages/stellar-wallet-snap/src/handlers/sep43/api.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { - array, - enums, - literal, - nonempty, - number, - object, - optional, - string, - union, -} from '@metamask/superstruct'; -import type { Infer } from '@metamask/superstruct'; -import { base64 } from '@metamask/utils'; - -import { StellarAddressStruct } from '../../api/address'; -import { KnownCaip2ChainIdStruct } from '../../api/network'; -import { UuidStruct } from '../../api/uuid'; -import { XdrStruct } from '../../api/xdr'; - -/** - * SEP-43 method names exposed via `onRpcRequest`. - * - * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md - */ -export enum Sep43Method { - SignMessage = 'SignMessage', - SignTransaction = 'SignTransaction', -} - -export const Sep43MethodStruct = enums(Object.values(Sep43Method)); - -/** - * Optional bag accepted by both SEP-43 methods. - * - * `submit` and `submitUrl` are intentionally omitted from the schema: - * the snap signs only and rejects any caller asking for submission. - */ -export const Sep43OptsStruct = object({ - networkPassphrase: optional(nonempty(string())), - address: optional(StellarAddressStruct), -}); - -export type Sep43Opts = Infer; - -/** - * SEP-43 SignMessage params. - */ -export const Sep43SignMessageParamsStruct = object({ - message: nonempty(base64(string())), - opts: optional(Sep43OptsStruct), -}); - -export type Sep43SignMessageParams = Infer; - -/** - * SEP-43 SignTransaction params. - */ -export const Sep43SignTransactionParamsStruct = object({ - xdr: XdrStruct, - opts: optional(Sep43OptsStruct), -}); - -export type Sep43SignTransactionParams = Infer< - typeof Sep43SignTransactionParamsStruct ->; - -/** - * Wrapper request as it arrives at `onRpcRequest`. - * - * `account` is the keyring account UUID resolved by the multichain middleware - * from the dapp's session-connected accounts (CAIP-25 caveat). - */ -const Sep43RequestWrapper = { - scope: KnownCaip2ChainIdStruct, - account: UuidStruct, - origin: nonempty(string()), - id: union([string(), number(), literal(null)] as const), -}; - -export const Sep43SignMessageRequestStruct = object({ - ...Sep43RequestWrapper, - request: object({ - method: literal(Sep43Method.SignMessage), - params: Sep43SignMessageParamsStruct, - }), -}); - -export type Sep43SignMessageRequest = Infer< - typeof Sep43SignMessageRequestStruct ->; - -export const Sep43SignTransactionRequestStruct = object({ - ...Sep43RequestWrapper, - request: object({ - method: literal(Sep43Method.SignTransaction), - params: Sep43SignTransactionParamsStruct, - }), -}); - -export type Sep43SignTransactionRequest = Infer< - typeof Sep43SignTransactionRequestStruct ->; - -/** - * Shape of the SEP-43 error envelope that may sit alongside the success fields. - */ -export const Sep43ErrorEnvelopeStruct = object({ - message: nonempty(string()), - code: number(), - ext: optional(array(string())), -}); - -export type Sep43ErrorEnvelope = Infer; - -/** - * SEP-43 SignMessage response. - * - * `signedMessage` is base64-encoded on success; empty string on error. - * `signerAddress` is the signer's G-address on success, or empty when - * account resolution failed before we could determine the address. - */ -export const Sep43SignMessageResponseStruct = object({ - signedMessage: union([nonempty(base64(string())), literal('')]), - signerAddress: union([StellarAddressStruct, literal('')]), - error: optional(Sep43ErrorEnvelopeStruct), -}); - -export type Sep43SignMessageResponse = Infer< - typeof Sep43SignMessageResponseStruct ->; - -/** - * SEP-43 SignTransaction response. - * - * `signedTxXdr` is the signed transaction envelope as base64 XDR on success; - * empty string on error. - * `signerAddress` is the signer's G-address on success, or empty when - * account resolution failed before we could determine the address. - */ -export const Sep43SignTransactionResponseStruct = object({ - signedTxXdr: union([XdrStruct, literal('')]), - signerAddress: union([StellarAddressStruct, literal('')]), - error: optional(Sep43ErrorEnvelopeStruct), -}); - -export type Sep43SignTransactionResponse = Infer< - typeof Sep43SignTransactionResponseStruct ->; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/base.ts deleted file mode 100644 index a7ef90f4..00000000 --- a/merged-packages/stellar-wallet-snap/src/handlers/sep43/base.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { Struct } from '@metamask/superstruct'; -import { Networks } from '@stellar/stellar-sdk'; - -import type { Sep43Opts } from './api'; -import { Sep43Error, Sep43ErrorCode, toSep43Error } from './exceptions'; -import type { KnownCaip2ChainId } from '../../api'; -import { KnownCaip2ChainId as Caip2 } from '../../api'; -import type { - AccountService, - StellarKeyringAccount, -} from '../../services/account'; -import type { Wallet, WalletService } from '../../services/wallet'; -import type { ILogger } from '../../utils'; -import { createPrefixedLogger } from '../../utils'; -import { validateRequest } from '../../utils/requestResponse'; - -/** Mainnet is the only network the snap currently signs for. */ -const SUPPORTED_PASSPHRASE: string = Networks.PUBLIC; - -/** - * Base class shared by SEP-43 SignMessage and SignTransaction handlers. - * - * Provides common cross-cutting concerns: validates `opts.networkPassphrase` - * (mainnet only), validates `scope` is mainnet (re-affirms what the middleware - * already did), forbids `submit` / `submitUrl` (snap is sign-only), resolves - * the keyring account by `opts.address` when provided (otherwise falls back to - * the wrapper's `account` UUID), and wraps thrown errors into the SEP-43 - * `error` envelope so the dapp always receives a well-formed payload. - * - * Subclasses implement {@link execute} which performs the wallet signing and - * returns the success-shaped fields. They never throw to the dapp directly. - */ -export abstract class BaseSep43Handler< - Request extends { - scope: KnownCaip2ChainId; - account: string; - request: { params: { opts?: Sep43Opts } }; - }, - Response extends { signerAddress: string; error?: unknown }, -> { - protected readonly logger: ILogger; - - protected readonly accountService: AccountService; - - protected readonly walletService: WalletService; - - protected readonly requestStruct: Struct; - - constructor({ - logger, - accountService, - walletService, - loggerPrefix, - requestStruct, - }: { - logger: ILogger; - accountService: AccountService; - walletService: WalletService; - loggerPrefix: string; - requestStruct: Struct; - }) { - this.logger = createPrefixedLogger(logger, loggerPrefix); - this.accountService = accountService; - this.walletService = walletService; - this.requestStruct = requestStruct; - } - - /** - * Top-level entry point. Runs the full pipeline (validate → override origin - * → check network/opts → resolve account → execute) inside a single try/catch - * so every failure (including struct validation) is serialized into the - * SEP-43 `error` envelope. The dapp never sees a thrown JSON-RPC error. - * - * @param rawRequest - The unvalidated SEP-43 request as it arrives from the dapp. - * @param trustedOrigin - The verified origin provided by MetaMask's `onRpcRequest` - * handler. Overrides the dapp-supplied `params.origin` so the confirmation UI - * cannot be spoofed by a malicious dapp. - * @returns The SEP-43 response with either the success fields or `error` populated. - */ - async handle(rawRequest: unknown, trustedOrigin: string): Promise { - let signerAddress = ''; - try { - const request = validateRequest(rawRequest, this.requestStruct); - - // Override the dapp-supplied origin with the MM-verified one. - const verifiedRequest = { ...request, origin: trustedOrigin }; - - this.assertSupportedNetwork(verifiedRequest); - this.assertNoSubmit(verifiedRequest.request.params.opts); - - const { account, wallet } = await this.resolveAccount(verifiedRequest); - signerAddress = account.address; - - return await this.execute(verifiedRequest, { account, wallet }); - } catch (error: unknown) { - const sep43 = toSep43Error(error); - this.logger.logErrorWithDetails('SEP-43 request failed', sep43); - return this.toErrorResponse(signerAddress, sep43); - } - } - - /** - * Subclass hook: do the actual signing. - * - * @param request - The validated request. - * @param resolved - The resolved keyring account and signing wallet. - * @returns The success-shaped response (no `error` field). - */ - protected abstract execute( - request: Request, - resolved: { account: StellarKeyringAccount; wallet: Wallet }, - ): Promise; - - /** - * Subclass hook: shape an error-only response when everything fails. - * - * @param signerAddress - The resolved address (or empty string when unknown). - * @param error - The classified SEP-43 error. - * @returns The error response in the subclass's response shape. - */ - protected abstract toErrorResponse( - signerAddress: string, - error: Sep43Error, - ): Response; - - /** - * Resolves the signing account. - * Prefers `opts.address` when provided; otherwise uses the wrapper's `account` UUID. - * When both are present, the resolved address must match. - * - * @param request - The SEP-43 request. - * @returns The resolved keyring account and signing wallet. - */ - protected async resolveAccount( - request: Request, - ): Promise<{ account: StellarKeyringAccount; wallet: Wallet }> { - const { account: accountId, scope } = request; - const optsAddress = request.request.params.opts?.address; - - const { account } = optsAddress - ? await this.accountService.resolveAccount({ - scope, - accountAddress: optsAddress, - }) - : await this.accountService.resolveAccount({ accountId }); - - if (optsAddress && account.id !== accountId) { - // The dapp picked an address that doesn't match the session-selected account. - throw new Sep43Error({ - code: Sep43ErrorCode.InvalidRequest, - ext: [ - `opts.address ${optsAddress} does not match the session-selected account.`, - ], - }); - } - - const wallet = await this.walletService.resolveWallet(account); - return { account, wallet }; - } - - /** - * Throws when the dapp asks for a network we don't support. - * Today: mainnet only. The snap rejects any other `opts.networkPassphrase` - * and any non-mainnet `scope`. - * - * @param request - The SEP-43 request. - */ - protected assertSupportedNetwork(request: Request): void { - if (request.scope !== Caip2.Mainnet) { - throw new Sep43Error({ - code: Sep43ErrorCode.InvalidRequest, - ext: [`Only mainnet is supported, received scope ${request.scope}.`], - }); - } - - const requestedPassphrase = request.request.params.opts?.networkPassphrase; - if ( - requestedPassphrase !== undefined && - requestedPassphrase !== SUPPORTED_PASSPHRASE - ) { - throw new Sep43Error({ - code: Sep43ErrorCode.InvalidRequest, - ext: [ - `Only Stellar mainnet is supported by this wallet. Received passphrase: ${requestedPassphrase}.`, - ], - }); - } - } - - /** - * Throws when the dapp set `submit` or `submitUrl`. The snap is sign-only. - * - * @param opts - The SEP-43 opts bag (may be undefined). - */ - protected assertNoSubmit(opts: Sep43Opts | undefined): void { - // Use property access to detect even runtime-injected fields the struct stripped. - const raw = opts as undefined | Record; - if (raw?.submit !== undefined || raw?.submitUrl !== undefined) { - throw new Sep43Error({ - code: Sep43ErrorCode.InvalidRequest, - ext: ['This wallet does not submit transactions; use sign only.'], - }); - } - } -} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/exceptions.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/exceptions.ts deleted file mode 100644 index 48d8cb3c..00000000 --- a/merged-packages/stellar-wallet-snap/src/handlers/sep43/exceptions.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { - InvalidParamsError, - UserRejectedRequestError, -} from '@metamask/snaps-sdk'; -import { StructError } from '@metamask/superstruct'; -import { ensureError } from '@metamask/utils'; - -import { AccountServiceException } from '../../services/account/exceptions'; -import { - AccountLoadException, - AccountNotActivatedException, - AssetDataFetchException, - BaseFeeFetchException, - NetworkServiceException, - SimulationException, - TransactionPollException, - TransactionRetryableException, - TransactionSendException, -} from '../../services/network/exceptions'; -import { - TransactionScopeNotMatchException, - TransactionValidationException, -} from '../../services/transaction/exceptions'; - -/** - * SEP-43 error codes. - * - * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md - */ -export enum Sep43ErrorCode { - /** Internal wallet error (JS runtime, programmer error, etc.). */ - Internal = -1, - /** External service (Horizon, RPC, …) returned an error. */ - ExternalService = -2, - /** Client app request is invalid (bad params, malformed XDR, unsupported option). */ - InvalidRequest = -3, - /** User declined the confirmation. */ - UserRejected = -4, -} - -/** - * Generic SEP-43 message that's user-safe to forward to the dapp. - * - * Per-code default messages used when callers throw without a specific message. - * The `ext` array carries optional richer context (e.g. underlying error message). - */ -export const SEP43_DEFAULT_MESSAGE: Record = { - [Sep43ErrorCode.Internal]: - 'The wallet encountered an internal error. Please try again or contact the wallet if the problem persists.', - [Sep43ErrorCode.ExternalService]: - 'An error occurred with an external service. Please try again.', - [Sep43ErrorCode.InvalidRequest]: - 'Request is invalid. Please check the details and try again.', - [Sep43ErrorCode.UserRejected]: 'The user rejected this request.', -}; - -/** - * Structured SEP-43 error envelope returned to the dapp on failure. - */ -export class Sep43Error extends Error { - readonly code: Sep43ErrorCode; - - readonly ext: string[] | undefined; - - constructor(params: { - code: Sep43ErrorCode; - message?: string; - ext?: string[]; - }) { - super(params.message ?? SEP43_DEFAULT_MESSAGE[params.code]); - this.name = 'Sep43Error'; - this.code = params.code; - this.ext = params.ext; - } - - /** - * Serializes to the SEP-43 `error` shape. - * - * @returns The serialized error envelope (`message`, `code`, optional `ext`). - */ - toEnvelope(): { message: string; code: number; ext?: string[] } { - return { - message: this.message, - code: this.code, - ...(this.ext === undefined ? {} : { ext: this.ext }), - }; - } -} - -/** - * Maps any thrown error to a {@link Sep43Error}, classifying by known internal types. - * Pass-through for `Sep43Error`; everything else falls back to {@link Sep43ErrorCode.Internal}. - * - * @param error - The thrown value. - * @returns A {@link Sep43Error} ready to serialize back to the dapp. - */ -export function toSep43Error(error: unknown): Sep43Error { - if (error instanceof Sep43Error) { - return error; - } - - const wrapped = ensureError(error); - - if (wrapped instanceof UserRejectedRequestError) { - return new Sep43Error({ code: Sep43ErrorCode.UserRejected }); - } - - if ( - // `validateRequest` rewraps StructError as InvalidParamsError before it - // reaches us, so we accept both shapes here. - wrapped instanceof InvalidParamsError || - wrapped instanceof StructError || - // Catches AccountNotFoundException + DerivedAccountAddressMismatchException - // (both extend AccountServiceException) — typically caused by a bad - // `opts.address` from the dapp. - wrapped instanceof AccountServiceException || - wrapped instanceof TransactionValidationException || - wrapped instanceof TransactionScopeNotMatchException - ) { - return new Sep43Error({ - code: Sep43ErrorCode.InvalidRequest, - ext: [wrapped.message], - }); - } - - if ( - wrapped instanceof AccountNotActivatedException || - wrapped instanceof AccountLoadException || - wrapped instanceof AssetDataFetchException || - wrapped instanceof BaseFeeFetchException || - wrapped instanceof SimulationException || - wrapped instanceof TransactionPollException || - wrapped instanceof TransactionRetryableException || - wrapped instanceof TransactionSendException || - wrapped instanceof NetworkServiceException - ) { - return new Sep43Error({ - code: Sep43ErrorCode.ExternalService, - ext: [wrapped.message], - }); - } - - return new Sep43Error({ code: Sep43ErrorCode.Internal }); -} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/index.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/index.ts deleted file mode 100644 index 49c4898d..00000000 --- a/merged-packages/stellar-wallet-snap/src/handlers/sep43/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './api'; -export * from './base'; -export * from './exceptions'; -export * from './signMessage'; -export * from './signTransaction'; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts deleted file mode 100644 index 45449c62..00000000 --- a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { Networks } from '@stellar/stellar-sdk'; - -import { Sep43Method, type Sep43SignMessageRequest } from './api'; -import { Sep43ErrorCode } from './exceptions'; -import { Sep43SignMessageHandler } from './signMessage'; -import { KnownCaip2ChainId } from '../../api'; -import { AccountService } from '../../services/account'; -import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; -import { AccountNotFoundException } from '../../services/account/exceptions'; -import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; -import { WalletService } from '../../services/wallet'; -import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; -import type { ConfirmationUXController } from '../../ui/confirmation/controller'; -import { logger } from '../../utils/logger'; - -jest.mock('../../utils/logger'); - -/** Simulates the verified origin MetaMask passes to `onRpcRequest`. */ -const TRUSTED_ORIGIN = 'https://example.com'; - -describe('Sep43SignMessageHandler', () => { - /** - * Builds a `Sep43SignMessageHandler` with mocked account / wallet resolution - * and a stubbed `ConfirmationUXController`. - * - * @returns Handler instance and the test doubles needed by each spec. - */ - function setupHandler() { - const wallet = getTestWallet(); - const accountId = globalThis.crypto.randomUUID(); - const mockAccount = generateStellarKeyringAccount( - accountId, - wallet.address, - 'entropy-source-1', - 0, - ); - - const { accountService, walletService } = mockOnChainAccountService(); - - const resolveAccountSpy = jest - .spyOn(AccountService.prototype, 'resolveAccount') - .mockResolvedValue({ account: mockAccount }); - - jest - .spyOn(WalletService.prototype, 'resolveWallet') - .mockResolvedValue(wallet); - - const renderConfirmationDialog = jest.fn(); - const confirmationUIController = { - renderConfirmationDialog, - } as Pick< - ConfirmationUXController, - 'renderConfirmationDialog' - > as unknown as ConfirmationUXController; - - const handler = new Sep43SignMessageHandler({ - logger, - accountService, - walletService, - confirmationUIController, - }); - - return { - handler, - mockAccount, - wallet, - renderConfirmationDialog, - resolveAccountSpy, - }; - } - - const buildRequest = ( - accountId: string, - overrides: Partial = {}, - ): Sep43SignMessageRequest => ({ - id: '11111111-1111-4111-8111-111111111111', - origin: 'https://example.com', - scope: KnownCaip2ChainId.Mainnet, - account: accountId, - request: { - method: Sep43Method.SignMessage, - params: { - message: btoa('hello stellar'), - ...overrides, - }, - }, - }); - - it('returns signedMessage and signerAddress on confirm', async () => { - const { handler, mockAccount, wallet, renderConfirmationDialog } = - setupHandler(); - renderConfirmationDialog.mockResolvedValue(true); - - const result = await handler.handle( - buildRequest(mockAccount.id), - TRUSTED_ORIGIN, - ); - - const expected = await wallet.signMessage(btoa('hello stellar')); - expect(result).toStrictEqual({ - signedMessage: expected, - signerAddress: wallet.address, - }); - }); - - it('returns error -4 when user rejects', async () => { - const { handler, mockAccount, wallet, renderConfirmationDialog } = - setupHandler(); - renderConfirmationDialog.mockResolvedValue(false); - - const result = await handler.handle( - buildRequest(mockAccount.id), - TRUSTED_ORIGIN, - ); - - expect(result.signedMessage).toBe(''); - expect(result.signerAddress).toBe(wallet.address); - expect(result.error?.code).toBe(Sep43ErrorCode.UserRejected); - }); - - it('returns error -3 when scope is testnet', async () => { - const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); - - const result = await handler.handle( - { ...buildRequest(mockAccount.id), scope: KnownCaip2ChainId.Testnet }, - TRUSTED_ORIGIN, - ); - - expect(result.signedMessage).toBe(''); - expect(result.signerAddress).toBe(''); - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); - }); - - it('returns error -3 when opts.networkPassphrase is not the mainnet passphrase', async () => { - const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); - - const result = await handler.handle( - buildRequest(mockAccount.id, { - opts: { networkPassphrase: Networks.TESTNET }, - }), - TRUSTED_ORIGIN, - ); - - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); - expect(result.error?.ext?.[0]).toContain('mainnet'); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); - }); - - it.each([ - ['opts.submit', { submit: true }], - ['opts.submitUrl', { submitUrl: 'https://horizon.stellar.org' }], - ])('returns error -3 when %s is provided', async (_label, forbiddenOpts) => { - const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); - - const base = buildRequest(mockAccount.id); - // Inject the forbidden opt bypassing the struct type so we can assert the - // handler rejects it at runtime with -3 InvalidRequest. - (base.request.params as unknown as { opts: Record }).opts = - forbiddenOpts; - - const result = await handler.handle(base, TRUSTED_ORIGIN); - - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); - }); - - it('returns error -3 when opts.address cannot be resolved', async () => { - const { handler, mockAccount, resolveAccountSpy } = setupHandler(); - const unknownAddress = - 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB'; - resolveAccountSpy.mockRejectedValueOnce( - new AccountNotFoundException(unknownAddress), - ); - - const result = await handler.handle( - buildRequest(mockAccount.id, { opts: { address: unknownAddress } }), - TRUSTED_ORIGIN, - ); - - expect(result.signedMessage).toBe(''); - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); - }); - - it('returns error -3 when opts.address resolves to a different account than the wrapper UUID', async () => { - const { - handler, - mockAccount, - renderConfirmationDialog, - resolveAccountSpy, - } = setupHandler(); - const otherAccount = generateStellarKeyringAccount( - globalThis.crypto.randomUUID(), - mockAccount.address, - 'entropy-source-1', - 1, - ); - resolveAccountSpy.mockResolvedValueOnce({ account: otherAccount }); - - const result = await handler.handle( - buildRequest(mockAccount.id, { - opts: { address: otherAccount.address }, - }), - TRUSTED_ORIGIN, - ); - - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); - }); - - it('returns error -3 when message is not valid base64', async () => { - const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); - - const result = await handler.handle( - buildRequest(mockAccount.id, { message: 'not valid base64 !!!' }), - TRUSTED_ORIGIN, - ); - - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); - }); -}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.ts deleted file mode 100644 index 57254182..00000000 --- a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signMessage.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { UserRejectedRequestError } from '@metamask/snaps-sdk'; - -import type { Sep43SignMessageRequest, Sep43SignMessageResponse } from './api'; -import { Sep43SignMessageRequestStruct } from './api'; -import { BaseSep43Handler } from './base'; -import type { Sep43Error } from './exceptions'; -import type { - AccountService, - StellarKeyringAccount, -} from '../../services/account'; -import type { Wallet, WalletService } from '../../services/wallet'; -import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; -import type { ConfirmationUXController } from '../../ui/confirmation/controller'; -import type { ILogger } from '../../utils'; -import { bufferToUint8Array } from '../../utils'; -import { isBase64 } from '../../utils/string'; - -/** - * SEP-43 `SignMessage` handler. - * - * Reuses the existing sign-message confirmation view via {@link ConfirmationUXController}. - * Returns the SEP-43 response shape (`signedMessage`, `signerAddress`, optional `error`) - * and never throws to the dapp — failures are wrapped in the `error` envelope by the base. - */ -export class Sep43SignMessageHandler extends BaseSep43Handler< - Sep43SignMessageRequest, - Sep43SignMessageResponse -> { - readonly #confirmationUIController: ConfirmationUXController; - - constructor({ - logger, - accountService, - walletService, - confirmationUIController, - }: { - logger: ILogger; - accountService: AccountService; - walletService: WalletService; - confirmationUIController: ConfirmationUXController; - }) { - super({ - logger, - accountService, - walletService, - loggerPrefix: '[✉️ Sep43SignMessageHandler]', - requestStruct: Sep43SignMessageRequestStruct, - }); - this.#confirmationUIController = confirmationUIController; - } - - protected async execute( - request: Sep43SignMessageRequest, - resolved: { account: StellarKeyringAccount; wallet: Wallet }, - ): Promise { - const { account, wallet } = resolved; - const { message } = request.request.params; - - if (!(await this.#confirm(request, account, message))) { - throw new UserRejectedRequestError() as unknown as Error; - } - - const signedMessage = await wallet.signMessage(message); - - return { - signedMessage, - signerAddress: account.address, - }; - } - - protected toErrorResponse( - signerAddress: string, - error: Sep43Error, - ): Sep43SignMessageResponse { - return { - // SEP-43 schema requires the field even on error; keep it empty when unknown. - signedMessage: '', - signerAddress, - error: error.toEnvelope(), - }; - } - - async #confirm( - request: Sep43SignMessageRequest, - account: StellarKeyringAccount, - message: string, - ): Promise { - return ( - (await this.#confirmationUIController.renderConfirmationDialog({ - scope: request.scope, - renderContext: { - account, - message: this.#getUtf8Message(message), - }, - origin: request.origin, - interfaceKey: ConfirmationInterfaceKey.SignMessage, - })) === true - ); - } - - #getUtf8Message(message: string): string { - if (isBase64(message)) { - return bufferToUint8Array(message, 'base64').toString('utf8'); - } - return message; - } -} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts deleted file mode 100644 index 85659042..00000000 --- a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.test.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { Keypair, Networks } from '@stellar/stellar-sdk'; - -import { Sep43Method, type Sep43SignTransactionRequest } from './api'; -import { Sep43ErrorCode } from './exceptions'; -import { Sep43SignTransactionHandler } from './signTransaction'; -import { KnownCaip2ChainId } from '../../api'; -import { AccountService } from '../../services/account'; -import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; -import { SimulationException } from '../../services/network/exceptions'; -import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; -import type { Transaction } from '../../services/transaction'; -import { TransactionService } from '../../services/transaction'; -import { - buildMockClassicTransaction, - createMockTransactionService, -} from '../../services/transaction/__mocks__/transaction.fixtures'; -import { WalletService } from '../../services/wallet'; -import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; -import type { ConfirmationUXController } from '../../ui/confirmation/controller'; -import { logger } from '../../utils/logger'; - -jest.mock('../../utils/logger'); - -/** Simulates the verified origin MetaMask passes to `onRpcRequest`. */ -const TRUSTED_ORIGIN = 'https://example.com'; - -describe('Sep43SignTransactionHandler', () => { - /** - * Builds a `Sep43SignTransactionHandler` with mocked services + account/wallet - * resolution, plus a stubbed `ConfirmationUXController`. - * - * @returns Handler instance and the test doubles needed by each spec. - */ - function setupHandler() { - const wallet = getTestWallet(); - const mockAccount = generateStellarKeyringAccount( - globalThis.crypto.randomUUID(), - wallet.address, - 'entropy-source-1', - 0, - ); - - const { transactionBuilder, transactionService } = - createMockTransactionService(); - const { accountService, walletService } = mockOnChainAccountService(); - - const resolveAccountSpy = jest - .spyOn(AccountService.prototype, 'resolveAccount') - .mockResolvedValue({ account: mockAccount }); - - jest - .spyOn(WalletService.prototype, 'resolveWallet') - .mockResolvedValue(wallet); - - // Default: pass-through fee (no Soroban simulation needed for classic tx). - jest - .spyOn(TransactionService.prototype, 'computingFee') - .mockImplementation(async (tx) => tx); - - const renderConfirmationDialog = jest.fn(); - const confirmationUIController = { - renderConfirmationDialog, - } as Pick< - ConfirmationUXController, - 'renderConfirmationDialog' - > as unknown as ConfirmationUXController; - - const handler = new Sep43SignTransactionHandler({ - logger, - accountService, - walletService, - transactionBuilder, - transactionService, - confirmationUIController, - }); - - return { - handler, - mockAccount, - wallet, - transactionBuilder, - transactionService, - renderConfirmationDialog, - resolveAccountSpy, - }; - } - - /** - * Builds a mainnet payment transaction whose source is the wallet so it - * passes `assertAccountInvolvesTransaction`. - * - * @param walletAddress - Wallet's Stellar public key (`G…`). - * @returns Mock transaction built with `Networks.PUBLIC`. - */ - function buildMainnetPaymentFromWallet(walletAddress: string): Transaction { - return buildMockClassicTransaction( - [ - { - type: 'payment', - params: { - destination: Keypair.random().publicKey(), - asset: 'native', - amount: '1', - }, - }, - ], - { - networkPassphrase: Networks.PUBLIC, - source: { accountId: walletAddress, sequence: '1' }, - }, - ); - } - - const buildRequest = ( - accountId: string, - xdr: string, - overrides: Partial = {}, - ): Sep43SignTransactionRequest => ({ - id: '22222222-2222-4222-8222-222222222222', - origin: 'https://example.com', - scope: KnownCaip2ChainId.Mainnet, - account: accountId, - request: { - method: Sep43Method.SignTransaction, - params: { xdr, ...overrides }, - }, - }); - - it('returns signedTxXdr and signerAddress on confirm', async () => { - const { - handler, - mockAccount, - wallet, - transactionBuilder, - renderConfirmationDialog, - } = setupHandler(); - - const transaction = buildMainnetPaymentFromWallet(wallet.address); - const xdr = transaction.getRaw().toXDR(); - jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); - const signSpy = jest.spyOn(wallet, 'signTransaction'); - renderConfirmationDialog.mockResolvedValue(true); - - const result = await handler.handle( - buildRequest(mockAccount.id, xdr), - TRUSTED_ORIGIN, - ); - - expect(signSpy).toHaveBeenCalledWith(transaction); - expect(result.signedTxXdr).toStrictEqual(transaction.getRaw().toXDR()); - expect(result.signerAddress).toBe(wallet.address); - expect(result.error).toBeUndefined(); - }); - - it('returns error -4 when user rejects', async () => { - const { - handler, - mockAccount, - wallet, - transactionBuilder, - renderConfirmationDialog, - } = setupHandler(); - - const transaction = buildMainnetPaymentFromWallet(wallet.address); - const xdr = transaction.getRaw().toXDR(); - jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); - const signSpy = jest.spyOn(wallet, 'signTransaction'); - renderConfirmationDialog.mockResolvedValue(false); - - const result = await handler.handle( - buildRequest(mockAccount.id, xdr), - TRUSTED_ORIGIN, - ); - - expect(signSpy).not.toHaveBeenCalled(); - expect(result.signedTxXdr).toBe(''); - expect(result.signerAddress).toBe(wallet.address); - expect(result.error?.code).toBe(Sep43ErrorCode.UserRejected); - }); - - it('returns error -3 when XDR is invalid', async () => { - const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); - - const result = await handler.handle( - buildRequest(mockAccount.id, 'not-an-xdr'), - TRUSTED_ORIGIN, - ); - - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); - }); - - it('returns error -3 when the transaction scope does not match the request scope', async () => { - const { - handler, - mockAccount, - wallet, - transactionBuilder, - renderConfirmationDialog, - } = setupHandler(); - - // Build a TESTNET transaction but request signing on MAINNET scope. - const testnetTx = buildMockClassicTransaction( - [ - { - type: 'payment', - params: { - destination: Keypair.random().publicKey(), - asset: 'native', - amount: '1', - }, - }, - ], - { - networkPassphrase: Networks.TESTNET, - source: { accountId: wallet.address, sequence: '1' }, - }, - ); - jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(testnetTx); - - const result = await handler.handle( - buildRequest(mockAccount.id, testnetTx.getRaw().toXDR()), - TRUSTED_ORIGIN, - ); - - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); - }); - - it('returns error -3 when the wallet does not participate in the transaction', async () => { - const { - handler, - mockAccount, - transactionBuilder, - renderConfirmationDialog, - } = setupHandler(); - - const strangerTx = buildMockClassicTransaction( - [ - { - type: 'payment', - params: { - destination: Keypair.random().publicKey(), - asset: 'native', - amount: '1', - }, - }, - ], - { - networkPassphrase: Networks.PUBLIC, - source: { - accountId: Keypair.random().publicKey(), - sequence: '1', - }, - }, - ); - jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(strangerTx); - - const result = await handler.handle( - buildRequest(mockAccount.id, strangerTx.getRaw().toXDR()), - TRUSTED_ORIGIN, - ); - - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); - }); - - it('returns error -3 when scope is testnet', async () => { - const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); - - const result = await handler.handle( - { - ...buildRequest(mockAccount.id, 'AAAA'), - scope: KnownCaip2ChainId.Testnet, - }, - TRUSTED_ORIGIN, - ); - - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); - }); - - it('returns error -3 when opts.networkPassphrase is not the mainnet passphrase', async () => { - const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); - - const result = await handler.handle( - buildRequest(mockAccount.id, 'AAAA', { - opts: { networkPassphrase: Networks.TESTNET }, - }), - TRUSTED_ORIGIN, - ); - - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); - }); - - it.each([ - ['opts.submit', { submit: true }], - ['opts.submitUrl', { submitUrl: 'https://horizon.stellar.org' }], - ])('returns error -3 when %s is provided', async (_label, forbiddenOpts) => { - const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); - - const base = buildRequest(mockAccount.id, 'AAAA'); - (base.request.params as unknown as { opts: Record }).opts = - forbiddenOpts; - - const result = await handler.handle(base, TRUSTED_ORIGIN); - - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); - }); - - it('returns error -2 when fee simulation fails', async () => { - const { - handler, - mockAccount, - wallet, - transactionBuilder, - transactionService, - renderConfirmationDialog, - } = setupHandler(); - - const transaction = buildMainnetPaymentFromWallet(wallet.address); - jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); - jest - .spyOn(transactionService, 'computingFee') - .mockRejectedValueOnce(new SimulationException('contract not found')); - - const result = await handler.handle( - buildRequest(mockAccount.id, transaction.getRaw().toXDR()), - TRUSTED_ORIGIN, - ); - - expect(result.error?.code).toBe(Sep43ErrorCode.ExternalService); - expect(result.error?.ext?.[0]).toContain('Failed to simulate transaction'); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); - }); -}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.ts deleted file mode 100644 index 14573ce9..00000000 --- a/merged-packages/stellar-wallet-snap/src/handlers/sep43/signTransaction.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { UserRejectedRequestError } from '@metamask/snaps-sdk'; - -import type { - Sep43SignTransactionRequest, - Sep43SignTransactionResponse, -} from './api'; -import { Sep43SignTransactionRequestStruct } from './api'; -import { BaseSep43Handler } from './base'; -import type { Sep43Error } from './exceptions'; -import type { - AccountService, - StellarKeyringAccount, -} from '../../services/account'; -import type { - Transaction, - TransactionBuilder, - TransactionService, -} from '../../services/transaction'; -import { OperationMapper } from '../../services/transaction'; -import { - assertAccountInvolvesTransaction, - assertTransactionScope, - collectTransactionAssetCaipIds, -} from '../../services/transaction/utils'; -import type { Wallet, WalletService } from '../../services/wallet'; -import type { ContextWithPrices } from '../../ui/confirmation/api'; -import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; -import type { ConfirmationUXController } from '../../ui/confirmation/controller'; -import type { ILogger } from '../../utils'; - -/** - * SEP-43 `SignTransaction` handler. - * - * Reuses the existing sign-transaction confirmation view via {@link ConfirmationUXController}. - * Returns the SEP-43 response shape (`signedTxXdr`, `signerAddress`, optional `error`) - * and never throws to the dapp — failures are wrapped in the `error` envelope by the base. - */ -export class Sep43SignTransactionHandler extends BaseSep43Handler< - Sep43SignTransactionRequest, - Sep43SignTransactionResponse -> { - readonly #transactionBuilder: TransactionBuilder; - - readonly #transactionService: TransactionService; - - readonly #confirmationUIController: ConfirmationUXController; - - constructor({ - logger, - accountService, - walletService, - transactionBuilder, - transactionService, - confirmationUIController, - }: { - logger: ILogger; - accountService: AccountService; - walletService: WalletService; - transactionBuilder: TransactionBuilder; - transactionService: TransactionService; - confirmationUIController: ConfirmationUXController; - }) { - super({ - logger, - accountService, - walletService, - loggerPrefix: '[📝 Sep43SignTransactionHandler]', - requestStruct: Sep43SignTransactionRequestStruct, - }); - this.#transactionBuilder = transactionBuilder; - this.#transactionService = transactionService; - this.#confirmationUIController = confirmationUIController; - } - - protected async execute( - request: Sep43SignTransactionRequest, - resolved: { account: StellarKeyringAccount; wallet: Wallet }, - ): Promise { - const { account, wallet } = resolved; - const { scope } = request; - const { xdr } = request.request.params; - - // Deserializing validates that the transaction is well-formed and scope-compatible. - const transaction = this.#transactionBuilder.deserialize({ xdr, scope }); - - assertTransactionScope(transaction, scope); - assertAccountInvolvesTransaction(transaction, wallet.address); - - const transactionWithFee = - await this.#transactionService.computingFee(transaction); - - if (!(await this.#confirm(request, transactionWithFee, account))) { - throw new UserRejectedRequestError() as unknown as Error; - } - - wallet.signTransaction(transactionWithFee); - const signedTxXdr = transactionWithFee.getRaw().toXDR(); - - return { - signedTxXdr, - signerAddress: account.address, - }; - } - - protected toErrorResponse( - signerAddress: string, - error: Sep43Error, - ): Sep43SignTransactionResponse { - return { - // SEP-43 schema requires the field even on error; keep it empty when unknown. - signedTxXdr: '', - signerAddress, - error: error.toEnvelope(), - }; - } - - async #confirm( - request: Sep43SignTransactionRequest, - transaction: Transaction, - account: StellarKeyringAccount, - ): Promise { - const readableTransaction = new OperationMapper().mapTransaction( - transaction, - ); - - // Seed every asset id we render so the cron refresh updates prices for all of them. - // The `as` cast bypasses superstruct typing that requires every union key. - const tokenPrices = Object.fromEntries( - collectTransactionAssetCaipIds(request.scope, readableTransaction).map( - (assetId) => [assetId, null] as const, - ), - ) as ContextWithPrices['tokenPrices']; - - return ( - (await this.#confirmationUIController.renderConfirmationDialog({ - scope: request.scope, - origin: request.origin, - interfaceKey: ConfirmationInterfaceKey.SignTransaction, - fee: readableTransaction.feeStroops, - renderContext: { - readableTransaction, - account, - }, - renderOptions: { loadPrice: true }, - tokenPrices, - })) === true - ); - } -} diff --git a/merged-packages/stellar-wallet-snap/src/index.ts b/merged-packages/stellar-wallet-snap/src/index.ts index 7c84fa8e..487c352d 100644 --- a/merged-packages/stellar-wallet-snap/src/index.ts +++ b/merged-packages/stellar-wallet-snap/src/index.ts @@ -9,19 +9,20 @@ import type { OnAssetsMarketDataHandler, } from '@metamask/snaps-sdk'; import { MethodNotFoundError } from '@metamask/snaps-sdk'; -import type { JsonRpcRequest } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; import { keyringHandler, - signMessageHandler, userInputHandler, - signTransactionHandler, cronjobHandler, assetsHandler, - sep43SignMessageHandler, - sep43SignTransactionHandler, + signMessageHandler, + signTransactionHandler, } from './context'; -import { Sep43Method } from './handlers/sep43'; +import type { + SignMessageRequest, + SignTransactionRequest, +} from './handlers/keyring/api'; export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ( args, @@ -47,35 +48,47 @@ export const onUserInput: OnUserInputHandler = async (params) => export const onCronjob: OnCronjobHandler = async ({ request }) => cronjobHandler.handle(request); -export const onRpcRequest: OnRpcRequestHandler = async ({ - origin, - request, -}) => { - const { method } = request; - - // SEP-43 dapp-facing methods. Both handlers always resolve to the SEP-43 - // response shape (success or error envelope) — they never throw to the dapp. - // `origin` comes from MetaMask (verified); we override the dapp-supplied - // `params.origin` so the confirmation UI cannot be phished. - // @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md - if (method === String(Sep43Method.SignMessage)) { - return sep43SignMessageHandler.handle(request.params, origin); - } - if (method === String(Sep43Method.SignTransaction)) { - return sep43SignTransactionHandler.handle(request.params, origin); - } - - // TODO: deprecate the legacy `stellar_*` methods once dapps migrate to SEP-43. - if (method === 'stellar_signMessage') { - return signMessageHandler.handle( - request.params as unknown as JsonRpcRequest, - ); - } - if (method === 'stellar_signTransaction') { - return signTransactionHandler.handle( - request.params as unknown as JsonRpcRequest, - ); +/** + * Dev-only RPC entry point. + * + * The production sign path goes through the multichain CAIP-25 API + * (`wallet_invokeMethod`) which routes to `onKeyringRequest` → + * `KeyringHandler.submitRequest` → the same SEP-43-shaped handlers below. + * + * `endowment:rpc` is added to the manifest only by `scripts/update-manifest-local.js` + * in the `local` / `test` environments, and stripped for `production`. As a result + * this entry point is inert in production: the snap framework refuses to route + * RPC traffic without the endowment. + * + * The `stellar_*` aliases exist purely so the local test dapp at + * `http://localhost:3000` can exercise the SEP-43 sign flow via + * `wallet_invokeSnap`, without having to bundle `@metamask/multichain-api-client` + * just to run the dev loop. The forwarded payload is the same SEP-43 keyring + * request shape; the response is the same SEP-43 envelope. + * + * **Reachability (for reviewers):** production flows use + * `wallet_invokeMethod` / the multichain stack so MetaMask routes + * `keyring_submitRequest` with an internal caller origin. Arbitrary dapp + * origins cannot invoke `keyring_submitRequest` from the page (MetaMask + * hard-restricts that in the extension). The dev aliases below are the + * supported way to hit the same handlers from a localhost dapp; production + * snaps built without `endowment:rpc` never expose `onRpcRequest` to the network. + * + * @param args - The RPC request from MetaMask. + * @param args.request - The JSON-RPC request payload. + * @returns The SEP-43 response envelope produced by the matching handler. + */ +export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { + switch (request.method) { + case 'stellar_signMessage': + return signMessageHandler.handle( + request.params as SignMessageRequest as Json, + ); + case 'stellar_signTransaction': + return signTransactionHandler.handle( + request.params as SignTransactionRequest as Json, + ); + default: + throw new MethodNotFoundError() as Error; } - - throw new MethodNotFoundError() as Error; }; diff --git a/merged-packages/stellar-wallet-snap/src/permissions.ts b/merged-packages/stellar-wallet-snap/src/permissions.ts index 69e07dbc..79fd8be6 100644 --- a/merged-packages/stellar-wallet-snap/src/permissions.ts +++ b/merged-packages/stellar-wallet-snap/src/permissions.ts @@ -9,7 +9,7 @@ const prodOrigins = ['https://portfolio.metamask.io']; const allowedOrigins = isDev ? ['http://localhost:3000'] : prodOrigins; const dappPermissions = isDev - ? new Set([ + ? new Set([ // Keyring methods KeyringRpcMethod.ListAccounts, KeyringRpcMethod.GetAccount, @@ -20,8 +20,12 @@ const dappPermissions = isDev KeyringRpcMethod.SubmitRequest, KeyringRpcMethod.ListAccountTransactions, KeyringRpcMethod.ListAccountAssets, + // Dev-only RPC aliases for the local test dapp (see `onRpcRequest`). + // Production dapps reach the same handlers via the multichain API. + 'stellar_signMessage', + 'stellar_signTransaction', ]) - : new Set([]); + : new Set([]); const metamaskPermissions = new Set([ // Keyring methods From cae6417b1a7ba8640260c02ffdce4dc76db8a906 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Mon, 27 Apr 2026 11:09:19 +0200 Subject: [PATCH 111/384] fix: enforce origin validation in onRpcRequest for SEP-43 methods --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../stellar-wallet-snap/src/index.ts | 21 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index d3b73662..e3bfc63d 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "JehlWHYUIoODRixjEn3Q7IUvZAItnrzPExXlg6u99sc=", + "shasum": "Sx8Wg/47PSEyb05erSmY/4dDEszilgdLsmTYRtHazQc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/index.ts b/merged-packages/stellar-wallet-snap/src/index.ts index 487c352d..67e75950 100644 --- a/merged-packages/stellar-wallet-snap/src/index.ts +++ b/merged-packages/stellar-wallet-snap/src/index.ts @@ -19,10 +19,7 @@ import { signMessageHandler, signTransactionHandler, } from './context'; -import type { - SignMessageRequest, - SignTransactionRequest, -} from './handlers/keyring/api'; +import { validateOrigin } from './utils'; export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ( args, @@ -75,19 +72,21 @@ export const onCronjob: OnCronjobHandler = async ({ request }) => * snaps built without `endowment:rpc` never expose `onRpcRequest` to the network. * * @param args - The RPC request from MetaMask. + * @param args.origin - The dapp or caller origin (enforced via {@link validateOrigin}). * @param args.request - The JSON-RPC request payload. * @returns The SEP-43 response envelope produced by the matching handler. */ -export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { +export const onRpcRequest: OnRpcRequestHandler = async ({ + origin, + request, +}) => { switch (request.method) { case 'stellar_signMessage': - return signMessageHandler.handle( - request.params as SignMessageRequest as Json, - ); + validateOrigin(origin, request.method); + return signMessageHandler.handle(request.params as Json); case 'stellar_signTransaction': - return signTransactionHandler.handle( - request.params as SignTransactionRequest as Json, - ); + validateOrigin(origin, request.method); + return signTransactionHandler.handle(request.params as Json); default: throw new MethodNotFoundError() as Error; } From 5677c498ff2f6a89d121abf273378b1563a0bfbe Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Mon, 27 Apr 2026 12:21:28 +0200 Subject: [PATCH 112/384] fix: fix comments --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../stellar-wallet-snap/src/context.ts | 4 +- .../src/handlers/keyring/api.ts | 27 +++- .../src/handlers/keyring/base.ts | 130 ++++-------------- .../src/handlers/keyring/signMessage.test.ts | 74 +--------- .../src/handlers/keyring/signMessage.ts | 4 - .../handlers/keyring/signTransaction.test.ts | 57 +------- .../src/handlers/keyring/signTransaction.ts | 4 - .../stellar-wallet-snap/src/index.ts | 9 +- .../stellar-wallet-snap/src/permissions.ts | 4 - 10 files changed, 57 insertions(+), 258 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index e3bfc63d..f8727ebe 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "Sx8Wg/47PSEyb05erSmY/4dDEszilgdLsmTYRtHazQc=", + "shasum": "lmiyz9HeyQjQq6gOIWTBlcAaeETAuhzb8qD721J7Mas=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 5a185468..6c9fb71a 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -96,7 +96,6 @@ const signTransactionHandler = new SignTransactionHandler({ logger, accountService, walletService, - onChainAccountService, transactionBuilder, transactionService, confirmationUIController, @@ -105,9 +104,8 @@ const signTransactionHandler = new SignTransactionHandler({ const signMessageHandler = new SignMessageHandler({ logger, accountService, - onChainAccountService, - confirmationUIController, walletService, + confirmationUIController, }); const keyringMethodHandlers: Record = diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts index 553ac65a..4edb9cc4 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts @@ -15,6 +15,7 @@ import { assign, nullable, enums, + refine, } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { base64 } from '@metamask/utils'; @@ -25,9 +26,10 @@ import { KnownCaip19Slip44IdStruct, } from '../../api'; import { StellarAddressStruct } from '../../api/address'; -import { KnownCaip2ChainIdStruct } from '../../api/network'; +import { KnownCaip2ChainId, KnownCaip2ChainIdStruct } from '../../api/network'; import { UuidStruct } from '../../api/uuid'; import { XdrStruct } from '../../api/xdr'; +import { networkToCaip2ChainId } from '../../services/network/utils'; /** JSON-RPC methods supported by this snap's multichain keyring. */ export enum MultichainMethod { @@ -90,13 +92,26 @@ export const DiscoverAccountsStruct = object({ /** * Optional bag accepted by both SEP-43 sign methods. * - * `submit` and `submitUrl` are intentionally omitted from the schema: - * the snap signs only and rejects any caller asking for submission. + * Network and submission constraints are enforced at the struct level: + * - `networkPassphrase`, when provided, must map to Stellar mainnet via + * {@link networkToCaip2ChainId}. + * - `submit` and `submitUrl` are declared as `never` so any present value + * fails validation with -3 InvalidRequest — the snap is sign-only. * * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md */ export const Sep43OptsStruct = object({ - networkPassphrase: optional(nonempty(string())), + networkPassphrase: optional( + refine(nonempty(string()), 'mainnet-passphrase', (value) => { + try { + return networkToCaip2ChainId(value) === KnownCaip2ChainId.Mainnet + ? true + : `Only Stellar mainnet is supported, received passphrase: ${value}`; + } catch { + return `Unknown network passphrase: ${value}`; + } + }), + ), address: optional(StellarAddressStruct), }); @@ -129,7 +144,7 @@ export const SignMessageRequestStruct = assign( opts: optional(Sep43OptsStruct), }), }), - scope: KnownCaip2ChainIdStruct, + scope: literal(KnownCaip2ChainId.Mainnet), account: UuidStruct, }), ); @@ -164,7 +179,7 @@ export const SignTransactionRequestStruct = assign( opts: optional(Sep43OptsStruct), }), }), - scope: KnownCaip2ChainIdStruct, + scope: literal(KnownCaip2ChainId.Mainnet), account: UuidStruct, }), ); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts index 2d4db474..3b27501d 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts @@ -1,19 +1,14 @@ import type { Struct } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; -import { Networks } from '@stellar/stellar-sdk'; import type { Sep43ErrorEnvelope, Sep43Opts } from './api'; import { Sep43Error, Sep43ErrorCode, toSep43Error } from './exceptions'; import type { KnownCaip2ChainId } from '../../api'; -import { KnownCaip2ChainId as Caip2 } from '../../api'; import type { AccountService, StellarKeyringAccount, } from '../../services/account'; -import { AccountNotActivatedException } from '../../services/network'; -import type { OnChainAccountService } from '../../services/on-chain-account'; import type { Wallet, WalletService } from '../../services/wallet'; -import { render as renderAccountActivationPrompt } from '../../ui/confirmation/views/AccountActivationPrompt/render'; import type { ILogger } from '../../utils'; import { createPrefixedLogger } from '../../utils'; import { validateRequest, validateResponse } from '../../utils/requestResponse'; @@ -25,9 +20,6 @@ export type IKeyringRequestHandler = { handle: (request: Json) => Promise; }; -/** Mainnet is the only network the snap currently signs for. */ -const SUPPORTED_PASSPHRASE: string = Networks.PUBLIC; - /** * Base class shared by the SEP-43 SignMessage and SignTransaction keyring * handlers. @@ -39,13 +31,9 @@ const SUPPORTED_PASSPHRASE: string = Networks.PUBLIC; * thrown errors into the SEP-43 `error` envelope so the dapp always receives a * well-formed payload. * - * After the keyring account is resolved, {@link OnChainAccountService} is used - * the same way as other activated-account flows: an unfunded ledger account - * triggers the account-activation UI, then a SEP-43 `error` (not a JSON-RPC - * error) is returned. - * - * `request.origin` is the dapp or wallet caller origin already validated by - * MetaMask before the snap runs; the confirmation UI uses it for display only. + * SEP-43 is a sign-only protocol — no on-chain activation check is performed. + * The dapp is responsible for ensuring the account exists on-chain before + * constructing the transaction or message. * * Subclasses implement {@link execute} which performs the wallet signing and * returns the success-shaped fields. They never throw to the dapp directly. @@ -70,8 +58,6 @@ export abstract class BaseSep43KeyringHandler< protected readonly walletService: WalletService; - protected readonly onChainAccountService: OnChainAccountService; - protected readonly requestStruct: Struct; protected readonly responseStruct: Struct; @@ -80,7 +66,6 @@ export abstract class BaseSep43KeyringHandler< logger, accountService, walletService, - onChainAccountService, loggerPrefix, requestStruct, responseStruct, @@ -88,7 +73,6 @@ export abstract class BaseSep43KeyringHandler< logger: ILogger; accountService: AccountService; walletService: WalletService; - onChainAccountService: OnChainAccountService; loggerPrefix: string; requestStruct: Struct; responseStruct: Struct; @@ -96,44 +80,48 @@ export abstract class BaseSep43KeyringHandler< this.logger = createPrefixedLogger(logger, loggerPrefix); this.accountService = accountService; this.walletService = walletService; - this.onChainAccountService = onChainAccountService; this.requestStruct = requestStruct; this.responseStruct = responseStruct; } /** - * Top-level entry point. Runs the full pipeline (validate → check - * network/opts → resolve account → execute) inside a single try/catch so - * every failure (including struct validation) is serialized into the SEP-43 - * `error` envelope. The dapp never sees a thrown JSON-RPC error. + * Top-level entry point. Runs the full pipeline (validate → resolve account + * → execute) inside a single try/catch so every failure (including struct + * validation) is serialized into the SEP-43 `error` envelope. The dapp + * never sees a thrown JSON-RPC error. * * @param rawRequest - The unvalidated keyring request as forwarded by - * `KeyringHandler.submitRequest` (or the dev `stellar_*` RPC aliases). The - * wrapper's `origin` is the caller origin MetaMask attached; treat it as - * system-trusted for labeling in confirmation UI, not as a crypto capability. + * `KeyringHandler.submitRequest` (or the dev `stellar_*` RPC aliases). * @returns The SEP-43 response with either the success fields or `error` * populated. */ async handle(rawRequest: Json): Promise { let signerAddress = ''; try { - const request = validateRequest(rawRequest, this.requestStruct); + // Check submit/submitUrl on the raw JSON before validateRequest coerces + // the opts struct and strips unknown fields. The snap is sign-only. + const rawOpts = ( + (rawRequest as Record)?.request as + | Record + | undefined + )?.params as Record | undefined; + const opts = rawOpts?.opts as Record | undefined; + if (opts?.submit !== undefined || opts?.submitUrl !== undefined) { + throw new Sep43Error({ + code: Sep43ErrorCode.InvalidRequest, + ext: ['This wallet does not submit transactions; use sign only.'], + }); + } - this.assertSupportedNetwork(request); - this.assertNoSubmit(request.request.params.opts); + const request = validateRequest(rawRequest, this.requestStruct); const { account, wallet } = await this.resolveAccount(request); signerAddress = account.address; - await this.assertAccountActivatedOnChain(request, account); - const result = await this.execute(request, { account, wallet }); validateResponse(result, this.responseStruct); return result; } catch (error: unknown) { - if (error instanceof AccountNotActivatedException) { - await renderAccountActivationPrompt(error.address); - } const sep43 = toSep43Error(error); this.logger.logErrorWithDetails('SEP-43 request failed', sep43); return this.toErrorResponse(signerAddress, sep43); @@ -185,79 +173,7 @@ export abstract class BaseSep43KeyringHandler< }) : await this.accountService.resolveAccount({ accountId }); - if (optsAddress && account.id !== accountId) { - throw new Sep43Error({ - code: Sep43ErrorCode.InvalidRequest, - ext: [ - `opts.address ${optsAddress} does not match the session-selected account.`, - ], - }); - } - const wallet = await this.walletService.resolveWallet(account); return { account, wallet }; } - - /** - * Throws when the dapp asks for a network we don't support. - * Today: mainnet only. The snap rejects any other `opts.networkPassphrase` - * and any non-mainnet `scope`. - * - * @param request - The keyring request. - */ - protected assertSupportedNetwork(request: Request): void { - if (request.scope !== Caip2.Mainnet) { - throw new Sep43Error({ - code: Sep43ErrorCode.InvalidRequest, - ext: [`Only mainnet is supported, received scope ${request.scope}.`], - }); - } - - const requestedPassphrase = request.request.params.opts?.networkPassphrase; - if ( - requestedPassphrase !== undefined && - requestedPassphrase !== SUPPORTED_PASSPHRASE - ) { - throw new Sep43Error({ - code: Sep43ErrorCode.InvalidRequest, - ext: [ - `Only Stellar mainnet is supported by this wallet. Received passphrase: ${requestedPassphrase}.`, - ], - }); - } - } - - /** - * Throws when the dapp set `submit` or `submitUrl`. The snap is sign-only. - * - * @param opts - The SEP-43 opts bag (may be undefined). - */ - protected assertNoSubmit(opts: Sep43Opts | undefined): void { - // Use property access to detect even runtime-injected fields the struct stripped. - const raw = opts as undefined | Record; - if (raw?.submit !== undefined || raw?.submitUrl !== undefined) { - throw new Sep43Error({ - code: Sep43ErrorCode.InvalidRequest, - ext: ['This wallet does not submit transactions; use sign only.'], - }); - } - } - - /** - * Ensures the account exists on the Stellar network (funded) before signing. - * Aligns with the `WithActiveAccountResolve` path in `handlers/base.ts` for - * non-SEP-43 client routes. - * - * @param request - The validated keyring request (used for `scope`). - * @param account - The resolved keyring account. - */ - protected async assertAccountActivatedOnChain( - request: Request, - account: StellarKeyringAccount, - ): Promise { - await this.onChainAccountService.resolveOnChainAccount( - account.address, - request.scope, - ); - } } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts index 0abd6739..ffd89a8a 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts @@ -5,24 +5,17 @@ import { Sep43ErrorCode } from './exceptions'; import { SignMessageHandler } from './signMessage'; import { KnownCaip2ChainId } from '../../api'; import { AccountService } from '../../services/account'; -import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; +import { + generateStellarKeyringAccount, + mockAccountService, +} from '../../services/account/__mocks__/account.fixtures'; import { AccountNotFoundException } from '../../services/account/exceptions'; -import { AccountNotActivatedException } from '../../services/network'; -import { OnChainAccountService } from '../../services/on-chain-account'; -import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; -import type { OnChainAccount } from '../../services/on-chain-account/OnChainAccount'; import { WalletService } from '../../services/wallet'; import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; import type { ConfirmationUXController } from '../../ui/confirmation/controller'; import { logger } from '../../utils/logger'; jest.mock('../../utils/logger'); -/* eslint-disable @typescript-eslint/naming-convention -- Jest ESM interop */ -jest.mock('../../ui/confirmation/views/AccountActivationPrompt/render', () => ({ - __esModule: true, - render: jest.fn().mockResolvedValue(undefined), -})); -/* eslint-enable @typescript-eslint/naming-convention */ describe('SignMessageHandler', () => { /** @@ -41,12 +34,7 @@ describe('SignMessageHandler', () => { 0, ); - const { accountService, walletService, onChainAccountService } = - mockOnChainAccountService(); - - const resolveOnChainAccountSpy = jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') - .mockResolvedValue({ assetIds: [] } as unknown as OnChainAccount); + const { accountService, walletService } = mockAccountService(); const resolveAccountSpy = jest .spyOn(AccountService.prototype, 'resolveAccount') @@ -68,7 +56,6 @@ describe('SignMessageHandler', () => { logger, accountService, walletService, - onChainAccountService, confirmationUIController, }); @@ -78,7 +65,6 @@ describe('SignMessageHandler', () => { wallet, renderConfirmationDialog, resolveAccountSpy, - resolveOnChainAccountSpy, }; } @@ -187,31 +173,6 @@ describe('SignMessageHandler', () => { expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); }); - it('returns error -3 when opts.address resolves to a different account than the wrapper UUID', async () => { - const { - handler, - mockAccount, - renderConfirmationDialog, - resolveAccountSpy, - } = setupHandler(); - const otherAccount = generateStellarKeyringAccount( - globalThis.crypto.randomUUID(), - mockAccount.address, - 'entropy-source-1', - 1, - ); - resolveAccountSpy.mockResolvedValueOnce({ account: otherAccount }); - - const result = await handler.handle( - buildRequest(mockAccount.id, { - opts: { address: otherAccount.address }, - }), - ); - - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); - }); - it('returns error -3 when message is not valid base64', async () => { const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); @@ -222,29 +183,4 @@ describe('SignMessageHandler', () => { expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); - - it('shows the account activation prompt and returns ExternalService when the account is not funded', async () => { - const { render: renderAccountActivationPrompt } = - await import('../../ui/confirmation/views/AccountActivationPrompt/render'); - const { - handler, - mockAccount, - renderConfirmationDialog, - resolveOnChainAccountSpy, - } = setupHandler(); - resolveOnChainAccountSpy.mockRejectedValueOnce( - new AccountNotActivatedException( - mockAccount.address, - KnownCaip2ChainId.Mainnet, - ), - ); - - const result = await handler.handle(buildRequest(mockAccount.id)); - - expect(jest.mocked(renderAccountActivationPrompt)).toHaveBeenCalledWith( - mockAccount.address, - ); - expect(result.error?.code).toBe(Sep43ErrorCode.ExternalService); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); - }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts index a0942630..d5e33142 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts @@ -8,7 +8,6 @@ import type { AccountService, StellarKeyringAccount, } from '../../services/account'; -import type { OnChainAccountService } from '../../services/on-chain-account'; import type { Wallet, WalletService } from '../../services/wallet'; import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; import type { ConfirmationUXController } from '../../ui/confirmation/controller'; @@ -35,20 +34,17 @@ export class SignMessageHandler extends BaseSep43KeyringHandler< logger, accountService, walletService, - onChainAccountService, confirmationUIController, }: { logger: ILogger; accountService: AccountService; walletService: WalletService; - onChainAccountService: OnChainAccountService; confirmationUIController: ConfirmationUXController; }) { super({ logger, accountService, walletService, - onChainAccountService, loggerPrefix: '[✉️ SignMessageHandler]', requestStruct: SignMessageRequestStruct, responseStruct: SignMessageResponseStruct, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts index eb7acc7e..64c31e6d 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts @@ -5,12 +5,11 @@ import { Sep43ErrorCode } from './exceptions'; import { SignTransactionHandler } from './signTransaction'; import { KnownCaip2ChainId } from '../../api'; import { AccountService } from '../../services/account'; -import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; -import { AccountNotActivatedException } from '../../services/network'; +import { + generateStellarKeyringAccount, + mockAccountService, +} from '../../services/account/__mocks__/account.fixtures'; import { SimulationException } from '../../services/network/exceptions'; -import { OnChainAccountService } from '../../services/on-chain-account'; -import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; -import type { OnChainAccount } from '../../services/on-chain-account/OnChainAccount'; import type { Transaction } from '../../services/transaction'; import { TransactionService } from '../../services/transaction'; import { @@ -23,12 +22,6 @@ import type { ConfirmationUXController } from '../../ui/confirmation/controller' import { logger } from '../../utils/logger'; jest.mock('../../utils/logger'); -/* eslint-disable @typescript-eslint/naming-convention -- Jest ESM interop */ -jest.mock('../../ui/confirmation/views/AccountActivationPrompt/render', () => ({ - __esModule: true, - render: jest.fn().mockResolvedValue(undefined), -})); -/* eslint-enable @typescript-eslint/naming-convention */ describe('SignTransactionHandler', () => { /** @@ -48,12 +41,7 @@ describe('SignTransactionHandler', () => { const { transactionBuilder, transactionService } = createMockTransactionService(); - const { accountService, walletService, onChainAccountService } = - mockOnChainAccountService(); - - const resolveOnChainAccountSpy = jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') - .mockResolvedValue({ assetIds: [] } as unknown as OnChainAccount); + const { accountService, walletService } = mockAccountService(); const resolveAccountSpy = jest .spyOn(AccountService.prototype, 'resolveAccount') @@ -80,7 +68,6 @@ describe('SignTransactionHandler', () => { logger, accountService, walletService, - onChainAccountService, transactionBuilder, transactionService, confirmationUIController, @@ -94,7 +81,6 @@ describe('SignTransactionHandler', () => { transactionService, renderConfirmationDialog, resolveAccountSpy, - resolveOnChainAccountSpy, }; } @@ -334,37 +320,4 @@ describe('SignTransactionHandler', () => { expect(result.error?.ext?.[0]).toContain('Failed to simulate transaction'); expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); - - it('shows the account activation prompt and returns ExternalService when the account is not funded', async () => { - const { render: renderAccountActivationPrompt } = - await import('../../ui/confirmation/views/AccountActivationPrompt/render'); - const { - handler, - mockAccount, - wallet, - transactionBuilder, - renderConfirmationDialog, - resolveOnChainAccountSpy, - } = setupHandler(); - - const transaction = buildMainnetPaymentFromWallet(wallet.address); - jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); - - resolveOnChainAccountSpy.mockRejectedValueOnce( - new AccountNotActivatedException( - mockAccount.address, - KnownCaip2ChainId.Mainnet, - ), - ); - - const result = await handler.handle( - buildRequest(mockAccount.id, transaction.getRaw().toXDR()), - ); - - expect(jest.mocked(renderAccountActivationPrompt)).toHaveBeenCalledWith( - mockAccount.address, - ); - expect(result.error?.code).toBe(Sep43ErrorCode.ExternalService); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); - }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts index a1859de4..0a60ba2a 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts @@ -11,7 +11,6 @@ import type { AccountService, StellarKeyringAccount, } from '../../services/account'; -import type { OnChainAccountService } from '../../services/on-chain-account'; import type { Transaction, TransactionBuilder, @@ -53,7 +52,6 @@ export class SignTransactionHandler extends BaseSep43KeyringHandler< logger, accountService, walletService, - onChainAccountService, transactionBuilder, transactionService, confirmationUIController, @@ -61,7 +59,6 @@ export class SignTransactionHandler extends BaseSep43KeyringHandler< logger: ILogger; accountService: AccountService; walletService: WalletService; - onChainAccountService: OnChainAccountService; transactionBuilder: TransactionBuilder; transactionService: TransactionService; confirmationUIController: ConfirmationUXController; @@ -70,7 +67,6 @@ export class SignTransactionHandler extends BaseSep43KeyringHandler< logger, accountService, walletService, - onChainAccountService, loggerPrefix: '[📝 SignTransactionHandler]', requestStruct: SignTransactionRequestStruct, responseStruct: SignTransactionResponseStruct, diff --git a/merged-packages/stellar-wallet-snap/src/index.ts b/merged-packages/stellar-wallet-snap/src/index.ts index 67e75950..0b32eec7 100644 --- a/merged-packages/stellar-wallet-snap/src/index.ts +++ b/merged-packages/stellar-wallet-snap/src/index.ts @@ -19,7 +19,6 @@ import { signMessageHandler, signTransactionHandler, } from './context'; -import { validateOrigin } from './utils'; export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ( args, @@ -72,20 +71,14 @@ export const onCronjob: OnCronjobHandler = async ({ request }) => * snaps built without `endowment:rpc` never expose `onRpcRequest` to the network. * * @param args - The RPC request from MetaMask. - * @param args.origin - The dapp or caller origin (enforced via {@link validateOrigin}). * @param args.request - The JSON-RPC request payload. * @returns The SEP-43 response envelope produced by the matching handler. */ -export const onRpcRequest: OnRpcRequestHandler = async ({ - origin, - request, -}) => { +export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { switch (request.method) { case 'stellar_signMessage': - validateOrigin(origin, request.method); return signMessageHandler.handle(request.params as Json); case 'stellar_signTransaction': - validateOrigin(origin, request.method); return signTransactionHandler.handle(request.params as Json); default: throw new MethodNotFoundError() as Error; diff --git a/merged-packages/stellar-wallet-snap/src/permissions.ts b/merged-packages/stellar-wallet-snap/src/permissions.ts index 79fd8be6..036c348b 100644 --- a/merged-packages/stellar-wallet-snap/src/permissions.ts +++ b/merged-packages/stellar-wallet-snap/src/permissions.ts @@ -20,10 +20,6 @@ const dappPermissions = isDev KeyringRpcMethod.SubmitRequest, KeyringRpcMethod.ListAccountTransactions, KeyringRpcMethod.ListAccountAssets, - // Dev-only RPC aliases for the local test dapp (see `onRpcRequest`). - // Production dapps reach the same handlers via the multichain API. - 'stellar_signMessage', - 'stellar_signTransaction', ]) : new Set([]); From a089b372e2e9c26dd4ec38c8a830b85ade03167a Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Mon, 27 Apr 2026 17:58:41 +0200 Subject: [PATCH 113/384] fix: fix last comments --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/keyring/api.test.ts | 22 ++++-- .../src/handlers/keyring/api.ts | 71 ++++++++++++++----- .../src/handlers/keyring/exceptions.ts | 4 +- .../src/handlers/keyring/signMessage.test.ts | 50 ++++++++----- .../src/handlers/keyring/signMessage.ts | 16 +++-- .../handlers/keyring/signTransaction.test.ts | 47 ++++++++---- .../src/handlers/keyring/signTransaction.ts | 2 +- 8 files changed, 150 insertions(+), 64 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index f8727ebe..2d391ed3 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "lmiyz9HeyQjQq6gOIWTBlcAaeETAuhzb8qD721J7Mas=", + "shasum": "nvnxyWTg42pCokBi15iaDY6EIs38qXfzuHqROi9GiIA=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts index f7973f60..e17efe9d 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts @@ -195,6 +195,21 @@ describe('SignMessageRequestStruct', () => { ).not.toThrow(); }); + it('accepts a UTF-8 string message (SEP-43 allows either base64 or UTF-8)', () => { + expect(() => + assert( + { + ...validSignMessageRequest, + request: { + method: MultichainMethod.SignMessage, + params: { message: 'Sign in to dapp' }, + }, + }, + SignMessageRequestStruct, + ), + ).not.toThrow(); + }); + it('accepts an SEP-43 opts bag with address and networkPassphrase', () => { expect(() => assert( @@ -232,13 +247,6 @@ describe('SignMessageRequestStruct', () => { params: { message: '' }, }, }, - { - ...validSignMessageRequest, - request: { - method: MultichainMethod.SignMessage, - params: { message: 'not valid base64 !!!' }, - }, - }, { ...validSignMessageRequest, account: 'not-a-uuid', diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts index 4edb9cc4..b4e7b189 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts @@ -27,6 +27,7 @@ import { } from '../../api'; import { StellarAddressStruct } from '../../api/address'; import { KnownCaip2ChainId, KnownCaip2ChainIdStruct } from '../../api/network'; +import { Utf8StringStruct } from '../../api/string'; import { UuidStruct } from '../../api/uuid'; import { XdrStruct } from '../../api/xdr'; import { networkToCaip2ChainId } from '../../services/network/utils'; @@ -131,8 +132,9 @@ export type Sep43ErrorEnvelope = Infer; /** * Validation struct for the signMessage request. * - * Params follow the SEP-43 `SignMessage` shape: a base64-encoded message and - * the optional `opts` bag (`address`, `networkPassphrase`). + * Params follow the SEP-43 `SignMessage` shape: per spec, `message` may be + * either a base64-encoded byte string or arbitrary UTF-8 text. The wallet + * detects which at sign time and signs the corresponding bytes. */ export const SignMessageRequestStruct = assign( KeyringRequestStruct, @@ -140,7 +142,7 @@ export const SignMessageRequestStruct = assign( request: object({ method: literal(MultichainMethod.SignMessage), params: object({ - message: nonempty(base64(string())), + message: nonempty(union([base64(string()), Utf8StringStruct])), opts: optional(Sep43OptsStruct), }), }), @@ -150,18 +152,34 @@ export const SignMessageRequestStruct = assign( ); /** - * Validation struct for the signMessage response. - * - * `signedMessage` is base64-encoded on success; empty string on error. - * `signerAddress` is the signer's G-address on success, or empty when - * account resolution failed before we could determine the address. + * Error-shape of the signMessage response: an `error` envelope is present. + * Success fields are kept loose to allow partial data alongside the error. */ -export const SignMessageResponseStruct = object({ +export const SignMessageResponseStructWithError = object({ signedMessage: union([nonempty(base64(string())), literal('')]), signerAddress: union([StellarAddressStruct, literal('')]), - error: optional(Sep43ErrorEnvelopeStruct), + error: Sep43ErrorEnvelopeStruct, +}); + +/** + * Success-shape of the signMessage response: signature present, no `error`. + */ +export const SignMessageResponseStructWithoutError = object({ + signedMessage: nonempty(base64(string())), + signerAddress: StellarAddressStruct, }); +/** + * Validation struct for the signMessage response. + * + * Modeled as a discriminated union: a response either has an `error` + * envelope or the success fields — never neither. + */ +export const SignMessageResponseStruct = union([ + SignMessageResponseStructWithError, + SignMessageResponseStructWithoutError, +]); + /** * Validation struct for the signTransaction request. * @@ -196,19 +214,36 @@ export const ListAccountTransactionsRequestStruct = object({ }); /** - * Validation struct for the signTransaction response. - * - * `signedTxXdr` is the signed transaction envelope as base64 XDR on success; - * empty string on error. - * `signerAddress` is the signer's G-address on success, or empty when - * account resolution failed before we could determine the address. + * Error-shape of the signTransaction response: an `error` envelope is + * present. Success fields are kept loose to allow partial data alongside + * the error. */ -export const SignTransactionResponseStruct = object({ +export const SignTransactionResponseStructWithError = object({ signedTxXdr: union([XdrStruct, literal('')]), signerAddress: union([StellarAddressStruct, literal('')]), - error: optional(Sep43ErrorEnvelopeStruct), + error: Sep43ErrorEnvelopeStruct, +}); + +/** + * Success-shape of the signTransaction response: signed XDR present, no + * `error`. + */ +export const SignTransactionResponseStructWithoutError = object({ + signedTxXdr: XdrStruct, + signerAddress: StellarAddressStruct, }); +/** + * Validation struct for the signTransaction response. + * + * Modeled as a discriminated union: a response either has an `error` + * envelope or the success fields — never neither. + */ +export const SignTransactionResponseStruct = union([ + SignTransactionResponseStructWithError, + SignTransactionResponseStructWithoutError, +]); + /** * Validation struct for the getAccount request. */ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts index b5e8dc13..87e226ef 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts @@ -85,9 +85,9 @@ export class Sep43Error extends Error { /** * Serializes to the SEP-43 `error` shape. * - * @returns The serialized error envelope (`message`, `code`, optional `ext`). + * @returns The serialized error payload (`message`, `code`, optional `ext`). */ - toEnvelope(): { message: string; code: number; ext?: string[] } { + toJSON(): { message: string; code: number; ext?: string[] } { return { message: this.message, code: this.code, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts index ffd89a8a..d6015184 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts @@ -106,9 +106,11 @@ describe('SignMessageHandler', () => { const result = await handler.handle(buildRequest(mockAccount.id)); - expect(result.signedMessage).toBe(''); - expect(result.signerAddress).toBe(wallet.address); - expect(result.error?.code).toBe(Sep43ErrorCode.UserRejected); + expect(result).toMatchObject({ + signedMessage: '', + signerAddress: wallet.address, + error: { code: Sep43ErrorCode.UserRejected }, + }); }); it('returns error -3 when scope is testnet', async () => { @@ -119,9 +121,11 @@ describe('SignMessageHandler', () => { scope: KnownCaip2ChainId.Testnet, }); - expect(result.signedMessage).toBe(''); - expect(result.signerAddress).toBe(''); - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(result).toMatchObject({ + signedMessage: '', + signerAddress: '', + error: { code: Sep43ErrorCode.InvalidRequest }, + }); expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); @@ -134,8 +138,12 @@ describe('SignMessageHandler', () => { }), ); - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); - expect(result.error?.ext?.[0]).toContain('mainnet'); + expect(result).toMatchObject({ + error: { + code: Sep43ErrorCode.InvalidRequest, + ext: [expect.stringContaining('mainnet')], + }, + }); expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); @@ -153,7 +161,9 @@ describe('SignMessageHandler', () => { const result = await handler.handle(base); - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(result).toMatchObject({ + error: { code: Sep43ErrorCode.InvalidRequest }, + }); expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); @@ -169,18 +179,26 @@ describe('SignMessageHandler', () => { buildRequest(mockAccount.id, { opts: { address: unknownAddress } }), ); - expect(result.signedMessage).toBe(''); - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(result).toMatchObject({ + signedMessage: '', + error: { code: Sep43ErrorCode.InvalidRequest }, + }); }); - it('returns error -3 when message is not valid base64', async () => { - const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + it('signs a non-base64 string as UTF-8 text', async () => { + const { handler, mockAccount, wallet, renderConfirmationDialog } = + setupHandler(); + renderConfirmationDialog.mockResolvedValue(true); + const utf8Message = 'Sign in to dapp'; const result = await handler.handle( - buildRequest(mockAccount.id, { message: 'not valid base64 !!!' }), + buildRequest(mockAccount.id, { message: utf8Message }), ); - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); - expect(renderConfirmationDialog).not.toHaveBeenCalled(); + const expected = await wallet.signMessage(utf8Message); + expect(result).toStrictEqual({ + signedMessage: expected, + signerAddress: wallet.address, + }); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts index d5e33142..57ea63cb 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.ts @@ -13,6 +13,7 @@ import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; import type { ConfirmationUXController } from '../../ui/confirmation/controller'; import type { ILogger } from '../../utils'; import { bufferToUint8Array } from '../../utils'; +import { isBase64 } from '../../utils/string'; /** * SEP-43 `signMessage` keyring handler. @@ -79,7 +80,7 @@ export class SignMessageHandler extends BaseSep43KeyringHandler< // SEP-43 schema requires the field even on error; keep it empty when unknown. signedMessage: '', signerAddress, - error: error.toEnvelope(), + error: error.toJSON(), }; } @@ -102,12 +103,17 @@ export class SignMessageHandler extends BaseSep43KeyringHandler< } /** - * Decodes the SEP-43 base64 message for display in the confirmation dialog. + * Resolves the message to a UTF-8 string for display in the confirmation + * dialog. SEP-43 accepts either base64-encoded bytes or UTF-8 text — we + * mirror the wallet's detection so the user sees the same content that + * gets signed. * - * @param message - Base64-encoded bytes (validated by {@link SignMessageRequestStruct}). - * @returns The same content interpreted as UTF-8 text for the UI. + * @param message - The raw message string from the request. + * @returns The message interpreted as UTF-8 text for the UI. */ #getUtf8Message(message: string): string { - return bufferToUint8Array(message, 'base64').toString('utf8'); + return isBase64(message) + ? bufferToUint8Array(message, 'base64').toString('utf8') + : message; } } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts index 64c31e6d..1c80e3f4 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts @@ -143,9 +143,10 @@ describe('SignTransactionHandler', () => { const result = await handler.handle(buildRequest(mockAccount.id, xdr)); expect(signSpy).toHaveBeenCalledWith(transaction); - expect(result.signedTxXdr).toStrictEqual(transaction.getRaw().toXDR()); - expect(result.signerAddress).toBe(wallet.address); - expect(result.error).toBeUndefined(); + expect(result).toStrictEqual({ + signedTxXdr: transaction.getRaw().toXDR(), + signerAddress: wallet.address, + }); }); it('returns error -4 when user rejects', async () => { @@ -166,9 +167,11 @@ describe('SignTransactionHandler', () => { const result = await handler.handle(buildRequest(mockAccount.id, xdr)); expect(signSpy).not.toHaveBeenCalled(); - expect(result.signedTxXdr).toBe(''); - expect(result.signerAddress).toBe(wallet.address); - expect(result.error?.code).toBe(Sep43ErrorCode.UserRejected); + expect(result).toMatchObject({ + signedTxXdr: '', + signerAddress: wallet.address, + error: { code: Sep43ErrorCode.UserRejected }, + }); }); it('returns error -3 when XDR is invalid', async () => { @@ -178,7 +181,9 @@ describe('SignTransactionHandler', () => { buildRequest(mockAccount.id, 'not-an-xdr'), ); - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(result).toMatchObject({ + error: { code: Sep43ErrorCode.InvalidRequest }, + }); expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); @@ -214,7 +219,9 @@ describe('SignTransactionHandler', () => { buildRequest(mockAccount.id, testnetTx.getRaw().toXDR()), ); - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(result).toMatchObject({ + error: { code: Sep43ErrorCode.InvalidRequest }, + }); expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); @@ -251,7 +258,9 @@ describe('SignTransactionHandler', () => { buildRequest(mockAccount.id, strangerTx.getRaw().toXDR()), ); - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(result).toMatchObject({ + error: { code: Sep43ErrorCode.InvalidRequest }, + }); expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); @@ -263,7 +272,9 @@ describe('SignTransactionHandler', () => { scope: KnownCaip2ChainId.Testnet, }); - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(result).toMatchObject({ + error: { code: Sep43ErrorCode.InvalidRequest }, + }); expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); @@ -276,7 +287,9 @@ describe('SignTransactionHandler', () => { }), ); - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(result).toMatchObject({ + error: { code: Sep43ErrorCode.InvalidRequest }, + }); expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); @@ -292,7 +305,9 @@ describe('SignTransactionHandler', () => { const result = await handler.handle(base); - expect(result.error?.code).toBe(Sep43ErrorCode.InvalidRequest); + expect(result).toMatchObject({ + error: { code: Sep43ErrorCode.InvalidRequest }, + }); expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); @@ -316,8 +331,12 @@ describe('SignTransactionHandler', () => { buildRequest(mockAccount.id, transaction.getRaw().toXDR()), ); - expect(result.error?.code).toBe(Sep43ErrorCode.ExternalService); - expect(result.error?.ext?.[0]).toContain('Failed to simulate transaction'); + expect(result).toMatchObject({ + error: { + code: Sep43ErrorCode.ExternalService, + ext: [expect.stringContaining('Failed to simulate transaction')], + }, + }); expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts index 0a60ba2a..6fddd250 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts @@ -121,7 +121,7 @@ export class SignTransactionHandler extends BaseSep43KeyringHandler< // SEP-43 schema requires the field even on error; keep it empty when unknown. signedTxXdr: '', signerAddress, - error: error.toEnvelope(), + error: error.toJSON(), }; } From ccbc43314ba1c0f1a898cd4f04f4822128ee3f56 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:04:45 +0800 Subject: [PATCH 114/384] feat: add account sync service --- .../stellar-wallet-snap/src/context.ts | 27 +- .../account/__mocks__/account.fixtures.ts | 2 +- .../__mocks__/assets.fixtures.ts | 19 + .../src/services/network/MultiCall.ts | 172 ++++++ .../services/network/NetworkService.test.ts | 74 +++ .../src/services/network/NetworkService.ts | 99 +++- .../src/services/network/utils.ts | 21 + .../on-chain-account/OnChainAccount.ts | 22 + .../OnChainAccountRepository.ts | 114 ++++ .../OnChainAccountService.test.ts | 95 ++- .../on-chain-account/OnChainAccountService.ts | 70 ++- .../OnChainAccountSynchronizeService.test.ts | 425 ++++++++++++++ .../OnChainAccountSynchronizeService.ts | 541 ++++++++++++++++++ .../__mocks__/onChainAccount.fixtures.ts | 14 +- .../src/services/on-chain-account/api.ts | 35 +- .../src/services/on-chain-account/index.ts | 2 + 16 files changed, 1687 insertions(+), 45 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 7b9cba09..4fc0387d 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -21,8 +21,11 @@ import { } from './services/asset-metadata'; import { StateCache } from './services/cache'; import { NetworkService } from './services/network'; -import type { OnChainAccountSnapshotState } from './services/on-chain-account'; -import { OnChainAccountService } from './services/on-chain-account'; +import type { OnChainAccountState } from './services/on-chain-account'; +import { + OnChainAccountRepository, + OnChainAccountService, +} from './services/on-chain-account'; import { PriceService } from './services/price'; import { State } from './services/state'; import { @@ -43,7 +46,7 @@ const state = new State({ assets: {}, transactions: {}, accountBalances: {} as AccountBalanceState['accountBalances'], - accountMetadata: {} as OnChainAccountSnapshotState['accountMetadata'], + onChainAccounts: {} as OnChainAccountState['onChainAccounts'], }, }); @@ -53,6 +56,13 @@ const assetMetadataRepository = new AssetMetadataRepository(state); /** ------------------------------ Services ------------------------------ */ const networkService = new NetworkService({ logger }); + +const assetMetadataService = new AssetMetadataService({ + networkService, + assetMetadataRepository, + logger, +}); + const transactionBuilder = new TransactionBuilder({ logger, }); @@ -64,8 +74,13 @@ const accountService = new AccountService({ walletService, }); +const onChainAccountRepository = new OnChainAccountRepository(state); + const onChainAccountService = new OnChainAccountService({ + logger, networkService, + onChainAccountRepository, + assetMetadataService, }); const transactionService = new TransactionService({ @@ -85,12 +100,6 @@ const confirmationUIController = new ConfirmationUXController({ logger, }); -const assetMetadataService = new AssetMetadataService({ - networkService, - assetMetadataRepository, - logger, -}); - /** ------------------------------ Keyring Handler ------------------------------ */ const signTransactionHandler = new SignTransactionHandler({ logger, diff --git a/merged-packages/stellar-wallet-snap/src/services/account/__mocks__/account.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/account/__mocks__/account.fixtures.ts index 7f8ead1d..701a5939 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/__mocks__/account.fixtures.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/__mocks__/account.fixtures.ts @@ -58,7 +58,7 @@ export const mockAccountService = () => { encrypted: false, defaultState: { keyringAccounts: {}, - accountMetadata: {}, + onChainAccounts: {}, }, }); const accountService = new AccountService({ diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts index ae392472..4e01090a 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts @@ -17,6 +17,8 @@ export const USDC_CLASSIC: KnownCaip19AssetIdOrSlip44Id = 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; export const USDC_SEP41: KnownCaip19AssetIdOrSlip44Id = 'stellar:pubnet/sep41:CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'; +export const USDT_SEP41: KnownCaip19AssetIdOrSlip44Id = + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J'; export const generateMockStellarAssetMetadata = (): AssetMetadataByAssetId => { return { @@ -49,6 +51,16 @@ export const generateMockStellarAssetMetadata = (): AssetMetadataByAssetId => { iconUrl: 'https://example.test/icon.png', units: [{ name: 'USDC', symbol: 'USDC', decimals: 7 }], }, + [USDT_SEP41]: { + assetId: USDT_SEP41, + assetType: AssetType.Sep41, + chainId: KnownCaip2ChainId.Mainnet, + name: 'USDT', + symbol: 'USDT', + fungible: true, + iconUrl: 'https://example.test/icon.png', + units: [{ name: 'USDT', symbol: 'USDT', decimals: 7 }], + }, } as AssetMetadataByAssetId; }; @@ -82,6 +94,13 @@ export const generateMockKeyringAssetMetadata = iconUrl: 'https://example.test/icon.png', units: [{ name: 'USDC', symbol: 'USDC', decimals: 7 }], }, + [USDT_SEP41]: { + name: 'USDT', + symbol: 'USDT', + fungible: true, + iconUrl: 'https://example.test/icon.png', + units: [{ name: 'USDT', symbol: 'USDT', decimals: 7 }], + }, } as KeyringAssetMetadataByAssetId; }; diff --git a/merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts b/merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts new file mode 100644 index 00000000..6ea21d73 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts @@ -0,0 +1,172 @@ +import { + Account, + Address, + Contract, + Networks, + type Operation, + rpc, + scValToNative, + type Transaction, + TransactionBuilder, + xdr, +} from '@stellar/stellar-sdk'; + +/** + * A simulation account to craft a transaction to simulate the balances read + * It is a funded account to prevent the transaction from failing due to insufficient funds. + * + * @see https://developers.stellar.org/docs/tools/sdks/contract-sdks + */ +export const SIMULATION_ACCOUNT: string = + 'GALAXYVOIDAOPZTDLHILAJQKCVVFMD4IKLXLSZV5YHO7VY74IWZILUTO'; + +/** + * Smart contract addresses for the Stellar MultiCall contract. + * + * @see https://stellar.org/developers/reference/stellar-multi-call/contracts + * it is recommended by Stellar official site. + * @see https://developers.stellar.org/docs/tools/sdks/contract-sdks#stellar-multicall--router-sdk + */ +export enum StellarRouterContract { + V0 = 'CBZV3HBP672BV7FF3ZILVT4CNPW3N5V2WTJ2LAGOAYW5R7L2D5SLUDFZ', + V1 = 'CCM23MFAJHDWUMF3IM3UPUI4ZUFFKX6OWJNJRUKO2W6MUTQNQWWFH7DC', +} + +export type StellarRouterParams = { + rpcClient: rpc.Server; + simulationAccount: string; +}; + +export class InvocationV0 { + contract: Address | string; + + method: string; + + args: xdr.ScVal[]; + + version = 'v0' as const; + + constructor(params: Omit) { + this.contract = params.contract; + this.method = params.method; + this.args = params.args; + } +} + +export class InvocationV1 { + contract: Address | string; + + method: string; + + args: xdr.ScVal[]; + + canFail?: boolean; + + version = 'v1' as const; + + constructor(params: Omit) { + this.contract = params.contract; + this.method = params.method; + this.args = params.args; + this.canFail = params.canFail; + } +} + +export class MultiCall { + readonly #rpcClient: rpc.Server; + + readonly #simulationAccount: string; + + readonly #routerContract: StellarRouterContract; + + constructor({ + rpcClient, + simulationAccount = SIMULATION_ACCOUNT, + routerContract = StellarRouterContract.V0, + }: { + rpcClient: rpc.Server; + simulationAccount?: string; + routerContract?: StellarRouterContract; + }) { + this.#rpcClient = rpcClient; + this.#simulationAccount = simulationAccount; + this.#routerContract = routerContract; + } + + /** + * This method generates the InvokeHostFunction Operation that you will be able to use within your transactions + * + * @param caller - The address that is calling the contract, this account must authorize the transaction even if none of the invocations require authorization. + * @param invocations - All the invocations the proxy will execute + * @returns An operation suitable for adding to a Stellar {@link Transaction}. + */ + exec( + caller: Contract | Address | string, + invocations: (InvocationV1 | InvocationV0)[], + ): xdr.Operation { + const args: xdr.ScVal[] = invocations.map((invocation) => { + switch (invocation.version) { + case 'v0': + return xdr.ScVal.scvVec([ + new Address(invocation.contract.toString()).toScVal(), + xdr.ScVal.scvSymbol(invocation.method), + xdr.ScVal.scvVec(invocation.args), + ]); + + case 'v1': + return xdr.ScVal.scvVec([ + new Address(invocation.contract.toString()).toScVal(), + xdr.ScVal.scvSymbol(invocation.method), + xdr.ScVal.scvVec(invocation.args), + xdr.ScVal.scvBool(invocation.canFail === true), + ]); + + default: + throw new Error(`Invocation version is not supported.`); + } + }); + + return new Contract(this.#routerContract).call( + 'exec', + new Address(caller.toString()).toScVal(), + xdr.ScVal.scvVec(args), + ); + } + + /** + * Simulates a multicall and returns the decoded result value. + * + * @param invocations - Invocations to batch. + * @param opts - Optional caller and source account overrides. + * @param opts.caller - Account that authorizes the host function call; defaults to the simulation account. + * @param opts.source - Transaction `source` account; defaults to the simulation account. + * @returns The simulation result as a native value. + */ + async simResult( + invocations: (InvocationV1 | InvocationV0)[], + opts?: { caller?: string; source?: string }, + ): Promise { + const sourceAccount = opts?.source ?? this.#simulationAccount; + const callerAccount = opts?.caller ?? this.#simulationAccount; + const tx: Transaction = new TransactionBuilder( + new Account(sourceAccount, '0'), + { networkPassphrase: Networks.PUBLIC, fee: '0' }, + ) + .setTimeout(0) + .addOperation(this.exec(callerAccount, invocations)) + .build(); + + const sim = await this.#rpcClient.simulateTransaction(tx); + + if (rpc.Api.isSimulationError(sim)) { + throw new Error(String(sim.error)); + } + + const retval = sim.result?.retval; + if (retval === undefined) { + throw new Error('Simulation returned no result'); + } + + return scValToNative(retval) as Result; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts index 04d19de2..6317f5e2 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts @@ -19,6 +19,7 @@ import { TransactionRetryableException, TransactionSendException, } from './exceptions'; +import { MultiCall } from './MultiCall'; import { NetworkService } from './NetworkService'; import type { KnownCaip19Sep41AssetId } from '../../api'; import { KnownCaip2ChainId } from '../../api'; @@ -609,4 +610,77 @@ describe('NetworkService', () => { expect(pollTransactionSpy).not.toHaveBeenCalled(); }); }); + + describe('getSep41AssetBalances', () => { + const account = 'GDYTQGVA3NCXM5JPVMOHLDUAHMI3OQ2B2YI25BXYKROAGXXT2T3ZGHE6'; + const secondAssetId = + 'stellar:pubnet/sep41:CBGV2QFQBBGEQRUKUMCPO3SZOHDDYO6SCP5CH6TW7EALKVHCXTMWDDOF' as KnownCaip19Sep41AssetId; + + it('returns empty object when accounts is empty', async () => { + const result = await networkService.getSep41AssetBalances({ + accounts: [], + assetIds: [validSep41AssetId], + scope: KnownCaip2ChainId.Mainnet, + }); + expect(result).toStrictEqual({}); + }); + + it('returns empty object when assetIds is empty', async () => { + const result = await networkService.getSep41AssetBalances({ + accounts: [account], + assetIds: [], + scope: KnownCaip2ChainId.Mainnet, + }); + expect(result).toStrictEqual({}); + }); + + it('maps multicall simulation vector to per-account balances on mainnet', async () => { + const simResultSpy = jest + .spyOn(MultiCall.prototype, 'simResult') + .mockResolvedValue([BigInt('100'), BigInt('200')]); + + const result = await networkService.getSep41AssetBalances({ + accounts: [account], + assetIds: [validSep41AssetId, secondAssetId], + scope: KnownCaip2ChainId.Mainnet, + }); + + expect(simResultSpy).toHaveBeenCalled(); + expect(result[account]?.[validSep41AssetId]?.toFixed()).toBe('100'); + expect(result[account]?.[secondAssetId]?.toFixed()).toBe('200'); + simResultSpy.mockRestore(); + }); + + it('maps failed multicall cells to null', async () => { + const simResultSpy = jest + .spyOn(MultiCall.prototype, 'simResult') + .mockResolvedValue([BigInt('1'), {}]); + + const result = await networkService.getSep41AssetBalances({ + accounts: [account], + assetIds: [validSep41AssetId, secondAssetId], + scope: KnownCaip2ChainId.Mainnet, + }); + + expect(result[account]?.[validSep41AssetId]?.toFixed()).toBe('1'); + expect(result[account]?.[secondAssetId]).toBeNull(); + simResultSpy.mockRestore(); + }); + + it('returns empty object on testnet (batch SEP-41 balances not supported)', async () => { + const simResultSpy = jest.spyOn(MultiCall.prototype, 'simResult'); + const testnetAssetId = + 'stellar:testnet/sep41:CDLZFC3SYJYDZT7K67VZ75HVSSBAXAVVD2XGDFEUCDZUFE7MDUROSPZM' as KnownCaip19Sep41AssetId; + + const result = await networkService.getSep41AssetBalances({ + accounts: [account], + assetIds: [testnetAssetId], + scope: KnownCaip2ChainId.Testnet, + }); + + expect(result).toStrictEqual({}); + expect(simResultSpy).not.toHaveBeenCalled(); + simResultSpy.mockRestore(); + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index 409cfee0..677f1dce 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -25,17 +25,24 @@ import { TransactionRetryableException, TransactionSendException, } from './exceptions'; +import { + InvocationV1, + MultiCall, + SIMULATION_ACCOUNT, + StellarRouterContract, +} from './MultiCall'; import { caip2ChainIdToNetwork, extractAssetDataFromContractData, isAccountNotFoundError, parseScValToNative, + sep41MulticallCellToBalance, } from './utils'; import type { KnownCaip19ClassicAssetId, KnownCaip19Sep41AssetId, - KnownCaip2ChainId, } from '../../api'; +import { KnownCaip2ChainId } from '../../api'; import type { NetworkConfig } from '../../config'; import { AppConfig } from '../../config'; import { STELLAR_DECIMAL_PLACES } from '../../constants'; @@ -363,7 +370,6 @@ export class NetworkService { }): Promise { const { accountAddress, assetId, scope, sequenceNumber } = params; const { assetReference: tokenAddress } = parseCaipAssetType(assetId); - // TODO: change to use https://github.com/Creit-Tech/Stellar-Router-SDK to batch collect balances try { const client = this.#getRpcClient(scope); const token = new Contract(tokenAddress); @@ -413,6 +419,95 @@ export class NetworkService { } } + /** + * Fetches SEP-41 asset balances for multiple accounts via Soroban simulation of `balance(Address)`. + * + * **Mainnet only** — uses the Stellar MultiCall router (single simulation). On testnet this method + * returns `{}` until batch SEP-41 reads are supported there. + * + * @param params - Balance query input. + * @param params.accounts - Accounts holding the token (`G…`). + * @param params.assetIds - CAIP-19 asset ids for SEP-41 tokens. + * @param params.scope - CAIP-2 chain id. + * @returns Per-account map of asset id to balance in smallest units, or `null` when a cell cannot be read. + * @throws {NetworkServiceException} When the RPC request fails or the multicall result length is wrong. + */ + async getSep41AssetBalances(params: { + accounts: string[]; + assetIds: KnownCaip19Sep41AssetId[]; + scope: KnownCaip2ChainId; + }): Promise< + Record> + > { + const { accounts, assetIds, scope } = params; + + if (accounts.length === 0 || assetIds.length === 0) { + return {}; + } + + if (scope === KnownCaip2ChainId.Testnet) { + return {}; + } + + try { + const multiCall = new MultiCall({ + rpcClient: this.#getRpcClient(scope), + routerContract: StellarRouterContract.V1, + // Caller for `exec` on the router; first funded user account is typical; else the shared sim account. + simulationAccount: accounts[0] ?? SIMULATION_ACCOUNT, + }); + + const invocations: InvocationV1[] = []; + for (const account of accounts) { + for (const assetId of assetIds) { + invocations.push( + new InvocationV1({ + contract: parseCaipAssetType(assetId).assetReference, + method: 'balance', + args: [new Address(account).toScVal()], + // Allow the batch simulation to continue when a cell fails (missing contract, etc.). + canFail: true, + }), + ); + } + } + const totalRecords = accounts.length * assetIds.length; + + const simResults: unknown[] = await multiCall.simResult(invocations); + + if (simResults.length !== totalRecords) { + throw new NetworkServiceException( + `Failed to load SEP-41 token balance - multicall result length: ${simResults.length} does not match the expected number of records: ${totalRecords}`, + ); + } + + const result: Record< + string, + Record + > = {}; + let idx = 0; + for (const account of accounts) { + for (const assetId of assetIds) { + const simResult = simResults[idx]; + result[account] ??= {}; + result[account][assetId] = sep41MulticallCellToBalance(simResult); + idx += 1; + } + } + return result; + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to load SEP-41 token balance', + error, + ); + return rethrowIfInstanceElseThrow( + error, + [NetworkServiceException], + new NetworkServiceException('Failed to load SEP-41 token balance'), + ); + } + } + /** * Loads account data when the account exists and is funded; returns `null` if the account is not on-chain. * diff --git a/merged-packages/stellar-wallet-snap/src/services/network/utils.ts b/merged-packages/stellar-wallet-snap/src/services/network/utils.ts index 7497a54a..129ec856 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/utils.ts @@ -154,6 +154,27 @@ export function parseScValToNative(value: string | bigint | number): BigNumber { return amountBn; } +/** + * Normalizes a single Stellar multicall `exec` result cell to a non-negative {@link BigNumber}. + * + * @param value - Native value from `scValToNative` for one invocation result. + * @returns Parsed balance, or `null` when the cell is missing or not a supported numeric shape. + */ +export function sep41MulticallCellToBalance(value: unknown): BigNumber | null { + if ( + typeof value === 'bigint' || + typeof value === 'number' || + typeof value === 'string' + ) { + try { + return parseScValToNative(value); + } catch { + return null; + } + } + return null; +} + /** * Detects the error shape thrown by Soroban RPC `getAccount` / `getAccountEntry` when the account * ledger entry is missing (`Error` with message `Account not found: `). diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts index 4b70c90b..c16191f4 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts @@ -24,6 +24,7 @@ import { calculateSpendableBalance } from './utils'; import type { KnownCaip19AssetIdOrSlip44Id, KnownCaip19ClassicAssetId, + KnownCaip19Sep41AssetId, KnownCaip2ChainId, } from '../../api'; import { NATIVE_ASSET_SYMBOL } from '../../constants'; @@ -149,6 +150,19 @@ export class OnChainAccount { return { ...entry }; } + /** + * Sets the balance for a SEP-41 asset id. + * + * @param assetId - The SEP-41 asset id to set the balance for. + * @param balanceEntry - The balance entry to set. + */ + setSep41Asset( + assetId: KnownCaip19Sep41AssetId, + balanceEntry: SpendableBalance, + ): void { + this.#balances.set(assetId, balanceEntry); + } + /** * Classic Stellar trustline asset ids (CAIP-19) that have a balance row with a limit. * @@ -316,6 +330,14 @@ export class OnChainAccount { }; } + toSerializableFull(): OnChainAccountSerializableFull { + const serialized = this.toSerializable(); + if (!OnChainAccountSerializableFullStruct.is(serialized)) { + throw new OnChainAccountException('Account is not fully hydrated'); + } + return serialized; + } + /** * Builds from a Horizon `loadAccount` response. * With a native balance line → full binding; otherwise → minimal binding (sequence-only style). diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts new file mode 100644 index 00000000..e9e89f42 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts @@ -0,0 +1,114 @@ +import { cloneDeep } from 'lodash'; + +import type { + OnChainAccountSnapshotsByKeyringId, + OnChainAccountState, +} from './api'; +import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; +import type { KnownCaip2ChainId } from '../../api'; +import type { IStateManager } from '../state/IStateManager'; + +export class OnChainAccountRepository { + readonly #state: IStateManager; + + readonly #stateKey = 'onChainAccounts'; + + constructor(state: IStateManager) { + this.#state = state; + } + + /** + * @param keyringAccountId - MetaMask keyring account id (not the Stellar G-address). + * @param scope - CAIP-2 chain id for the cached snapshot. + * @returns The stored snapshot, or `null` when none exists for this keyring id and scope. + */ + async findByAccountId( + keyringAccountId: string, + scope: KnownCaip2ChainId, + ): Promise { + const snapshotsByAccountId = await this.findByAccountIds( + [keyringAccountId], + scope, + ); + + return snapshotsByAccountId[keyringAccountId] ?? null; + } + + /** + * @param keyringAccountIds - MetaMask keyring account ids (not Stellar G-addresses). + * @param scope - CAIP-2 chain id for the cached snapshots. + * @returns Account id -> snapshot (or `null` when missing for the given scope). + */ + async findByAccountIds( + keyringAccountIds: string[], + scope: KnownCaip2ChainId, + ): Promise> { + const byKeyring = + (await this.#state.getKey( + this.#stateKey, + )) ?? {}; + const snapshotsByAccountId: Record< + string, + OnChainAccountSerializableFull | null + > = {}; + + for (const keyringAccountId of keyringAccountIds) { + snapshotsByAccountId[keyringAccountId] = + byKeyring[keyringAccountId]?.[scope] ?? null; + } + + return snapshotsByAccountId; + } + + /** + * Persists one snapshot under `onChainAccounts[keyringId][account.scope]` in a single atomic + * `snap_manageState` update (avoids races between separate get/set paths). + * + * @param keyringAccountId - MetaMask keyring account id (not the Stellar G-address). + * @param account - Serializable snapshot; `account.scope` selects the nested key. + */ + async save( + keyringAccountId: string, + account: OnChainAccountSerializableFull, + ): Promise { + await this.#state.update((state) => { + const newState = cloneDeep(state); + if (!newState[this.#stateKey]) { + newState[this.#stateKey] = {} as OnChainAccountSnapshotsByKeyringId; + } + const root = newState[this.#stateKey]; + root[keyringAccountId] ??= {}; + root[keyringAccountId][account.scope] = account; + return newState; + }); + } + + /** + * Writes accounts in one atomic `IStateManager.update` (full state blob). Callers that read then + * merge outside this method should serialize those steps if updates can overlap (see + * `OnChainAccountSynchronizeService` mutex). + * + * @param accounts - Map of keyring account id → snapshot for `accounts[id].scope`. + */ + async saveMany( + accounts: Record, + ): Promise { + if (Object.keys(accounts).length === 0) { + return; + } + + await this.#state.update((state) => { + const newState = cloneDeep(state); + if (!newState[this.#stateKey]) { + newState[this.#stateKey] = {} as OnChainAccountSnapshotsByKeyringId; + } + const accountsByKeyringId = newState[this.#stateKey]; + + for (const [keyringAccountId, account] of Object.entries(accounts)) { + accountsByKeyringId[keyringAccountId] ??= {}; + accountsByKeyringId[keyringAccountId][account.scope] = account; + } + return newState; + }); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index f88a3eed..93d977d4 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -9,8 +9,13 @@ import { mockOnChainAccountService, } from './__mocks__/onChainAccount.fixtures'; import { OnChainAccount } from './OnChainAccount'; +import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; +import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; import { bufferToUint8Array } from '../../utils/buffer'; -import { generateStellarKeyringAccount } from '../account/__mocks__/account.fixtures'; +import { + generateMockStellarKeyringAccounts, + generateStellarKeyringAccount, +} from '../account/__mocks__/account.fixtures'; import { DerivedAccountAddressMismatchException } from '../account/exceptions'; import { NetworkService } from '../network'; import { getTestWallet } from '../wallet/__mocks__/wallet.fixtures'; @@ -32,6 +37,10 @@ describe('OnChainAccountService', () => { NetworkService.prototype, 'loadOnChainAccount', ), + loadActivatedAccountOrNullSpy: jest.spyOn( + NetworkService.prototype, + 'loadActivatedAccountOrNull', + ), }); describe('isAccountActivated', () => { @@ -133,4 +142,88 @@ describe('OnChainAccountService', () => { ).rejects.toThrow(DerivedAccountAddressMismatchException); }); }); + + describe('resolveOnChainAccountByAccountId', () => { + it('returns null when no snapshot exists for the keyring id and scope', async () => { + const keyringAccountId = globalThis.crypto.randomUUID(); + const { onChainAccountService, onChainAccountRepository } = + mockOnChainAccountService(); + const findByAccountIdSpy = jest.spyOn( + onChainAccountRepository, + 'findByAccountId', + ); + findByAccountIdSpy.mockResolvedValue(null); + + const result = + await onChainAccountService.resolveOnChainAccountByAccountId( + keyringAccountId, + KnownCaip2ChainId.Mainnet, + ); + + expect(result).toBeNull(); + expect(findByAccountIdSpy).toHaveBeenCalledWith( + keyringAccountId, + KnownCaip2ChainId.Mainnet, + ); + }); + + it('returns rehydrated OnChainAccount when a snapshot exists', async () => { + const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + const keyringAccountId = globalThis.crypto.randomUUID(); + const loadedAcc = createMockAccountWithBalances( + signer.publicKey(), + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const binding = horizonSource( + loadedAcc, + KnownCaip2ChainId.Mainnet, + ) as OnChainAccountSerializableFull; + + const { onChainAccountService, onChainAccountRepository } = + mockOnChainAccountService(); + const findByAccountIdSpy = jest.spyOn( + onChainAccountRepository, + 'findByAccountId', + ); + findByAccountIdSpy.mockResolvedValue(binding); + + const result = + await onChainAccountService.resolveOnChainAccountByAccountId( + keyringAccountId, + KnownCaip2ChainId.Mainnet, + ); + + expect(result).toBeInstanceOf(OnChainAccount); + expect(result?.accountId).toStrictEqual(signer.publicKey()); + expect(findByAccountIdSpy).toHaveBeenCalledWith( + keyringAccountId, + KnownCaip2ChainId.Mainnet, + ); + }); + }); + + describe('synchronize', () => { + it('calls OnChainAccountSynchronizeService', async () => { + const keyringAccounts = generateMockStellarKeyringAccounts( + 2, + 'entropy-source-1', + ); + const { onChainAccountService } = mockOnChainAccountService(); + const synchronizeSpy = jest.spyOn( + OnChainAccountSynchronizeService.prototype, + 'synchronize', + ); + + await onChainAccountService.synchronize( + keyringAccounts, + KnownCaip2ChainId.Mainnet, + ); + + expect(synchronizeSpy).toHaveBeenCalledWith( + keyringAccounts, + KnownCaip2ChainId.Mainnet, + ); + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 4cbf7761..1a8b626c 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -1,7 +1,12 @@ -import type { OnChainAccount } from './OnChainAccount'; +import { OnChainAccount } from './OnChainAccount'; +import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; import type { KnownCaip2ChainId } from '../../api'; +import type { ILogger } from '../../utils'; +import type { StellarKeyringAccount } from '../account'; import { assertSameAddress } from '../account/utils'; -import type { NetworkService } from '../network'; +import { type NetworkService } from '../network'; +import type { OnChainAccountRepository } from './OnChainAccountRepository'; +import type { AssetMetadataService } from '../asset-metadata/AssetMetadataService'; /** * Stellar on-chain account operations: activation checks and loading {@link OnChainAccount} @@ -10,8 +15,30 @@ import type { NetworkService } from '../network'; export class OnChainAccountService { readonly #networkService: NetworkService; - constructor({ networkService }: { networkService: NetworkService }) { + readonly #onChainAccountSynchronizeService: OnChainAccountSynchronizeService; + + readonly #onChainAccountRepository: OnChainAccountRepository; + + constructor({ + networkService, + onChainAccountRepository, + assetMetadataService, + logger, + }: { + networkService: NetworkService; + onChainAccountRepository: OnChainAccountRepository; + assetMetadataService: AssetMetadataService; + logger: ILogger; + }) { this.#networkService = networkService; + this.#onChainAccountSynchronizeService = + new OnChainAccountSynchronizeService({ + networkService, + onChainAccountRepository, + assetMetadataService, + logger, + }); + this.#onChainAccountRepository = onChainAccountRepository; } /** @@ -54,4 +81,41 @@ export class OnChainAccountService { assertSameAddress(accountAddress, loaded.accountId); return loaded; } + + /** + * Loads the on-chain account for the given keyring account id from the State. + * + * @param keyringAccountId - The keyring account id to load the on-chain account for. + * @param scope - The CAIP-2 chain id to load the on-chain account for. + * @returns The on-chain account, or `null` if not found. + */ + async resolveOnChainAccountByAccountId( + keyringAccountId: string, + scope: KnownCaip2ChainId, + ): Promise { + const onChainAccount = await this.#onChainAccountRepository.findByAccountId( + keyringAccountId, + scope, + ); + return onChainAccount + ? OnChainAccount.fromSerializable(onChainAccount) + : null; + } + + /** + * Enriches accounts with SEP-41 balances, persists snapshots, then notifies the keyring when + * balances or the tracked asset set changed. Delegates to {@link OnChainAccountSynchronizeService}. + * + * @param keyringAccount - Stellar keyring accounts to sync for `scope`. + * @param scope - CAIP-2 network. + */ + async synchronize( + keyringAccount: StellarKeyringAccount[], + scope: KnownCaip2ChainId, + ): Promise { + await this.#onChainAccountSynchronizeService.synchronize( + keyringAccount, + scope, + ); + } } diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts new file mode 100644 index 00000000..8b02b3ad --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts @@ -0,0 +1,425 @@ +import { KeyringEvent } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { hexToBytes } from '@metamask/utils'; +import { Keypair } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import type { KnownCaip19Sep41AssetId } from '../../api'; +import { KnownCaip2ChainId } from '../../api'; +import { + createMockAccountWithBalances, + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + horizonSource, + mockOnChainAccountService, +} from './__mocks__/onChainAccount.fixtures'; +import { OnChainAccount } from './OnChainAccount'; +import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; +import { bufferToUint8Array } from '../../utils/buffer'; +import { generateStellarKeyringAccount } from '../account/__mocks__/account.fixtures'; +import { + USDT_SEP41, + USDC_SEP41, + generateMockStellarAssetMetadata, +} from '../asset-metadata/__mocks__/assets.fixtures'; +import type { StellarAssetMetadata } from '../asset-metadata/api'; +import { AssetMetadataService } from '../asset-metadata/AssetMetadataService'; +import { AccountNotActivatedException, NetworkService } from '../network'; + +jest.mock('../../utils/logger'); +jest.mock('../../utils/snap'); +jest.mock('@metamask/keyring-snap-sdk', () => ({ + emitSnapKeyringEvent: jest.fn(), +})); + +describe('OnChainAccountService.synchronize', () => { + const seed = hexToBytes( + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ); + const sep41Id = USDC_SEP41 as KnownCaip19Sep41AssetId; + const backupSep41Id = USDT_SEP41 as KnownCaip19Sep41AssetId; + + const getNetworkServiceSpies = () => ({ + loadOnChainAccountSpy: jest.spyOn( + NetworkService.prototype, + 'loadOnChainAccount', + ), + getSep41AssetBalancesSpy: jest.spyOn( + NetworkService.prototype, + 'getSep41AssetBalances', + ), + }); + + const getRepositorySpies = ( + onChainAccountRepository: ReturnType< + typeof mockOnChainAccountService + >['onChainAccountRepository'], + ) => ({ + findByAccountIdsSpy: jest.spyOn( + onChainAccountRepository, + 'findByAccountIds', + ), + saveManySpy: jest.spyOn(onChainAccountRepository, 'saveMany'), + }); + + const getKeyringEventSpies = () => ({ + emitSnapKeyringEventSpy: jest.mocked(emitSnapKeyringEvent), + }); + + const setupSynchronizeService = () => { + const { onChainAccountService, onChainAccountRepository } = + mockOnChainAccountService(); + return { + onChainAccountService, + onChainAccountRepository, + ...getRepositorySpies(onChainAccountRepository), + }; + }; + + const getSavedSnapshotFromFirstSave = ( + saveManySpy: ReturnType['saveManySpy'], + keyringAccountId: string, + ): OnChainAccountSerializableFull => { + expect(saveManySpy).toHaveBeenCalledTimes(1); + expect(saveManySpy.mock.calls[0]).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- narrowed by expect above + const payload = saveManySpy.mock.calls[0]![0]; + return payload[keyringAccountId] as OnChainAccountSerializableFull; + }; + + const setupTest = () => { + jest.mocked(emitSnapKeyringEvent).mockResolvedValue(undefined); + const metadata = generateMockStellarAssetMetadata(); + const usdcSep41Row = metadata[USDC_SEP41]; + if (!usdcSep41Row) { + throw new Error('expected USDC_SEP41 in mock asset metadata'); + } + const usdtSep41Row = metadata[USDT_SEP41]; + if (!usdtSep41Row) { + throw new Error('expected USDT_SEP41 in mock asset metadata'); + } + jest + .spyOn(AssetMetadataService.prototype, 'getPersistedSep41AssetsMetadata') + .mockResolvedValue([usdcSep41Row, usdtSep41Row]); + // getKey('assets') in tests does not merge defaultState, so getAllByScope is empty unless mocked. + jest + .spyOn(AssetMetadataService.prototype, 'getAllByScope') + .mockImplementation(async (scope) => { + const byAssetId = generateMockStellarAssetMetadata(); + return Object.values(byAssetId).filter( + (asset): asset is StellarAssetMetadata => + asset !== undefined && asset.chainId === scope, + ); + }); + }; + + const setupOnChainAccountWithBalance = (entropySource: string) => { + const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + const keyringAccount = generateStellarKeyringAccount( + globalThis.crypto.randomUUID(), + signer.publicKey(), + entropySource, + 0, + ); + const loadedAcc = createMockAccountWithBalances( + signer.publicKey(), + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const binding = horizonSource( + loadedAcc, + KnownCaip2ChainId.Mainnet, + ) as OnChainAccountSerializableFull; + const onChainAccount = OnChainAccount.fromSerializable(binding); + + return { + signer, + keyringAccount, + binding, + onChainAccount, + }; + }; + + it('returns early without saveMany when accountsPairs is empty', async () => { + setupTest(); + + const { onChainAccountService, saveManySpy } = setupSynchronizeService(); + + await onChainAccountService.synchronize([], KnownCaip2ChainId.Mainnet); + + expect(saveManySpy).not.toHaveBeenCalled(); + }); + + it('returns early when no activated account is loaded', async () => { + setupTest(); + + const keyringAccount = generateStellarKeyringAccount( + globalThis.crypto.randomUUID(), + Keypair.random().publicKey(), + 'entropy-sync-no-activated', + 0, + ); + const { loadOnChainAccountSpy, getSep41AssetBalancesSpy } = + getNetworkServiceSpies(); + loadOnChainAccountSpy.mockRejectedValue( + new AccountNotActivatedException( + keyringAccount.address, + KnownCaip2ChainId.Mainnet, + ), + ); + + const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); + const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + setupSynchronizeService(); + + await onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ); + + expect(getSep41AssetBalancesSpy).not.toHaveBeenCalled(); + expect(findByAccountIdsSpy).not.toHaveBeenCalled(); + expect(saveManySpy).not.toHaveBeenCalled(); + expect(emitSnapKeyringEventSpy).not.toHaveBeenCalled(); + }); + + it('persists SEP-41 balances after sync and writes onChainAccounts state', async () => { + setupTest(); + + const { signer, keyringAccount, onChainAccount } = + setupOnChainAccountWithBalance('entropy-sync-1'); + const { getSep41AssetBalancesSpy, loadOnChainAccountSpy } = + getNetworkServiceSpies(); + getSep41AssetBalancesSpy.mockResolvedValue({ + [signer.publicKey()]: { + [sep41Id]: new BigNumber('1000'), + }, + }); + loadOnChainAccountSpy.mockResolvedValue(onChainAccount); + + const { onChainAccountService, saveManySpy } = setupSynchronizeService(); + + await onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ); + + const saved = getSavedSnapshotFromFirstSave(saveManySpy, keyringAccount.id); + expect(saved).toBeDefined(); + const sepRow = saved.balances.find((b) => b.assetId === sep41Id); + expect(sepRow?.balance).toBe('1000'); + }); + + it('emits AccountBalancesUpdated and AccountAssetListUpdated when SEP-41 is added versus persisted state', async () => { + setupTest(); + + const { signer, keyringAccount, binding } = + setupOnChainAccountWithBalance('entropy-sync-2'); + const withSep: OnChainAccountSerializableFull = { + ...binding, + balances: [ + ...binding.balances, + { assetId: sep41Id, balance: '500', symbol: 'USDC' }, + ], + }; + const onChainAccount = OnChainAccount.fromSerializable(withSep); + + const { getSep41AssetBalancesSpy, loadOnChainAccountSpy } = + getNetworkServiceSpies(); + getSep41AssetBalancesSpy.mockResolvedValue({ + [signer.publicKey()]: { + [sep41Id]: new BigNumber('500'), + }, + }); + loadOnChainAccountSpy.mockResolvedValue(onChainAccount); + + const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); + const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + setupSynchronizeService(); + findByAccountIdsSpy.mockResolvedValue({ + [keyringAccount.id]: binding, + }); + + await onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ); + + expect(emitSnapKeyringEventSpy).toHaveBeenCalledTimes(2); + expect(emitSnapKeyringEventSpy).toHaveBeenNthCalledWith( + 1, + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [keyringAccount.id]: { + [sep41Id]: { unit: 'USDC', amount: '500' }, + }, + }, + }, + ); + expect(emitSnapKeyringEventSpy).toHaveBeenNthCalledWith( + 2, + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [keyringAccount.id]: { added: [sep41Id], removed: [] }, + }, + }, + ); + expect(saveManySpy).toHaveBeenCalled(); + expect(saveManySpy.mock.invocationCallOrder).toHaveLength(1); + expect(emitSnapKeyringEventSpy.mock.invocationCallOrder).toHaveLength(2); + expect(Number(saveManySpy.mock.invocationCallOrder[0])).toBeLessThan( + Number(emitSnapKeyringEventSpy.mock.invocationCallOrder[0]), + ); + }); + + it('emits removal when SEP-41 was persisted and new sync has zero', async () => { + setupTest(); + + const { + signer, + keyringAccount, + binding: base, + onChainAccount, + } = setupOnChainAccountWithBalance('entropy-sync-3'); + const withSep: OnChainAccountSerializableFull = { + ...base, + balances: [ + ...base.balances, + { assetId: sep41Id, balance: '200', symbol: 'USDC' }, + ], + }; + const { getSep41AssetBalancesSpy, loadOnChainAccountSpy } = + getNetworkServiceSpies(); + getSep41AssetBalancesSpy.mockResolvedValue({ + [signer.publicKey()]: { + [sep41Id]: new BigNumber(0), + }, + }); + + const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); + const { onChainAccountService, findByAccountIdsSpy } = + setupSynchronizeService(); + findByAccountIdsSpy.mockResolvedValue({ + [keyringAccount.id]: withSep, + }); + loadOnChainAccountSpy.mockResolvedValue(onChainAccount); + + await onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ); + + expect(emitSnapKeyringEventSpy).toHaveBeenCalledTimes(2); + expect(emitSnapKeyringEventSpy).toHaveBeenNthCalledWith( + 1, + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [keyringAccount.id]: { + [sep41Id]: { unit: 'USDC', amount: '0' }, + }, + }, + }, + ); + expect(emitSnapKeyringEventSpy).toHaveBeenNthCalledWith( + 2, + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [keyringAccount.id]: { added: [], removed: [sep41Id] }, + }, + }, + ); + }); + + it('restores persisted SEP-41 rows when SEP-41 balance fetch fails', async () => { + setupTest(); + + const { + keyringAccount, + binding: base, + onChainAccount, + } = setupOnChainAccountWithBalance('entropy-sync-fallback-all-fail'); + const withPersistedSep41: OnChainAccountSerializableFull = { + ...base, + balances: [ + ...base.balances, + { assetId: sep41Id, balance: '700', symbol: 'USDC' }, + ], + }; + const { getSep41AssetBalancesSpy, loadOnChainAccountSpy } = + getNetworkServiceSpies(); + getSep41AssetBalancesSpy.mockRejectedValue( + new Error('sep41 fetch temporarily unavailable'), + ); + loadOnChainAccountSpy.mockResolvedValue(onChainAccount); + + const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); + const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + setupSynchronizeService(); + findByAccountIdsSpy.mockResolvedValue({ + [keyringAccount.id]: withPersistedSep41, + }); + + await onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ); + + const saved = getSavedSnapshotFromFirstSave(saveManySpy, keyringAccount.id); + const persistedSep41Row = saved.balances.find((b) => b.assetId === sep41Id); + expect(persistedSep41Row?.balance).toBe('700'); + expect(emitSnapKeyringEventSpy).not.toHaveBeenCalled(); + }); + + it('restores unresolved persisted SEP-41 rows when only some SEP-41 balances fail', async () => { + setupTest(); + + const { + signer, + keyringAccount, + binding: base, + onChainAccount, + } = setupOnChainAccountWithBalance('entropy-sync-fallback-some-fail'); + const withPersistedBackupSep41: OnChainAccountSerializableFull = { + ...base, + balances: [ + ...base.balances, + { assetId: backupSep41Id, balance: '250', symbol: 'USDT' }, + ], + }; + const { getSep41AssetBalancesSpy, loadOnChainAccountSpy } = + getNetworkServiceSpies(); + getSep41AssetBalancesSpy.mockResolvedValue({ + [signer.publicKey()]: { + [sep41Id]: new BigNumber('500'), + [backupSep41Id]: null, + }, + }); + loadOnChainAccountSpy.mockResolvedValue(onChainAccount); + + const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + setupSynchronizeService(); + findByAccountIdsSpy.mockResolvedValue({ + [keyringAccount.id]: withPersistedBackupSep41, + }); + + await onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ); + + const saved = getSavedSnapshotFromFirstSave(saveManySpy, keyringAccount.id); + const resolvedSep41Row = saved.balances.find((b) => b.assetId === sep41Id); + const restoredSep41Row = saved.balances.find( + (b) => b.assetId === backupSep41Id, + ); + expect(resolvedSep41Row?.balance).toBe('500'); + expect(restoredSep41Row?.balance).toBe('250'); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts new file mode 100644 index 00000000..9b44ea5d --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts @@ -0,0 +1,541 @@ +import { KeyringEvent } from '@metamask/keyring-api'; +import type { KeyringEventPayload } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { Mutex } from 'async-mutex'; +import { BigNumber } from 'bignumber.js'; + +import { OnChainAccount } from './OnChainAccount'; +import type { OnChainAccountRepository } from './OnChainAccountRepository'; +import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip19Sep41AssetId, + KnownCaip2ChainId, +} from '../../api'; +import type { ILogger } from '../../utils'; +import { + createPrefixedLogger, + getSlip44AssetId, + getSnapProvider, + isSep41Id, +} from '../../utils'; +import type { StellarKeyringAccount } from '../account'; +import type { AssetMetadataService } from '../asset-metadata/AssetMetadataService'; +import { AccountNotActivatedException, type NetworkService } from '../network'; + +type AccountAssetListDelta = + KeyringEventPayload['assets'][string]; + +type ActivatedAccountPair = { + keyringAccount: StellarKeyringAccount; + onChainAccount: OnChainAccount; +}; + +type Sep41BalanceFetchResult = { + assetIds: KnownCaip19Sep41AssetId[]; + symbolsByAssetId: Record; + balancesByAccountId: Record< + string, + Record + >; +}; + +/** + * Persists on-chain account snapshots and emits keyring balance / asset-list events after a sync. + * + * {@link synchronize} uses a mutex so overlapping syncs cannot interleave read–merge–write across + * `findByAccountIds` and `saveMany`. Each `saveMany` call is still one atomic `IStateManager.update`. + */ +export class OnChainAccountSynchronizeService { + readonly #networkService: NetworkService; + + readonly #onChainAccountRepository: OnChainAccountRepository; + + readonly #assetMetadataService: AssetMetadataService; + + readonly #logger: ILogger; + + /** Serializes full sync runs; see class JSDoc. */ + readonly #synchronizeMutex = new Mutex(); + + constructor({ + networkService, + onChainAccountRepository, + assetMetadataService, + logger, + }: { + networkService: NetworkService; + onChainAccountRepository: OnChainAccountRepository; + assetMetadataService: AssetMetadataService; + logger: ILogger; + }) { + this.#networkService = networkService; + this.#onChainAccountRepository = onChainAccountRepository; + this.#assetMetadataService = assetMetadataService; + this.#logger = createPrefixedLogger( + logger, + '[💼 OnChainAccountSynchronizeService]', + ); + } + + /** + * Enriches accounts with SEP-41 balances, persists snapshots, then notifies the keyring when + * balances or the non-native tracked asset set changed. + * + * @param keyringAccounts - Stellar keyring accounts to sync for `scope`. + * @param scope - CAIP-2 network. + */ + async synchronize( + keyringAccounts: StellarKeyringAccount[], + scope: KnownCaip2ChainId, + ): Promise { + if (keyringAccounts.length === 0) { + this.#logger.debug('No accounts to synchronize'); + return; + } + + // Adding a mutex to prevent multiple syncs from running simultaneously, + // And ensure the read and write consistency in state. + // Trade off of the mutex: Synchronize request may send every second, due to user switch accounts, we will block the next request until the current request is finished. + await this.#synchronizeMutex.runExclusive(async () => { + this.#logger.debug('Load on-chain accounts - no of accounts to load', { + noOfAccounts: keyringAccounts.length, + }); + // 1. Horizon: funded accounts only (unfunded / errors skipped in #loadActivatedPairs). + const activatedAccountPairs = await this.#loadActivatedPairs( + keyringAccounts, + scope, + ); + this.#logger.debug( + 'Loaded activated account pairs - no of accounts loaded', + { + noOfAccounts: activatedAccountPairs.length, + }, + ); + if (activatedAccountPairs.length === 0) { + return; + } + + const stellarAccountIds: string[] = []; + const keyringAccountIds: string[] = []; + for (const { keyringAccount, onChainAccount } of activatedAccountPairs) { + keyringAccountIds.push(keyringAccount.id); + stellarAccountIds.push(onChainAccount.accountId); + } + + // 2. SEP-41 token balances (best effort): + // - Try to load each tracked SEP-41 token for every activated account. + // - If this step throws, the rest of the sync still runs; step 4 can copy missing tokens from the last snapshot. + this.#logger.debug('Load SEP-41 token balances'); + let sep41BalanceFetchResult: Sep41BalanceFetchResult | null = null; + try { + sep41BalanceFetchResult = await this.#synchronizeSep41AssetBalances( + stellarAccountIds, + scope, + ); + } catch { + this.#logger.debug( + 'SEP-41 token balance step failed; merge will reuse last-saved SEP-41 token rows where needed', + ); + } + + // 3. Snap state: latest serialized snapshots before this run (merge source + keyring diff baseline). + this.#logger.debug('Load latest state snapshots for on-chain accounts'); + const latestSerializedAccountSnaphotByKeyringId = + await this.#onChainAccountRepository.findByAccountIds( + keyringAccountIds, + scope, + ); + const lengthOfSnapshot = Object.keys( + latestSerializedAccountSnaphotByKeyringId, + ).length; + this.#logger.debug( + 'Loaded latest state snapshots for on-chain accounts - no of accounts loaded', + { + noOfAccounts: lengthOfSnapshot, + newActivedAccountPairs: + activatedAccountPairs.length - lengthOfSnapshot, + }, + ); + // 4. Per activated account: + // - apply fetched SEP-41 balances (if the fetch step succeeded), + // - restore unresolved SEP-41 rows from the latest state snapshot, + // - compute keyring event deltas, + // - prepare the serialized snapshot payload for one batched save. + const snapshotsToSave: Record = + {}; + let balancesPayload: + | KeyringEventPayload['balances'] + | null = null; + let assetsPayload: + | KeyringEventPayload['assets'] + | null = null; + + this.#logger.debug('Diff full snapshots for on-chain accounts'); + for (const { + keyringAccount, + onChainAccount: synchronizedOnChainAccount, + } of activatedAccountPairs) { + const keyringAccountId = keyringAccount.id; + const latestStateSnapshotSerialized = + latestSerializedAccountSnaphotByKeyringId[keyringAccountId] ?? null; + const stateSnapshotOnChainAccount = + latestStateSnapshotSerialized === null + ? null + : OnChainAccount.fromSerializable(latestStateSnapshotSerialized); + const unresolvedSep41AssetIds = this.#setSep41BalancesForAccount( + synchronizedOnChainAccount, + sep41BalanceFetchResult, + ); + + // fill gaps for SEP-41 tokens using the last saved snapshot from State: + // - If step 2 failed completely, copy every SEP-41 token row from the snapshot that is still missing on `synchronizedOnChainAccount`. + // - If step 2 ran but some token ids failed, copy only those ids from the snapshot when they are still missing. + // - Any SEP-41 token that already has a row from step 2 is left unchanged here. + this.#mergePersistedSep41Rows( + synchronizedOnChainAccount, + latestStateSnapshotSerialized, + unresolvedSep41AssetIds, + ); + + const { balanceChanges, assetListChanges } = + this.#diffFullSnapshotsForKeyring( + stateSnapshotOnChainAccount, + synchronizedOnChainAccount, + ); + + if (balanceChanges !== null) { + balancesPayload ??= {}; + balancesPayload[keyringAccountId] = balanceChanges; + this.#logger.debug( + 'Differences in full snapshots for keyring account - balanceChanges', + { + keyringAccountId, + balanceChangesLength: Object.keys(balanceChanges).length, + }, + ); + } + if (assetListChanges !== null) { + assetsPayload ??= {}; + assetsPayload[keyringAccountId] = assetListChanges; + this.#logger.debug( + 'Differences in full snapshots for keyring account - asset list changes', + { + keyringAccountId, + assetListChangesLength: Object.keys(assetListChanges).length, + }, + ); + } + + snapshotsToSave[keyringAccountId] = + synchronizedOnChainAccount.toSerializableFull(); + } + + // 5. Save the snapshots to the State. + this.#logger.debug('Save snapshots to the State'); + await this.#onChainAccountRepository.saveMany(snapshotsToSave); + + // 6. Emit the keyring events if the balances or the non-native asset list changed. + this.#logger.debug('Emit keyring events'); + await this.#emitKeyringEvents(balancesPayload, assetsPayload); + }); + } + + /** + * Loads each account from Horizon; skips unfunded accounts and logs other failures. + * + * @param accounts - Keyring accounts to load. + * @param scope - CAIP-2 network to query. + * @returns Pairs keyed for SEP-41 sync and persistence. + */ + async #loadActivatedPairs( + accounts: StellarKeyringAccount[], + scope: KnownCaip2ChainId, + ): Promise { + const pairs: ActivatedAccountPair[] = []; + + const results = await Promise.allSettled( + accounts.map(async (account) => ({ + keyringAccount: account, + onChainAccount: await this.#networkService.loadOnChainAccount( + account.address, + scope, + ), + })), + ); + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + pairs.push(result.value); + return; + } + if (result.reason instanceof AccountNotActivatedException) { + return; + } + this.#logger.logErrorWithDetails('Failed to load account for sync', { + accountId: accounts[index]?.id, + error: result.reason, + }); + }); + + return pairs; + } + + /** + * Loads SEP-41 token balances from the network (no per-account mutation here). + * + * @param stellarAccountIds - Stellar account ids to query in one batch call. + * @param scope - Network to query. + * @returns Shared SEP-41 inputs consumed in the main synchronize loop. + */ + async #synchronizeSep41AssetBalances( + stellarAccountIds: string[], + scope: KnownCaip2ChainId, + ): Promise { + // Get all SEP-41 assets for the given scope. + const allAssets = await this.#assetMetadataService.getAllByScope(scope); + + if (allAssets.length === 0) { + this.#logger.debug('No assets found in the state, synchronizing assets'); + // It is possible that the state is empty, due to the first sync. + // Hence, we synchronize the assets once. + await this.#assetMetadataService.synchronize(scope); + } + + const sep41Assets = + await this.#assetMetadataService.getPersistedSep41AssetsMetadata(scope); + + this.#logger.debug('SEP-41 assets to query balances for', { + noOfAssets: sep41Assets.length, + }); + + const assetIds: KnownCaip19Sep41AssetId[] = []; + const sep41AssetSymbols = sep41Assets.reduce< + Record + >((acc, asset) => { + const assetId = asset.assetId as KnownCaip19Sep41AssetId; + acc[assetId] = asset.symbol; + assetIds.push(assetId); + return acc; + }, {}); + + // One batched balance read: Stellar account id → balance per SEP-41 token id. + const sep41AssetBalancesByAccount = + await this.#networkService.getSep41AssetBalances({ + accounts: stellarAccountIds, + assetIds, + scope, + }); + + return { + assetIds, + symbolsByAssetId: sep41AssetSymbols, + balancesByAccountId: sep41AssetBalancesByAccount, + }; + } + + /** + * Applies fetched SEP-41 balances for one account and returns unresolved token ids. + * + * Returning `undefined` means the whole SEP-41 fetch step failed; merge will copy any missing + * persisted SEP-41 rows. Returning a set means the fetch step succeeded and merge should only + * restore rows for token ids still unresolved here. + * + * @param onChainAccount - In-memory account after classic Horizon load; receives nonzero SEP-41 rows. + * @param sep41BalanceFetchResult - Batch balance/symbol data from the SEP-41 step, or `null` if that step failed. + * @returns Token ids that could not be resolved to a balance (for merge from last snapshot), or `undefined` if the fetch step did not run. + */ + #setSep41BalancesForAccount( + onChainAccount: OnChainAccount, + sep41BalanceFetchResult: Sep41BalanceFetchResult | null, + ): Set | undefined { + if (sep41BalanceFetchResult === null) { + return undefined; + } + + const unresolvedSep41AssetIds = new Set(); + // Missing address entry: batch result had no map for this account (often an empty overall result). Not a throw — the call still resolved. + const sep41AssetBalances = + sep41BalanceFetchResult.balancesByAccountId[onChainAccount.accountId] ?? + {}; + for (const assetId of sep41BalanceFetchResult.assetIds) { + const balance = sep41AssetBalances[assetId]; + if (!sep41BalanceFetchResult.symbolsByAssetId[assetId]) { + continue; + } + // No balance value for this SEP-41 token — mark unresolved so the merge step can reuse the last snapshot row. + if (balance === null || balance === undefined) { + unresolvedSep41AssetIds.add(assetId); + continue; + } + // Balance is zero — user does not hold this SEP-41 token; do not add a row (merge will not revive it when the step succeeded). + if (balance.isZero()) { + continue; + } + onChainAccount.setSep41Asset(assetId, { + balance, + symbol: sep41BalanceFetchResult.symbolsByAssetId[assetId], + }); + } + + if (unresolvedSep41AssetIds.size > 0) { + this.#logger.debug('SEP-41 balances unresolved for account', { + accountId: onChainAccount.accountId, + unresolvedAssetIds: Array.from(unresolvedSep41AssetIds), + }); + } + + return unresolvedSep41AssetIds; + } + + /** + * Fills missing **SEP-41 token** rows on `current` using the **last saved snap snapshot** (`persisted`). + * This is normal persisted JSON state, not a temporary cache. + * + * Behaviour: + * - Only rows for SEP-41 tokens; skip tokens already on `current`. + * - If `unresolvedSep41AssetIds` is omitted (whole SEP-41 balance step failed): copy every matching persisted row still missing on `current`. + * - If it is a set (step ran): copy only persisted rows whose token id is in the set and still missing on `current`. + * + * @param current - In-memory account after classic load + any SEP-41 token balances from this run. + * @param persisted - Same account’s snapshot from before this sync (`null` if none). + * @param unresolvedSep41AssetIds - See “Behaviour” above. + * @returns `current` with allowed gaps filled from `persisted`. + */ + #mergePersistedSep41Rows( + current: OnChainAccount, + persisted: OnChainAccountSerializableFull | null, + unresolvedSep41AssetIds?: Set, + ): OnChainAccount { + if (!persisted) { + return current; + } + + for (const row of persisted.balances) { + const { assetId } = row; + if (!isSep41Id(assetId) || current.hasAsset(assetId)) { + continue; + } + + // This SEP-41 token is still missing on `current` after the balance step — restore the last saved row when allowed above. + if ( + unresolvedSep41AssetIds === undefined || + unresolvedSep41AssetIds.has(assetId) + ) { + current.setSep41Asset(assetId, { + balance: new BigNumber(row.balance), + symbol: row.symbol, + }); + } + } + + return current; + } + + /** + * Builds keyring event deltas: + * - `stateSnapshotOnChainAccount`: rehydrated account from the latest serialized state snapshot. + * - `synchronizedOnChainAccount`: in-memory account after merge (matches what was just serialized to state). + * + * @param stateSnapshotOnChainAccount - Latest account from state (`null` on first sync for this id/scope). + * @param synchronizedOnChainAccount - Bound account after Horizon + SEP-41 + merge. + * @returns Nullable payloads for balance and non-native asset-list deltas. + */ + #diffFullSnapshotsForKeyring( + stateSnapshotOnChainAccount: OnChainAccount | null, + synchronizedOnChainAccount: OnChainAccount, + ): { + balanceChanges: Record | null; + assetListChanges: AccountAssetListDelta | null; + } { + const nativeAssetId = getSlip44AssetId(synchronizedOnChainAccount.scope); + const assetIds = new Set([ + ...(stateSnapshotOnChainAccount?.assetIds ?? []), + ...synchronizedOnChainAccount.assetIds, + ]); + + const balanceChanges: Record = {}; + const addedAssets: AccountAssetListDelta['added'] = []; + const removedAssets: AccountAssetListDelta['removed'] = []; + + for (const assetId of assetIds) { + const latestStateRow = + stateSnapshotOnChainAccount === null + ? undefined + : stateSnapshotOnChainAccount.getAsset(assetId); + const currentRow = synchronizedOnChainAccount.getAsset(assetId); + const latestStateBalance = + latestStateRow === undefined + ? undefined + : latestStateRow.balance.toString(); + const currentBalance = + currentRow === undefined ? undefined : currentRow.balance.toString(); + + if (latestStateBalance !== currentBalance) { + balanceChanges[assetId as string] = { + unit: currentRow?.symbol ?? latestStateRow?.symbol ?? '', + amount: currentBalance ?? '0', + }; + } + + if (assetId === nativeAssetId) { + continue; + } + if ( + synchronizedOnChainAccount.hasAsset(assetId) && + !stateSnapshotOnChainAccount?.hasAsset(assetId) + ) { + addedAssets.push(assetId); + } + if ( + stateSnapshotOnChainAccount?.hasAsset(assetId) && + !synchronizedOnChainAccount.hasAsset(assetId) + ) { + removedAssets.push(assetId); + } + } + + return { + balanceChanges: + Object.keys(balanceChanges).length > 0 ? balanceChanges : null, + assetListChanges: + addedAssets.length > 0 || removedAssets.length > 0 + ? { + added: addedAssets, + removed: removedAssets, + } + : null, + }; + } + + async #emitKeyringEvents( + balancesPayload: + | KeyringEventPayload['balances'] + | null, + assetsPayload: + | KeyringEventPayload['assets'] + | null, + ): Promise { + try { + if (balancesPayload !== null) { + await emitSnapKeyringEvent( + getSnapProvider(), + KeyringEvent.AccountBalancesUpdated, + { balances: balancesPayload }, + ); + } + if (assetsPayload !== null) { + await emitSnapKeyringEvent( + getSnapProvider(), + KeyringEvent.AccountAssetListUpdated, + { assets: assetsPayload }, + ); + } + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to emit keyring events after synchronize', + error, + ); + } + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts index 7cf87e0a..d47e550f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts @@ -6,10 +6,12 @@ import type { KnownCaip2ChainId } from '../../../api'; import { logger } from '../../../utils/logger'; import { AccountService } from '../../account/AccountService'; import { AccountsRepository } from '../../account/AccountsRepository'; +import { createMockAssetMetadataService } from '../../asset-metadata/__mocks__/assets.fixtures'; import { NetworkService } from '../../network'; import { State } from '../../state/State'; import { WalletService } from '../../wallet'; import { OnChainAccount } from '../OnChainAccount'; +import { OnChainAccountRepository } from '../OnChainAccountRepository'; import type { OnChainAccountMinimalSerializable, OnChainAccountSerializable, @@ -153,7 +155,7 @@ export function mockOnChainAccountService() { encrypted: false, defaultState: { keyringAccounts: {}, - accountMetadata: {}, + onChainAccounts: {}, }, }); const accountService = new AccountService({ @@ -162,10 +164,18 @@ export function mockOnChainAccountService() { walletService, }); const networkService = new NetworkService({ logger }); - const onChainAccountService = new OnChainAccountService({ networkService }); + const onChainAccountRepository = new OnChainAccountRepository(state); + const { service: assetMetadataService } = createMockAssetMetadataService(); + const onChainAccountService = new OnChainAccountService({ + logger, + networkService, + onChainAccountRepository, + assetMetadataService, + }); return { onChainAccountService, + onChainAccountRepository, accountService, walletService, }; diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts index a170e767..34b86f56 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts @@ -1,3 +1,4 @@ +import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; import type { KnownCaip2ChainId } from '../../api'; /** Per-asset view: native, classic trustline (limit + issuer in `address`), or SEP-41. */ @@ -10,37 +11,17 @@ export type SpendableBalance = { sponsored?: boolean; }; -/** Ledger fields used for native reserve / spendable math (Horizon or persisted snapshot). */ -export type OnChainAccountLedgerMeta = { - subentryCount: number; - numSponsoring: number; - numSponsored: number; -}; - -/** - * Persisted on-chain account header fields for one keyring account on one network, refreshed on sync. - * Does not include trustline balances (see `accountBalances` state). - */ -export type OnChainAccountSnapshot = { - accountId: string; - sequenceNumber: string; - subentryCount: number; - numSponsoring: number; - numSponsored: number; - /** Unix ms when this row was written to snap state. */ - persistedAt?: number; -}; +type AccountId = string; -/** `accountMetadata[keyringAccountId][scope]` → last synced {@link OnChainAccountSnapshot}. */ +/** `onChainAccounts[keyringAccountId][scope]` → last synced snapshot. */ export type OnChainAccountSnapshotsByKeyringId = Record< - string, - Partial> + AccountId, + Partial> >; /** - * Snap state slice for cached on-chain account snapshots. - * The root key stays `accountMetadata` for persisted snap state compatibility. + * Snap state slice for cached on-chain account snapshots (persisted under `onChainAccounts`). */ -export type OnChainAccountSnapshotState = { - accountMetadata: OnChainAccountSnapshotsByKeyringId; +export type OnChainAccountState = { + onChainAccounts: OnChainAccountSnapshotsByKeyringId; }; diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts index d7f0a807..19b69b96 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts @@ -1,4 +1,6 @@ export type * from './api'; export type * from './OnChainAccountSerializable'; export * from './OnChainAccount'; +export * from './OnChainAccountRepository'; export * from './OnChainAccountService'; +export * from './OnChainAccountSynchronizeService'; From b6f3705e99e4cbbee287983435cdadf1853718a0 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:09:16 +0800 Subject: [PATCH 115/384] chore: update code comment --- .../on-chain-account/OnChainAccountSynchronizeService.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts index 9b44ea5d..b048ef81 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts @@ -354,7 +354,6 @@ export class OnChainAccountSynchronizeService { } const unresolvedSep41AssetIds = new Set(); - // Missing address entry: batch result had no map for this account (often an empty overall result). Not a throw — the call still resolved. const sep41AssetBalances = sep41BalanceFetchResult.balancesByAccountId[onChainAccount.accountId] ?? {}; From 16a7aecb9ac6094b761c017e3aa3a1af07c63998 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:13:14 +0800 Subject: [PATCH 116/384] chore: update asset service --- .../asset-metadata/AssetMetadataRepository.ts | 18 ++++++++++++++++++ .../asset-metadata/AssetMetadataService.ts | 12 ++++++++++++ 2 files changed, 30 insertions(+) diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts index 5edc45b5..c0e7268f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts @@ -73,6 +73,24 @@ export class AssetMetadataRepository { ); } + /** + * Returns all persisted assets for the given scope. + * + * @param scope - The chain ID to look up. + * @returns A Promise that resolves to all persisted assets for the given scope. + */ + async getAllByScope( + scope: KnownCaip2ChainId, + ): Promise { + const assets = + (await this.#state.getKey(this.#stateKey)) ?? {}; + + return Object.values(assets).filter( + (asset): asset is StellarAssetMetadata => + asset !== undefined && asset.chainId === scope, + ); + } + /** * Returns persisted assets for the given asset type and chain ID. * diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts index 82475df0..334a3c97 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -135,6 +135,18 @@ export class AssetMetadataService { return persistedAssets; } + /** + * Returns all persisted assets for the given scope. + * + * @param scope - The chain ID to look up. + * @returns A Promise that resolves to all persisted assets for the given scope. + */ + async getAllByScope( + scope: KnownCaip2ChainId, + ): Promise { + return this.#assetMetadataRepository.getAllByScope(scope); + } + /** * Fetches and persists all Assets for the given chain ID from the token API. * From fe6e6c1e8a76cb5bfff7a720fac9aa6c9b586b73 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:16:47 +0800 Subject: [PATCH 117/384] chore: address comment --- .../src/services/network/MultiCall.ts | 18 +++++++--- .../src/services/network/NetworkService.ts | 5 ++- .../OnChainAccountRepository.ts | 10 ++++-- .../OnChainAccountService.test.ts | 10 +++--- .../on-chain-account/OnChainAccountService.ts | 17 +++++----- .../OnChainAccountSynchronizeService.test.ts | 26 +++++++------- .../OnChainAccountSynchronizeService.ts | 34 +++++++++---------- .../src/services/on-chain-account/api.ts | 4 +-- 8 files changed, 71 insertions(+), 53 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts b/merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts index 6ea21d73..2e349317 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts @@ -2,7 +2,6 @@ import { Account, Address, Contract, - Networks, type Operation, rpc, scValToNative, @@ -11,6 +10,10 @@ import { xdr, } from '@stellar/stellar-sdk'; +import { caip2ChainIdToNetwork } from './utils'; +import { KnownCaip2ChainId } from '../../api/network'; +import { BASE_FEE } from '../../constants'; + /** * A simulation account to craft a transaction to simulate the balances read * It is a funded account to prevent the transaction from failing due to insufficient funds. @@ -137,20 +140,27 @@ export class MultiCall { * Simulates a multicall and returns the decoded result value. * * @param invocations - Invocations to batch. - * @param opts - Optional caller and source account overrides. + * @param opts - Optional caller, source account and scope overrides. * @param opts.caller - Account that authorizes the host function call; defaults to the simulation account. * @param opts.source - Transaction `source` account; defaults to the simulation account. + * @param opts.scope - CAIP-2 network ID; defaults to Mainnet. * @returns The simulation result as a native value. */ async simResult( invocations: (InvocationV1 | InvocationV0)[], - opts?: { caller?: string; source?: string }, + opts?: { caller?: string; source?: string; scope?: KnownCaip2ChainId }, ): Promise { const sourceAccount = opts?.source ?? this.#simulationAccount; const callerAccount = opts?.caller ?? this.#simulationAccount; + const scope = opts?.scope ?? KnownCaip2ChainId.Mainnet; const tx: Transaction = new TransactionBuilder( + // The account sequence number is not used for the simulation, + // so we can safely set it to 0. new Account(sourceAccount, '0'), - { networkPassphrase: Networks.PUBLIC, fee: '0' }, + { + networkPassphrase: caip2ChainIdToNetwork(scope), + fee: BASE_FEE.toString(), + }, ) .setTimeout(0) .addOperation(this.exec(callerAccount, invocations)) diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index 677f1dce..3b600d46 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -445,6 +445,7 @@ export class NetworkService { return {}; } + // Multicall is not supported on testnet. if (scope === KnownCaip2ChainId.Testnet) { return {}; } @@ -473,7 +474,9 @@ export class NetworkService { } const totalRecords = accounts.length * assetIds.length; - const simResults: unknown[] = await multiCall.simResult(invocations); + const simResults: unknown[] = await multiCall.simResult(invocations, { + scope, + }); if (simResults.length !== totalRecords) { throw new NetworkServiceException( diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts index e9e89f42..1de5f4da 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts @@ -18,15 +18,17 @@ export class OnChainAccountRepository { } /** + * Find the on-chain account for the given keyring account id from the State. + * * @param keyringAccountId - MetaMask keyring account id (not the Stellar G-address). * @param scope - CAIP-2 chain id for the cached snapshot. * @returns The stored snapshot, or `null` when none exists for this keyring id and scope. */ - async findByAccountId( + async findByKeyringAccountId( keyringAccountId: string, scope: KnownCaip2ChainId, ): Promise { - const snapshotsByAccountId = await this.findByAccountIds( + const snapshotsByAccountId = await this.findByKeyringAccountIds( [keyringAccountId], scope, ); @@ -35,11 +37,13 @@ export class OnChainAccountRepository { } /** + * Find the on-chain accounts for the given keyring account ids from the State. + * * @param keyringAccountIds - MetaMask keyring account ids (not Stellar G-addresses). * @param scope - CAIP-2 chain id for the cached snapshots. * @returns Account id -> snapshot (or `null` when missing for the given scope). */ - async findByAccountIds( + async findByKeyringAccountIds( keyringAccountIds: string[], scope: KnownCaip2ChainId, ): Promise> { diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 93d977d4..b87beeeb 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -143,19 +143,19 @@ describe('OnChainAccountService', () => { }); }); - describe('resolveOnChainAccountByAccountId', () => { + describe('resolveOnChainAccountByKeyringAccountId', () => { it('returns null when no snapshot exists for the keyring id and scope', async () => { const keyringAccountId = globalThis.crypto.randomUUID(); const { onChainAccountService, onChainAccountRepository } = mockOnChainAccountService(); const findByAccountIdSpy = jest.spyOn( onChainAccountRepository, - 'findByAccountId', + 'findByKeyringAccountId', ); findByAccountIdSpy.mockResolvedValue(null); const result = - await onChainAccountService.resolveOnChainAccountByAccountId( + await onChainAccountService.resolveOnChainAccountByKeyringAccountId( keyringAccountId, KnownCaip2ChainId.Mainnet, ); @@ -184,12 +184,12 @@ describe('OnChainAccountService', () => { mockOnChainAccountService(); const findByAccountIdSpy = jest.spyOn( onChainAccountRepository, - 'findByAccountId', + 'findByKeyringAccountId', ); findByAccountIdSpy.mockResolvedValue(binding); const result = - await onChainAccountService.resolveOnChainAccountByAccountId( + await onChainAccountService.resolveOnChainAccountByKeyringAccountId( keyringAccountId, KnownCaip2ChainId.Mainnet, ); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 1a8b626c..d8c069ff 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -89,14 +89,15 @@ export class OnChainAccountService { * @param scope - The CAIP-2 chain id to load the on-chain account for. * @returns The on-chain account, or `null` if not found. */ - async resolveOnChainAccountByAccountId( + async resolveOnChainAccountByKeyringAccountId( keyringAccountId: string, scope: KnownCaip2ChainId, ): Promise { - const onChainAccount = await this.#onChainAccountRepository.findByAccountId( - keyringAccountId, - scope, - ); + const onChainAccount = + await this.#onChainAccountRepository.findByKeyringAccountId( + keyringAccountId, + scope, + ); return onChainAccount ? OnChainAccount.fromSerializable(onChainAccount) : null; @@ -106,15 +107,15 @@ export class OnChainAccountService { * Enriches accounts with SEP-41 balances, persists snapshots, then notifies the keyring when * balances or the tracked asset set changed. Delegates to {@link OnChainAccountSynchronizeService}. * - * @param keyringAccount - Stellar keyring accounts to sync for `scope`. + * @param keyringAccounts - Stellar keyring accounts to sync for `scope`. * @param scope - CAIP-2 network. */ async synchronize( - keyringAccount: StellarKeyringAccount[], + keyringAccounts: StellarKeyringAccount[], scope: KnownCaip2ChainId, ): Promise { await this.#onChainAccountSynchronizeService.synchronize( - keyringAccount, + keyringAccounts, scope, ); } diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts index 8b02b3ad..275d0cbd 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts @@ -31,7 +31,7 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ emitSnapKeyringEvent: jest.fn(), })); -describe('OnChainAccountService.synchronize', () => { +describe('OnChainAccountSynchronizeService', () => { const seed = hexToBytes( '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', ); @@ -54,9 +54,9 @@ describe('OnChainAccountService.synchronize', () => { typeof mockOnChainAccountService >['onChainAccountRepository'], ) => ({ - findByAccountIdsSpy: jest.spyOn( + findByKeyringAccountIdsSpy: jest.spyOn( onChainAccountRepository, - 'findByAccountIds', + 'findByKeyringAccountIds', ), saveManySpy: jest.spyOn(onChainAccountRepository, 'saveMany'), }); @@ -168,7 +168,7 @@ describe('OnChainAccountService.synchronize', () => { ); const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); - const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + const { onChainAccountService, findByKeyringAccountIdsSpy, saveManySpy } = setupSynchronizeService(); await onChainAccountService.synchronize( @@ -177,7 +177,7 @@ describe('OnChainAccountService.synchronize', () => { ); expect(getSep41AssetBalancesSpy).not.toHaveBeenCalled(); - expect(findByAccountIdsSpy).not.toHaveBeenCalled(); + expect(findByKeyringAccountIdsSpy).not.toHaveBeenCalled(); expect(saveManySpy).not.toHaveBeenCalled(); expect(emitSnapKeyringEventSpy).not.toHaveBeenCalled(); }); @@ -233,9 +233,9 @@ describe('OnChainAccountService.synchronize', () => { loadOnChainAccountSpy.mockResolvedValue(onChainAccount); const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); - const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + const { onChainAccountService, findByKeyringAccountIdsSpy, saveManySpy } = setupSynchronizeService(); - findByAccountIdsSpy.mockResolvedValue({ + findByKeyringAccountIdsSpy.mockResolvedValue({ [keyringAccount.id]: binding, }); @@ -300,9 +300,9 @@ describe('OnChainAccountService.synchronize', () => { }); const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); - const { onChainAccountService, findByAccountIdsSpy } = + const { onChainAccountService, findByKeyringAccountIdsSpy } = setupSynchronizeService(); - findByAccountIdsSpy.mockResolvedValue({ + findByKeyringAccountIdsSpy.mockResolvedValue({ [keyringAccount.id]: withSep, }); loadOnChainAccountSpy.mockResolvedValue(onChainAccount); @@ -360,9 +360,9 @@ describe('OnChainAccountService.synchronize', () => { loadOnChainAccountSpy.mockResolvedValue(onChainAccount); const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); - const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + const { onChainAccountService, findByKeyringAccountIdsSpy, saveManySpy } = setupSynchronizeService(); - findByAccountIdsSpy.mockResolvedValue({ + findByKeyringAccountIdsSpy.mockResolvedValue({ [keyringAccount.id]: withPersistedSep41, }); @@ -403,9 +403,9 @@ describe('OnChainAccountService.synchronize', () => { }); loadOnChainAccountSpy.mockResolvedValue(onChainAccount); - const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + const { onChainAccountService, findByKeyringAccountIdsSpy, saveManySpy } = setupSynchronizeService(); - findByAccountIdsSpy.mockResolvedValue({ + findByKeyringAccountIdsSpy.mockResolvedValue({ [keyringAccount.id]: withPersistedBackupSep41, }); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts index b048ef81..f8e513b4 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts @@ -44,7 +44,7 @@ type Sep41BalanceFetchResult = { * Persists on-chain account snapshots and emits keyring balance / asset-list events after a sync. * * {@link synchronize} uses a mutex so overlapping syncs cannot interleave read–merge–write across - * `findByAccountIds` and `saveMany`. Each `saveMany` call is still one atomic `IStateManager.update`. + * `findByKeyringAccountIds` and `saveMany`. Each `saveMany` call is still one atomic `IStateManager.update`. */ export class OnChainAccountSynchronizeService { readonly #networkService: NetworkService; @@ -133,27 +133,28 @@ export class OnChainAccountSynchronizeService { stellarAccountIds, scope, ); - } catch { - this.#logger.debug( + } catch (error: unknown) { + this.#logger.logErrorWithDetails( 'SEP-41 token balance step failed; merge will reuse last-saved SEP-41 token rows where needed', + error, ); } // 3. Snap state: latest serialized snapshots before this run (merge source + keyring diff baseline). this.#logger.debug('Load latest state snapshots for on-chain accounts'); - const latestSerializedAccountSnaphotByKeyringId = - await this.#onChainAccountRepository.findByAccountIds( + const latestSerializedAccountSnapshotByKeyringId = + await this.#onChainAccountRepository.findByKeyringAccountIds( keyringAccountIds, scope, ); const lengthOfSnapshot = Object.keys( - latestSerializedAccountSnaphotByKeyringId, - ).length; + latestSerializedAccountSnapshotByKeyringId, + ).filter((snapshot) => snapshot !== null).length; this.#logger.debug( 'Loaded latest state snapshots for on-chain accounts - no of accounts loaded', { noOfAccounts: lengthOfSnapshot, - newActivedAccountPairs: + newActivatedAccountPairs: activatedAccountPairs.length - lengthOfSnapshot, }, ); @@ -178,7 +179,7 @@ export class OnChainAccountSynchronizeService { } of activatedAccountPairs) { const keyringAccountId = keyringAccount.id; const latestStateSnapshotSerialized = - latestSerializedAccountSnaphotByKeyringId[keyringAccountId] ?? null; + latestSerializedAccountSnapshotByKeyringId[keyringAccountId] ?? null; const stateSnapshotOnChainAccount = latestStateSnapshotSerialized === null ? null @@ -199,7 +200,7 @@ export class OnChainAccountSynchronizeService { ); const { balanceChanges, assetListChanges } = - this.#diffFullSnapshotsForKeyring( + this.#computeKeyringSyncDeltas( stateSnapshotOnChainAccount, synchronizedOnChainAccount, ); @@ -432,15 +433,14 @@ export class OnChainAccountSynchronizeService { } /** - * Builds keyring event deltas: - * - `stateSnapshotOnChainAccount`: rehydrated account from the latest serialized state snapshot. - * - `synchronizedOnChainAccount`: in-memory account after merge (matches what was just serialized to state). + * Compares persisted on-chain state to the account after this sync and produces keyring + * event data: per-asset balance updates (all assets) and non-native token add/remove. * - * @param stateSnapshotOnChainAccount - Latest account from state (`null` on first sync for this id/scope). - * @param synchronizedOnChainAccount - Bound account after Horizon + SEP-41 + merge. - * @returns Nullable payloads for balance and non-native asset-list deltas. + * @param stateSnapshotOnChainAccount - Last saved account from state, or `null` when none exists. + * @param synchronizedOnChainAccount - Same account after Horizon, SEP-41, and merge steps. + * @returns `balanceChanges` and/or `assetListChanges`, each `null` when that side is unchanged. */ - #diffFullSnapshotsForKeyring( + #computeKeyringSyncDeltas( stateSnapshotOnChainAccount: OnChainAccount | null, synchronizedOnChainAccount: OnChainAccount, ): { diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts index 34b86f56..48895e39 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts @@ -11,11 +11,11 @@ export type SpendableBalance = { sponsored?: boolean; }; -type AccountId = string; +type KeyringAccountId = string; /** `onChainAccounts[keyringAccountId][scope]` → last synced snapshot. */ export type OnChainAccountSnapshotsByKeyringId = Record< - AccountId, + KeyringAccountId, Partial> >; From bdd922d1d394184cb7e99538116183ef242f9b08 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Tue, 28 Apr 2026 11:58:59 +0200 Subject: [PATCH 118/384] fix: fix comments --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/keyring/api.test.ts | 25 ++++- .../src/handlers/keyring/api.ts | 13 ++- .../src/handlers/keyring/base.ts | 92 +++++++++---------- .../src/handlers/keyring/signMessage.test.ts | 22 +---- 5 files changed, 72 insertions(+), 82 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 2d391ed3..e726502a 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "nvnxyWTg42pCokBi15iaDY6EIs38qXfzuHqROi9GiIA=", + "shasum": "mhMVx1iiCgiiZTCg2eW49BoiOMuiWGSwlGTjNxR5QB4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts index e17efe9d..c5176d96 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts @@ -210,7 +210,7 @@ describe('SignMessageRequestStruct', () => { ).not.toThrow(); }); - it('accepts an SEP-43 opts bag with address and networkPassphrase', () => { + it('accepts an SEP-43 opts bag with networkPassphrase', () => { expect(() => assert( { @@ -220,7 +220,6 @@ describe('SignMessageRequestStruct', () => { params: { message: btoa('Hello, world!'), opts: { - address: account.address, networkPassphrase: 'Public Global Stellar Network ; September 2015', }, @@ -232,6 +231,24 @@ describe('SignMessageRequestStruct', () => { ).not.toThrow(); }); + it('rejects opts.address (signer is determined by the keyring account UUID)', () => { + expect(() => + assert( + { + ...validSignMessageRequest, + request: { + method: MultichainMethod.SignMessage, + params: { + message: btoa('Hello, world!'), + opts: { address: account.address }, + }, + }, + }, + SignMessageRequestStruct, + ), + ).toThrow(StructError); + }); + it.each([ { ...validSignMessageRequest, @@ -320,7 +337,7 @@ describe('SignTransactionRequestStruct', () => { ).not.toThrow(); }); - it('accepts an SEP-43 opts bag with address', () => { + it('rejects opts.address (signer is determined by the keyring account UUID)', () => { expect(() => assert( { @@ -332,7 +349,7 @@ describe('SignTransactionRequestStruct', () => { }, SignTransactionRequestStruct, ), - ).not.toThrow(); + ).toThrow(StructError); }); it.each([ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts index b4e7b189..33d5bbc8 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts @@ -96,8 +96,12 @@ export const DiscoverAccountsStruct = object({ * Network and submission constraints are enforced at the struct level: * - `networkPassphrase`, when provided, must map to Stellar mainnet via * {@link networkToCaip2ChainId}. - * - `submit` and `submitUrl` are declared as `never` so any present value - * fails validation with -3 InvalidRequest — the snap is sign-only. + * - `submit` / `submitUrl` are not declared, so superstruct rejects them + * as unknown keys with -3 InvalidRequest — the snap is sign-only. + * - `address` is not declared. The MetaMask keyring framework has already + * mapped the dapp's selection to a UUID; we trust that as the source of + * truth and ignore any dapp-supplied `opts.address` to prevent a + * redirected signer. * * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md */ @@ -113,7 +117,6 @@ export const Sep43OptsStruct = object({ } }), ), - address: optional(StellarAddressStruct), }); export type Sep43Opts = Infer; @@ -184,8 +187,8 @@ export const SignMessageResponseStruct = union([ * Validation struct for the signTransaction request. * * Params follow the SEP-43 `SignTransaction` shape: a base64-encoded - * transaction envelope XDR and the optional `opts` bag (`address`, - * `networkPassphrase`). + * transaction envelope XDR and the optional `opts` bag + * (`networkPassphrase`). */ export const SignTransactionRequestStruct = assign( KeyringRequestStruct, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts index 3b27501d..92206d4b 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts @@ -2,7 +2,8 @@ import type { Struct } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; import type { Sep43ErrorEnvelope, Sep43Opts } from './api'; -import { Sep43Error, Sep43ErrorCode, toSep43Error } from './exceptions'; +import type { Sep43Error } from './exceptions'; +import { toSep43Error } from './exceptions'; import type { KnownCaip2ChainId } from '../../api'; import type { AccountService, @@ -12,6 +13,7 @@ import type { Wallet, WalletService } from '../../services/wallet'; import type { ILogger } from '../../utils'; import { createPrefixedLogger } from '../../utils'; import { validateRequest, validateResponse } from '../../utils/requestResponse'; +import { BaseHandler } from '../base'; /** * Interface for the client request handler. @@ -24,24 +26,23 @@ export type IKeyringRequestHandler = { * Base class shared by the SEP-43 SignMessage and SignTransaction keyring * handlers. * - * Provides common cross-cutting concerns: validates `opts.networkPassphrase` - * (mainnet only), validates `scope` is mainnet, forbids `submit` / `submitUrl` - * (snap is sign-only), resolves the keyring account by `opts.address` when - * provided (otherwise falls back to the wrapper's `account` UUID), and wraps - * thrown errors into the SEP-43 `error` envelope so the dapp always receives a - * well-formed payload. + * Extends {@link BaseHandler} for codebase consistency (inherits + * `logger` / `requestStruct` / `responseStruct`) but overrides `handle()`: + * `BaseHandler.handle()` throws on validation/handler errors, whereas SEP-43 + * must serialize every failure into the response `error` envelope so the + * dapp always receives a well-formed payload. * * SEP-43 is a sign-only protocol — no on-chain activation check is performed. * The dapp is responsible for ensuring the account exists on-chain before * constructing the transaction or message. * * Subclasses implement {@link execute} which performs the wallet signing and - * returns the success-shaped fields. They never throw to the dapp directly. + * returns the success-shaped fields. * * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md */ export abstract class BaseSep43KeyringHandler< - Request extends { + Request extends Json & { scope: KnownCaip2ChainId; account: string; origin: string; @@ -51,17 +52,14 @@ export abstract class BaseSep43KeyringHandler< signerAddress: string; error?: Sep43ErrorEnvelope; }, -> implements IKeyringRequestHandler { - protected readonly logger: ILogger; - +> + extends BaseHandler + implements IKeyringRequestHandler +{ protected readonly accountService: AccountService; protected readonly walletService: WalletService; - protected readonly requestStruct: Struct; - - protected readonly responseStruct: Struct; - constructor({ logger, accountService, @@ -77,11 +75,13 @@ export abstract class BaseSep43KeyringHandler< requestStruct: Struct; responseStruct: Struct; }) { - this.logger = createPrefixedLogger(logger, loggerPrefix); + super({ + logger: createPrefixedLogger(logger, loggerPrefix), + requestStruct, + responseStruct, + }); this.accountService = accountService; this.walletService = walletService; - this.requestStruct = requestStruct; - this.responseStruct = responseStruct; } /** @@ -91,33 +91,16 @@ export abstract class BaseSep43KeyringHandler< * never sees a thrown JSON-RPC error. * * @param rawRequest - The unvalidated keyring request as forwarded by - * `KeyringHandler.submitRequest` (or the dev `stellar_*` RPC aliases). + * `KeyringHandler.submitRequest`. * @returns The SEP-43 response with either the success fields or `error` * populated. */ - async handle(rawRequest: Json): Promise { + override async handle(rawRequest: Json): Promise { let signerAddress = ''; try { - // Check submit/submitUrl on the raw JSON before validateRequest coerces - // the opts struct and strips unknown fields. The snap is sign-only. - const rawOpts = ( - (rawRequest as Record)?.request as - | Record - | undefined - )?.params as Record | undefined; - const opts = rawOpts?.opts as Record | undefined; - if (opts?.submit !== undefined || opts?.submitUrl !== undefined) { - throw new Sep43Error({ - code: Sep43ErrorCode.InvalidRequest, - ext: ['This wallet does not submit transactions; use sign only.'], - }); - } - const request = validateRequest(rawRequest, this.requestStruct); - const { account, wallet } = await this.resolveAccount(request); signerAddress = account.address; - const result = await this.execute(request, { account, wallet }); validateResponse(result, this.responseStruct); return result; @@ -128,6 +111,20 @@ export abstract class BaseSep43KeyringHandler< } } + /** + * Implements {@link BaseHandler.handleRequest}. Not on the SEP-43 hot path + * (the {@link handle} override calls {@link execute} directly so that + * `signerAddress` can be captured for the error envelope), but kept as a + * sane delegation for any caller that invokes `super.handle()`. + * + * @param request - The validated request. + * @returns The execute result. + */ + protected async handleRequest(request: Request): Promise { + const { account, wallet } = await this.resolveAccount(request); + return await this.execute(request, { account, wallet }); + } + /** * Subclass hook: do the actual signing. * @@ -153,9 +150,10 @@ export abstract class BaseSep43KeyringHandler< ): Response; /** - * Resolves the signing account. - * Prefers `opts.address` when provided; otherwise uses the wrapper's - * `account` UUID. When both are present, the resolved address must match. + * Resolves the signing account by the keyring `account` UUID. The keyring + * framework has already mapped the dapp's selection to a UUID, so we trust + * it as the single source of truth — `opts.address` is intentionally not + * honored to avoid letting the dapp redirect the signer. * * @param request - The keyring request. * @returns The resolved keyring account and signing wallet. @@ -163,16 +161,8 @@ export abstract class BaseSep43KeyringHandler< protected async resolveAccount( request: Request, ): Promise<{ account: StellarKeyringAccount; wallet: Wallet }> { - const { account: accountId, scope } = request; - const optsAddress = request.request.params.opts?.address; - - const { account } = optsAddress - ? await this.accountService.resolveAccount({ - scope, - accountAddress: optsAddress, - }) - : await this.accountService.resolveAccount({ accountId }); - + const { account: accountId } = request; + const { account } = await this.accountService.resolveAccount({ accountId }); const wallet = await this.walletService.resolveWallet(account); return { account, wallet }; } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts index d6015184..fd1a2000 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts @@ -9,7 +9,6 @@ import { generateStellarKeyringAccount, mockAccountService, } from '../../services/account/__mocks__/account.fixtures'; -import { AccountNotFoundException } from '../../services/account/exceptions'; import { WalletService } from '../../services/wallet'; import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; import type { ConfirmationUXController } from '../../ui/confirmation/controller'; @@ -36,7 +35,7 @@ describe('SignMessageHandler', () => { const { accountService, walletService } = mockAccountService(); - const resolveAccountSpy = jest + jest .spyOn(AccountService.prototype, 'resolveAccount') .mockResolvedValue({ account: mockAccount }); @@ -64,7 +63,6 @@ describe('SignMessageHandler', () => { mockAccount, wallet, renderConfirmationDialog, - resolveAccountSpy, }; } @@ -167,24 +165,6 @@ describe('SignMessageHandler', () => { expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); - it('returns error -3 when opts.address cannot be resolved', async () => { - const { handler, mockAccount, resolveAccountSpy } = setupHandler(); - const unknownAddress = - 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB'; - resolveAccountSpy.mockRejectedValueOnce( - new AccountNotFoundException(unknownAddress), - ); - - const result = await handler.handle( - buildRequest(mockAccount.id, { opts: { address: unknownAddress } }), - ); - - expect(result).toMatchObject({ - signedMessage: '', - error: { code: Sep43ErrorCode.InvalidRequest }, - }); - }); - it('signs a non-base64 string as UTF-8 text', async () => { const { handler, mockAccount, wallet, renderConfirmationDialog } = setupHandler(); From a4d537126539148040ae29d962d361c122255a00 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Tue, 28 Apr 2026 12:19:19 +0200 Subject: [PATCH 119/384] fix: drop opts.address resolve path --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/keyring/api.test.ts | 25 +++---------------- .../src/handlers/keyring/api.ts | 14 +++++------ .../src/handlers/keyring/base.ts | 10 +++++--- .../src/handlers/keyring/signMessage.test.ts | 22 +++++++++++++++- 5 files changed, 39 insertions(+), 34 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index e726502a..52e63e9d 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "mhMVx1iiCgiiZTCg2eW49BoiOMuiWGSwlGTjNxR5QB4=", + "shasum": "Ic0IEg5Tjc7zzJEhl94Oa+/d8UOzP5YUi5tXw6FUjv8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts index c5176d96..e17efe9d 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts @@ -210,7 +210,7 @@ describe('SignMessageRequestStruct', () => { ).not.toThrow(); }); - it('accepts an SEP-43 opts bag with networkPassphrase', () => { + it('accepts an SEP-43 opts bag with address and networkPassphrase', () => { expect(() => assert( { @@ -220,6 +220,7 @@ describe('SignMessageRequestStruct', () => { params: { message: btoa('Hello, world!'), opts: { + address: account.address, networkPassphrase: 'Public Global Stellar Network ; September 2015', }, @@ -231,24 +232,6 @@ describe('SignMessageRequestStruct', () => { ).not.toThrow(); }); - it('rejects opts.address (signer is determined by the keyring account UUID)', () => { - expect(() => - assert( - { - ...validSignMessageRequest, - request: { - method: MultichainMethod.SignMessage, - params: { - message: btoa('Hello, world!'), - opts: { address: account.address }, - }, - }, - }, - SignMessageRequestStruct, - ), - ).toThrow(StructError); - }); - it.each([ { ...validSignMessageRequest, @@ -337,7 +320,7 @@ describe('SignTransactionRequestStruct', () => { ).not.toThrow(); }); - it('rejects opts.address (signer is determined by the keyring account UUID)', () => { + it('accepts an SEP-43 opts bag with address', () => { expect(() => assert( { @@ -349,7 +332,7 @@ describe('SignTransactionRequestStruct', () => { }, SignTransactionRequestStruct, ), - ).toThrow(StructError); + ).not.toThrow(); }); it.each([ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts index 33d5bbc8..c22ca500 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts @@ -93,15 +93,14 @@ export const DiscoverAccountsStruct = object({ /** * Optional bag accepted by both SEP-43 sign methods. * - * Network and submission constraints are enforced at the struct level: * - `networkPassphrase`, when provided, must map to Stellar mainnet via * {@link networkToCaip2ChainId}. + * - `address` is accepted for SEP-43 spec compliance but NOT used for + * signer resolution. MetaMask's keyring controller has already mapped + * `opts.address` to the keyring `account` UUID before the request reaches + * this snap, so we trust the UUID as the single source of truth. * - `submit` / `submitUrl` are not declared, so superstruct rejects them * as unknown keys with -3 InvalidRequest — the snap is sign-only. - * - `address` is not declared. The MetaMask keyring framework has already - * mapped the dapp's selection to a UUID; we trust that as the source of - * truth and ignore any dapp-supplied `opts.address` to prevent a - * redirected signer. * * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md */ @@ -117,6 +116,7 @@ export const Sep43OptsStruct = object({ } }), ), + address: optional(StellarAddressStruct), }); export type Sep43Opts = Infer; @@ -187,8 +187,8 @@ export const SignMessageResponseStruct = union([ * Validation struct for the signTransaction request. * * Params follow the SEP-43 `SignTransaction` shape: a base64-encoded - * transaction envelope XDR and the optional `opts` bag - * (`networkPassphrase`). + * transaction envelope XDR and the optional `opts` bag (`address`, + * `networkPassphrase`). */ export const SignTransactionRequestStruct = assign( KeyringRequestStruct, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts index 92206d4b..a69df45b 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts @@ -150,10 +150,12 @@ export abstract class BaseSep43KeyringHandler< ): Response; /** - * Resolves the signing account by the keyring `account` UUID. The keyring - * framework has already mapped the dapp's selection to a UUID, so we trust - * it as the single source of truth — `opts.address` is intentionally not - * honored to avoid letting the dapp redirect the signer. + * Resolves the signing account by the keyring `account` UUID. MetaMask's + * keyring controller has already used `opts.address` (when provided by the + * dapp) to route to the right account before this snap sees the request, + * so the UUID is the single source of truth. We accept `opts.address` in + * the struct for SEP-43 spec compliance but intentionally do not honor it + * here, to avoid letting the dapp redirect the signer. * * @param request - The keyring request. * @returns The resolved keyring account and signing wallet. diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts index fd1a2000..a439eb51 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signMessage.test.ts @@ -1,4 +1,4 @@ -import { Networks } from '@stellar/stellar-sdk'; +import { Keypair, Networks } from '@stellar/stellar-sdk'; import { MultichainMethod, type SignMessageRequest } from './api'; import { Sep43ErrorCode } from './exceptions'; @@ -181,4 +181,24 @@ describe('SignMessageHandler', () => { signerAddress: wallet.address, }); }); + + it('ignores opts.address: signer is always determined by the keyring account UUID', async () => { + const { handler, mockAccount, wallet, renderConfirmationDialog } = + setupHandler(); + renderConfirmationDialog.mockResolvedValue(true); + + // A different (well-formed) Stellar address that the dapp might pass — + // MetaMask routed to `mockAccount` via the UUID, so this MUST be ignored. + const otherAddress = Keypair.random().publicKey(); + + const result = await handler.handle( + buildRequest(mockAccount.id, { opts: { address: otherAddress } }), + ); + + const expected = await wallet.signMessage(btoa('hello stellar')); + expect(result).toStrictEqual({ + signedMessage: expected, + signerAddress: wallet.address, + }); + }); }); From 6065036cde9972952182cdef2a8fd6bf00bf4b76 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:04:45 +0800 Subject: [PATCH 120/384] feat: add account sync service --- .../stellar-wallet-snap/src/context.ts | 27 +- .../account/__mocks__/account.fixtures.ts | 2 +- .../__mocks__/assets.fixtures.ts | 19 + .../src/services/network/MultiCall.ts | 172 ++++++ .../services/network/NetworkService.test.ts | 74 +++ .../src/services/network/NetworkService.ts | 99 +++- .../src/services/network/utils.ts | 21 + .../on-chain-account/OnChainAccount.ts | 22 + .../OnChainAccountRepository.ts | 114 ++++ .../OnChainAccountService.test.ts | 95 ++- .../on-chain-account/OnChainAccountService.ts | 70 ++- .../OnChainAccountSynchronizeService.test.ts | 425 ++++++++++++++ .../OnChainAccountSynchronizeService.ts | 541 ++++++++++++++++++ .../__mocks__/onChainAccount.fixtures.ts | 14 +- .../src/services/on-chain-account/api.ts | 35 +- .../src/services/on-chain-account/index.ts | 2 + 16 files changed, 1687 insertions(+), 45 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 6c9fb71a..2ec87b64 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -21,8 +21,11 @@ import { } from './services/asset-metadata'; import { StateCache } from './services/cache'; import { NetworkService } from './services/network'; -import type { OnChainAccountSnapshotState } from './services/on-chain-account'; -import { OnChainAccountService } from './services/on-chain-account'; +import type { OnChainAccountState } from './services/on-chain-account'; +import { + OnChainAccountRepository, + OnChainAccountService, +} from './services/on-chain-account'; import { PriceService } from './services/price'; import { State } from './services/state'; import { @@ -43,7 +46,7 @@ const state = new State({ assets: {}, transactions: {}, accountBalances: {} as AccountBalanceState['accountBalances'], - accountMetadata: {} as OnChainAccountSnapshotState['accountMetadata'], + onChainAccounts: {} as OnChainAccountState['onChainAccounts'], }, }); @@ -53,6 +56,13 @@ const assetMetadataRepository = new AssetMetadataRepository(state); /** ------------------------------ Services ------------------------------ */ const networkService = new NetworkService({ logger }); + +const assetMetadataService = new AssetMetadataService({ + networkService, + assetMetadataRepository, + logger, +}); + const transactionBuilder = new TransactionBuilder({ logger, }); @@ -64,8 +74,13 @@ const accountService = new AccountService({ walletService, }); +const onChainAccountRepository = new OnChainAccountRepository(state); + const onChainAccountService = new OnChainAccountService({ + logger, networkService, + onChainAccountRepository, + assetMetadataService, }); const transactionService = new TransactionService({ @@ -85,12 +100,6 @@ const confirmationUIController = new ConfirmationUXController({ logger, }); -const assetMetadataService = new AssetMetadataService({ - networkService, - assetMetadataRepository, - logger, -}); - /** ------------------------------ Keyring Handler ------------------------------ */ const signTransactionHandler = new SignTransactionHandler({ logger, diff --git a/merged-packages/stellar-wallet-snap/src/services/account/__mocks__/account.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/account/__mocks__/account.fixtures.ts index 7f8ead1d..701a5939 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/__mocks__/account.fixtures.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/__mocks__/account.fixtures.ts @@ -58,7 +58,7 @@ export const mockAccountService = () => { encrypted: false, defaultState: { keyringAccounts: {}, - accountMetadata: {}, + onChainAccounts: {}, }, }); const accountService = new AccountService({ diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts index ae392472..4e01090a 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts @@ -17,6 +17,8 @@ export const USDC_CLASSIC: KnownCaip19AssetIdOrSlip44Id = 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; export const USDC_SEP41: KnownCaip19AssetIdOrSlip44Id = 'stellar:pubnet/sep41:CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'; +export const USDT_SEP41: KnownCaip19AssetIdOrSlip44Id = + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J'; export const generateMockStellarAssetMetadata = (): AssetMetadataByAssetId => { return { @@ -49,6 +51,16 @@ export const generateMockStellarAssetMetadata = (): AssetMetadataByAssetId => { iconUrl: 'https://example.test/icon.png', units: [{ name: 'USDC', symbol: 'USDC', decimals: 7 }], }, + [USDT_SEP41]: { + assetId: USDT_SEP41, + assetType: AssetType.Sep41, + chainId: KnownCaip2ChainId.Mainnet, + name: 'USDT', + symbol: 'USDT', + fungible: true, + iconUrl: 'https://example.test/icon.png', + units: [{ name: 'USDT', symbol: 'USDT', decimals: 7 }], + }, } as AssetMetadataByAssetId; }; @@ -82,6 +94,13 @@ export const generateMockKeyringAssetMetadata = iconUrl: 'https://example.test/icon.png', units: [{ name: 'USDC', symbol: 'USDC', decimals: 7 }], }, + [USDT_SEP41]: { + name: 'USDT', + symbol: 'USDT', + fungible: true, + iconUrl: 'https://example.test/icon.png', + units: [{ name: 'USDT', symbol: 'USDT', decimals: 7 }], + }, } as KeyringAssetMetadataByAssetId; }; diff --git a/merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts b/merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts new file mode 100644 index 00000000..6ea21d73 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts @@ -0,0 +1,172 @@ +import { + Account, + Address, + Contract, + Networks, + type Operation, + rpc, + scValToNative, + type Transaction, + TransactionBuilder, + xdr, +} from '@stellar/stellar-sdk'; + +/** + * A simulation account to craft a transaction to simulate the balances read + * It is a funded account to prevent the transaction from failing due to insufficient funds. + * + * @see https://developers.stellar.org/docs/tools/sdks/contract-sdks + */ +export const SIMULATION_ACCOUNT: string = + 'GALAXYVOIDAOPZTDLHILAJQKCVVFMD4IKLXLSZV5YHO7VY74IWZILUTO'; + +/** + * Smart contract addresses for the Stellar MultiCall contract. + * + * @see https://stellar.org/developers/reference/stellar-multi-call/contracts + * it is recommended by Stellar official site. + * @see https://developers.stellar.org/docs/tools/sdks/contract-sdks#stellar-multicall--router-sdk + */ +export enum StellarRouterContract { + V0 = 'CBZV3HBP672BV7FF3ZILVT4CNPW3N5V2WTJ2LAGOAYW5R7L2D5SLUDFZ', + V1 = 'CCM23MFAJHDWUMF3IM3UPUI4ZUFFKX6OWJNJRUKO2W6MUTQNQWWFH7DC', +} + +export type StellarRouterParams = { + rpcClient: rpc.Server; + simulationAccount: string; +}; + +export class InvocationV0 { + contract: Address | string; + + method: string; + + args: xdr.ScVal[]; + + version = 'v0' as const; + + constructor(params: Omit) { + this.contract = params.contract; + this.method = params.method; + this.args = params.args; + } +} + +export class InvocationV1 { + contract: Address | string; + + method: string; + + args: xdr.ScVal[]; + + canFail?: boolean; + + version = 'v1' as const; + + constructor(params: Omit) { + this.contract = params.contract; + this.method = params.method; + this.args = params.args; + this.canFail = params.canFail; + } +} + +export class MultiCall { + readonly #rpcClient: rpc.Server; + + readonly #simulationAccount: string; + + readonly #routerContract: StellarRouterContract; + + constructor({ + rpcClient, + simulationAccount = SIMULATION_ACCOUNT, + routerContract = StellarRouterContract.V0, + }: { + rpcClient: rpc.Server; + simulationAccount?: string; + routerContract?: StellarRouterContract; + }) { + this.#rpcClient = rpcClient; + this.#simulationAccount = simulationAccount; + this.#routerContract = routerContract; + } + + /** + * This method generates the InvokeHostFunction Operation that you will be able to use within your transactions + * + * @param caller - The address that is calling the contract, this account must authorize the transaction even if none of the invocations require authorization. + * @param invocations - All the invocations the proxy will execute + * @returns An operation suitable for adding to a Stellar {@link Transaction}. + */ + exec( + caller: Contract | Address | string, + invocations: (InvocationV1 | InvocationV0)[], + ): xdr.Operation { + const args: xdr.ScVal[] = invocations.map((invocation) => { + switch (invocation.version) { + case 'v0': + return xdr.ScVal.scvVec([ + new Address(invocation.contract.toString()).toScVal(), + xdr.ScVal.scvSymbol(invocation.method), + xdr.ScVal.scvVec(invocation.args), + ]); + + case 'v1': + return xdr.ScVal.scvVec([ + new Address(invocation.contract.toString()).toScVal(), + xdr.ScVal.scvSymbol(invocation.method), + xdr.ScVal.scvVec(invocation.args), + xdr.ScVal.scvBool(invocation.canFail === true), + ]); + + default: + throw new Error(`Invocation version is not supported.`); + } + }); + + return new Contract(this.#routerContract).call( + 'exec', + new Address(caller.toString()).toScVal(), + xdr.ScVal.scvVec(args), + ); + } + + /** + * Simulates a multicall and returns the decoded result value. + * + * @param invocations - Invocations to batch. + * @param opts - Optional caller and source account overrides. + * @param opts.caller - Account that authorizes the host function call; defaults to the simulation account. + * @param opts.source - Transaction `source` account; defaults to the simulation account. + * @returns The simulation result as a native value. + */ + async simResult( + invocations: (InvocationV1 | InvocationV0)[], + opts?: { caller?: string; source?: string }, + ): Promise { + const sourceAccount = opts?.source ?? this.#simulationAccount; + const callerAccount = opts?.caller ?? this.#simulationAccount; + const tx: Transaction = new TransactionBuilder( + new Account(sourceAccount, '0'), + { networkPassphrase: Networks.PUBLIC, fee: '0' }, + ) + .setTimeout(0) + .addOperation(this.exec(callerAccount, invocations)) + .build(); + + const sim = await this.#rpcClient.simulateTransaction(tx); + + if (rpc.Api.isSimulationError(sim)) { + throw new Error(String(sim.error)); + } + + const retval = sim.result?.retval; + if (retval === undefined) { + throw new Error('Simulation returned no result'); + } + + return scValToNative(retval) as Result; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts index 04d19de2..6317f5e2 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts @@ -19,6 +19,7 @@ import { TransactionRetryableException, TransactionSendException, } from './exceptions'; +import { MultiCall } from './MultiCall'; import { NetworkService } from './NetworkService'; import type { KnownCaip19Sep41AssetId } from '../../api'; import { KnownCaip2ChainId } from '../../api'; @@ -609,4 +610,77 @@ describe('NetworkService', () => { expect(pollTransactionSpy).not.toHaveBeenCalled(); }); }); + + describe('getSep41AssetBalances', () => { + const account = 'GDYTQGVA3NCXM5JPVMOHLDUAHMI3OQ2B2YI25BXYKROAGXXT2T3ZGHE6'; + const secondAssetId = + 'stellar:pubnet/sep41:CBGV2QFQBBGEQRUKUMCPO3SZOHDDYO6SCP5CH6TW7EALKVHCXTMWDDOF' as KnownCaip19Sep41AssetId; + + it('returns empty object when accounts is empty', async () => { + const result = await networkService.getSep41AssetBalances({ + accounts: [], + assetIds: [validSep41AssetId], + scope: KnownCaip2ChainId.Mainnet, + }); + expect(result).toStrictEqual({}); + }); + + it('returns empty object when assetIds is empty', async () => { + const result = await networkService.getSep41AssetBalances({ + accounts: [account], + assetIds: [], + scope: KnownCaip2ChainId.Mainnet, + }); + expect(result).toStrictEqual({}); + }); + + it('maps multicall simulation vector to per-account balances on mainnet', async () => { + const simResultSpy = jest + .spyOn(MultiCall.prototype, 'simResult') + .mockResolvedValue([BigInt('100'), BigInt('200')]); + + const result = await networkService.getSep41AssetBalances({ + accounts: [account], + assetIds: [validSep41AssetId, secondAssetId], + scope: KnownCaip2ChainId.Mainnet, + }); + + expect(simResultSpy).toHaveBeenCalled(); + expect(result[account]?.[validSep41AssetId]?.toFixed()).toBe('100'); + expect(result[account]?.[secondAssetId]?.toFixed()).toBe('200'); + simResultSpy.mockRestore(); + }); + + it('maps failed multicall cells to null', async () => { + const simResultSpy = jest + .spyOn(MultiCall.prototype, 'simResult') + .mockResolvedValue([BigInt('1'), {}]); + + const result = await networkService.getSep41AssetBalances({ + accounts: [account], + assetIds: [validSep41AssetId, secondAssetId], + scope: KnownCaip2ChainId.Mainnet, + }); + + expect(result[account]?.[validSep41AssetId]?.toFixed()).toBe('1'); + expect(result[account]?.[secondAssetId]).toBeNull(); + simResultSpy.mockRestore(); + }); + + it('returns empty object on testnet (batch SEP-41 balances not supported)', async () => { + const simResultSpy = jest.spyOn(MultiCall.prototype, 'simResult'); + const testnetAssetId = + 'stellar:testnet/sep41:CDLZFC3SYJYDZT7K67VZ75HVSSBAXAVVD2XGDFEUCDZUFE7MDUROSPZM' as KnownCaip19Sep41AssetId; + + const result = await networkService.getSep41AssetBalances({ + accounts: [account], + assetIds: [testnetAssetId], + scope: KnownCaip2ChainId.Testnet, + }); + + expect(result).toStrictEqual({}); + expect(simResultSpy).not.toHaveBeenCalled(); + simResultSpy.mockRestore(); + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index 409cfee0..677f1dce 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -25,17 +25,24 @@ import { TransactionRetryableException, TransactionSendException, } from './exceptions'; +import { + InvocationV1, + MultiCall, + SIMULATION_ACCOUNT, + StellarRouterContract, +} from './MultiCall'; import { caip2ChainIdToNetwork, extractAssetDataFromContractData, isAccountNotFoundError, parseScValToNative, + sep41MulticallCellToBalance, } from './utils'; import type { KnownCaip19ClassicAssetId, KnownCaip19Sep41AssetId, - KnownCaip2ChainId, } from '../../api'; +import { KnownCaip2ChainId } from '../../api'; import type { NetworkConfig } from '../../config'; import { AppConfig } from '../../config'; import { STELLAR_DECIMAL_PLACES } from '../../constants'; @@ -363,7 +370,6 @@ export class NetworkService { }): Promise { const { accountAddress, assetId, scope, sequenceNumber } = params; const { assetReference: tokenAddress } = parseCaipAssetType(assetId); - // TODO: change to use https://github.com/Creit-Tech/Stellar-Router-SDK to batch collect balances try { const client = this.#getRpcClient(scope); const token = new Contract(tokenAddress); @@ -413,6 +419,95 @@ export class NetworkService { } } + /** + * Fetches SEP-41 asset balances for multiple accounts via Soroban simulation of `balance(Address)`. + * + * **Mainnet only** — uses the Stellar MultiCall router (single simulation). On testnet this method + * returns `{}` until batch SEP-41 reads are supported there. + * + * @param params - Balance query input. + * @param params.accounts - Accounts holding the token (`G…`). + * @param params.assetIds - CAIP-19 asset ids for SEP-41 tokens. + * @param params.scope - CAIP-2 chain id. + * @returns Per-account map of asset id to balance in smallest units, or `null` when a cell cannot be read. + * @throws {NetworkServiceException} When the RPC request fails or the multicall result length is wrong. + */ + async getSep41AssetBalances(params: { + accounts: string[]; + assetIds: KnownCaip19Sep41AssetId[]; + scope: KnownCaip2ChainId; + }): Promise< + Record> + > { + const { accounts, assetIds, scope } = params; + + if (accounts.length === 0 || assetIds.length === 0) { + return {}; + } + + if (scope === KnownCaip2ChainId.Testnet) { + return {}; + } + + try { + const multiCall = new MultiCall({ + rpcClient: this.#getRpcClient(scope), + routerContract: StellarRouterContract.V1, + // Caller for `exec` on the router; first funded user account is typical; else the shared sim account. + simulationAccount: accounts[0] ?? SIMULATION_ACCOUNT, + }); + + const invocations: InvocationV1[] = []; + for (const account of accounts) { + for (const assetId of assetIds) { + invocations.push( + new InvocationV1({ + contract: parseCaipAssetType(assetId).assetReference, + method: 'balance', + args: [new Address(account).toScVal()], + // Allow the batch simulation to continue when a cell fails (missing contract, etc.). + canFail: true, + }), + ); + } + } + const totalRecords = accounts.length * assetIds.length; + + const simResults: unknown[] = await multiCall.simResult(invocations); + + if (simResults.length !== totalRecords) { + throw new NetworkServiceException( + `Failed to load SEP-41 token balance - multicall result length: ${simResults.length} does not match the expected number of records: ${totalRecords}`, + ); + } + + const result: Record< + string, + Record + > = {}; + let idx = 0; + for (const account of accounts) { + for (const assetId of assetIds) { + const simResult = simResults[idx]; + result[account] ??= {}; + result[account][assetId] = sep41MulticallCellToBalance(simResult); + idx += 1; + } + } + return result; + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to load SEP-41 token balance', + error, + ); + return rethrowIfInstanceElseThrow( + error, + [NetworkServiceException], + new NetworkServiceException('Failed to load SEP-41 token balance'), + ); + } + } + /** * Loads account data when the account exists and is funded; returns `null` if the account is not on-chain. * diff --git a/merged-packages/stellar-wallet-snap/src/services/network/utils.ts b/merged-packages/stellar-wallet-snap/src/services/network/utils.ts index 7497a54a..129ec856 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/utils.ts @@ -154,6 +154,27 @@ export function parseScValToNative(value: string | bigint | number): BigNumber { return amountBn; } +/** + * Normalizes a single Stellar multicall `exec` result cell to a non-negative {@link BigNumber}. + * + * @param value - Native value from `scValToNative` for one invocation result. + * @returns Parsed balance, or `null` when the cell is missing or not a supported numeric shape. + */ +export function sep41MulticallCellToBalance(value: unknown): BigNumber | null { + if ( + typeof value === 'bigint' || + typeof value === 'number' || + typeof value === 'string' + ) { + try { + return parseScValToNative(value); + } catch { + return null; + } + } + return null; +} + /** * Detects the error shape thrown by Soroban RPC `getAccount` / `getAccountEntry` when the account * ledger entry is missing (`Error` with message `Account not found: `). diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts index 4b70c90b..c16191f4 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts @@ -24,6 +24,7 @@ import { calculateSpendableBalance } from './utils'; import type { KnownCaip19AssetIdOrSlip44Id, KnownCaip19ClassicAssetId, + KnownCaip19Sep41AssetId, KnownCaip2ChainId, } from '../../api'; import { NATIVE_ASSET_SYMBOL } from '../../constants'; @@ -149,6 +150,19 @@ export class OnChainAccount { return { ...entry }; } + /** + * Sets the balance for a SEP-41 asset id. + * + * @param assetId - The SEP-41 asset id to set the balance for. + * @param balanceEntry - The balance entry to set. + */ + setSep41Asset( + assetId: KnownCaip19Sep41AssetId, + balanceEntry: SpendableBalance, + ): void { + this.#balances.set(assetId, balanceEntry); + } + /** * Classic Stellar trustline asset ids (CAIP-19) that have a balance row with a limit. * @@ -316,6 +330,14 @@ export class OnChainAccount { }; } + toSerializableFull(): OnChainAccountSerializableFull { + const serialized = this.toSerializable(); + if (!OnChainAccountSerializableFullStruct.is(serialized)) { + throw new OnChainAccountException('Account is not fully hydrated'); + } + return serialized; + } + /** * Builds from a Horizon `loadAccount` response. * With a native balance line → full binding; otherwise → minimal binding (sequence-only style). diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts new file mode 100644 index 00000000..e9e89f42 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts @@ -0,0 +1,114 @@ +import { cloneDeep } from 'lodash'; + +import type { + OnChainAccountSnapshotsByKeyringId, + OnChainAccountState, +} from './api'; +import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; +import type { KnownCaip2ChainId } from '../../api'; +import type { IStateManager } from '../state/IStateManager'; + +export class OnChainAccountRepository { + readonly #state: IStateManager; + + readonly #stateKey = 'onChainAccounts'; + + constructor(state: IStateManager) { + this.#state = state; + } + + /** + * @param keyringAccountId - MetaMask keyring account id (not the Stellar G-address). + * @param scope - CAIP-2 chain id for the cached snapshot. + * @returns The stored snapshot, or `null` when none exists for this keyring id and scope. + */ + async findByAccountId( + keyringAccountId: string, + scope: KnownCaip2ChainId, + ): Promise { + const snapshotsByAccountId = await this.findByAccountIds( + [keyringAccountId], + scope, + ); + + return snapshotsByAccountId[keyringAccountId] ?? null; + } + + /** + * @param keyringAccountIds - MetaMask keyring account ids (not Stellar G-addresses). + * @param scope - CAIP-2 chain id for the cached snapshots. + * @returns Account id -> snapshot (or `null` when missing for the given scope). + */ + async findByAccountIds( + keyringAccountIds: string[], + scope: KnownCaip2ChainId, + ): Promise> { + const byKeyring = + (await this.#state.getKey( + this.#stateKey, + )) ?? {}; + const snapshotsByAccountId: Record< + string, + OnChainAccountSerializableFull | null + > = {}; + + for (const keyringAccountId of keyringAccountIds) { + snapshotsByAccountId[keyringAccountId] = + byKeyring[keyringAccountId]?.[scope] ?? null; + } + + return snapshotsByAccountId; + } + + /** + * Persists one snapshot under `onChainAccounts[keyringId][account.scope]` in a single atomic + * `snap_manageState` update (avoids races between separate get/set paths). + * + * @param keyringAccountId - MetaMask keyring account id (not the Stellar G-address). + * @param account - Serializable snapshot; `account.scope` selects the nested key. + */ + async save( + keyringAccountId: string, + account: OnChainAccountSerializableFull, + ): Promise { + await this.#state.update((state) => { + const newState = cloneDeep(state); + if (!newState[this.#stateKey]) { + newState[this.#stateKey] = {} as OnChainAccountSnapshotsByKeyringId; + } + const root = newState[this.#stateKey]; + root[keyringAccountId] ??= {}; + root[keyringAccountId][account.scope] = account; + return newState; + }); + } + + /** + * Writes accounts in one atomic `IStateManager.update` (full state blob). Callers that read then + * merge outside this method should serialize those steps if updates can overlap (see + * `OnChainAccountSynchronizeService` mutex). + * + * @param accounts - Map of keyring account id → snapshot for `accounts[id].scope`. + */ + async saveMany( + accounts: Record, + ): Promise { + if (Object.keys(accounts).length === 0) { + return; + } + + await this.#state.update((state) => { + const newState = cloneDeep(state); + if (!newState[this.#stateKey]) { + newState[this.#stateKey] = {} as OnChainAccountSnapshotsByKeyringId; + } + const accountsByKeyringId = newState[this.#stateKey]; + + for (const [keyringAccountId, account] of Object.entries(accounts)) { + accountsByKeyringId[keyringAccountId] ??= {}; + accountsByKeyringId[keyringAccountId][account.scope] = account; + } + return newState; + }); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index f88a3eed..93d977d4 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -9,8 +9,13 @@ import { mockOnChainAccountService, } from './__mocks__/onChainAccount.fixtures'; import { OnChainAccount } from './OnChainAccount'; +import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; +import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; import { bufferToUint8Array } from '../../utils/buffer'; -import { generateStellarKeyringAccount } from '../account/__mocks__/account.fixtures'; +import { + generateMockStellarKeyringAccounts, + generateStellarKeyringAccount, +} from '../account/__mocks__/account.fixtures'; import { DerivedAccountAddressMismatchException } from '../account/exceptions'; import { NetworkService } from '../network'; import { getTestWallet } from '../wallet/__mocks__/wallet.fixtures'; @@ -32,6 +37,10 @@ describe('OnChainAccountService', () => { NetworkService.prototype, 'loadOnChainAccount', ), + loadActivatedAccountOrNullSpy: jest.spyOn( + NetworkService.prototype, + 'loadActivatedAccountOrNull', + ), }); describe('isAccountActivated', () => { @@ -133,4 +142,88 @@ describe('OnChainAccountService', () => { ).rejects.toThrow(DerivedAccountAddressMismatchException); }); }); + + describe('resolveOnChainAccountByAccountId', () => { + it('returns null when no snapshot exists for the keyring id and scope', async () => { + const keyringAccountId = globalThis.crypto.randomUUID(); + const { onChainAccountService, onChainAccountRepository } = + mockOnChainAccountService(); + const findByAccountIdSpy = jest.spyOn( + onChainAccountRepository, + 'findByAccountId', + ); + findByAccountIdSpy.mockResolvedValue(null); + + const result = + await onChainAccountService.resolveOnChainAccountByAccountId( + keyringAccountId, + KnownCaip2ChainId.Mainnet, + ); + + expect(result).toBeNull(); + expect(findByAccountIdSpy).toHaveBeenCalledWith( + keyringAccountId, + KnownCaip2ChainId.Mainnet, + ); + }); + + it('returns rehydrated OnChainAccount when a snapshot exists', async () => { + const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + const keyringAccountId = globalThis.crypto.randomUUID(); + const loadedAcc = createMockAccountWithBalances( + signer.publicKey(), + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const binding = horizonSource( + loadedAcc, + KnownCaip2ChainId.Mainnet, + ) as OnChainAccountSerializableFull; + + const { onChainAccountService, onChainAccountRepository } = + mockOnChainAccountService(); + const findByAccountIdSpy = jest.spyOn( + onChainAccountRepository, + 'findByAccountId', + ); + findByAccountIdSpy.mockResolvedValue(binding); + + const result = + await onChainAccountService.resolveOnChainAccountByAccountId( + keyringAccountId, + KnownCaip2ChainId.Mainnet, + ); + + expect(result).toBeInstanceOf(OnChainAccount); + expect(result?.accountId).toStrictEqual(signer.publicKey()); + expect(findByAccountIdSpy).toHaveBeenCalledWith( + keyringAccountId, + KnownCaip2ChainId.Mainnet, + ); + }); + }); + + describe('synchronize', () => { + it('calls OnChainAccountSynchronizeService', async () => { + const keyringAccounts = generateMockStellarKeyringAccounts( + 2, + 'entropy-source-1', + ); + const { onChainAccountService } = mockOnChainAccountService(); + const synchronizeSpy = jest.spyOn( + OnChainAccountSynchronizeService.prototype, + 'synchronize', + ); + + await onChainAccountService.synchronize( + keyringAccounts, + KnownCaip2ChainId.Mainnet, + ); + + expect(synchronizeSpy).toHaveBeenCalledWith( + keyringAccounts, + KnownCaip2ChainId.Mainnet, + ); + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 4cbf7761..1a8b626c 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -1,7 +1,12 @@ -import type { OnChainAccount } from './OnChainAccount'; +import { OnChainAccount } from './OnChainAccount'; +import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; import type { KnownCaip2ChainId } from '../../api'; +import type { ILogger } from '../../utils'; +import type { StellarKeyringAccount } from '../account'; import { assertSameAddress } from '../account/utils'; -import type { NetworkService } from '../network'; +import { type NetworkService } from '../network'; +import type { OnChainAccountRepository } from './OnChainAccountRepository'; +import type { AssetMetadataService } from '../asset-metadata/AssetMetadataService'; /** * Stellar on-chain account operations: activation checks and loading {@link OnChainAccount} @@ -10,8 +15,30 @@ import type { NetworkService } from '../network'; export class OnChainAccountService { readonly #networkService: NetworkService; - constructor({ networkService }: { networkService: NetworkService }) { + readonly #onChainAccountSynchronizeService: OnChainAccountSynchronizeService; + + readonly #onChainAccountRepository: OnChainAccountRepository; + + constructor({ + networkService, + onChainAccountRepository, + assetMetadataService, + logger, + }: { + networkService: NetworkService; + onChainAccountRepository: OnChainAccountRepository; + assetMetadataService: AssetMetadataService; + logger: ILogger; + }) { this.#networkService = networkService; + this.#onChainAccountSynchronizeService = + new OnChainAccountSynchronizeService({ + networkService, + onChainAccountRepository, + assetMetadataService, + logger, + }); + this.#onChainAccountRepository = onChainAccountRepository; } /** @@ -54,4 +81,41 @@ export class OnChainAccountService { assertSameAddress(accountAddress, loaded.accountId); return loaded; } + + /** + * Loads the on-chain account for the given keyring account id from the State. + * + * @param keyringAccountId - The keyring account id to load the on-chain account for. + * @param scope - The CAIP-2 chain id to load the on-chain account for. + * @returns The on-chain account, or `null` if not found. + */ + async resolveOnChainAccountByAccountId( + keyringAccountId: string, + scope: KnownCaip2ChainId, + ): Promise { + const onChainAccount = await this.#onChainAccountRepository.findByAccountId( + keyringAccountId, + scope, + ); + return onChainAccount + ? OnChainAccount.fromSerializable(onChainAccount) + : null; + } + + /** + * Enriches accounts with SEP-41 balances, persists snapshots, then notifies the keyring when + * balances or the tracked asset set changed. Delegates to {@link OnChainAccountSynchronizeService}. + * + * @param keyringAccount - Stellar keyring accounts to sync for `scope`. + * @param scope - CAIP-2 network. + */ + async synchronize( + keyringAccount: StellarKeyringAccount[], + scope: KnownCaip2ChainId, + ): Promise { + await this.#onChainAccountSynchronizeService.synchronize( + keyringAccount, + scope, + ); + } } diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts new file mode 100644 index 00000000..8b02b3ad --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts @@ -0,0 +1,425 @@ +import { KeyringEvent } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { hexToBytes } from '@metamask/utils'; +import { Keypair } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import type { KnownCaip19Sep41AssetId } from '../../api'; +import { KnownCaip2ChainId } from '../../api'; +import { + createMockAccountWithBalances, + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + horizonSource, + mockOnChainAccountService, +} from './__mocks__/onChainAccount.fixtures'; +import { OnChainAccount } from './OnChainAccount'; +import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; +import { bufferToUint8Array } from '../../utils/buffer'; +import { generateStellarKeyringAccount } from '../account/__mocks__/account.fixtures'; +import { + USDT_SEP41, + USDC_SEP41, + generateMockStellarAssetMetadata, +} from '../asset-metadata/__mocks__/assets.fixtures'; +import type { StellarAssetMetadata } from '../asset-metadata/api'; +import { AssetMetadataService } from '../asset-metadata/AssetMetadataService'; +import { AccountNotActivatedException, NetworkService } from '../network'; + +jest.mock('../../utils/logger'); +jest.mock('../../utils/snap'); +jest.mock('@metamask/keyring-snap-sdk', () => ({ + emitSnapKeyringEvent: jest.fn(), +})); + +describe('OnChainAccountService.synchronize', () => { + const seed = hexToBytes( + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ); + const sep41Id = USDC_SEP41 as KnownCaip19Sep41AssetId; + const backupSep41Id = USDT_SEP41 as KnownCaip19Sep41AssetId; + + const getNetworkServiceSpies = () => ({ + loadOnChainAccountSpy: jest.spyOn( + NetworkService.prototype, + 'loadOnChainAccount', + ), + getSep41AssetBalancesSpy: jest.spyOn( + NetworkService.prototype, + 'getSep41AssetBalances', + ), + }); + + const getRepositorySpies = ( + onChainAccountRepository: ReturnType< + typeof mockOnChainAccountService + >['onChainAccountRepository'], + ) => ({ + findByAccountIdsSpy: jest.spyOn( + onChainAccountRepository, + 'findByAccountIds', + ), + saveManySpy: jest.spyOn(onChainAccountRepository, 'saveMany'), + }); + + const getKeyringEventSpies = () => ({ + emitSnapKeyringEventSpy: jest.mocked(emitSnapKeyringEvent), + }); + + const setupSynchronizeService = () => { + const { onChainAccountService, onChainAccountRepository } = + mockOnChainAccountService(); + return { + onChainAccountService, + onChainAccountRepository, + ...getRepositorySpies(onChainAccountRepository), + }; + }; + + const getSavedSnapshotFromFirstSave = ( + saveManySpy: ReturnType['saveManySpy'], + keyringAccountId: string, + ): OnChainAccountSerializableFull => { + expect(saveManySpy).toHaveBeenCalledTimes(1); + expect(saveManySpy.mock.calls[0]).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- narrowed by expect above + const payload = saveManySpy.mock.calls[0]![0]; + return payload[keyringAccountId] as OnChainAccountSerializableFull; + }; + + const setupTest = () => { + jest.mocked(emitSnapKeyringEvent).mockResolvedValue(undefined); + const metadata = generateMockStellarAssetMetadata(); + const usdcSep41Row = metadata[USDC_SEP41]; + if (!usdcSep41Row) { + throw new Error('expected USDC_SEP41 in mock asset metadata'); + } + const usdtSep41Row = metadata[USDT_SEP41]; + if (!usdtSep41Row) { + throw new Error('expected USDT_SEP41 in mock asset metadata'); + } + jest + .spyOn(AssetMetadataService.prototype, 'getPersistedSep41AssetsMetadata') + .mockResolvedValue([usdcSep41Row, usdtSep41Row]); + // getKey('assets') in tests does not merge defaultState, so getAllByScope is empty unless mocked. + jest + .spyOn(AssetMetadataService.prototype, 'getAllByScope') + .mockImplementation(async (scope) => { + const byAssetId = generateMockStellarAssetMetadata(); + return Object.values(byAssetId).filter( + (asset): asset is StellarAssetMetadata => + asset !== undefined && asset.chainId === scope, + ); + }); + }; + + const setupOnChainAccountWithBalance = (entropySource: string) => { + const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + const keyringAccount = generateStellarKeyringAccount( + globalThis.crypto.randomUUID(), + signer.publicKey(), + entropySource, + 0, + ); + const loadedAcc = createMockAccountWithBalances( + signer.publicKey(), + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const binding = horizonSource( + loadedAcc, + KnownCaip2ChainId.Mainnet, + ) as OnChainAccountSerializableFull; + const onChainAccount = OnChainAccount.fromSerializable(binding); + + return { + signer, + keyringAccount, + binding, + onChainAccount, + }; + }; + + it('returns early without saveMany when accountsPairs is empty', async () => { + setupTest(); + + const { onChainAccountService, saveManySpy } = setupSynchronizeService(); + + await onChainAccountService.synchronize([], KnownCaip2ChainId.Mainnet); + + expect(saveManySpy).not.toHaveBeenCalled(); + }); + + it('returns early when no activated account is loaded', async () => { + setupTest(); + + const keyringAccount = generateStellarKeyringAccount( + globalThis.crypto.randomUUID(), + Keypair.random().publicKey(), + 'entropy-sync-no-activated', + 0, + ); + const { loadOnChainAccountSpy, getSep41AssetBalancesSpy } = + getNetworkServiceSpies(); + loadOnChainAccountSpy.mockRejectedValue( + new AccountNotActivatedException( + keyringAccount.address, + KnownCaip2ChainId.Mainnet, + ), + ); + + const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); + const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + setupSynchronizeService(); + + await onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ); + + expect(getSep41AssetBalancesSpy).not.toHaveBeenCalled(); + expect(findByAccountIdsSpy).not.toHaveBeenCalled(); + expect(saveManySpy).not.toHaveBeenCalled(); + expect(emitSnapKeyringEventSpy).not.toHaveBeenCalled(); + }); + + it('persists SEP-41 balances after sync and writes onChainAccounts state', async () => { + setupTest(); + + const { signer, keyringAccount, onChainAccount } = + setupOnChainAccountWithBalance('entropy-sync-1'); + const { getSep41AssetBalancesSpy, loadOnChainAccountSpy } = + getNetworkServiceSpies(); + getSep41AssetBalancesSpy.mockResolvedValue({ + [signer.publicKey()]: { + [sep41Id]: new BigNumber('1000'), + }, + }); + loadOnChainAccountSpy.mockResolvedValue(onChainAccount); + + const { onChainAccountService, saveManySpy } = setupSynchronizeService(); + + await onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ); + + const saved = getSavedSnapshotFromFirstSave(saveManySpy, keyringAccount.id); + expect(saved).toBeDefined(); + const sepRow = saved.balances.find((b) => b.assetId === sep41Id); + expect(sepRow?.balance).toBe('1000'); + }); + + it('emits AccountBalancesUpdated and AccountAssetListUpdated when SEP-41 is added versus persisted state', async () => { + setupTest(); + + const { signer, keyringAccount, binding } = + setupOnChainAccountWithBalance('entropy-sync-2'); + const withSep: OnChainAccountSerializableFull = { + ...binding, + balances: [ + ...binding.balances, + { assetId: sep41Id, balance: '500', symbol: 'USDC' }, + ], + }; + const onChainAccount = OnChainAccount.fromSerializable(withSep); + + const { getSep41AssetBalancesSpy, loadOnChainAccountSpy } = + getNetworkServiceSpies(); + getSep41AssetBalancesSpy.mockResolvedValue({ + [signer.publicKey()]: { + [sep41Id]: new BigNumber('500'), + }, + }); + loadOnChainAccountSpy.mockResolvedValue(onChainAccount); + + const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); + const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + setupSynchronizeService(); + findByAccountIdsSpy.mockResolvedValue({ + [keyringAccount.id]: binding, + }); + + await onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ); + + expect(emitSnapKeyringEventSpy).toHaveBeenCalledTimes(2); + expect(emitSnapKeyringEventSpy).toHaveBeenNthCalledWith( + 1, + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [keyringAccount.id]: { + [sep41Id]: { unit: 'USDC', amount: '500' }, + }, + }, + }, + ); + expect(emitSnapKeyringEventSpy).toHaveBeenNthCalledWith( + 2, + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [keyringAccount.id]: { added: [sep41Id], removed: [] }, + }, + }, + ); + expect(saveManySpy).toHaveBeenCalled(); + expect(saveManySpy.mock.invocationCallOrder).toHaveLength(1); + expect(emitSnapKeyringEventSpy.mock.invocationCallOrder).toHaveLength(2); + expect(Number(saveManySpy.mock.invocationCallOrder[0])).toBeLessThan( + Number(emitSnapKeyringEventSpy.mock.invocationCallOrder[0]), + ); + }); + + it('emits removal when SEP-41 was persisted and new sync has zero', async () => { + setupTest(); + + const { + signer, + keyringAccount, + binding: base, + onChainAccount, + } = setupOnChainAccountWithBalance('entropy-sync-3'); + const withSep: OnChainAccountSerializableFull = { + ...base, + balances: [ + ...base.balances, + { assetId: sep41Id, balance: '200', symbol: 'USDC' }, + ], + }; + const { getSep41AssetBalancesSpy, loadOnChainAccountSpy } = + getNetworkServiceSpies(); + getSep41AssetBalancesSpy.mockResolvedValue({ + [signer.publicKey()]: { + [sep41Id]: new BigNumber(0), + }, + }); + + const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); + const { onChainAccountService, findByAccountIdsSpy } = + setupSynchronizeService(); + findByAccountIdsSpy.mockResolvedValue({ + [keyringAccount.id]: withSep, + }); + loadOnChainAccountSpy.mockResolvedValue(onChainAccount); + + await onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ); + + expect(emitSnapKeyringEventSpy).toHaveBeenCalledTimes(2); + expect(emitSnapKeyringEventSpy).toHaveBeenNthCalledWith( + 1, + expect.anything(), + KeyringEvent.AccountBalancesUpdated, + { + balances: { + [keyringAccount.id]: { + [sep41Id]: { unit: 'USDC', amount: '0' }, + }, + }, + }, + ); + expect(emitSnapKeyringEventSpy).toHaveBeenNthCalledWith( + 2, + expect.anything(), + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [keyringAccount.id]: { added: [], removed: [sep41Id] }, + }, + }, + ); + }); + + it('restores persisted SEP-41 rows when SEP-41 balance fetch fails', async () => { + setupTest(); + + const { + keyringAccount, + binding: base, + onChainAccount, + } = setupOnChainAccountWithBalance('entropy-sync-fallback-all-fail'); + const withPersistedSep41: OnChainAccountSerializableFull = { + ...base, + balances: [ + ...base.balances, + { assetId: sep41Id, balance: '700', symbol: 'USDC' }, + ], + }; + const { getSep41AssetBalancesSpy, loadOnChainAccountSpy } = + getNetworkServiceSpies(); + getSep41AssetBalancesSpy.mockRejectedValue( + new Error('sep41 fetch temporarily unavailable'), + ); + loadOnChainAccountSpy.mockResolvedValue(onChainAccount); + + const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); + const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + setupSynchronizeService(); + findByAccountIdsSpy.mockResolvedValue({ + [keyringAccount.id]: withPersistedSep41, + }); + + await onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ); + + const saved = getSavedSnapshotFromFirstSave(saveManySpy, keyringAccount.id); + const persistedSep41Row = saved.balances.find((b) => b.assetId === sep41Id); + expect(persistedSep41Row?.balance).toBe('700'); + expect(emitSnapKeyringEventSpy).not.toHaveBeenCalled(); + }); + + it('restores unresolved persisted SEP-41 rows when only some SEP-41 balances fail', async () => { + setupTest(); + + const { + signer, + keyringAccount, + binding: base, + onChainAccount, + } = setupOnChainAccountWithBalance('entropy-sync-fallback-some-fail'); + const withPersistedBackupSep41: OnChainAccountSerializableFull = { + ...base, + balances: [ + ...base.balances, + { assetId: backupSep41Id, balance: '250', symbol: 'USDT' }, + ], + }; + const { getSep41AssetBalancesSpy, loadOnChainAccountSpy } = + getNetworkServiceSpies(); + getSep41AssetBalancesSpy.mockResolvedValue({ + [signer.publicKey()]: { + [sep41Id]: new BigNumber('500'), + [backupSep41Id]: null, + }, + }); + loadOnChainAccountSpy.mockResolvedValue(onChainAccount); + + const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + setupSynchronizeService(); + findByAccountIdsSpy.mockResolvedValue({ + [keyringAccount.id]: withPersistedBackupSep41, + }); + + await onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ); + + const saved = getSavedSnapshotFromFirstSave(saveManySpy, keyringAccount.id); + const resolvedSep41Row = saved.balances.find((b) => b.assetId === sep41Id); + const restoredSep41Row = saved.balances.find( + (b) => b.assetId === backupSep41Id, + ); + expect(resolvedSep41Row?.balance).toBe('500'); + expect(restoredSep41Row?.balance).toBe('250'); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts new file mode 100644 index 00000000..9b44ea5d --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts @@ -0,0 +1,541 @@ +import { KeyringEvent } from '@metamask/keyring-api'; +import type { KeyringEventPayload } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { Mutex } from 'async-mutex'; +import { BigNumber } from 'bignumber.js'; + +import { OnChainAccount } from './OnChainAccount'; +import type { OnChainAccountRepository } from './OnChainAccountRepository'; +import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip19Sep41AssetId, + KnownCaip2ChainId, +} from '../../api'; +import type { ILogger } from '../../utils'; +import { + createPrefixedLogger, + getSlip44AssetId, + getSnapProvider, + isSep41Id, +} from '../../utils'; +import type { StellarKeyringAccount } from '../account'; +import type { AssetMetadataService } from '../asset-metadata/AssetMetadataService'; +import { AccountNotActivatedException, type NetworkService } from '../network'; + +type AccountAssetListDelta = + KeyringEventPayload['assets'][string]; + +type ActivatedAccountPair = { + keyringAccount: StellarKeyringAccount; + onChainAccount: OnChainAccount; +}; + +type Sep41BalanceFetchResult = { + assetIds: KnownCaip19Sep41AssetId[]; + symbolsByAssetId: Record; + balancesByAccountId: Record< + string, + Record + >; +}; + +/** + * Persists on-chain account snapshots and emits keyring balance / asset-list events after a sync. + * + * {@link synchronize} uses a mutex so overlapping syncs cannot interleave read–merge–write across + * `findByAccountIds` and `saveMany`. Each `saveMany` call is still one atomic `IStateManager.update`. + */ +export class OnChainAccountSynchronizeService { + readonly #networkService: NetworkService; + + readonly #onChainAccountRepository: OnChainAccountRepository; + + readonly #assetMetadataService: AssetMetadataService; + + readonly #logger: ILogger; + + /** Serializes full sync runs; see class JSDoc. */ + readonly #synchronizeMutex = new Mutex(); + + constructor({ + networkService, + onChainAccountRepository, + assetMetadataService, + logger, + }: { + networkService: NetworkService; + onChainAccountRepository: OnChainAccountRepository; + assetMetadataService: AssetMetadataService; + logger: ILogger; + }) { + this.#networkService = networkService; + this.#onChainAccountRepository = onChainAccountRepository; + this.#assetMetadataService = assetMetadataService; + this.#logger = createPrefixedLogger( + logger, + '[💼 OnChainAccountSynchronizeService]', + ); + } + + /** + * Enriches accounts with SEP-41 balances, persists snapshots, then notifies the keyring when + * balances or the non-native tracked asset set changed. + * + * @param keyringAccounts - Stellar keyring accounts to sync for `scope`. + * @param scope - CAIP-2 network. + */ + async synchronize( + keyringAccounts: StellarKeyringAccount[], + scope: KnownCaip2ChainId, + ): Promise { + if (keyringAccounts.length === 0) { + this.#logger.debug('No accounts to synchronize'); + return; + } + + // Adding a mutex to prevent multiple syncs from running simultaneously, + // And ensure the read and write consistency in state. + // Trade off of the mutex: Synchronize request may send every second, due to user switch accounts, we will block the next request until the current request is finished. + await this.#synchronizeMutex.runExclusive(async () => { + this.#logger.debug('Load on-chain accounts - no of accounts to load', { + noOfAccounts: keyringAccounts.length, + }); + // 1. Horizon: funded accounts only (unfunded / errors skipped in #loadActivatedPairs). + const activatedAccountPairs = await this.#loadActivatedPairs( + keyringAccounts, + scope, + ); + this.#logger.debug( + 'Loaded activated account pairs - no of accounts loaded', + { + noOfAccounts: activatedAccountPairs.length, + }, + ); + if (activatedAccountPairs.length === 0) { + return; + } + + const stellarAccountIds: string[] = []; + const keyringAccountIds: string[] = []; + for (const { keyringAccount, onChainAccount } of activatedAccountPairs) { + keyringAccountIds.push(keyringAccount.id); + stellarAccountIds.push(onChainAccount.accountId); + } + + // 2. SEP-41 token balances (best effort): + // - Try to load each tracked SEP-41 token for every activated account. + // - If this step throws, the rest of the sync still runs; step 4 can copy missing tokens from the last snapshot. + this.#logger.debug('Load SEP-41 token balances'); + let sep41BalanceFetchResult: Sep41BalanceFetchResult | null = null; + try { + sep41BalanceFetchResult = await this.#synchronizeSep41AssetBalances( + stellarAccountIds, + scope, + ); + } catch { + this.#logger.debug( + 'SEP-41 token balance step failed; merge will reuse last-saved SEP-41 token rows where needed', + ); + } + + // 3. Snap state: latest serialized snapshots before this run (merge source + keyring diff baseline). + this.#logger.debug('Load latest state snapshots for on-chain accounts'); + const latestSerializedAccountSnaphotByKeyringId = + await this.#onChainAccountRepository.findByAccountIds( + keyringAccountIds, + scope, + ); + const lengthOfSnapshot = Object.keys( + latestSerializedAccountSnaphotByKeyringId, + ).length; + this.#logger.debug( + 'Loaded latest state snapshots for on-chain accounts - no of accounts loaded', + { + noOfAccounts: lengthOfSnapshot, + newActivedAccountPairs: + activatedAccountPairs.length - lengthOfSnapshot, + }, + ); + // 4. Per activated account: + // - apply fetched SEP-41 balances (if the fetch step succeeded), + // - restore unresolved SEP-41 rows from the latest state snapshot, + // - compute keyring event deltas, + // - prepare the serialized snapshot payload for one batched save. + const snapshotsToSave: Record = + {}; + let balancesPayload: + | KeyringEventPayload['balances'] + | null = null; + let assetsPayload: + | KeyringEventPayload['assets'] + | null = null; + + this.#logger.debug('Diff full snapshots for on-chain accounts'); + for (const { + keyringAccount, + onChainAccount: synchronizedOnChainAccount, + } of activatedAccountPairs) { + const keyringAccountId = keyringAccount.id; + const latestStateSnapshotSerialized = + latestSerializedAccountSnaphotByKeyringId[keyringAccountId] ?? null; + const stateSnapshotOnChainAccount = + latestStateSnapshotSerialized === null + ? null + : OnChainAccount.fromSerializable(latestStateSnapshotSerialized); + const unresolvedSep41AssetIds = this.#setSep41BalancesForAccount( + synchronizedOnChainAccount, + sep41BalanceFetchResult, + ); + + // fill gaps for SEP-41 tokens using the last saved snapshot from State: + // - If step 2 failed completely, copy every SEP-41 token row from the snapshot that is still missing on `synchronizedOnChainAccount`. + // - If step 2 ran but some token ids failed, copy only those ids from the snapshot when they are still missing. + // - Any SEP-41 token that already has a row from step 2 is left unchanged here. + this.#mergePersistedSep41Rows( + synchronizedOnChainAccount, + latestStateSnapshotSerialized, + unresolvedSep41AssetIds, + ); + + const { balanceChanges, assetListChanges } = + this.#diffFullSnapshotsForKeyring( + stateSnapshotOnChainAccount, + synchronizedOnChainAccount, + ); + + if (balanceChanges !== null) { + balancesPayload ??= {}; + balancesPayload[keyringAccountId] = balanceChanges; + this.#logger.debug( + 'Differences in full snapshots for keyring account - balanceChanges', + { + keyringAccountId, + balanceChangesLength: Object.keys(balanceChanges).length, + }, + ); + } + if (assetListChanges !== null) { + assetsPayload ??= {}; + assetsPayload[keyringAccountId] = assetListChanges; + this.#logger.debug( + 'Differences in full snapshots for keyring account - asset list changes', + { + keyringAccountId, + assetListChangesLength: Object.keys(assetListChanges).length, + }, + ); + } + + snapshotsToSave[keyringAccountId] = + synchronizedOnChainAccount.toSerializableFull(); + } + + // 5. Save the snapshots to the State. + this.#logger.debug('Save snapshots to the State'); + await this.#onChainAccountRepository.saveMany(snapshotsToSave); + + // 6. Emit the keyring events if the balances or the non-native asset list changed. + this.#logger.debug('Emit keyring events'); + await this.#emitKeyringEvents(balancesPayload, assetsPayload); + }); + } + + /** + * Loads each account from Horizon; skips unfunded accounts and logs other failures. + * + * @param accounts - Keyring accounts to load. + * @param scope - CAIP-2 network to query. + * @returns Pairs keyed for SEP-41 sync and persistence. + */ + async #loadActivatedPairs( + accounts: StellarKeyringAccount[], + scope: KnownCaip2ChainId, + ): Promise { + const pairs: ActivatedAccountPair[] = []; + + const results = await Promise.allSettled( + accounts.map(async (account) => ({ + keyringAccount: account, + onChainAccount: await this.#networkService.loadOnChainAccount( + account.address, + scope, + ), + })), + ); + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + pairs.push(result.value); + return; + } + if (result.reason instanceof AccountNotActivatedException) { + return; + } + this.#logger.logErrorWithDetails('Failed to load account for sync', { + accountId: accounts[index]?.id, + error: result.reason, + }); + }); + + return pairs; + } + + /** + * Loads SEP-41 token balances from the network (no per-account mutation here). + * + * @param stellarAccountIds - Stellar account ids to query in one batch call. + * @param scope - Network to query. + * @returns Shared SEP-41 inputs consumed in the main synchronize loop. + */ + async #synchronizeSep41AssetBalances( + stellarAccountIds: string[], + scope: KnownCaip2ChainId, + ): Promise { + // Get all SEP-41 assets for the given scope. + const allAssets = await this.#assetMetadataService.getAllByScope(scope); + + if (allAssets.length === 0) { + this.#logger.debug('No assets found in the state, synchronizing assets'); + // It is possible that the state is empty, due to the first sync. + // Hence, we synchronize the assets once. + await this.#assetMetadataService.synchronize(scope); + } + + const sep41Assets = + await this.#assetMetadataService.getPersistedSep41AssetsMetadata(scope); + + this.#logger.debug('SEP-41 assets to query balances for', { + noOfAssets: sep41Assets.length, + }); + + const assetIds: KnownCaip19Sep41AssetId[] = []; + const sep41AssetSymbols = sep41Assets.reduce< + Record + >((acc, asset) => { + const assetId = asset.assetId as KnownCaip19Sep41AssetId; + acc[assetId] = asset.symbol; + assetIds.push(assetId); + return acc; + }, {}); + + // One batched balance read: Stellar account id → balance per SEP-41 token id. + const sep41AssetBalancesByAccount = + await this.#networkService.getSep41AssetBalances({ + accounts: stellarAccountIds, + assetIds, + scope, + }); + + return { + assetIds, + symbolsByAssetId: sep41AssetSymbols, + balancesByAccountId: sep41AssetBalancesByAccount, + }; + } + + /** + * Applies fetched SEP-41 balances for one account and returns unresolved token ids. + * + * Returning `undefined` means the whole SEP-41 fetch step failed; merge will copy any missing + * persisted SEP-41 rows. Returning a set means the fetch step succeeded and merge should only + * restore rows for token ids still unresolved here. + * + * @param onChainAccount - In-memory account after classic Horizon load; receives nonzero SEP-41 rows. + * @param sep41BalanceFetchResult - Batch balance/symbol data from the SEP-41 step, or `null` if that step failed. + * @returns Token ids that could not be resolved to a balance (for merge from last snapshot), or `undefined` if the fetch step did not run. + */ + #setSep41BalancesForAccount( + onChainAccount: OnChainAccount, + sep41BalanceFetchResult: Sep41BalanceFetchResult | null, + ): Set | undefined { + if (sep41BalanceFetchResult === null) { + return undefined; + } + + const unresolvedSep41AssetIds = new Set(); + // Missing address entry: batch result had no map for this account (often an empty overall result). Not a throw — the call still resolved. + const sep41AssetBalances = + sep41BalanceFetchResult.balancesByAccountId[onChainAccount.accountId] ?? + {}; + for (const assetId of sep41BalanceFetchResult.assetIds) { + const balance = sep41AssetBalances[assetId]; + if (!sep41BalanceFetchResult.symbolsByAssetId[assetId]) { + continue; + } + // No balance value for this SEP-41 token — mark unresolved so the merge step can reuse the last snapshot row. + if (balance === null || balance === undefined) { + unresolvedSep41AssetIds.add(assetId); + continue; + } + // Balance is zero — user does not hold this SEP-41 token; do not add a row (merge will not revive it when the step succeeded). + if (balance.isZero()) { + continue; + } + onChainAccount.setSep41Asset(assetId, { + balance, + symbol: sep41BalanceFetchResult.symbolsByAssetId[assetId], + }); + } + + if (unresolvedSep41AssetIds.size > 0) { + this.#logger.debug('SEP-41 balances unresolved for account', { + accountId: onChainAccount.accountId, + unresolvedAssetIds: Array.from(unresolvedSep41AssetIds), + }); + } + + return unresolvedSep41AssetIds; + } + + /** + * Fills missing **SEP-41 token** rows on `current` using the **last saved snap snapshot** (`persisted`). + * This is normal persisted JSON state, not a temporary cache. + * + * Behaviour: + * - Only rows for SEP-41 tokens; skip tokens already on `current`. + * - If `unresolvedSep41AssetIds` is omitted (whole SEP-41 balance step failed): copy every matching persisted row still missing on `current`. + * - If it is a set (step ran): copy only persisted rows whose token id is in the set and still missing on `current`. + * + * @param current - In-memory account after classic load + any SEP-41 token balances from this run. + * @param persisted - Same account’s snapshot from before this sync (`null` if none). + * @param unresolvedSep41AssetIds - See “Behaviour” above. + * @returns `current` with allowed gaps filled from `persisted`. + */ + #mergePersistedSep41Rows( + current: OnChainAccount, + persisted: OnChainAccountSerializableFull | null, + unresolvedSep41AssetIds?: Set, + ): OnChainAccount { + if (!persisted) { + return current; + } + + for (const row of persisted.balances) { + const { assetId } = row; + if (!isSep41Id(assetId) || current.hasAsset(assetId)) { + continue; + } + + // This SEP-41 token is still missing on `current` after the balance step — restore the last saved row when allowed above. + if ( + unresolvedSep41AssetIds === undefined || + unresolvedSep41AssetIds.has(assetId) + ) { + current.setSep41Asset(assetId, { + balance: new BigNumber(row.balance), + symbol: row.symbol, + }); + } + } + + return current; + } + + /** + * Builds keyring event deltas: + * - `stateSnapshotOnChainAccount`: rehydrated account from the latest serialized state snapshot. + * - `synchronizedOnChainAccount`: in-memory account after merge (matches what was just serialized to state). + * + * @param stateSnapshotOnChainAccount - Latest account from state (`null` on first sync for this id/scope). + * @param synchronizedOnChainAccount - Bound account after Horizon + SEP-41 + merge. + * @returns Nullable payloads for balance and non-native asset-list deltas. + */ + #diffFullSnapshotsForKeyring( + stateSnapshotOnChainAccount: OnChainAccount | null, + synchronizedOnChainAccount: OnChainAccount, + ): { + balanceChanges: Record | null; + assetListChanges: AccountAssetListDelta | null; + } { + const nativeAssetId = getSlip44AssetId(synchronizedOnChainAccount.scope); + const assetIds = new Set([ + ...(stateSnapshotOnChainAccount?.assetIds ?? []), + ...synchronizedOnChainAccount.assetIds, + ]); + + const balanceChanges: Record = {}; + const addedAssets: AccountAssetListDelta['added'] = []; + const removedAssets: AccountAssetListDelta['removed'] = []; + + for (const assetId of assetIds) { + const latestStateRow = + stateSnapshotOnChainAccount === null + ? undefined + : stateSnapshotOnChainAccount.getAsset(assetId); + const currentRow = synchronizedOnChainAccount.getAsset(assetId); + const latestStateBalance = + latestStateRow === undefined + ? undefined + : latestStateRow.balance.toString(); + const currentBalance = + currentRow === undefined ? undefined : currentRow.balance.toString(); + + if (latestStateBalance !== currentBalance) { + balanceChanges[assetId as string] = { + unit: currentRow?.symbol ?? latestStateRow?.symbol ?? '', + amount: currentBalance ?? '0', + }; + } + + if (assetId === nativeAssetId) { + continue; + } + if ( + synchronizedOnChainAccount.hasAsset(assetId) && + !stateSnapshotOnChainAccount?.hasAsset(assetId) + ) { + addedAssets.push(assetId); + } + if ( + stateSnapshotOnChainAccount?.hasAsset(assetId) && + !synchronizedOnChainAccount.hasAsset(assetId) + ) { + removedAssets.push(assetId); + } + } + + return { + balanceChanges: + Object.keys(balanceChanges).length > 0 ? balanceChanges : null, + assetListChanges: + addedAssets.length > 0 || removedAssets.length > 0 + ? { + added: addedAssets, + removed: removedAssets, + } + : null, + }; + } + + async #emitKeyringEvents( + balancesPayload: + | KeyringEventPayload['balances'] + | null, + assetsPayload: + | KeyringEventPayload['assets'] + | null, + ): Promise { + try { + if (balancesPayload !== null) { + await emitSnapKeyringEvent( + getSnapProvider(), + KeyringEvent.AccountBalancesUpdated, + { balances: balancesPayload }, + ); + } + if (assetsPayload !== null) { + await emitSnapKeyringEvent( + getSnapProvider(), + KeyringEvent.AccountAssetListUpdated, + { assets: assetsPayload }, + ); + } + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to emit keyring events after synchronize', + error, + ); + } + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts index 7cf87e0a..d47e550f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts @@ -6,10 +6,12 @@ import type { KnownCaip2ChainId } from '../../../api'; import { logger } from '../../../utils/logger'; import { AccountService } from '../../account/AccountService'; import { AccountsRepository } from '../../account/AccountsRepository'; +import { createMockAssetMetadataService } from '../../asset-metadata/__mocks__/assets.fixtures'; import { NetworkService } from '../../network'; import { State } from '../../state/State'; import { WalletService } from '../../wallet'; import { OnChainAccount } from '../OnChainAccount'; +import { OnChainAccountRepository } from '../OnChainAccountRepository'; import type { OnChainAccountMinimalSerializable, OnChainAccountSerializable, @@ -153,7 +155,7 @@ export function mockOnChainAccountService() { encrypted: false, defaultState: { keyringAccounts: {}, - accountMetadata: {}, + onChainAccounts: {}, }, }); const accountService = new AccountService({ @@ -162,10 +164,18 @@ export function mockOnChainAccountService() { walletService, }); const networkService = new NetworkService({ logger }); - const onChainAccountService = new OnChainAccountService({ networkService }); + const onChainAccountRepository = new OnChainAccountRepository(state); + const { service: assetMetadataService } = createMockAssetMetadataService(); + const onChainAccountService = new OnChainAccountService({ + logger, + networkService, + onChainAccountRepository, + assetMetadataService, + }); return { onChainAccountService, + onChainAccountRepository, accountService, walletService, }; diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts index a170e767..34b86f56 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts @@ -1,3 +1,4 @@ +import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; import type { KnownCaip2ChainId } from '../../api'; /** Per-asset view: native, classic trustline (limit + issuer in `address`), or SEP-41. */ @@ -10,37 +11,17 @@ export type SpendableBalance = { sponsored?: boolean; }; -/** Ledger fields used for native reserve / spendable math (Horizon or persisted snapshot). */ -export type OnChainAccountLedgerMeta = { - subentryCount: number; - numSponsoring: number; - numSponsored: number; -}; - -/** - * Persisted on-chain account header fields for one keyring account on one network, refreshed on sync. - * Does not include trustline balances (see `accountBalances` state). - */ -export type OnChainAccountSnapshot = { - accountId: string; - sequenceNumber: string; - subentryCount: number; - numSponsoring: number; - numSponsored: number; - /** Unix ms when this row was written to snap state. */ - persistedAt?: number; -}; +type AccountId = string; -/** `accountMetadata[keyringAccountId][scope]` → last synced {@link OnChainAccountSnapshot}. */ +/** `onChainAccounts[keyringAccountId][scope]` → last synced snapshot. */ export type OnChainAccountSnapshotsByKeyringId = Record< - string, - Partial> + AccountId, + Partial> >; /** - * Snap state slice for cached on-chain account snapshots. - * The root key stays `accountMetadata` for persisted snap state compatibility. + * Snap state slice for cached on-chain account snapshots (persisted under `onChainAccounts`). */ -export type OnChainAccountSnapshotState = { - accountMetadata: OnChainAccountSnapshotsByKeyringId; +export type OnChainAccountState = { + onChainAccounts: OnChainAccountSnapshotsByKeyringId; }; diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts index d7f0a807..19b69b96 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/index.ts @@ -1,4 +1,6 @@ export type * from './api'; export type * from './OnChainAccountSerializable'; export * from './OnChainAccount'; +export * from './OnChainAccountRepository'; export * from './OnChainAccountService'; +export * from './OnChainAccountSynchronizeService'; From 2c5bd39a165527add3fca69df1304a575b66fa4e Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:09:16 +0800 Subject: [PATCH 121/384] chore: update code comment --- .../on-chain-account/OnChainAccountSynchronizeService.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts index 9b44ea5d..b048ef81 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts @@ -354,7 +354,6 @@ export class OnChainAccountSynchronizeService { } const unresolvedSep41AssetIds = new Set(); - // Missing address entry: batch result had no map for this account (often an empty overall result). Not a throw — the call still resolved. const sep41AssetBalances = sep41BalanceFetchResult.balancesByAccountId[onChainAccount.accountId] ?? {}; From c3c81f8bcc84020a6ae00771683b5185bb0d8eb5 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:13:14 +0800 Subject: [PATCH 122/384] chore: update asset service --- .../asset-metadata/AssetMetadataRepository.ts | 18 ++++++++++++++++++ .../asset-metadata/AssetMetadataService.ts | 12 ++++++++++++ 2 files changed, 30 insertions(+) diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts index 5edc45b5..c0e7268f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts @@ -73,6 +73,24 @@ export class AssetMetadataRepository { ); } + /** + * Returns all persisted assets for the given scope. + * + * @param scope - The chain ID to look up. + * @returns A Promise that resolves to all persisted assets for the given scope. + */ + async getAllByScope( + scope: KnownCaip2ChainId, + ): Promise { + const assets = + (await this.#state.getKey(this.#stateKey)) ?? {}; + + return Object.values(assets).filter( + (asset): asset is StellarAssetMetadata => + asset !== undefined && asset.chainId === scope, + ); + } + /** * Returns persisted assets for the given asset type and chain ID. * diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts index 82475df0..334a3c97 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -135,6 +135,18 @@ export class AssetMetadataService { return persistedAssets; } + /** + * Returns all persisted assets for the given scope. + * + * @param scope - The chain ID to look up. + * @returns A Promise that resolves to all persisted assets for the given scope. + */ + async getAllByScope( + scope: KnownCaip2ChainId, + ): Promise { + return this.#assetMetadataRepository.getAllByScope(scope); + } + /** * Fetches and persists all Assets for the given chain ID from the token API. * From 22d2290392ae43e32ef292d1ede1f9b746b8144d Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:16:47 +0800 Subject: [PATCH 123/384] chore: address comment --- .../src/services/network/MultiCall.ts | 18 +++++++--- .../src/services/network/NetworkService.ts | 5 ++- .../OnChainAccountRepository.ts | 10 ++++-- .../OnChainAccountService.test.ts | 10 +++--- .../on-chain-account/OnChainAccountService.ts | 17 +++++----- .../OnChainAccountSynchronizeService.test.ts | 26 +++++++------- .../OnChainAccountSynchronizeService.ts | 34 +++++++++---------- .../src/services/on-chain-account/api.ts | 4 +-- 8 files changed, 71 insertions(+), 53 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts b/merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts index 6ea21d73..2e349317 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/MultiCall.ts @@ -2,7 +2,6 @@ import { Account, Address, Contract, - Networks, type Operation, rpc, scValToNative, @@ -11,6 +10,10 @@ import { xdr, } from '@stellar/stellar-sdk'; +import { caip2ChainIdToNetwork } from './utils'; +import { KnownCaip2ChainId } from '../../api/network'; +import { BASE_FEE } from '../../constants'; + /** * A simulation account to craft a transaction to simulate the balances read * It is a funded account to prevent the transaction from failing due to insufficient funds. @@ -137,20 +140,27 @@ export class MultiCall { * Simulates a multicall and returns the decoded result value. * * @param invocations - Invocations to batch. - * @param opts - Optional caller and source account overrides. + * @param opts - Optional caller, source account and scope overrides. * @param opts.caller - Account that authorizes the host function call; defaults to the simulation account. * @param opts.source - Transaction `source` account; defaults to the simulation account. + * @param opts.scope - CAIP-2 network ID; defaults to Mainnet. * @returns The simulation result as a native value. */ async simResult( invocations: (InvocationV1 | InvocationV0)[], - opts?: { caller?: string; source?: string }, + opts?: { caller?: string; source?: string; scope?: KnownCaip2ChainId }, ): Promise { const sourceAccount = opts?.source ?? this.#simulationAccount; const callerAccount = opts?.caller ?? this.#simulationAccount; + const scope = opts?.scope ?? KnownCaip2ChainId.Mainnet; const tx: Transaction = new TransactionBuilder( + // The account sequence number is not used for the simulation, + // so we can safely set it to 0. new Account(sourceAccount, '0'), - { networkPassphrase: Networks.PUBLIC, fee: '0' }, + { + networkPassphrase: caip2ChainIdToNetwork(scope), + fee: BASE_FEE.toString(), + }, ) .setTimeout(0) .addOperation(this.exec(callerAccount, invocations)) diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index 677f1dce..3b600d46 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -445,6 +445,7 @@ export class NetworkService { return {}; } + // Multicall is not supported on testnet. if (scope === KnownCaip2ChainId.Testnet) { return {}; } @@ -473,7 +474,9 @@ export class NetworkService { } const totalRecords = accounts.length * assetIds.length; - const simResults: unknown[] = await multiCall.simResult(invocations); + const simResults: unknown[] = await multiCall.simResult(invocations, { + scope, + }); if (simResults.length !== totalRecords) { throw new NetworkServiceException( diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts index e9e89f42..1de5f4da 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts @@ -18,15 +18,17 @@ export class OnChainAccountRepository { } /** + * Find the on-chain account for the given keyring account id from the State. + * * @param keyringAccountId - MetaMask keyring account id (not the Stellar G-address). * @param scope - CAIP-2 chain id for the cached snapshot. * @returns The stored snapshot, or `null` when none exists for this keyring id and scope. */ - async findByAccountId( + async findByKeyringAccountId( keyringAccountId: string, scope: KnownCaip2ChainId, ): Promise { - const snapshotsByAccountId = await this.findByAccountIds( + const snapshotsByAccountId = await this.findByKeyringAccountIds( [keyringAccountId], scope, ); @@ -35,11 +37,13 @@ export class OnChainAccountRepository { } /** + * Find the on-chain accounts for the given keyring account ids from the State. + * * @param keyringAccountIds - MetaMask keyring account ids (not Stellar G-addresses). * @param scope - CAIP-2 chain id for the cached snapshots. * @returns Account id -> snapshot (or `null` when missing for the given scope). */ - async findByAccountIds( + async findByKeyringAccountIds( keyringAccountIds: string[], scope: KnownCaip2ChainId, ): Promise> { diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 93d977d4..b87beeeb 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -143,19 +143,19 @@ describe('OnChainAccountService', () => { }); }); - describe('resolveOnChainAccountByAccountId', () => { + describe('resolveOnChainAccountByKeyringAccountId', () => { it('returns null when no snapshot exists for the keyring id and scope', async () => { const keyringAccountId = globalThis.crypto.randomUUID(); const { onChainAccountService, onChainAccountRepository } = mockOnChainAccountService(); const findByAccountIdSpy = jest.spyOn( onChainAccountRepository, - 'findByAccountId', + 'findByKeyringAccountId', ); findByAccountIdSpy.mockResolvedValue(null); const result = - await onChainAccountService.resolveOnChainAccountByAccountId( + await onChainAccountService.resolveOnChainAccountByKeyringAccountId( keyringAccountId, KnownCaip2ChainId.Mainnet, ); @@ -184,12 +184,12 @@ describe('OnChainAccountService', () => { mockOnChainAccountService(); const findByAccountIdSpy = jest.spyOn( onChainAccountRepository, - 'findByAccountId', + 'findByKeyringAccountId', ); findByAccountIdSpy.mockResolvedValue(binding); const result = - await onChainAccountService.resolveOnChainAccountByAccountId( + await onChainAccountService.resolveOnChainAccountByKeyringAccountId( keyringAccountId, KnownCaip2ChainId.Mainnet, ); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 1a8b626c..d8c069ff 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -89,14 +89,15 @@ export class OnChainAccountService { * @param scope - The CAIP-2 chain id to load the on-chain account for. * @returns The on-chain account, or `null` if not found. */ - async resolveOnChainAccountByAccountId( + async resolveOnChainAccountByKeyringAccountId( keyringAccountId: string, scope: KnownCaip2ChainId, ): Promise { - const onChainAccount = await this.#onChainAccountRepository.findByAccountId( - keyringAccountId, - scope, - ); + const onChainAccount = + await this.#onChainAccountRepository.findByKeyringAccountId( + keyringAccountId, + scope, + ); return onChainAccount ? OnChainAccount.fromSerializable(onChainAccount) : null; @@ -106,15 +107,15 @@ export class OnChainAccountService { * Enriches accounts with SEP-41 balances, persists snapshots, then notifies the keyring when * balances or the tracked asset set changed. Delegates to {@link OnChainAccountSynchronizeService}. * - * @param keyringAccount - Stellar keyring accounts to sync for `scope`. + * @param keyringAccounts - Stellar keyring accounts to sync for `scope`. * @param scope - CAIP-2 network. */ async synchronize( - keyringAccount: StellarKeyringAccount[], + keyringAccounts: StellarKeyringAccount[], scope: KnownCaip2ChainId, ): Promise { await this.#onChainAccountSynchronizeService.synchronize( - keyringAccount, + keyringAccounts, scope, ); } diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts index 8b02b3ad..275d0cbd 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts @@ -31,7 +31,7 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ emitSnapKeyringEvent: jest.fn(), })); -describe('OnChainAccountService.synchronize', () => { +describe('OnChainAccountSynchronizeService', () => { const seed = hexToBytes( '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', ); @@ -54,9 +54,9 @@ describe('OnChainAccountService.synchronize', () => { typeof mockOnChainAccountService >['onChainAccountRepository'], ) => ({ - findByAccountIdsSpy: jest.spyOn( + findByKeyringAccountIdsSpy: jest.spyOn( onChainAccountRepository, - 'findByAccountIds', + 'findByKeyringAccountIds', ), saveManySpy: jest.spyOn(onChainAccountRepository, 'saveMany'), }); @@ -168,7 +168,7 @@ describe('OnChainAccountService.synchronize', () => { ); const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); - const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + const { onChainAccountService, findByKeyringAccountIdsSpy, saveManySpy } = setupSynchronizeService(); await onChainAccountService.synchronize( @@ -177,7 +177,7 @@ describe('OnChainAccountService.synchronize', () => { ); expect(getSep41AssetBalancesSpy).not.toHaveBeenCalled(); - expect(findByAccountIdsSpy).not.toHaveBeenCalled(); + expect(findByKeyringAccountIdsSpy).not.toHaveBeenCalled(); expect(saveManySpy).not.toHaveBeenCalled(); expect(emitSnapKeyringEventSpy).not.toHaveBeenCalled(); }); @@ -233,9 +233,9 @@ describe('OnChainAccountService.synchronize', () => { loadOnChainAccountSpy.mockResolvedValue(onChainAccount); const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); - const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + const { onChainAccountService, findByKeyringAccountIdsSpy, saveManySpy } = setupSynchronizeService(); - findByAccountIdsSpy.mockResolvedValue({ + findByKeyringAccountIdsSpy.mockResolvedValue({ [keyringAccount.id]: binding, }); @@ -300,9 +300,9 @@ describe('OnChainAccountService.synchronize', () => { }); const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); - const { onChainAccountService, findByAccountIdsSpy } = + const { onChainAccountService, findByKeyringAccountIdsSpy } = setupSynchronizeService(); - findByAccountIdsSpy.mockResolvedValue({ + findByKeyringAccountIdsSpy.mockResolvedValue({ [keyringAccount.id]: withSep, }); loadOnChainAccountSpy.mockResolvedValue(onChainAccount); @@ -360,9 +360,9 @@ describe('OnChainAccountService.synchronize', () => { loadOnChainAccountSpy.mockResolvedValue(onChainAccount); const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); - const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + const { onChainAccountService, findByKeyringAccountIdsSpy, saveManySpy } = setupSynchronizeService(); - findByAccountIdsSpy.mockResolvedValue({ + findByKeyringAccountIdsSpy.mockResolvedValue({ [keyringAccount.id]: withPersistedSep41, }); @@ -403,9 +403,9 @@ describe('OnChainAccountService.synchronize', () => { }); loadOnChainAccountSpy.mockResolvedValue(onChainAccount); - const { onChainAccountService, findByAccountIdsSpy, saveManySpy } = + const { onChainAccountService, findByKeyringAccountIdsSpy, saveManySpy } = setupSynchronizeService(); - findByAccountIdsSpy.mockResolvedValue({ + findByKeyringAccountIdsSpy.mockResolvedValue({ [keyringAccount.id]: withPersistedBackupSep41, }); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts index b048ef81..f8e513b4 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts @@ -44,7 +44,7 @@ type Sep41BalanceFetchResult = { * Persists on-chain account snapshots and emits keyring balance / asset-list events after a sync. * * {@link synchronize} uses a mutex so overlapping syncs cannot interleave read–merge–write across - * `findByAccountIds` and `saveMany`. Each `saveMany` call is still one atomic `IStateManager.update`. + * `findByKeyringAccountIds` and `saveMany`. Each `saveMany` call is still one atomic `IStateManager.update`. */ export class OnChainAccountSynchronizeService { readonly #networkService: NetworkService; @@ -133,27 +133,28 @@ export class OnChainAccountSynchronizeService { stellarAccountIds, scope, ); - } catch { - this.#logger.debug( + } catch (error: unknown) { + this.#logger.logErrorWithDetails( 'SEP-41 token balance step failed; merge will reuse last-saved SEP-41 token rows where needed', + error, ); } // 3. Snap state: latest serialized snapshots before this run (merge source + keyring diff baseline). this.#logger.debug('Load latest state snapshots for on-chain accounts'); - const latestSerializedAccountSnaphotByKeyringId = - await this.#onChainAccountRepository.findByAccountIds( + const latestSerializedAccountSnapshotByKeyringId = + await this.#onChainAccountRepository.findByKeyringAccountIds( keyringAccountIds, scope, ); const lengthOfSnapshot = Object.keys( - latestSerializedAccountSnaphotByKeyringId, - ).length; + latestSerializedAccountSnapshotByKeyringId, + ).filter((snapshot) => snapshot !== null).length; this.#logger.debug( 'Loaded latest state snapshots for on-chain accounts - no of accounts loaded', { noOfAccounts: lengthOfSnapshot, - newActivedAccountPairs: + newActivatedAccountPairs: activatedAccountPairs.length - lengthOfSnapshot, }, ); @@ -178,7 +179,7 @@ export class OnChainAccountSynchronizeService { } of activatedAccountPairs) { const keyringAccountId = keyringAccount.id; const latestStateSnapshotSerialized = - latestSerializedAccountSnaphotByKeyringId[keyringAccountId] ?? null; + latestSerializedAccountSnapshotByKeyringId[keyringAccountId] ?? null; const stateSnapshotOnChainAccount = latestStateSnapshotSerialized === null ? null @@ -199,7 +200,7 @@ export class OnChainAccountSynchronizeService { ); const { balanceChanges, assetListChanges } = - this.#diffFullSnapshotsForKeyring( + this.#computeKeyringSyncDeltas( stateSnapshotOnChainAccount, synchronizedOnChainAccount, ); @@ -432,15 +433,14 @@ export class OnChainAccountSynchronizeService { } /** - * Builds keyring event deltas: - * - `stateSnapshotOnChainAccount`: rehydrated account from the latest serialized state snapshot. - * - `synchronizedOnChainAccount`: in-memory account after merge (matches what was just serialized to state). + * Compares persisted on-chain state to the account after this sync and produces keyring + * event data: per-asset balance updates (all assets) and non-native token add/remove. * - * @param stateSnapshotOnChainAccount - Latest account from state (`null` on first sync for this id/scope). - * @param synchronizedOnChainAccount - Bound account after Horizon + SEP-41 + merge. - * @returns Nullable payloads for balance and non-native asset-list deltas. + * @param stateSnapshotOnChainAccount - Last saved account from state, or `null` when none exists. + * @param synchronizedOnChainAccount - Same account after Horizon, SEP-41, and merge steps. + * @returns `balanceChanges` and/or `assetListChanges`, each `null` when that side is unchanged. */ - #diffFullSnapshotsForKeyring( + #computeKeyringSyncDeltas( stateSnapshotOnChainAccount: OnChainAccount | null, synchronizedOnChainAccount: OnChainAccount, ): { diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts index 34b86f56..48895e39 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/api.ts @@ -11,11 +11,11 @@ export type SpendableBalance = { sponsored?: boolean; }; -type AccountId = string; +type KeyringAccountId = string; /** `onChainAccounts[keyringAccountId][scope]` → last synced snapshot. */ export type OnChainAccountSnapshotsByKeyringId = Record< - AccountId, + KeyringAccountId, Partial> >; From 846b066237dbcf8882197022d80193a022e8325d Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Tue, 28 Apr 2026 16:03:25 +0200 Subject: [PATCH 124/384] fix(keyring): read resolveAccountAddress from params.opts.address (SEP-43) --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/keyring/api.test.ts | 46 +++++++++++++++---- .../src/handlers/keyring/api.ts | 12 ++++- .../src/handlers/keyring/exceptions.ts | 2 +- .../src/handlers/keyring/keyring.test.ts | 8 ++-- .../src/handlers/keyring/keyring.ts | 2 +- 6 files changed, 54 insertions(+), 18 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 52e63e9d..227bba59 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "Ic0IEg5Tjc7zzJEhl94Oa+/d8UOzP5YUi5tXw6FUjv8=", + "shasum": "mql9GXWyJelFDQKQ8C3Dva87uPTTkb07ZvtxmREuy84=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts index e17efe9d..7a12e94b 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts @@ -59,26 +59,32 @@ describe('CreateAccountOptionsStruct', () => { describe('ResolveAccountAddressRequestStruct', () => { it.each([ - // Test case: SignMessage are allowed + // Test case: SignMessage with opts.address { jsonrpc: '2.0', id: '1', method: MultichainMethod.SignMessage, - params: { address: account.address }, + params: { opts: { address: account.address } }, }, - // Test case: SignTransaction are allowed + // Test case: SignTransaction with opts.address { jsonrpc: '2.0', id: '1', method: MultichainMethod.SignTransaction, - params: { address: account.address }, + params: { opts: { address: account.address } }, }, - // Test case: Additional Params are allowed + // Test case: SEP-43 method-specific fields pass through (loose params/opts) { jsonrpc: '2.0', id: '1', - method: MultichainMethod.SignTransaction, - params: { address: account.address, message: 'Hello, world!' }, + method: MultichainMethod.SignMessage, + params: { + message: btoa('Hello, world!'), + opts: { + address: account.address, + networkPassphrase: 'Public Global Stellar Network ; September 2015', + }, + }, }, ])( 'accepts a valid resolveAccountAddressJsonRpcRequest request', @@ -102,25 +108,45 @@ describe('ResolveAccountAddressRequestStruct', () => { jsonrpc: '2.0', id: '1', method: 'resolveAccountAddress', - params: { address: account.address }, + params: { opts: { address: account.address } }, }, scope: KnownCaip2ChainId.Mainnet, }, // Test case: Missing JSON-RPC fields { request: { + method: MultichainMethod.SignMessage, + params: { opts: { address: account.address } }, + }, + scope: KnownCaip2ChainId.Mainnet, + }, + // Test case: Invalid address inside opts + { + request: { + jsonrpc: '2.0', + id: '1', + method: MultichainMethod.SignMessage, + params: { opts: { address: 'invalid-address' } }, + }, + scope: KnownCaip2ChainId.Mainnet, + }, + // Test case: Address at the wrong path (top-level params, not opts) + { + request: { + jsonrpc: '2.0', + id: '1', method: MultichainMethod.SignMessage, params: { address: account.address }, }, scope: KnownCaip2ChainId.Mainnet, }, - // Test case: Invalid address + // Test case: Missing opts entirely { request: { jsonrpc: '2.0', id: '1', method: MultichainMethod.SignMessage, - params: { address: 'invalid-address' }, + params: { message: btoa('Hello') }, }, scope: KnownCaip2ChainId.Mainnet, }, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts index c22ca500..3f432167 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts @@ -65,12 +65,22 @@ export const CreateAccountOptionsStruct = optional( /** * Validation struct for the resolveAccountAddress JSON-RPC request. + * + * Per SEP-43, the address that identifies the requested signer lives at + * `params.opts.address` (alongside the method-specific fields like + * `message` / `xdr`). `type()` is used at both levels so the SEP-43 + * payload's other fields pass through untouched — only `opts.address` is + * required for resolution. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md */ export const ResolveAccountAddressJsonRpcRequestStruct = object({ jsonrpc: literal('2.0'), id: union([string(), number(), literal(null)] as const), method: MultichainMethodStruct, - params: type({ address: StellarAddressStruct }), + params: type({ + opts: type({ address: StellarAddressStruct }), + }), }); /** diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts index 87e226ef..bb26d105 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts @@ -202,7 +202,7 @@ export class KeyringResolveAccountAddressException extends KeyringException { request: ResolveAccountAddressJsonRpcRequest, ) { super( - `Failed to resolve account address for scope ${scope} and address ${request.params.address}`, + `Failed to resolve account address for scope ${scope} and address ${request.params.opts.address}`, ); } } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index c1e0cc2b..519540d0 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -550,7 +550,7 @@ describe('KeyringHandler', () => { }); describe('resolveAccountAddress', () => { - it('resolves an account address', async () => { + it('resolves an account address from opts.address', async () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount, @@ -563,7 +563,7 @@ describe('KeyringHandler', () => { id: '1', jsonrpc: '2.0', params: { - address: mockAccount.address, + opts: { address: mockAccount.address }, }, }, ); @@ -588,7 +588,7 @@ describe('KeyringHandler', () => { method: MultichainMethod.SignMessage, id: '1', jsonrpc: '2.0', - params: { address: mockAccount.address }, + params: { opts: { address: mockAccount.address } }, }), ).rejects.toThrow(KeyringResolveAccountAddressException); }); @@ -600,7 +600,7 @@ describe('KeyringHandler', () => { id: '1', jsonrpc: '2.0', params: { - address: mockAccount.address, + opts: { address: mockAccount.address }, }, }), ).rejects.toThrow(InvalidParamsError); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index edd3193e..cd7b122d 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -471,7 +471,7 @@ export class KeyringHandler implements Keyring { try { const { account } = await this.#accountService.resolveAccount({ scope, - accountAddress: request.params.address, + accountAddress: request.params.opts.address, }); return { address: `${scope}:${account.address}` }; } catch (error: unknown) { From 29326f761926206ad57819c67493d10eb4092ee2 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Tue, 28 Apr 2026 19:17:11 +0200 Subject: [PATCH 125/384] fix: return null from resolveAccountAddress when account is missing --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/keyring/keyring.test.ts | 21 ++++++++++++++++++- .../src/handlers/keyring/keyring.ts | 9 +++++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 227bba59..dde1e5f5 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "mql9GXWyJelFDQKQ8C3Dva87uPTTkb07ZvtxmREuy84=", + "shasum": "Hi6aSk/GDRc5ThG13cAe8VuYgkNdcNIYe6jWlqLPu7I=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 519540d0..a2f27393 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -577,7 +577,26 @@ describe('KeyringHandler', () => { }); }); - it('throws an error if the account address resolution fails', async () => { + it('returns null when the account is not in this snap (AccountNotFoundException)', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockRejectedValue( + new AccountNotFoundException(mockAccount.address), + ); + + const result = await keyringHandler.resolveAccountAddress( + KnownCaip2ChainId.Mainnet, + { + method: MultichainMethod.SignMessage, + id: '1', + jsonrpc: '2.0', + params: { opts: { address: mockAccount.address } }, + }, + ); + + expect(result).toBeNull(); + }); + + it('throws an error if the account address resolution fails for other reasons', async () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockRejectedValue( new Error('Account address resolution failed'), diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index cd7b122d..c3c620f6 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -60,6 +60,7 @@ import type { AccountService, StellarKeyringAccount, } from '../../services/account'; +import { AccountNotFoundException } from '../../services/account/exceptions'; import type { AssetMetadataService } from '../../services/asset-metadata'; import { getNativeAssetMetadata } from '../../services/asset-metadata/utils'; import { AccountNotActivatedException } from '../../services/network'; @@ -459,7 +460,7 @@ export class KeyringHandler implements Keyring { async resolveAccountAddress( scope: KnownCaip2ChainId, request: ResolveAccountAddressJsonRpcRequest, - ): Promise { + ): Promise { validateRequest( { request, @@ -475,6 +476,12 @@ export class KeyringHandler implements Keyring { }); return { address: `${scope}:${account.address}` }; } catch (error: unknown) { + // Per the keyring API, returning `null` signals "this snap does not + // own the requested address" so MetaMask's routing layer can try the + // next snap. Throwing here would be treated as a hard routing error. + if (error instanceof AccountNotFoundException) { + return null; + } this.#logger.logErrorWithDetails( 'Failed to resolve account address', ensureError(error).message, From 05026aa788d3c62f8f7ccf0082191a93b583c273 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Wed, 29 Apr 2026 00:46:09 +0200 Subject: [PATCH 126/384] chore: add createAccounts in permissions --- merged-packages/stellar-wallet-snap/src/permissions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/merged-packages/stellar-wallet-snap/src/permissions.ts b/merged-packages/stellar-wallet-snap/src/permissions.ts index 69e07dbc..cbf54db6 100644 --- a/merged-packages/stellar-wallet-snap/src/permissions.ts +++ b/merged-packages/stellar-wallet-snap/src/permissions.ts @@ -28,6 +28,7 @@ const metamaskPermissions = new Set([ KeyringRpcMethod.ListAccounts, KeyringRpcMethod.GetAccount, KeyringRpcMethod.CreateAccount, + KeyringRpcMethod.CreateAccounts, KeyringRpcMethod.DeleteAccount, KeyringRpcMethod.DiscoverAccounts, KeyringRpcMethod.GetAccountBalances, From db87920c38ec47a6d1b20926850eb7b7b9a4bc32 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Wed, 29 Apr 2026 01:05:31 +0200 Subject: [PATCH 127/384] feat: createAccounts --- .../src/handlers/keyring/keyring.test.ts | 108 +++++++++++++++++- .../src/handlers/keyring/keyring.ts | 50 ++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 46b93bce..fd7136ae 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -1,5 +1,6 @@ import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { + AccountCreationType, DiscoveredAccountType, KeyringEvent, KeyringRpcMethod, @@ -31,7 +32,10 @@ import { AccountService, type StellarKeyringAccount, } from '../../services/account'; -import { generateMockStellarKeyringAccounts } from '../../services/account/__mocks__/account.fixtures'; +import { + generateMockStellarKeyringAccounts, + generateStellarKeyringAccount, +} from '../../services/account/__mocks__/account.fixtures'; import { AccountNotFoundException } from '../../services/account/exceptions'; import { createMockAssetMetadataService } from '../../services/asset-metadata/__mocks__/assets.fixtures'; import { AccountNotActivatedException } from '../../services/network'; @@ -277,6 +281,108 @@ describe('KeyringHandler', () => { }); }); + describe('createAccounts', () => { + it('creates one account for bip44:derive-index without emitting AccountCreated', async () => { + const { createAccountSpy } = getAccountServiceSpies(); + createAccountSpy.mockResolvedValue(mockAccount); + const emitSnapKeyringEventSpy = jest.mocked(emitSnapKeyringEvent); + + const result = await keyringHandler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: entropySourceId, + groupIndex: 2, + }); + + expect(createAccountSpy).toHaveBeenCalledTimes(1); + expect(createAccountSpy).toHaveBeenCalledWith({ + entropySource: entropySourceId, + index: 2, + }); + expect(result).toStrictEqual([toKeyringAccount(mockAccount)]); + expect(emitSnapKeyringEventSpy).not.toHaveBeenCalled(); + }); + + it('creates accounts for each index in bip44:derive-index-range', async () => { + const { createAccountSpy } = getAccountServiceSpies(); + const accountAt1 = generateStellarKeyringAccount( + 'id-1', + mockAccount.address, + entropySourceId, + 1, + ); + const accountAt2 = generateStellarKeyringAccount( + 'id-2', + mockAccount.address, + entropySourceId, + 2, + ); + const accountAt3 = generateStellarKeyringAccount( + 'id-3', + mockAccount.address, + entropySourceId, + 3, + ); + createAccountSpy + .mockResolvedValueOnce(accountAt1) + .mockResolvedValueOnce(accountAt2) + .mockResolvedValueOnce(accountAt3); + + const result = await keyringHandler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + entropySource: entropySourceId, + range: { from: 1, to: 3 }, + }); + + expect(createAccountSpy).toHaveBeenCalledTimes(3); + expect(createAccountSpy).toHaveBeenNthCalledWith(1, { + entropySource: entropySourceId, + index: 1, + }); + expect(createAccountSpy).toHaveBeenNthCalledWith(2, { + entropySource: entropySourceId, + index: 2, + }); + expect(createAccountSpy).toHaveBeenNthCalledWith(3, { + entropySource: entropySourceId, + index: 3, + }); + expect(result).toHaveLength(3); + expect(result[0]?.options).toMatchObject({ + entropy: expect.objectContaining({ groupIndex: 1 }), + }); + expect(result[1]?.options).toMatchObject({ + entropy: expect.objectContaining({ groupIndex: 2 }), + }); + expect(result[2]?.options).toMatchObject({ + entropy: expect.objectContaining({ groupIndex: 3 }), + }); + expect(jest.mocked(emitSnapKeyringEvent)).not.toHaveBeenCalled(); + }); + + it('throws KeyringCreateAccountException when account creation fails', async () => { + const { createAccountSpy } = getAccountServiceSpies(); + createAccountSpy.mockRejectedValue(new Error('Batch create failed')); + + await expect( + keyringHandler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + entropySource: entropySourceId, + groupIndex: 0, + }), + ).rejects.toThrow(KeyringCreateAccountException); + }); + + it('throws when create account option type is not supported', async () => { + await expect( + keyringHandler.createAccounts({ + type: AccountCreationType.Bip44Discover, + entropySource: entropySourceId, + groupIndex: 0, + }), + ).rejects.toThrow('Unsupported create account option type'); + }); + }); + describe('listAccountAssets', () => { it('returns on-chain asset ids for the account', async () => { const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index edd3193e..22f7f1a9 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -1,7 +1,10 @@ import { + AccountCreationType, + assertCreateAccountOptionIsSupported, DiscoveredAccountType, KeyringEvent, type Balance, + type CreateAccountOptions as KeyringApiCreateAccountOptions, type DiscoveredAccount, type EntropySourceId, type Keyring, @@ -178,6 +181,53 @@ export class KeyringHandler implements Keyring { } } + /** + * Batch account creation for the Snap keyring v2 path (no `AccountCreated` events). + * + * @param options - BIP-44 derive-index or derive-index-range options from the keyring API. + * @returns Keyring accounts created or already present for each index. + */ + async createAccounts( + options: KeyringApiCreateAccountOptions, + ): Promise { + assertCreateAccountOptionIsSupported(options, [ + `${AccountCreationType.Bip44DeriveIndex}`, + `${AccountCreationType.Bip44DeriveIndexRange}`, + ] as const); + + try { + const accounts: KeyringAccount[] = []; + + if (options.type === AccountCreationType.Bip44DeriveIndex) { + const account = await this.#accountService.create({ + entropySource: options.entropySource, + index: options.groupIndex, + }); + accounts.push(this.#toKeyringAccount(account)); + } else { + for ( + let groupIndex = options.range.from; + groupIndex <= options.range.to; + groupIndex += 1 + ) { + const account = await this.#accountService.create({ + entropySource: options.entropySource, + index: groupIndex, + }); + accounts.push(this.#toKeyringAccount(account)); + } + } + + return accounts; + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to create accounts', + ensureError(error).message, + ); + throw new KeyringCreateAccountException(); + } + } + /** * Emits the account-created event to the wallet. * This triggers the wallet to prompt the user to add the account. From acedfe36bd80acdad87695f6128ef018531d3dcb Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Wed, 29 Apr 2026 01:06:35 +0200 Subject: [PATCH 128/384] fix: balance formatting --- .../src/handlers/keyring/keyring.ts | 8 ++----- .../src/utils/currency.test.ts | 16 +++++++++++++ .../stellar-wallet-snap/src/utils/currency.ts | 23 +++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 22f7f1a9..f013cb42 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -78,7 +78,7 @@ import { getSnapProvider, isSep41Id, isSlip44Id, - normalizeAmount, + formatBalanceAmountForKeyringApi, rethrowIfInstanceElseThrow, validateOrigin, validateRequest, @@ -478,11 +478,7 @@ export class KeyringHandler implements Keyring { const decimal = assetMetadata.units[0].decimals; assetBalances[assetId] = { unit: asset.symbol ?? '', - amount: normalizeAmount( - asset.balance, - decimal, - // TODO: Handle decimal places overflow - ).toString(), + amount: formatBalanceAmountForKeyringApi(asset.balance, decimal), }; } return assetBalances; diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts index 21e919fa..2f4462dd 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts @@ -2,6 +2,7 @@ import type { CaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { + formatBalanceAmountForKeyringApi, formatFiat, getFiatTicker, isFiat, @@ -38,6 +39,21 @@ describe('normalizeAmount', () => { }); }); +describe('formatBalanceAmountForKeyringApi', () => { + it('avoids scientific notation for one stroop', () => { + expect(formatBalanceAmountForKeyringApi(new BigNumber(1), 7)).toBe( + '0.0000001', + ); + expect(normalizeAmount(new BigNumber(1), 7).toString()).toBe('1e-7'); + }); + + it('trims trailing zeros while keeping significant fractional digits', () => { + expect(formatBalanceAmountForKeyringApi(new BigNumber(10), 7)).toBe( + '0.000001', + ); + }); +}); + describe('toSmallestUnit and normalizeAmount', () => { it('roundtrips for representative values', () => { const human = new BigNumber('12.3456789'); diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.ts index 8a29ffac..4cf17e68 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/currency.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.ts @@ -38,6 +38,29 @@ export function normalizeAmount( return amount.dividedBy(BigNumber(10).pow(decimalPlaces)); } +/** + * Decimal string for keyring / MetaMask multichain balances. + * {@link BigNumber#toString} may use scientific notation (e.g. `1e-7`); the extension's + * `parseBalanceWithDecimals` only accepts `\d+(\.\d+)?`, so we use `toFixed` and trim + * redundant trailing zeros. + * + * @param amountInSmallestUnit - Balance in the asset's smallest unit (e.g. stroops). + * @param decimalPlaces - Asset decimals (e.g. 7 for XLM / classic Stellar assets). + */ +export function formatBalanceAmountForKeyringApi( + amountInSmallestUnit: BigNumber, + decimalPlaces: number, +): string { + const fixed = normalizeAmount(amountInSmallestUnit, decimalPlaces).toFixed( + decimalPlaces, + ); + if (!fixed.includes('.')) { + return fixed; + } + const trimmed = fixed.replace(/0+$/u, '').replace(/\.$/u, ''); + return trimmed === '' ? '0' : trimmed; +} + /** * Formats a number as currency (half-up rounded to 2 decimal places). * From d961349649cff5c45bbfcd2ac58e055aeccb43f3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:21:33 +0800 Subject: [PATCH 129/384] chore: update test --- .../OnChainAccountSynchronizeService.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts index 275d0cbd..9fd40dfb 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts @@ -86,6 +86,10 @@ describe('OnChainAccountSynchronizeService', () => { return payload[keyringAccountId] as OnChainAccountSerializableFull; }; + const getOnChainAccountServiceSpies = () => ({ + setSep41AssetSpy: jest.spyOn(OnChainAccount.prototype, 'setSep41Asset'), + }); + const setupTest = () => { jest.mocked(emitSnapKeyringEvent).mockResolvedValue(undefined); const metadata = generateMockStellarAssetMetadata(); @@ -337,6 +341,33 @@ describe('OnChainAccountSynchronizeService', () => { ); }); + it('does not restore SEP-41 rows when SEP-41 balance fetch fails and no persisted snapshot is found', async () => { + setupTest(); + + const { keyringAccount, onChainAccount } = setupOnChainAccountWithBalance( + 'entropy-sync-fallback-all-fail', + ); + const { getSep41AssetBalancesSpy, loadOnChainAccountSpy } = + getNetworkServiceSpies(); + getSep41AssetBalancesSpy.mockRejectedValue( + new Error('sep41 fetch temporarily unavailable'), + ); + loadOnChainAccountSpy.mockResolvedValue(onChainAccount); + + const { setSep41AssetSpy } = getOnChainAccountServiceSpies(); + const { onChainAccountService, findByKeyringAccountIdsSpy } = + setupSynchronizeService(); + // Mock no persisted snapshot is found for the account. + findByKeyringAccountIdsSpy.mockResolvedValue({ [keyringAccount.id]: null }); + + await onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ); + + expect(setSep41AssetSpy).not.toHaveBeenCalled(); + }); + it('restores persisted SEP-41 rows when SEP-41 balance fetch fails', async () => { setupTest(); @@ -422,4 +453,33 @@ describe('OnChainAccountSynchronizeService', () => { expect(resolvedSep41Row?.balance).toBe('500'); expect(restoredSep41Row?.balance).toBe('250'); }); + + it('does not emit keyring events when saveMany fails', async () => { + setupTest(); + + const { signer, keyringAccount, onChainAccount } = + setupOnChainAccountWithBalance('entropy-sync-1'); + const { getSep41AssetBalancesSpy, loadOnChainAccountSpy } = + getNetworkServiceSpies(); + getSep41AssetBalancesSpy.mockResolvedValue({ + [signer.publicKey()]: { + [sep41Id]: new BigNumber('1000'), + }, + }); + loadOnChainAccountSpy.mockResolvedValue(onChainAccount); + + const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); + const { onChainAccountService, saveManySpy } = setupSynchronizeService(); + saveManySpy.mockRejectedValue(new Error('saveMany failed')); + + await expect( + onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ), + ).rejects.toThrow('saveMany failed'); + + expect(saveManySpy).toHaveBeenCalled(); + expect(emitSnapKeyringEventSpy).not.toHaveBeenCalled(); + }); }); From 97e2532f1a353d053edcf933dfd9317e5b4c6dab Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:24:20 +0800 Subject: [PATCH 130/384] chore: update test --- .../OnChainAccountSynchronizeService.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts index 275d0cbd..9fd40dfb 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts @@ -86,6 +86,10 @@ describe('OnChainAccountSynchronizeService', () => { return payload[keyringAccountId] as OnChainAccountSerializableFull; }; + const getOnChainAccountServiceSpies = () => ({ + setSep41AssetSpy: jest.spyOn(OnChainAccount.prototype, 'setSep41Asset'), + }); + const setupTest = () => { jest.mocked(emitSnapKeyringEvent).mockResolvedValue(undefined); const metadata = generateMockStellarAssetMetadata(); @@ -337,6 +341,33 @@ describe('OnChainAccountSynchronizeService', () => { ); }); + it('does not restore SEP-41 rows when SEP-41 balance fetch fails and no persisted snapshot is found', async () => { + setupTest(); + + const { keyringAccount, onChainAccount } = setupOnChainAccountWithBalance( + 'entropy-sync-fallback-all-fail', + ); + const { getSep41AssetBalancesSpy, loadOnChainAccountSpy } = + getNetworkServiceSpies(); + getSep41AssetBalancesSpy.mockRejectedValue( + new Error('sep41 fetch temporarily unavailable'), + ); + loadOnChainAccountSpy.mockResolvedValue(onChainAccount); + + const { setSep41AssetSpy } = getOnChainAccountServiceSpies(); + const { onChainAccountService, findByKeyringAccountIdsSpy } = + setupSynchronizeService(); + // Mock no persisted snapshot is found for the account. + findByKeyringAccountIdsSpy.mockResolvedValue({ [keyringAccount.id]: null }); + + await onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ); + + expect(setSep41AssetSpy).not.toHaveBeenCalled(); + }); + it('restores persisted SEP-41 rows when SEP-41 balance fetch fails', async () => { setupTest(); @@ -422,4 +453,33 @@ describe('OnChainAccountSynchronizeService', () => { expect(resolvedSep41Row?.balance).toBe('500'); expect(restoredSep41Row?.balance).toBe('250'); }); + + it('does not emit keyring events when saveMany fails', async () => { + setupTest(); + + const { signer, keyringAccount, onChainAccount } = + setupOnChainAccountWithBalance('entropy-sync-1'); + const { getSep41AssetBalancesSpy, loadOnChainAccountSpy } = + getNetworkServiceSpies(); + getSep41AssetBalancesSpy.mockResolvedValue({ + [signer.publicKey()]: { + [sep41Id]: new BigNumber('1000'), + }, + }); + loadOnChainAccountSpy.mockResolvedValue(onChainAccount); + + const { emitSnapKeyringEventSpy } = getKeyringEventSpies(); + const { onChainAccountService, saveManySpy } = setupSynchronizeService(); + saveManySpy.mockRejectedValue(new Error('saveMany failed')); + + await expect( + onChainAccountService.synchronize( + [keyringAccount], + KnownCaip2ChainId.Mainnet, + ), + ).rejects.toThrow('saveMany failed'); + + expect(saveManySpy).toHaveBeenCalled(); + expect(emitSnapKeyringEventSpy).not.toHaveBeenCalled(); + }); }); From 69c40e157a159394fb9d0abf26756a5eb66a1d8a Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:22:25 +0800 Subject: [PATCH 131/384] fix: token api --- .../token-api/TokenApiClient.test.ts | 188 +++++++++++++++++- .../token-api/TokenApiClient.ts | 49 +++-- .../services/asset-metadata/token-api/api.ts | 25 ++- .../src/utils/exceptions.ts | 9 + 4 files changed, 253 insertions(+), 18 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/utils/exceptions.ts diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.test.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.test.ts index 7b4a9261..deb25afb 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.test.ts @@ -2,6 +2,12 @@ import { TokenApiClient } from './TokenApiClient'; import { AssetType, KnownCaip2ChainId } from '../../../api'; import { buildUrl, logger } from '../../../utils'; +const pubnetClassicUsdc = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as const; + +const sep41AssetIdC = + 'stellar:pubnet/sep41:CBGV2QFQBBGEQRUKUMCPO3SZOHDDYO6SCP5CH6TW7EALKVHCXTMWDDOF' as const; + jest.mock('../../../config', () => ({ AppConfig: { api: { @@ -45,6 +51,33 @@ const tokenApiClientOptions = { chunkSize: 2, } as const; +/** + * Batches of chunks can be executed in parallel; the mock must branch on the request URL + * to return a failure for one chunk and success for the other. + * + * @param secondChunkRequestMarker - Substring present only in the successful chunk request URL. + * @param secondChunkResponseBody - JSON body for a 200 response for that chunk. + */ +function createFetchForPartiallyFailingBatches( + secondChunkRequestMarker: string, + secondChunkResponseBody: unknown, +): typeof fetch { + return async (url: RequestInfo | URL) => { + let href: string; + if (typeof url === 'string') { + href = url; + } else if (url instanceof URL) { + href = url.href; + } else { + href = url.url; + } + if (href.includes(secondChunkRequestMarker)) { + return jsonResponse(secondChunkResponseBody); + } + return jsonResponse([], { ok: false, status: 503 }); + }; +} + describe('TokenApiClient', () => { const mockFetch = jest.fn() as jest.MockedFunction; @@ -53,6 +86,9 @@ describe('TokenApiClient', () => { beforeEach(() => { jest.clearAllMocks(); + // clearAllMocks does not remove mockImplementation; a prior test that used it + // would otherwise take precedence over mockResolvedValueOnce. + mockFetch.mockReset(); }); describe('getTokensMetadata', () => { @@ -193,12 +229,160 @@ describe('TokenApiClient', () => { ); }); - it('wraps invalid response bodies in TokenApiException', async () => { + it('returns empty array when response body is invalid (batch is skipped)', async () => { mockFetch.mockResolvedValueOnce(jsonResponse({ notAnArray: true })); + const client = createClient(); + expect(await client.getTokensMetadata([classicAssetId])).toStrictEqual( + [], + ); + }); + + it('returns metadata from successful chunk when another chunk fails', async () => { + mockFetch.mockImplementation( + createFetchForPartiallyFailingBatches('CBGV2QFQBBGEQR', [ + { + assetId: sep41AssetIdB, + decimals: 18, + name: 'B', + symbol: 'B', + }, + ]), + ); + + const client = createClient(); + const result = await client.getTokensMetadata([ + pubnetClassicUsdc, + sep41AssetIdA, + sep41AssetIdB, + sep41AssetIdC, + ]); + + expect(result).toHaveLength(1); + expect(result[0]?.assetId).toBe(sep41AssetIdB); + }); + }); + + describe('getAllTokensMetadata', () => { + it('requests chain token API and maps data array to metadata', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: [ + { + assetId: sep41AssetIdA, + decimals: 7, + name: 'A', + symbol: 'A', + }, + ], + count: 1, + totalCount: 1, + }), + ); + + const client = createClient(); + const result = await client.getAllTokensMetadata( + KnownCaip2ChainId.Mainnet, + ); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const urlArg = mockFetch.mock.calls[0]?.[0]; + expect(urlArg).toBe( + buildUrl({ + baseUrl: 'https://tokens.test', + path: `/v3/chains/${KnownCaip2ChainId.Mainnet}/assets`, + queryParams: { + first: '1000', + includeIconUrl: 'true', + includeDuplicateSymbolAssets: 'true', + useAggregatorIcons: 'true', + }, + }), + ); + + const row = result.find((entry) => entry.assetId === sep41AssetIdA); + expect(row).toMatchObject({ + name: 'A', + symbol: 'A', + chainId: KnownCaip2ChainId.Mainnet, + assetType: AssetType.Sep41, + fungible: true, + units: [{ name: 'A', symbol: 'A', decimals: 7 }], + }); + expect(row?.iconUrl).toBe( + buildUrl({ + baseUrl: 'https://static.test', + path: '/api/v2/tokenIcons/assets/{assetId}.png', + pathParams: { + assetId: sep41AssetIdA.replace(/:/gu, '/'), + }, + encodePathParams: false, + }), + ); + }); + + it('returns empty array when response data is empty', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: [], + count: 0, + totalCount: 0, + }), + ); + + const client = createClient(); + expect( + await client.getAllTokensMetadata(KnownCaip2ChainId.Testnet), + ).toStrictEqual([]); + }); + + it('uses response iconUrl when provided', async () => { + const iconUrl = 'https://cdn.example/chain-asset.png'; + mockFetch.mockResolvedValueOnce( + jsonResponse({ + data: [ + { + assetId: sep41AssetIdA, + decimals: 7, + name: 'A', + symbol: 'A', + iconUrl, + }, + ], + count: 1, + totalCount: 1, + }), + ); + + const client = createClient(); + const result = await client.getAllTokensMetadata( + KnownCaip2ChainId.Mainnet, + ); + expect(result.find((row) => row.assetId === sep41AssetIdA)?.iconUrl).toBe( + iconUrl, + ); + }); + + it('rejects with TokenApiException on HTTP error', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: [] }, { ok: false, status: 502 }), + ); + + const client = createClient(); + await expect( + client.getAllTokensMetadata(KnownCaip2ChainId.Mainnet), + ).rejects.toMatchObject({ + name: 'TokenApiException', + message: 'HTTP error! status: 502', + }); + }); + + it('rejects with TokenApiException when body does not match schema', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse([{ notValid: true }])); + const client = createClient(); await expect( - client.getTokensMetadata([classicAssetId]), + client.getAllTokensMetadata(KnownCaip2ChainId.Mainnet), ).rejects.toMatchObject({ name: 'TokenApiException', message: 'Failed to fetch token metadata', diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts index e0c48dc1..0289672c 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts @@ -1,12 +1,19 @@ +import { assert } from '@metamask/superstruct'; import { - assert, ensureError, parseCaipAssetType, type NonEmptyArray, } from '@metamask/utils'; -import type { TokenMetadata, TokenMetadataResponse } from './api'; -import { TokenMetadataResponseStruct } from './api'; +import type { + TokenMetadata, + TokenMetadataByAssetIdsResponse, + TokenMetadataByChainIdResponse, +} from './api'; +import { + TokenMetadataByAssetIdsResponseStruct, + TokenMetadatabyChainIdResponseStruct, +} from './api'; import { TokenApiException } from './exceptions'; import type { AssetType, @@ -52,7 +59,7 @@ export class TokenApiClient { async #fetchTokenMetadataBatch( assetIds: KnownCaip19AssetIdOrSlip44Id[], - ): Promise { + ): Promise { try { const url = buildUrl({ baseUrl: this.#baseUrl, @@ -70,7 +77,7 @@ export class TokenApiClient { const data = await response.json(); - assert(TokenMetadataResponseStruct, data); + assert(data, TokenMetadataByAssetIdsResponseStruct); return data; } catch (error) { @@ -96,7 +103,7 @@ export class TokenApiClient { */ async #fetchAllTokensMetadata( scope: KnownCaip2ChainId, - ): Promise { + ): Promise { try { const url = buildUrl({ baseUrl: this.#baseUrl, @@ -117,7 +124,7 @@ export class TokenApiClient { const data = await response.json(); - assert(TokenMetadataResponseStruct, data); + assert(data, TokenMetadatabyChainIdResponseStruct); return data; } catch (error) { @@ -168,7 +175,11 @@ export class TokenApiClient { 'Error fetching token metadata', ensureError(error).message, ); - throw new TokenApiException(`Failed to fetch token metadata`); + return rethrowIfInstanceElseThrow( + error, + [TokenApiException], + new TokenApiException(`Failed to fetch token metadata`), + ); } } @@ -181,11 +192,23 @@ export class TokenApiClient { async getAllTokensMetadata( scope: KnownCaip2ChainId, ): Promise { - const tokenMetadataResponses = await this.#fetchAllTokensMetadata(scope); - // Note: it is possible that the token metadata does not contain all the asset ids. - return tokenMetadataResponses.map((tokenMetadata) => - this.#toAssetMetadata(tokenMetadata), - ); + try { + const tokenMetadataResponses = await this.#fetchAllTokensMetadata(scope); + // Note: it is possible that the token metadata does not contain all the asset ids. + return tokenMetadataResponses.data.map((tokenMetadata) => + this.#toAssetMetadata(tokenMetadata), + ); + } catch (error) { + this.#logger.logErrorWithDetails( + 'Error fetching token metadata', + ensureError(error).message, + ); + return rethrowIfInstanceElseThrow( + error, + [TokenApiException], + new TokenApiException(`Failed to fetch token metadata`), + ); + } } #toAssetMetadata(tokenMetadata: TokenMetadata): StellarAssetMetadata { diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts index 9a90da74..277ac7f0 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts @@ -7,25 +7,44 @@ import { string, union, nonempty, + number, } from '@metamask/superstruct'; import { KnownCaip19ClassicAssetStruct, KnownCaip19Sep41AssetStruct, + KnownCaip19Slip44IdStruct, UrlStruct, } from '../../../api'; export const TokenMetadataStruct = object({ decimals: integer(), // there should be no slip44 assets in the token metadata response - assetId: union([KnownCaip19ClassicAssetStruct, KnownCaip19Sep41AssetStruct]), + assetId: union([ + KnownCaip19ClassicAssetStruct, + KnownCaip19Sep41AssetStruct, + KnownCaip19Slip44IdStruct, + ]), name: optional(nonempty(string())), symbol: optional(nonempty(string())), iconUrl: optional(UrlStruct), }); -export const TokenMetadataResponseStruct = array(TokenMetadataStruct); +export const TokenMetadataByAssetIdsResponseStruct = array(TokenMetadataStruct); -export type TokenMetadataResponse = Infer; +export const TokenMetadatabyChainIdResponseStruct = object({ + data: array(TokenMetadataStruct), + count: number(), + totalCount: number(), + // Accept any fields from the pageInfo object, as we don't use them yet + pageInfo: optional(object()), +}); + +export type TokenMetadataByAssetIdsResponse = Infer< + typeof TokenMetadataByAssetIdsResponseStruct +>; +export type TokenMetadataByChainIdResponse = Infer< + typeof TokenMetadatabyChainIdResponseStruct +>; export type TokenMetadata = Infer; diff --git a/merged-packages/stellar-wallet-snap/src/utils/exceptions.ts b/merged-packages/stellar-wallet-snap/src/utils/exceptions.ts new file mode 100644 index 00000000..43cbb66d --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/utils/exceptions.ts @@ -0,0 +1,9 @@ +export class HttpException extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'HttpException'; + this.status = status; + } +} From fde0b2b4f7fec746ee085932355e322849b511e9 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:25:39 +0800 Subject: [PATCH 132/384] feat/add account sync cronjob --- .../stellar-wallet-snap/snap.manifest.json | 14 +- .../stellar-wallet-snap/src/context.ts | 8 + .../src/handlers/cronjob/api.test.ts | 175 ++++++++++++++++++ .../src/handlers/cronjob/api.ts | 24 ++- .../src/handlers/cronjob/base.ts | 2 +- .../src/handlers/cronjob/cronjob.ts | 7 +- .../cronjob/refreshConfirmationPrices.ts | 21 ++- .../src/handlers/cronjob/syncAccounts.test.ts | 135 ++++++++++++++ .../src/handlers/cronjob/syncAccounts.ts | 72 +++++++ .../src/handlers/cronjob/trackTransaction.ts | 8 +- .../src/handlers/keyring/keyring.test.ts | 71 +++++-- .../src/handlers/keyring/keyring.ts | 52 ++++-- .../src/services/account/AccountService.ts | 39 ++++ .../src/ui/confirmation/controller.tsx | 3 +- .../src/utils/__mocks__/snap.ts | 18 +- .../stellar-wallet-snap/src/utils/snap.ts | 14 +- 16 files changed, 591 insertions(+), 72 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/syncAccounts.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/syncAccounts.ts diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index eddde23d..fd18bd64 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -36,7 +36,19 @@ "snap_manageState": {}, "snap_dialog": {}, "snap_getPreferences": {}, - "endowment:cronjob": {}, + "endowment:cronjob": { + "jobs": [ + { + "duration": "PT30S", + "request": { + "method": "synchronizeAccounts", + "params": { + "accountIds": "selected" + } + } + } + ] + }, "endowment:assets": { "scopes": ["stellar:pubnet"] } diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 4fc0387d..70e09b4e 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -6,6 +6,7 @@ import { AssetsHandler } from './handlers/asset/assets'; import type { ICronjobRequestHandler } from './handlers/cronjob/api'; import { BackgroundEventMethod } from './handlers/cronjob/api'; import { RefreshConfirmationPricesHandler } from './handlers/cronjob/refreshConfirmationPrices'; +import { SyncAccountsHandler } from './handlers/cronjob/syncAccounts'; import { TrackTransactionHandler } from './handlers/cronjob/trackTransaction'; import type { IKeyringRequestHandler } from './handlers/keyring'; import { @@ -151,6 +152,12 @@ const trackTransactionHandler = new TrackTransactionHandler({ logger, }); +const syncAccountsHandler = new SyncAccountsHandler({ + logger, + accountService, + onChainAccountService, +}); + const cronjobMethodHandlers: Record< BackgroundEventMethod, ICronjobRequestHandler @@ -158,6 +165,7 @@ const cronjobMethodHandlers: Record< [BackgroundEventMethod.RefreshConfirmationPrices]: refreshConfirmationPricesHandler, [BackgroundEventMethod.TrackTransaction]: trackTransactionHandler, + [BackgroundEventMethod.SynchronizeAccounts]: syncAccountsHandler, }; const cronjobHandler = new CronjobHandler({ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts new file mode 100644 index 00000000..4076431a --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts @@ -0,0 +1,175 @@ +import { assert, StructError } from '@metamask/superstruct'; + +import { + BackgroundEventMethod, + BackgroundEventMethodStruct, + CronjobJsonRpcRequestStruct, + RefreshConfirmationPricesJsonRpcRequestStruct, + SyncAccountJsonRpcRequestStruct, + SyncAccountParamsStruct, + TrackTransactionJsonRpcRequestStruct, +} from './api'; +import { KnownCaip2ChainId } from '../../api'; +import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; + +describe('Cronjob API structs', () => { + const jsonRpcBase = { + jsonrpc: '2.0', + id: 'request-id', + } as const; + + describe('BackgroundEventMethodStruct', () => { + it.each(Object.values(BackgroundEventMethod))( + 'accepts %s', + (methodValue) => { + expect(() => + assert(methodValue, BackgroundEventMethodStruct), + ).not.toThrow(); + }, + ); + + it('rejects unknown method values', () => { + expect(() => + assert('unknownBackgroundMethod', BackgroundEventMethodStruct), + ).toThrow(StructError); + }); + }); + + describe('SyncAccountParamsStruct', () => { + it('accepts selected as accountIds value', () => { + const value = { accountIds: 'selected' as const }; + assert(value, SyncAccountParamsStruct); + expect(value).toStrictEqual({ accountIds: 'selected' }); + }); + + it('accepts non-empty account id arrays', () => { + const id = '4dd94666-52a0-4478-91f8-979292f91fae'; + const value = { accountIds: [id] }; + assert(value, SyncAccountParamsStruct); + expect(value).toStrictEqual({ accountIds: [id] }); + }); + + it('rejects empty account id arrays', () => { + expect(() => assert({ accountIds: [] }, SyncAccountParamsStruct)).toThrow( + StructError, + ); + }); + + it('rejects unknown properties', () => { + expect(() => + assert( + { accountIds: 'selected', extra: 'not-allowed' }, + SyncAccountParamsStruct, + ), + ).toThrow(StructError); + }); + }); + + describe('SyncAccountJsonRpcRequestStruct', () => { + it('accepts synchronize accounts requests', () => { + const value = { + ...jsonRpcBase, + method: BackgroundEventMethod.SynchronizeAccounts, + params: { accountIds: 'selected' as const }, + }; + assert(value, SyncAccountJsonRpcRequestStruct); + expect(value).toStrictEqual({ + ...jsonRpcBase, + method: BackgroundEventMethod.SynchronizeAccounts, + params: { accountIds: 'selected' }, + }); + }); + + it('rejects wrong method for synchronize accounts request', () => { + expect(() => + assert( + { + ...jsonRpcBase, + method: BackgroundEventMethod.TrackTransaction, + params: { accountIds: 'selected' }, + }, + SyncAccountJsonRpcRequestStruct, + ), + ).toThrow(StructError); + }); + }); + + describe('RefreshConfirmationPricesJsonRpcRequestStruct', () => { + it('accepts refresh confirmation prices requests', () => { + const value = { + ...jsonRpcBase, + method: BackgroundEventMethod.RefreshConfirmationPrices, + params: { + scope: KnownCaip2ChainId.Mainnet, + interfaceId: 'interface-id', + interfaceKey: ConfirmationInterfaceKey.SignTransaction, + }, + }; + assert(value, RefreshConfirmationPricesJsonRpcRequestStruct); + expect(value).toStrictEqual({ + ...jsonRpcBase, + method: BackgroundEventMethod.RefreshConfirmationPrices, + params: { + scope: KnownCaip2ChainId.Mainnet, + interfaceId: 'interface-id', + interfaceKey: ConfirmationInterfaceKey.SignTransaction, + }, + }); + }); + }); + + describe('TrackTransactionJsonRpcRequestStruct', () => { + it('accepts track transaction requests', () => { + const value = { + ...jsonRpcBase, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId: 'tx-id', + scope: KnownCaip2ChainId.Mainnet, + accountIds: ['4dd94666-52a0-4478-91f8-979292f91fae'], + }, + }; + assert(value, TrackTransactionJsonRpcRequestStruct); + expect(value).toStrictEqual({ + ...jsonRpcBase, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId: 'tx-id', + scope: KnownCaip2ChainId.Mainnet, + accountIds: ['4dd94666-52a0-4478-91f8-979292f91fae'], + }, + }); + }); + + it('rejects invalid accountIds values', () => { + expect(() => + assert( + { + ...jsonRpcBase, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId: 'tx-id', + scope: KnownCaip2ChainId.Mainnet, + accountIds: ['invalid-uuid'], + }, + }, + TrackTransactionJsonRpcRequestStruct, + ), + ).toThrow(StructError); + }); + }); + + describe('CronjobJsonRpcRequestStruct', () => { + it('accepts successful cronjob responses', () => { + const value = { status: true }; + assert(value, CronjobJsonRpcRequestStruct); + expect(value).toStrictEqual({ status: true }); + }); + + it('rejects non-boolean status values', () => { + expect(() => + assert({ status: 'true' }, CronjobJsonRpcRequestStruct), + ).toThrow(StructError); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts index 79766a8c..6ddc3d0f 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts @@ -9,6 +9,7 @@ import { object, string, type, + union, } from '@metamask/superstruct'; import type { Json, JsonRpcRequest } from '@metamask/utils'; @@ -26,11 +27,8 @@ export type ICronjobRequestHandler = { handle: (request: JsonRpcRequest) => Promise; }; -export enum CronjobMethod { - SynchronizeAssets = 'synchronizeAssets', -} - export enum BackgroundEventMethod { + SynchronizeAccounts = 'synchronizeAccounts', RefreshConfirmationPrices = 'refreshConfirmationPrices', TrackTransaction = 'trackTransaction', } @@ -51,6 +49,10 @@ export const TrackTransactionParamsStruct = type({ accountIds: nonempty(array(UuidStruct)), }); +export const SyncAccountParamsStruct = object({ + accountIds: union([nonempty(array(UuidStruct)), literal('selected')]), +}); + export const RefreshConfirmationPricesJsonRpcRequestStruct = assign( JsonRpcRequestStruct, object({ @@ -67,6 +69,14 @@ export const TrackTransactionJsonRpcRequestStruct = assign( }), ); +export const SyncAccountJsonRpcRequestStruct = assign( + JsonRpcRequestStruct, + object({ + method: literal(BackgroundEventMethod.SynchronizeAccounts), + params: SyncAccountParamsStruct, + }), +); + export const CronjobJsonRpcRequestStruct = object({ status: boolean(), }); @@ -86,3 +96,9 @@ export type TrackTransactionJsonRpcRequest = Infer< >; export type TrackTransactionParams = Infer; + +export type SyncAccountJsonRpcRequest = Infer< + typeof SyncAccountJsonRpcRequestStruct +>; + +export type SyncAccountParams = Infer; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/base.ts index c1374b6e..7da5a8db 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/base.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/base.ts @@ -30,5 +30,5 @@ export abstract class CronjobBaseHandler< }; } - abstract handleCronJobRequest(request: RequestType): Promise; + protected abstract handleCronJobRequest(request: RequestType): Promise; } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts index 4ac9f9f4..df1fa597 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts @@ -1,5 +1,4 @@ import type { JsonRpcRequest } from '@metamask/snaps-sdk'; -import { ensureError } from '@metamask/utils'; import type { BackgroundEventMethod, ICronjobRequestHandler } from './api'; import { BackgroundEventMethodStruct } from './api'; @@ -25,16 +24,16 @@ export class CronjobHandler { return; } - await this.#handleClientRequest(request); + await this.#handleRequest(request); } - async #handleClientRequest(request: JsonRpcRequest): Promise { + async #handleRequest(request: JsonRpcRequest): Promise { const { method } = request; const [validateError, validatedMethod] = BackgroundEventMethodStruct.validate(method); if (validateError !== undefined) { - throw ensureError(new CronjobMethodNotFoundError(method)); + throw new CronjobMethodNotFoundError(method); } const handler = this.#handlers[validatedMethod]; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts index 82ed6bb0..98553960 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts @@ -23,6 +23,7 @@ import type { ConfirmationUXController } from '../../ui/confirmation/controller' import type { ILogger } from '../../utils/logger'; import { createPrefixedLogger } from '../../utils/logger'; import { + Duration, getInterfaceContextIfExists, scheduleBackgroundEvent, } from '../../utils/snap'; @@ -30,12 +31,9 @@ import { export class RefreshConfirmationPricesHandler extends CronjobBaseHandler { readonly #priceService: PriceService; - // Refresh interval - static readonly duration = 'PT20S'; - static async scheduleBackgroundEvent( params: RefreshConfirmationPricesParams, - duration: string = RefreshConfirmationPricesHandler.duration, + duration: Duration = Duration.TwentySeconds, ): Promise { await scheduleBackgroundEvent({ method: BackgroundEventMethod.RefreshConfirmationPrices, @@ -72,7 +70,7 @@ export class RefreshConfirmationPricesHandler extends CronjobBaseHandler { this.logger.info('Refreshing confirmation prices...'); @@ -134,11 +132,14 @@ export class RefreshConfirmationPricesHandler extends CronjobBaseHandler { + const mockEntropySourceId = 'entropy-source-1'; + const [firstAccount, secondAccount] = generateMockStellarKeyringAccounts( + 2, + mockEntropySourceId, + ) as [StellarKeyringAccount, StellarKeyringAccount]; + + const setupTest = () => { + const accountService: jest.Mocked< + Pick + > = { + getAllSelected: jest.fn(), + findByIds: jest.fn(), + }; + const onChainAccountService: jest.Mocked< + Pick + > = { + synchronize: jest.fn(), + }; + + const handler = new SyncAccountsHandler({ + logger, + accountService: accountService as unknown as AccountService, + onChainAccountService: + onChainAccountService as unknown as OnChainAccountService, + }); + + return { + handler, + accountService, + onChainAccountService, + }; + }; + + it('schedules background event for selected accounts with default duration', async () => { + await SyncAccountsHandler.scheduleBackgroundEvent({ + accountIds: 'selected', + }); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration: Duration.OneSecond, + request: { + method: BackgroundEventMethod.SynchronizeAccounts, + params: { + accountIds: 'selected', + }, + }, + }, + }); + }); + + it('schedules background event with the provided duration', async () => { + const accountIds = [firstAccount.id]; + + await SyncAccountsHandler.scheduleBackgroundEvent( + { accountIds }, + Duration.FiveSeconds, + ); + + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration: Duration.FiveSeconds, + request: { + method: BackgroundEventMethod.SynchronizeAccounts, + params: { + accountIds, + }, + }, + }, + }); + }); + + it('synchronizes selected accounts when accountIds is `selected`', async () => { + const { handler, accountService, onChainAccountService } = setupTest(); + const selectedAccounts = [firstAccount, secondAccount]; + accountService.getAllSelected.mockResolvedValue(selectedAccounts); + + const request = { + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.SynchronizeAccounts, + params: { accountIds: 'selected' }, + }; + + await handler.handle(request); + + expect(accountService.getAllSelected).toHaveBeenCalledTimes(1); + expect(accountService.findByIds).not.toHaveBeenCalled(); + expect(onChainAccountService.synchronize).toHaveBeenCalledWith( + selectedAccounts, + AppConfig.selectedNetwork, + ); + }); + + it('synchronizes accounts fetched by ids when accountIds is an array of account ids', async () => { + const { handler, accountService, onChainAccountService } = setupTest(); + const accountIds = [firstAccount.id, secondAccount.id]; + const accountsByIds = [firstAccount]; + accountService.findByIds.mockResolvedValue(accountsByIds); + + const request = { + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.SynchronizeAccounts, + params: { accountIds }, + }; + + await handler.handle(request); + + expect(accountService.findByIds).toHaveBeenCalledWith(accountIds); + expect(accountService.getAllSelected).not.toHaveBeenCalled(); + expect(onChainAccountService.synchronize).toHaveBeenCalledWith( + accountsByIds, + AppConfig.selectedNetwork, + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/syncAccounts.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/syncAccounts.ts new file mode 100644 index 00000000..ea296496 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/syncAccounts.ts @@ -0,0 +1,72 @@ +import type { SyncAccountJsonRpcRequest, SyncAccountParams } from './api'; +import { BackgroundEventMethod, SyncAccountJsonRpcRequestStruct } from './api'; +import { CronjobBaseHandler } from './base'; +import { AppConfig } from '../../config'; +import type { + AccountService, + StellarKeyringAccount, +} from '../../services/account'; +import type { OnChainAccountService } from '../../services/on-chain-account'; +import { Duration, scheduleBackgroundEvent } from '../../utils'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; + +export class SyncAccountsHandler extends CronjobBaseHandler { + static async scheduleBackgroundEvent( + params: SyncAccountParams, + duration: Duration = Duration.OneSecond, + ): Promise { + await scheduleBackgroundEvent({ + method: BackgroundEventMethod.SynchronizeAccounts, + params, + duration, + }); + } + + readonly #onChainAccountService: OnChainAccountService; + + readonly #accountService: AccountService; + + constructor({ + logger, + onChainAccountService, + accountService, + }: { + logger: ILogger; + onChainAccountService: OnChainAccountService; + accountService: AccountService; + }) { + const prefixedLogger = createPrefixedLogger( + logger, + '[SyncAccountsHandler]', + ); + super({ + logger: prefixedLogger, + requestStruct: SyncAccountJsonRpcRequestStruct, + }); + this.#onChainAccountService = onChainAccountService; + this.#accountService = accountService; + } + + protected async handleCronJobRequest( + request: SyncAccountJsonRpcRequest, + ): Promise { + const scope = AppConfig.selectedNetwork; + const { + params: { accountIds }, + } = request; + + let accounts: StellarKeyringAccount[] = []; + if (accountIds === 'selected') { + this.logger.debug('Synchronizing selected accounts'); + accounts = await this.#accountService.getAllSelected(); + } else { + this.logger.debug('Synchronizing accounts by IDs', { + accountIds, + }); + accounts = await this.#accountService.findByIds(accountIds); + } + + await this.#onChainAccountService.synchronize(accounts, scope); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts index eaa21889..9c1466d9 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts @@ -9,14 +9,12 @@ import { import { CronjobBaseHandler } from './base'; import type { ILogger } from '../../utils/logger'; import { createPrefixedLogger } from '../../utils/logger'; -import { scheduleBackgroundEvent } from '../../utils/snap'; +import { Duration, scheduleBackgroundEvent } from '../../utils/snap'; export class TrackTransactionHandler extends CronjobBaseHandler { - static readonly duration = 'PT1S'; - static async scheduleBackgroundEvent( params: TrackTransactionParams, - duration: string = TrackTransactionHandler.duration, + duration: Duration = Duration.OneSecond, ): Promise { await scheduleBackgroundEvent({ method: BackgroundEventMethod.TrackTransaction, @@ -36,7 +34,7 @@ export class TrackTransactionHandler extends CronjobBaseHandler { // TODO: Implement transaction tracking. diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 46b93bce..f9479036 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -34,7 +34,6 @@ import { import { generateMockStellarKeyringAccounts } from '../../services/account/__mocks__/account.fixtures'; import { AccountNotFoundException } from '../../services/account/exceptions'; import { createMockAssetMetadataService } from '../../services/asset-metadata/__mocks__/assets.fixtures'; -import { AccountNotActivatedException } from '../../services/network'; import { OnChainAccountService } from '../../services/on-chain-account'; import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; import type { OnChainAccount } from '../../services/on-chain-account/OnChainAccount'; @@ -46,9 +45,11 @@ import { getSlip44AssetId, getDefaultEntropySource, getSnapProvider, + Duration, } from '../../utils'; import { bufferToUint8Array } from '../../utils/buffer'; import { logger } from '../../utils/logger'; +import { SyncAccountsHandler } from '../cronjob/syncAccounts'; jest.mock('../../utils/logger'); jest.mock('../../utils/snap'); @@ -283,7 +284,10 @@ describe('KeyringHandler', () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) .mockResolvedValue({ assetIds: [slipId], } as unknown as OnChainAccount); @@ -298,13 +302,11 @@ describe('KeyringHandler', () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') - .mockRejectedValue( - new AccountNotActivatedException( - mockAccount.address, - KnownCaip2ChainId.Mainnet, - ), - ); + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue(null); const result = await keyringHandler.listAccountAssets(mockAccountId); @@ -315,7 +317,10 @@ describe('KeyringHandler', () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) .mockRejectedValue(new Error('Horizon unavailable')); await expect( @@ -489,7 +494,10 @@ describe('KeyringHandler', () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) .mockResolvedValue({ assetIds: [slipId], getAsset: () => ({ @@ -512,13 +520,11 @@ describe('KeyringHandler', () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') - .mockRejectedValue( - new AccountNotActivatedException( - mockAccount.address, - KnownCaip2ChainId.Mainnet, - ), - ); + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue(null); const result = await keyringHandler.getAccountBalances(mockAccountId, [ slipId, @@ -534,7 +540,10 @@ describe('KeyringHandler', () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) .mockRejectedValue(new Error('Horizon unavailable')); await expect( @@ -778,4 +787,28 @@ describe('KeyringHandler', () => { expect(mockSignTransactionHandler.handle).not.toHaveBeenCalled(); }); }); + + describe('setSelectedAccounts', () => { + it('schedules a background event to synchronize the selected accounts', async () => { + const syncSpy = jest.spyOn( + SyncAccountsHandler, + 'scheduleBackgroundEvent', + ); + + await keyringHandler.setSelectedAccounts([mockAccountId]); + + expect(syncSpy).toHaveBeenCalledWith( + { + accountIds: [mockAccountId], + }, + Duration.OneSecond, + ); + }); + + it('throws an error if the account ids are invalid', async () => { + await expect( + keyringHandler.setSelectedAccounts(['invalid:account:id']), + ).rejects.toThrow(InvalidParamsError); + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index edd3193e..c117b1c2 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -62,7 +62,6 @@ import type { } from '../../services/account'; import type { AssetMetadataService } from '../../services/asset-metadata'; import { getNativeAssetMetadata } from '../../services/asset-metadata/utils'; -import { AccountNotActivatedException } from '../../services/network'; import type { OnChainAccount, OnChainAccountService, @@ -71,6 +70,7 @@ import type { TransactionService } from '../../services/transaction/TransactionS import type { ILogger } from '../../utils'; import { createPrefixedLogger, + Duration, getSlip44AssetId, getSnapProvider, isSep41Id, @@ -81,6 +81,7 @@ import { validateRequest, withCatchAndThrowSnapError, } from '../../utils'; +import { SyncAccountsHandler } from '../cronjob/syncAccounts'; export class KeyringHandler implements Keyring { readonly #logger: ILogger; @@ -252,6 +253,11 @@ export class KeyringHandler implements Keyring { scope, ); + // If the account is not activated or not yet synced, return the native asset with zero balance + if (onChainAccount === null) { + return [getSlip44AssetId(scope)]; + } + // Non-SEP-41 (native + classic): always list. SEP-41: only if row exists and balance > 0. return onChainAccount.assetIds.filter((assetId) => { return ( @@ -259,10 +265,6 @@ export class KeyringHandler implements Keyring { ); }); } catch (error: unknown) { - // Always include native asset in the response when the account is not activated - if (error instanceof AccountNotActivatedException) { - return [getSlip44AssetId(scope)]; - } this.#logger.logErrorWithDetails( 'Failed to list account assets', ensureError(error).message, @@ -402,6 +404,18 @@ export class KeyringHandler implements Keyring { scope, ); + // If the account is not activated or not yet synced, return the native asset with zero balance + if (onChainAccount === null) { + const nativeAssetId = assets.find(isSlip44Id); + if (nativeAssetId !== undefined) { + assetBalances[nativeAssetId] = { + unit: getNativeAssetMetadata(scope).symbol ?? '', + amount: '0', + }; + } + return assetBalances; + } + const assetsMetadata = await this.#assetMetadataService.getAssetsMetadataByAssetIds(assets); @@ -437,17 +451,6 @@ export class KeyringHandler implements Keyring { } return assetBalances; } catch (error: unknown) { - if (error instanceof AccountNotActivatedException) { - const nativeAssetId = assets.find(isSlip44Id); - if (nativeAssetId !== undefined) { - assetBalances[nativeAssetId] = { - unit: getNativeAssetMetadata(scope).symbol ?? '', - amount: '0', - }; - } - return assetBalances; - } - this.#logger.logErrorWithDetails( 'Failed to get account balances', ensureError(error).message, @@ -522,6 +525,14 @@ export class KeyringHandler implements Keyring { async setSelectedAccounts(accountIds: string[]): Promise { validateRequest(accountIds, SetSelectedAccountsRequestStruct); + + await SyncAccountsHandler.scheduleBackgroundEvent( + { + accountIds, + }, + // Start immediately + Duration.OneSecond, + ); } async submitRequest(request: KeyringRequest): Promise { @@ -545,15 +556,18 @@ export class KeyringHandler implements Keyring { scope: KnownCaip2ChainId, ): Promise<{ account: StellarKeyringAccount; - onChainAccount: OnChainAccount; + onChainAccount: OnChainAccount | null; }> { const { account } = await this.#accountService.resolveAccount({ accountId, }); + // We read the on-chain account from state, which is synced in the background. + // This improves performance compared to fetching account data from the network on every request. + // The trade-off is that the data can be slightly stale within the sync window. const onChainAccount = - await this.#onChainAccountService.resolveOnChainAccount( - account.address, + await this.#onChainAccountService.resolveOnChainAccountByKeyringAccountId( + accountId, scope, ); diff --git a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts index f3c8490b..5fbf444f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts @@ -1,4 +1,5 @@ import type { EntropySourceId } from '@metamask/keyring-api'; +import { getSelectedAccounts } from '@metamask/keyring-snap-sdk'; import { ensureError } from '@metamask/utils'; import type { AccountsRepository } from './AccountsRepository'; @@ -18,6 +19,7 @@ import { createPrefixedLogger, getDefaultEntropySource, getLowestIndex, + getSnapProvider, } from '../../utils'; import { getDerivationPath, type WalletService } from '../wallet'; @@ -219,6 +221,16 @@ export class AccountService { return await this.#accountsRepository.getAll(); } + /** + * Lists all Stellar accounts in the keyring by their IDs. + * + * @param ids - The IDs of the accounts to find. + * @returns A Promise that resolves to the list of accounts that match the given IDs. + */ + async findByIds(ids: string[]): Promise { + return await this.#accountsRepository.findByIds(ids); + } + /** * Finds a Stellar account by ID. * @@ -229,6 +241,33 @@ export class AccountService { return (await this.#accountsRepository.findById(id)) ?? undefined; } + /** + * Retrieves all selected Stellar accounts. + * Selected accounts are accounts that are selected by the user in the MetaMask Client. + * + * @returns A Promise that resolves to the list of all selected accounts. + */ + async getAllSelected(): Promise { + const [allAccounts, selectedAccountIds] = await Promise.all([ + this.#accountsRepository.getAll(), + getSelectedAccounts(getSnapProvider()), + ]); + + this.#logger.debug( + 'getAllSelected:', + 'selectedAccountIds', + selectedAccountIds, + 'allAccounts', + allAccounts.map((account) => account.id), + ); + + const selectedAccountIdsSet = new Set(selectedAccountIds); + + return allAccounts.filter((account) => + selectedAccountIdsSet.has(account.id), + ); + } + async #resolveKeyringAccountByAddress({ scope, address, diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 901a0eb1..92d843ac 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -16,6 +16,7 @@ import type { ILogger, Locale } from '../../utils'; import { createInterface, createPrefixedLogger, + Duration, getSlip44AssetId, scheduleBackgroundEvent, showDialog, @@ -184,7 +185,7 @@ export class ConfirmationUXController { // Trigger immediate price fetch (1 second), then continue every 20 seconds await scheduleBackgroundEvent({ method: BackgroundEventMethod.RefreshConfirmationPrices, - duration: 'PT1S', // Start immediately + duration: Duration.OneSecond, // Start immediately params: { scope, interfaceId: id, diff --git a/merged-packages/stellar-wallet-snap/src/utils/__mocks__/snap.ts b/merged-packages/stellar-wallet-snap/src/utils/__mocks__/snap.ts index c7299a94..416baf31 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/__mocks__/snap.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/__mocks__/snap.ts @@ -14,10 +14,14 @@ export const listEntropySources = jest.fn(); export const getDefaultEntropySource = jest.fn(); -export const { getState } = actual; -export const { setState } = actual; -export const { updateState } = actual; -export const { createInterface } = actual; -export const { showDialog } = actual; -export const { getPreferences } = actual; -export const { resolveInterface } = actual; +export const { + getState, + setState, + updateState, + createInterface, + showDialog, + getPreferences, + resolveInterface, + scheduleBackgroundEvent, + Duration, +} = actual; diff --git a/merged-packages/stellar-wallet-snap/src/utils/snap.ts b/merged-packages/stellar-wallet-snap/src/utils/snap.ts index 890756f9..1dccd4b0 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/snap.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/snap.ts @@ -14,6 +14,18 @@ import type { import { type Serializable, serialize, deserialize } from './serialization'; +export enum Duration { + OneSecond = 'PT1S', + FiveSeconds = 'PT5S', + TwentySeconds = 'PT20S', + ThirtySeconds = 'PT30S', + OneMinute = 'PT1M', + FiveMinutes = 'PT5M', + TenMinutes = 'PT10M', + ThirtyMinutes = 'PT30M', + OneHour = 'PT1H', +} + /** * Returns the Snap provider. * @@ -194,7 +206,7 @@ export async function scheduleBackgroundEvent({ }: { method: string; params?: Record; - duration: string; + duration: Duration; }): Promise { return getSnapProvider().request({ method: 'snap_scheduleBackgroundEvent', From 74268f82f2483291af7d49a248aae10e7eb4eb70 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:44:01 +0800 Subject: [PATCH 133/384] chore: remove unused file --- .../stellar-wallet-snap/src/utils/exceptions.ts | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 merged-packages/stellar-wallet-snap/src/utils/exceptions.ts diff --git a/merged-packages/stellar-wallet-snap/src/utils/exceptions.ts b/merged-packages/stellar-wallet-snap/src/utils/exceptions.ts deleted file mode 100644 index 43cbb66d..00000000 --- a/merged-packages/stellar-wallet-snap/src/utils/exceptions.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class HttpException extends Error { - readonly status: number; - - constructor(message: string, status: number) { - super(message); - this.name = 'HttpException'; - this.status = status; - } -} From bf7426e08b0f6ca539c1e27f7051dbccc7ebd8cb Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:54:42 +0800 Subject: [PATCH 134/384] fix: comment --- .../token-api/TokenApiClient.ts | 26 +++++-------------- .../services/asset-metadata/token-api/api.ts | 5 ++-- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts index 0289672c..38e4f363 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.ts @@ -12,7 +12,7 @@ import type { } from './api'; import { TokenMetadataByAssetIdsResponseStruct, - TokenMetadatabyChainIdResponseStruct, + TokenMetadataByChainIdResponseStruct, } from './api'; import { TokenApiException } from './exceptions'; import type { @@ -124,7 +124,7 @@ export class TokenApiClient { const data = await response.json(); - assert(data, TokenMetadatabyChainIdResponseStruct); + assert(data, TokenMetadataByChainIdResponseStruct); return data; } catch (error) { @@ -192,23 +192,11 @@ export class TokenApiClient { async getAllTokensMetadata( scope: KnownCaip2ChainId, ): Promise { - try { - const tokenMetadataResponses = await this.#fetchAllTokensMetadata(scope); - // Note: it is possible that the token metadata does not contain all the asset ids. - return tokenMetadataResponses.data.map((tokenMetadata) => - this.#toAssetMetadata(tokenMetadata), - ); - } catch (error) { - this.#logger.logErrorWithDetails( - 'Error fetching token metadata', - ensureError(error).message, - ); - return rethrowIfInstanceElseThrow( - error, - [TokenApiException], - new TokenApiException(`Failed to fetch token metadata`), - ); - } + const tokenMetadataResponses = await this.#fetchAllTokensMetadata(scope); + // Note: it is possible that the token metadata does not contain all the asset ids. + return tokenMetadataResponses.data.map((tokenMetadata) => + this.#toAssetMetadata(tokenMetadata), + ); } #toAssetMetadata(tokenMetadata: TokenMetadata): StellarAssetMetadata { diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts index 277ac7f0..ce9a027f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/api.ts @@ -19,7 +19,6 @@ import { export const TokenMetadataStruct = object({ decimals: integer(), - // there should be no slip44 assets in the token metadata response assetId: union([ KnownCaip19ClassicAssetStruct, KnownCaip19Sep41AssetStruct, @@ -32,7 +31,7 @@ export const TokenMetadataStruct = object({ export const TokenMetadataByAssetIdsResponseStruct = array(TokenMetadataStruct); -export const TokenMetadatabyChainIdResponseStruct = object({ +export const TokenMetadataByChainIdResponseStruct = object({ data: array(TokenMetadataStruct), count: number(), totalCount: number(), @@ -44,7 +43,7 @@ export type TokenMetadataByAssetIdsResponse = Infer< typeof TokenMetadataByAssetIdsResponseStruct >; export type TokenMetadataByChainIdResponse = Infer< - typeof TokenMetadatabyChainIdResponseStruct + typeof TokenMetadataByChainIdResponseStruct >; export type TokenMetadata = Infer; From 3941efac900b2a216c59a325a5869065d86aab44 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:56:31 +0800 Subject: [PATCH 135/384] fix: test --- .../asset-metadata/token-api/TokenApiClient.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.test.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.test.ts index deb25afb..51e29357 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/token-api/TokenApiClient.test.ts @@ -84,13 +84,6 @@ describe('TokenApiClient', () => { const createClient = () => new TokenApiClient(tokenApiClientOptions, logger, mockFetch); - beforeEach(() => { - jest.clearAllMocks(); - // clearAllMocks does not remove mockImplementation; a prior test that used it - // would otherwise take precedence over mockResolvedValueOnce. - mockFetch.mockReset(); - }); - describe('getTokensMetadata', () => { it('returns empty array when assetIds is empty', async () => { const client = createClient(); From 22747189317a5f8240ce58d5c8ee3a0eb675d691 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:34:08 +0800 Subject: [PATCH 136/384] feat: add local transaction simulation --- .../transaction/TransactionSimulator.test.ts | 1399 +++++++++++++++++ .../transaction/TransactionSimulator.ts | 409 +++++ .../src/services/transaction/index.ts | 1 + .../services/transaction/simulation/api.ts | 72 + .../services/transaction/simulation/index.ts | 3 + .../transaction/simulation/simulators.ts | 411 +++++ .../services/transaction/simulation/utils.ts | 133 ++ 7 files changed, 2428 insertions(+) create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/simulation/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/simulation/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/simulation/utils.ts diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts new file mode 100644 index 00000000..c453510e --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts @@ -0,0 +1,1399 @@ +import type { Operation } from '@stellar/stellar-sdk'; +import { + Account, + Asset, + Keypair, + nativeToScVal, + Networks, + Operation as StellarOperation, + TransactionBuilder, +} from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import { + InsufficientBalanceException, + InsufficientBalanceToCoverBaseReserveException, + InsufficientBalanceToCoverFeeException, + InvalidAmountForCreateAccountException, + InvalidInvokeContractStructureException, + RemoveTrustlineWithNonZeroBalanceException, + TransactionScopeNotMatchException, + TransactionValidationException, + TrustlineNotAuthorizedException, + TrustlineNotFoundException, + UnsupportedOperationTypeException, + UpdateTrustlineException, +} from './exceptions'; +import { Transaction } from './Transaction'; +import { + SupportedOperations, + TransactionSimulator, +} from './TransactionSimulator'; +import { KnownCaip2ChainId } from '../../api'; +import { caip2ChainIdToNetwork } from '../network/utils'; +import { + createMockAccountWithBalances, + horizonSource, + type MockAccountWithBalancesData, +} from '../on-chain-account/__mocks__/onChainAccount.fixtures'; +import { OnChainAccount } from '../on-chain-account/OnChainAccount'; +import { + buildMockClassicTransaction, + buildMockInvokeHostFunctionTransaction, + type BuildMockTransactionOptions, +} from './__mocks__/transaction.fixtures'; +import { getTestWallet } from '../wallet/__mocks__/wallet.fixtures'; + +const SEP41_ASSET_MAINNET = + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as const; + +const SEP41_CONTRACT_MAINNET = + 'CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as const; + +const USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + +/** Source account used for Soroban `invokeHostFunction` simulator tests (mainnet). */ +const SOROBAN_INVOKE_SOURCE = + 'GDRZ4B4X2GCM3IINPEBUYQXTO2GJX6YDTV5OMLC7TKTGL33WNEKLUSKF'; + +const MOCK_USDC_ASSET = { code: 'USDC', issuer: USDC_ISSUER } as const; + +const SWAP_TEST_CONTRACT_ID = + 'CASUP2OPFVEHCWGP2XLBXOV7DQIQIT42AQISG4MXAZGNLVFFN63X7WRT'; + +const MAX_TRUST_LIMIT = '922337203685.4775807'; + +/** + * Default {@link buildMockClassicTransaction} / Soroban mock options for mainnet envelopes + * in this file (matches prior `buildEnvelopeTransaction` fee and time bounds). + * + * @param source - The source account id (`G…`). + * @param sequence - The source sequence number. + * @param overrides - The overrides for the transaction options. + * @param overrides.baseFeePerOperation - The base fee per operation. + * @param overrides.timeout - The timeout. + * @returns The transaction options. + */ +function mainnetSimulatorTxOptions( + source: string, + sequence: string, + overrides?: Partial< + Pick + >, +): BuildMockTransactionOptions { + return { + networkPassphrase: Networks.PUBLIC, + source: { accountId: source, sequence }, + baseFeePerOperation: overrides?.baseFeePerOperation ?? '100', + timeout: overrides?.timeout ?? 30, + }; +} + +/** + * Builds a mock transaction with a single Soroban `invokeHostFunction` operation. + * + * @returns A {@link Transaction} wrapper. + */ +function buildSoleNonSep41InvokeTx(): Transaction { + return buildMockInvokeHostFunctionTransaction( + 'swap', + [ + 'GDRZ4B4X2GCM3IINPEBUYQXTO2GJX6YDTV5OMLC7TKTGL33WNEKLUSKF', + 'GDRZ4B4X2GCM3IINPEBUYQXTO2GJX6YDTV5OMLC7TKTGL33WNEKLUSKF', + ], + { + ...mainnetSimulatorTxOptions(SOROBAN_INVOKE_SOURCE, '1'), + contractId: SWAP_TEST_CONTRACT_ID, + argNativeToScValOptions: [{ type: 'address' }, { type: 'address' }], + }, + ); +} + +/** + * Builds a wrapped transaction with one SEP-41 `transfer(from, to, amount)` invoke (for simulator tests). + * + * @param params - Transfer build parameters. + * @param params.source - Transaction source account id (`G…`). + * @param params.sequence - Source sequence string. + * @param params.contractId - Token contract id (`C…`). + * @param params.from - `transfer` `from` address. + * @param params.to - `transfer` `to` address. + * @param params.amountSmallestUnits - Amount in token smallest units (integer string). + * @param params.feeStroops - Optional fee in stroops. + * @param params.scope - Optional CAIP-2 chain id (defaults to mainnet). + * @returns A {@link Transaction} wrapper. + */ +function buildSep41TransferTransaction(params: { + source: string; + sequence: string; + contractId: string; + from: string; + to: string; + amountSmallestUnits: string; + feeStroops?: string; + scope?: KnownCaip2ChainId; +}): Transaction { + const scope = params.scope ?? KnownCaip2ChainId.Mainnet; + return buildMockInvokeHostFunctionTransaction( + 'transfer', + [params.from, params.to, params.amountSmallestUnits], + { + source: { accountId: params.source, sequence: params.sequence }, + baseFeePerOperation: params.feeStroops ?? '100', + networkPassphrase: caip2ChainIdToNetwork(scope), + contractId: params.contractId, + timeout: 30, + argNativeToScValOptions: [ + { type: 'address' }, + { type: 'address' }, + { type: 'i128' }, + ], + }, + ); +} + +/** + * Builds a wrapped classic transaction for cases not covered by {@link buildMockClassicTransaction} + * (empty envelope, `accountMerge`, or Soroban `invokeContractFunction` mixed with classic ops). + * + * @param source - Transaction source account public key. + * @param sequence - Current sequence number string for the source account. + * @param addOperations - Callback that adds one or more operations to the builder. + * @param options - Optional builder settings. + * @param options.feeStroops - Total fee in stroops (string for SDK). Defaults to `100`. + * @param options.scope - The CAIP-2 chain ID. Defaults to `KnownCaip2ChainId.Mainnet`. + * @returns A {@link Transaction} wrapper around the built Stellar envelope. + */ +function buildEnvelopeTransaction( + source: string, + sequence: string, + addOperations: (tb: TransactionBuilder) => TransactionBuilder, + options?: { feeStroops?: string; scope?: KnownCaip2ChainId }, +): Transaction { + const account = new Account(source, sequence); + + const raw = addOperations( + new TransactionBuilder(account, { + fee: options?.feeStroops ?? '100', + networkPassphrase: caip2ChainIdToNetwork( + options?.scope ?? KnownCaip2ChainId.Mainnet, + ), + }), + ) + .setTimeout(30) + .build(); + return new Transaction(raw); +} + +/** + * Builds a preloaded destination account with a USDC trustline for {@link TransactionSimulator.simulate}. + * + * @param destPublicKey - Payment destination Stellar account id (G…). + * @returns Horizon-shaped loaded account for preload. + */ +function destOnChainAccount(destPublicKey: string): OnChainAccount { + return onChainFromMockBalances(destPublicKey, '1', { + nativeBalance: 50, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + balance: 0, + }, + ], + }); +} + +/** + * Destination with a USDC trustline that exists but is not authorized (`is_authorized` false). + * + * @param destPublicKey - Payment destination Stellar account id (G…). + * @returns Horizon-shaped loaded account for preload. + */ +function destOnChainAccountUnauthorized(destPublicKey: string): OnChainAccount { + return onChainFromMockBalances(destPublicKey, '1', { + nativeBalance: 50, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + balance: 0, + isAuthorized: false, + }, + ], + }); +} + +/** + * Builds {@link OnChainAccount} from {@link createMockAccountWithBalances} with a serializable binding from mock Horizon data. + * + * @param accountId - Stellar public key (`G…`). + * @param sequence - Account sequence string. + * @param data - Native balance, subentries, and optional trustline mocks. + * @param scope - CAIP-2 chain (defaults to mainnet). + * @returns Hydrated on-chain account for simulator tests. + */ +function onChainFromMockBalances( + accountId: string, + sequence: string, + data: MockAccountWithBalancesData, + scope: KnownCaip2ChainId = KnownCaip2ChainId.Mainnet, +): OnChainAccount { + const acc = createMockAccountWithBalances(accountId, sequence, data); + return new OnChainAccount(acc, scope, horizonSource(acc, scope)); +} + +describe('TransactionSimulator', () => { + const simulator = new TransactionSimulator(); + + describe('preflight validation', () => { + it('throws when account scope does not match transaction network', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '10', + }, + }, + ], + { + ...mainnetSimulatorTxOptions(wallet.address, '1'), + networkPassphrase: Networks.TESTNET, + }, + ); + + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + TransactionScopeNotMatchException, + ); + }); + + it('throws when the envelope has no operations', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const tx = buildEnvelopeTransaction(wallet.address, '1', (tb) => tb); + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + TransactionValidationException, + ); + }); + + it('throws when an operation has an unsupported type (unsupported in preflight)', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const tx = buildEnvelopeTransaction(wallet.address, '1', (tb) => + tb.addOperation( + StellarOperation.accountMerge({ + source: wallet.address, + destination: wallet.address, + }), + ), + ); + + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + UnsupportedOperationTypeException, + ); + }); + + it('rejects invokeHostFunction combined with other operations', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildEnvelopeTransaction(wallet.address, '1', (tb) => + tb + .addOperation( + StellarOperation.payment({ + source: wallet.address, + destination: dest, + asset: Asset.native(), + amount: '1', + }), + ) + .addOperation( + StellarOperation.invokeContractFunction({ + contract: SWAP_TEST_CONTRACT_ID, + function: 'swap', + args: [ + nativeToScVal( + 'GDRZ4B4X2GCM3IINPEBUYQXTO2GJX6YDTV5OMLC7TKTGL33WNEKLUSKF', + { + type: 'address', + }, + ), + nativeToScVal( + 'GDRZ4B4X2GCM3IINPEBUYQXTO2GJX6YDTV5OMLC7TKTGL33WNEKLUSKF', + { + type: 'address', + }, + ), + ], + }), + ), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(InvalidInvokeContractStructureException); + }); + + it('throws when expectedOPTypes omits an operation type on a mixed classic envelope', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '10', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + expectedOPTypes: [SupportedOperations.Payment], + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(TransactionValidationException); + }); + }); + + describe('payment', () => { + it('succeeds for native payment when destination is preloaded', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '10', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + const stack = simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }); + expect(stack).toHaveLength(2); + }); + + it('throws when destination account is not in the simulation set', () => { + const walletKey = Keypair.random().publicKey(); + const external = Keypair.random().publicKey(); + const loaded = onChainFromMockBalances(walletKey, '1', { + nativeBalance: 100, + subentryCount: 0, + assets: [], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: walletKey, + destination: external, + asset: 'native', + amount: '1', + }, + }, + ], + mainnetSimulatorTxOptions(walletKey, '1'), + ); + expect(() => simulator.simulate(tx, loaded)).toThrow( + TransactionValidationException, + ); + }); + + it('throws when source spendable native is below payment amount', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 1.01, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '1', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(InsufficientBalanceException); + }); + + it('throws when source has no trustline for a credit asset payment', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: MOCK_USDC_ASSET, + amount: '1', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(TrustlineNotFoundException); + }); + + it('throws when source trustline is not authorized (is_authorized)', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + balance: 100, + isAuthorized: false, + }, + ], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: MOCK_USDC_ASSET, + amount: '1', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(TrustlineNotAuthorizedException); + }); + + it('throws when destination trustline is not authorized (is_authorized)', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + balance: 100, + }, + ], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: MOCK_USDC_ASSET, + amount: '1', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccountUnauthorized(dest)], + }), + ).toThrow(TrustlineNotAuthorizedException); + }); + }); + + describe('createAccount', () => { + it('succeeds when funder has enough XLM and destination is absent from state', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'createAccount', + params: { + source: wallet.address, + destination: dest, + startingBalance: '2', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(simulator.simulate(tx, onChainAccount)).toHaveLength(2); + }); + + it('throws when starting balance is below minimum (1 XLM)', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'createAccount', + params: { + source: wallet.address, + destination: dest, + startingBalance: '0.5', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + InvalidAmountForCreateAccountException, + ); + }); + + it('throws when destination already exists in simulation state', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'createAccount', + params: { + source: wallet.address, + destination: dest, + startingBalance: '2', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(TransactionValidationException); + }); + }); + + describe('changeTrust', () => { + it('succeeds when adding a new trustline and spendable covers base reserve', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(simulator.simulate(tx, onChainAccount)).toHaveLength(2); + }); + + it('throws when adding a trustline but spendable native is below one base reserve', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 1.1, + subentryCount: 0, + assets: [], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + InsufficientBalanceToCoverBaseReserveException, + ); + }); + + it('succeeds when removing an existing trustline with zero balance', () => { + const issuer = Keypair.random().publicKey(); + const removable = { code: 'REM', issuer } as const; + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'REM', + assetIssuer: issuer, + balance: 0, + }, + ], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: removable, + limit: '0', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(simulator.simulate(tx, onChainAccount)).toHaveLength(2); + }); + + it('throws when removing a trustline that does not exist', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: '0', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + TrustlineNotFoundException, + ); + }); + + it('throws when removing a trustline with non-zero balance', () => { + const issuer = Keypair.random().publicKey(); + const removable = { code: 'REM', issuer } as const; + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'REM', + assetIssuer: issuer, + balance: 10, + }, + ], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: removable, + limit: '0', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + RemoveTrustlineWithNonZeroBalanceException, + ); + }); + + it('throws when lowering limit below current asset balance', () => { + const issuer = Keypair.random().publicKey(); + const line = { code: 'REM', issuer } as const; + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'REM', + assetIssuer: issuer, + balance: 10, + }, + ], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: line, + limit: '5', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + UpdateTrustlineException, + ); + }); + }); + + describe('invokeHostFunction', () => { + it('succeeds for sole invoke (fee debit + one op snapshot, no classic balance effects)', () => { + const sorobanTx = buildSoleNonSep41InvokeTx(); + const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }); + + expect(simulator.simulate(sorobanTx, loaded)).toHaveLength(2); + }); + + it('throws when invoke effective source account is not in simulation state', () => { + const sorobanTx = buildSoleNonSep41InvokeTx(); + const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }); + const otherSource = Keypair.random().publicKey(); + const [invokeOp] = sorobanTx.transactionOperations; + jest + .spyOn(sorobanTx, 'transactionOperations', 'get') + .mockReturnValue([{ ...invokeOp, source: otherSource } as Operation]); + + expect(() => simulator.simulate(sorobanTx, loaded)).toThrow( + TransactionValidationException, + ); + }); + + it('passes preloadedTokenBalance when invoke is SEP-41 transfer and balance covers amount', () => { + const dest = Keypair.random().publicKey(); + const sorobanTx = buildSep41TransferTransaction({ + source: SOROBAN_INVOKE_SOURCE, + sequence: '1', + contractId: SEP41_CONTRACT_MAINNET, + from: SOROBAN_INVOKE_SOURCE, + to: dest, + amountSmallestUnits: '1', + }); + const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }); + + expect( + simulator.simulate(sorobanTx, loaded, { + preloadedTokenBalance: { + [SOROBAN_INVOKE_SOURCE]: { + [SEP41_ASSET_MAINNET]: new BigNumber(1_000_000), + }, + }, + }), + ).toHaveLength(2); + }); + + it('throws InsufficientBalanceException when SEP-41 transfer amount exceeds preloaded balance', () => { + const dest = Keypair.random().publicKey(); + const sorobanTx = buildSep41TransferTransaction({ + source: SOROBAN_INVOKE_SOURCE, + sequence: '1', + contractId: SEP41_CONTRACT_MAINNET, + from: SOROBAN_INVOKE_SOURCE, + to: dest, + amountSmallestUnits: '10', + }); + const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }); + + expect(() => + simulator.simulate(sorobanTx, loaded, { + preloadedTokenBalance: { + [SOROBAN_INVOKE_SOURCE]: { + [SEP41_ASSET_MAINNET]: new BigNumber(5), + }, + }, + }), + ).toThrow(InsufficientBalanceException); + }); + + it('throws when preloadedTokenBalance does not match SEP-41 transfer sender or contract', () => { + const dest = Keypair.random().publicKey(); + const sorobanTx = buildSep41TransferTransaction({ + source: SOROBAN_INVOKE_SOURCE, + sequence: '1', + contractId: SEP41_CONTRACT_MAINNET, + from: SOROBAN_INVOKE_SOURCE, + to: dest, + amountSmallestUnits: '1', + }); + const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }); + const other = Keypair.random().publicKey(); + + expect(() => + simulator.simulate(sorobanTx, loaded, { + preloadedTokenBalance: { + [other]: { [SEP41_ASSET_MAINNET]: new BigNumber(100) }, + }, + }), + ).toThrow(TransactionValidationException); + }); + + it('throws when SEP-41 transfer has no preloaded entry for sender and contract', () => { + const dest = Keypair.random().publicKey(); + const sorobanTx = buildSep41TransferTransaction({ + source: SOROBAN_INVOKE_SOURCE, + sequence: '1', + contractId: SEP41_CONTRACT_MAINNET, + from: SOROBAN_INVOKE_SOURCE, + to: dest, + amountSmallestUnits: '1', + }); + const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }); + + expect(() => simulator.simulate(sorobanTx, loaded)).toThrow( + TransactionValidationException, + ); + }); + + it('ignores preloadedTokenBalance when invoke is not a SEP-41 transfer', () => { + const sorobanTx = buildSoleNonSep41InvokeTx(); + const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }); + + expect( + simulator.simulate(sorobanTx, loaded, { + preloadedTokenBalance: { + [SOROBAN_INVOKE_SOURCE]: { + [SEP41_ASSET_MAINNET]: new BigNumber(1_000_000), + }, + }, + }), + ).toHaveLength(2); + }); + }); + + describe('mixed multi-operation flows', () => { + it('allows createAccount then native payment to the new account', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'createAccount', + params: { + source: wallet.address, + destination: dest, + startingBalance: '2', + }, + }, + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '5', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(simulator.simulate(tx, onChainAccount)).toHaveLength(3); + }); + + it('allows changeTrust add then native payment when destination is preloaded', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '10', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + const stack = simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }); + expect(stack).toHaveLength(3); + }); + + it('throws when payment uses credit asset before changeTrust add in the same tx', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: MOCK_USDC_ASSET, + amount: '1', + }, + }, + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(TrustlineNotFoundException); + }); + + it('allows mixed changeTrust and payment when expectedOPTypes lists both', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '10', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect( + simulator.simulate(tx, onChainAccount, { + expectedOPTypes: [ + SupportedOperations.ChangeTrust, + SupportedOperations.Payment, + ], + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toHaveLength(3); + }); + + it('allows adding one trustline and removing another in the same transaction', () => { + const issuerToRemove = Keypair.random().publicKey(); + const removable = { code: 'REM', issuer: issuerToRemove } as const; + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'REM', + assetIssuer: issuerToRemove, + balance: 0, + }, + ], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: removable, + limit: '0', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(simulator.simulate(tx, onChainAccount)).toHaveLength(3); + }); + + it('allows createAccount then native payment then changeTrust add', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( + [ + { + type: 'createAccount', + params: { + source: wallet.address, + destination: dest, + startingBalance: '2', + }, + }, + { + type: 'payment', + params: { + source: wallet.address, + destination: dest, + asset: 'native', + amount: '5', + }, + }, + { + type: 'changeTrust', + params: { + source: wallet.address, + asset: MOCK_USDC_ASSET, + limit: MAX_TRUST_LIMIT, + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect( + simulator.simulate(tx, onChainAccount, { + expectedOPTypes: [ + SupportedOperations.CreateAccount, + SupportedOperations.Payment, + SupportedOperations.ChangeTrust, + ], + }), + ).toHaveLength(4); + }); + }); + + describe('fee validation', () => { + it('fails when spendable native is below the fee even if later ops would free reserve', () => { + const issuerA = Keypair.random().publicKey(); + const issuerB = Keypair.random().publicKey(); + const sourceKey = Keypair.random().publicKey(); + const dest = Keypair.random().publicKey(); + + const loaded = onChainFromMockBalances(sourceKey, '1', { + nativeBalance: 1.5, + subentryCount: 2, + sponsoredCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'AAA', + assetIssuer: issuerA, + balance: 0, + }, + { + assetType: 'credit_alphanum4', + assetCode: 'BBB', + assetIssuer: issuerB, + balance: 0, + }, + ], + }); + + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: sourceKey, + asset: { code: 'AAA', issuer: issuerA }, + limit: '0', + }, + }, + { + type: 'changeTrust', + params: { + source: sourceKey, + asset: { code: 'BBB', issuer: issuerB }, + limit: '0', + }, + }, + { + type: 'payment', + params: { + source: sourceKey, + destination: dest, + asset: 'native', + amount: '0.4', + }, + }, + ], + mainnetSimulatorTxOptions(sourceKey, '1', { + baseFeePerOperation: '300', + }), + ); + + expect(() => + simulator.simulate(tx, loaded, { + expectedOPTypes: [ + SupportedOperations.ChangeTrust, + SupportedOperations.Payment, + ], + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toThrow(InsufficientBalanceToCoverFeeException); + }); + + it('succeeds when spendable native covers the envelope fee (same op sequence as failure case)', () => { + const issuerA = Keypair.random().publicKey(); + const issuerB = Keypair.random().publicKey(); + const sourceKey = Keypair.random().publicKey(); + const dest = Keypair.random().publicKey(); + + const loaded = onChainFromMockBalances(sourceKey, '1', { + nativeBalance: 1.6, + subentryCount: 2, + sponsoredCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'AAA', + assetIssuer: issuerA, + balance: 0, + }, + { + assetType: 'credit_alphanum4', + assetCode: 'BBB', + assetIssuer: issuerB, + balance: 0, + }, + ], + }); + + const tx = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + source: sourceKey, + asset: { code: 'AAA', issuer: issuerA }, + limit: '0', + }, + }, + { + type: 'changeTrust', + params: { + source: sourceKey, + asset: { code: 'BBB', issuer: issuerB }, + limit: '0', + }, + }, + { + type: 'payment', + params: { + source: sourceKey, + destination: dest, + asset: 'native', + amount: '0.4', + }, + }, + ], + mainnetSimulatorTxOptions(sourceKey, '1', { + baseFeePerOperation: '300', + }), + ); + + expect( + simulator.simulate(tx, loaded, { + expectedOPTypes: [ + SupportedOperations.ChangeTrust, + SupportedOperations.Payment, + ], + preloadedAccounts: [destOnChainAccount(dest)], + }), + ).toHaveLength(4); + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts new file mode 100644 index 00000000..3408af6e --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts @@ -0,0 +1,409 @@ +import type { Operation } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import { + InsufficientBalanceToCoverFeeException, + InvalidInvokeContractStructureException, + TransactionValidationException, + UnsupportedOperationTypeException, +} from './exceptions'; +import type { + AccountState, + SimulationState, + TrustlineState, + OperationSimulator, + Sep41TokenBalanceMapKey, +} from './simulation'; +import { + ChangeTrustOPSimulator, + CreateAccountOPSimulator, + InvokeHostFunctionOPSimulator, + PaymentOPSimulator, + getSpendableNative, + getAccount, + toSep41TokenBalanceMapKey, +} from './simulation'; +import type { Transaction } from './Transaction'; +import { + assertTransactionScope, + assertTransactionSourceAccount, +} from './utils'; +import type { + KnownCaip19ClassicAssetId, + KnownCaip19Sep41AssetId, + KnownCaip2ChainId, +} from '../../api'; +import { entries } from '../../utils/array'; +import type { OnChainAccount } from '../on-chain-account/OnChainAccount'; + +/** + * Supported operation types in MetaMask. + */ +type SupportedOPType = + | Operation.Payment + | Operation.CreateAccount + | Operation.ChangeTrust + | Operation.InvokeHostFunction; + +/** + * Stellar operation kinds used when validating or simulating transactions. + */ +export enum SupportedOperations { + Payment = 'payment', + CreateAccount = 'createAccount', + ChangeTrust = 'changeTrust', + InvokeHostFunction = 'invokeHostFunction', +} + +/** + * Optional settings for {@link TransactionSimulator.simulate}. + */ +export type TransactionSimulatorOptions = { + expectedOPTypes?: SupportedOperations[]; + /** + * Extra accounts merged into simulation (e.g. payment destinations). Ignored when simulation path does not apply. + */ + preloadedAccounts?: OnChainAccount[]; + /** + * Per-account token balances (account id → SEP-41 asset id → smallest units). Flattened internally with {@link toSep41TokenBalanceMapKey}. + * Used only when the sole invoke is a SEP-41 `transfer`; spend is read from the invoke, not from this map. + */ + preloadedTokenBalance?: Record< + string, + Record + >; +}; + +export class TransactionSimulator { + readonly #operationSimulator: Record; + + constructor() { + this.#operationSimulator = { + payment: new PaymentOPSimulator(), + createAccount: new CreateAccountOPSimulator(), + changeTrust: new ChangeTrustOPSimulator(), + invokeHostFunction: new InvokeHostFunctionOPSimulator(), + }; + } + + /** + * Inspects envelope operations, optionally enforces expected operation types and Soroban rules, + * then runs ordered simulation for supported ops; classic ops update balances / trustlines. + * Soroban `invokeHostFunction` is only allowed as a single-op tx and is a no-op for state. + * All involved accounts must be known from the wallet snapshot (or {@link TransactionSimulatorOptions.preloadedAccounts}). + * + * @param transaction - Wrapped Stellar transaction. + * @param account - Loaded signing account (Horizon-shaped raw for balances). + * @param options - Optional `expectedOPTypes`, `preloadedAccounts`, and `preloadedTokenBalance` (invoke-only SEP-41 `transfer` balance check after fee debit). + * @returns Stack of simulation states: fee snapshot first, then one entry per operation after apply. + * @throws {TransactionScopeNotMatchException} If the transaction scope does not match the account scope. + * @throws {TransactionValidationException} When the transaction cannot be simulated (wallet not source/fee source, unsupported op, unknown accounts for payments, etc.). + */ + simulate( + transaction: Transaction, + account: OnChainAccount, + options?: TransactionSimulatorOptions, + ): SimulationState[] { + const ops = transaction.transactionOperations; + + // Allow to quit early if any operation not valid (not supported or not expected) + this.#preflightValidation(ops, account, transaction, options); + + return this.#run({ + operations: ops, + transaction, + initialState: this.#buildInitialState(account, options), + }); + } + + #run(params: { + operations: SupportedOPType[]; + transaction: Transaction; + initialState: SimulationState; + }): SimulationState[] { + const { operations, initialState, transaction } = params; + + const txSource = transaction.sourceAccount; + const feeSource = transaction.feeSourceAccount; + const fee = transaction.totalFee; + const { scope } = transaction; + + // Validate that the current balance can cover the fee, even if later + // operations would free the base reserve. + // + // For example: + // - A account has 1.5 XLM total, two trust lines, one of them sponsored (`num_sponsored` = 1). + // - The Spendable balance = 1.5 XLM - 1 XLM (account base reserve) - 0.5 XLM (trust line base reserve) = 0 XLM + // - A transaction with 0.0000001 XLM fee will fail the fee validation. + const feeState = this.#validateAndApplyFeeState({ + state: this.#cloneSimulationState(initialState), + feeSource, + fee, + }); + + return operations.reduce( + (stack, op, opIndex) => { + const beforeState = stack[stack.length - 1]; + if (beforeState === undefined) { + throw new TransactionValidationException( + 'Simulation failed: missing state snapshot', + ); + } + + // Each stack entry is an independent snapshot: we clone before apply. + const state = this.#cloneSimulationState(beforeState); + this.#validateOP({ + op, + opIndex, + state, + txSource, + scope, + operations, + }); + this.#applyOP({ op, state, txSource, scope, opIndex }); + stack.push(state); + return stack; + }, + [feeState], + ); + } + + #preflightValidation( + ops: Operation[], + account: OnChainAccount, + transaction: Transaction, + options?: TransactionSimulatorOptions, + ): asserts ops is SupportedOPType[] { + const { expectedOPTypes = [] } = options ?? {}; + + // Ensure the transaction scope matches the account scope. + assertTransactionScope(transaction, account.scope); + // Envelope must involve this wallet as source or fee source (API XDR or in-app builds). + // TODO: we may need to relax it in future when we support fee payment by other account. + assertTransactionSourceAccount(transaction, account.accountId); + + const expectedOPTypeSet = new Set(expectedOPTypes); + const supportedOPTypeSet = new Set( + Object.values(SupportedOperations), + ); + + this.#assertOPLength(ops); + this.#assertInvokeHostFunctionSoleOP(transaction); + + for (const op of ops) { + this.#assertSupportedOP(op, supportedOPTypeSet); + this.#assertExpectedOP(op, expectedOPTypeSet); + } + } + + #buildInitialState( + sourceAccount: OnChainAccount, + options?: TransactionSimulatorOptions, + ): SimulationState { + const { preloadedAccounts, preloadedTokenBalance } = options ?? {}; + const sourceAccountState = this.#buildAccountState(sourceAccount); + const accounts = new Map([[sourceAccount.accountId, sourceAccountState]]); + + if (preloadedAccounts !== undefined && preloadedAccounts.length > 0) { + for (const account of preloadedAccounts) { + if (!accounts.has(account.accountId)) { + accounts.set(account.accountId, this.#buildAccountState(account)); + } + } + } + + let simulationPreloadedTokenBalance: SimulationState['preloadedTokenBalance']; + if (preloadedTokenBalance !== undefined) { + const preloadedTokenBalanceMap = new Map< + Sep41TokenBalanceMapKey, + BigNumber + >(); + + entries(preloadedTokenBalance).forEach(([accountId, balancesByAsset]) => { + entries(balancesByAsset).forEach(([assetId, balance]) => { + preloadedTokenBalanceMap.set( + toSep41TokenBalanceMapKey(accountId, assetId), + balance, + ); + }); + }); + + simulationPreloadedTokenBalance = + preloadedTokenBalanceMap.size > 0 + ? preloadedTokenBalanceMap + : undefined; + } + + return { + accounts, + preloadedTokenBalance: simulationPreloadedTokenBalance, + }; + } + + #buildAccountState(account: OnChainAccount): AccountState { + const trustlines = new Map(); + + for (const assetId of account.classicTrustlineAssetIds) { + const row = account.getAsset(assetId); + if (row === undefined) { + continue; + } + const { limit } = row; + if (limit === undefined) { + continue; + } + trustlines.set(assetId, { + balance: row.balance, + limit, + authorized: row.authorized !== false, + sponsored: row.sponsored === true, + }); + } + + return { + nativeRawBalance: account.nativeRawBalance, + subentryCount: account.subentryCount, + numSponsoring: account.numSponsoring, + numSponsored: account.numSponsored, + trustlines, + }; + } + + #assertInvokeHostFunctionSoleOP(transaction: Transaction): void { + if (transaction.hasInvokeHostFunction && transaction.operationCount !== 1) { + throw new InvalidInvokeContractStructureException(); + } + } + + #assertSupportedOP( + op: Operation, + supportedOPTypeSet: Set, + ): asserts op is SupportedOPType { + if (!supportedOPTypeSet.has(op.type)) { + throw new UnsupportedOperationTypeException(op.type); + } + } + + #assertExpectedOP(op: Operation, types: Set): void { + if (types.size > 0 && !types.has(op.type)) { + throw new TransactionValidationException( + `Unexpected operation type ${op.type}, expected one of: ${Array.from(types).join(', ')}`, + ); + } + } + + #assertOPLength( + ops: Operation[], + ): asserts ops is [Operation, ...Operation[]] { + if (ops.length === 0) { + throw new TransactionValidationException( + `Transaction must have at least one operation`, + ); + } + } + + #validateAndApplyFeeState(params: { + state: SimulationState; + feeSource: string; + fee: BigNumber; + }): SimulationState { + // Assume the state is cloned beforehand + const { state, feeSource, fee } = params; + // it is possible that the transaction fee source is different than the wallet user, + // if the transaction is passed from external, we dont support it yet, + // hence `getAccount` will throw an error. + const feePayer = getAccount(state, feeSource); + + const spendable = getSpendableNative(feePayer); + if (spendable.isLessThan(fee)) { + throw new InsufficientBalanceToCoverFeeException( + spendable.toString(), + fee.toString(), + ); + } + + // assign new native raw balance to the fee payer in the cloned state + feePayer.nativeRawBalance = feePayer.nativeRawBalance.minus(fee); + return state; + } + + #validateOP(params: { + op: SupportedOPType; + opIndex: number; + state: SimulationState; + txSource: string; + scope: KnownCaip2ChainId; + operations: readonly Operation[]; + }): void { + const { op, opIndex, state, txSource, scope, operations } = params; + + this.#operationSimulator[op.type].validate( + { + state, + txSource, + scope, + opIndex, + }, + op, + operations, + ); + } + + #applyOP(params: { + op: SupportedOPType; + state: SimulationState; + txSource: string; + scope: KnownCaip2ChainId; + opIndex: number; + }): SimulationState { + const { op, state, txSource, scope, opIndex } = params; + // the state will pass by reference, so the changes will be reflected in the original state + this.#operationSimulator[op.type].apply( + { state, txSource, scope, opIndex }, + op, + ); + // return the state after the operation is applied + return state; + } + + #cloneSimulationState(state: SimulationState): SimulationState { + const accounts = new Map(); + for (const [accountId, accountState] of state.accounts) { + accounts.set(accountId, this.#cloneAccountState(accountState)); + } + const tokenBalances = state.preloadedTokenBalance; + let preloadedTokenBalance: SimulationState['preloadedTokenBalance']; + if (tokenBalances !== undefined && tokenBalances.size > 0) { + const cloned = new Map(); + for (const [key, balance] of tokenBalances) { + cloned.set(key, new BigNumber(balance.toString())); + } + preloadedTokenBalance = cloned; + } + return { + accounts, + preloadedTokenBalance, + }; + } + + #cloneAccountState(accountState: AccountState): AccountState { + const trustlines = new Map(); + const { nativeRawBalance, subentryCount, numSponsoring, numSponsored } = + accountState; + for (const [assetId, trustline] of accountState.trustlines) { + trustlines.set(assetId, { + balance: new BigNumber(trustline.balance.toString()), + limit: new BigNumber(trustline.limit.toString()), + authorized: trustline.authorized, + sponsored: trustline.sponsored, + }); + } + return { + nativeRawBalance: new BigNumber(nativeRawBalance.toString()), + subentryCount, + numSponsoring, + numSponsored, + trustlines, + }; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/index.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/index.ts index 406766c0..30dc4c05 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/index.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/index.ts @@ -4,3 +4,4 @@ export * from './Transaction'; export * from './TransactionBuilder'; export * from './TransactionRepository'; export * from './TransactionService'; +export * from './TransactionSimulator'; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/api.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/api.ts new file mode 100644 index 00000000..30455ec0 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/api.ts @@ -0,0 +1,72 @@ +import type { Operation } from '@stellar/stellar-sdk'; + +import type { + KnownCaip19ClassicAssetId, + KnownCaip19Sep41AssetId, + KnownCaip2ChainId, +} from '../../../api'; + +export type Sep41TokenBalanceMapKey = `${string}-${KnownCaip19Sep41AssetId}`; + +/** + * Trustline row for simulation. `sponsored` mirrors Horizon: non-empty `balance.sponsor` means reserve is sponsored. + */ +export type TrustlineState = { + balance: BigNumber; + limit: BigNumber; + /** + * Horizon `is_authorized`: when false, the account cannot send or receive this credit asset + * (issuer auth required / revoked). + */ + authorized: boolean; + /** When true, this line's reserve is counted in the account's `numSponsored` (not self-paid). */ + sponsored: boolean; +}; + +/** + * Per-account view used for ordered (stack-based) simulation of classic operations. + * Amounts are in stroops / smallest units; trustline limit and balance match Horizon semantics. + */ +export type AccountState = { + nativeRawBalance: BigNumber; + subentryCount: number; + numSponsoring: number; + numSponsored: number; + trustlines: Map; +}; + +/** + * Global simulation: keyed by account id (G… only; muxed ids resolved to base account). + */ +export type SimulationState = { + /** + * Map of account id to account state. + */ + accounts: Map; + /** + * Optional map for preloaded SEP-41 token balances. + */ + preloadedTokenBalance?: Map; +}; + +/** + * Context for validating one classic operation against the current simulation snapshot. + */ +export type Context = { + state: SimulationState; + txSource: string; + scope: KnownCaip2ChainId; + opIndex: number; +}; + +/** + * Validates and applies a single supported classic operation against {@link SimulationState}. + */ +export type OperationSimulator = { + validate( + ctx: Context, + op: Operation, + allOperations?: readonly Operation[], + ): void; + apply(ctx: Context, op: Operation): void; +}; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/index.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/index.ts new file mode 100644 index 00000000..25c07818 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/index.ts @@ -0,0 +1,3 @@ +export type * from './api'; +export * from './simulators'; +export * from './utils'; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts new file mode 100644 index 00000000..ffff9f65 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts @@ -0,0 +1,411 @@ +import type { Operation } from '@stellar/stellar-sdk'; +import { Asset } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import type { OperationSimulator, Context, AccountState } from './api'; +import { + getAccount, + effectiveSource, + getSpendableNative, + tryParseSep41TransferInvoke, + toSep41TokenBalanceMapKey, +} from './utils'; +import type { + KnownCaip19ClassicAssetId, + KnownCaip19Slip44Id, + KnownCaip2ChainId, +} from '../../../api'; +import { BASE_RESERVE_STROOPS, MAX_INT64 } from '../../../constants'; +import { + getSlip44AssetId, + isSlip44Id, + toCaip19ClassicAssetId, + toSmallestUnit, +} from '../../../utils'; +import { + InsufficientBalanceException, + InsufficientBalanceToCoverBaseReserveException, + InvalidAmountForCreateAccountException, + InvalidTrustlineException, + RemoveTrustlineWithNonZeroBalanceException, + TransactionValidationException, + TrustlineNotAuthorizedException, + TrustlineNotFoundException, + UpdateTrustlineException, +} from '../exceptions'; + +export class PaymentOPSimulator implements OperationSimulator { + validate(ctx: Context, op: Operation.Payment): void { + const payment = op; + const { opIndex } = ctx; + const { assetId, payAmt, source, dest, sourceId, destId } = + this.#getContextData(ctx, op); + + if (payment.amount === undefined || payment.amount === null) { + throw new TransactionValidationException( + `Payment operation at index ${opIndex} has no amount`, + ); + } + + // verify if the asset is a native asset and if the source has enough balance + if (isSlip44Id(assetId)) { + const spendable = getSpendableNative(source); + if (spendable.isLessThan(payAmt)) { + throw new InsufficientBalanceException( + spendable.toString(), + payAmt.toString(), + ); + } + const newDestNative = dest.nativeRawBalance.plus(payAmt); + if (newDestNative.isGreaterThan(new BigNumber(MAX_INT64))) { + throw new TransactionValidationException( + 'Payment would exceed maximum int64 balance for destination', + ); + } + return; + } + + // verify if the trustline exists and if the source has enough balance + const line = source.trustlines.get(assetId); + if (line === undefined) { + throw new TrustlineNotFoundException(assetId, sourceId); + } + + if (!line.authorized) { + throw new TrustlineNotAuthorizedException(assetId, sourceId); + } + + if (line.balance.isLessThan(payAmt)) { + throw new InsufficientBalanceException( + line.balance.toString(), + payAmt.toString(), + ); + } + + // verify if the destination has trustline and the receiving amount not exceed the trustline limit + const destLine = dest.trustlines.get(assetId); + if (destLine === undefined) { + throw new TrustlineNotFoundException(assetId, destId); + } + + if (!destLine.authorized) { + throw new TrustlineNotAuthorizedException(assetId, destId); + } + + const newBalance = destLine.balance.plus(payAmt); + if (newBalance.isGreaterThan(destLine.limit)) { + throw new TransactionValidationException( + `Payment would exceed trustline limit for asset ${assetId} on destination`, + ); + } + } + + apply(ctx: Context, op: Operation.Payment): void { + const { assetId, payAmt, source, dest } = this.#getContextData(ctx, op); + + if (isSlip44Id(assetId)) { + source.nativeRawBalance = source.nativeRawBalance.minus(payAmt); + dest.nativeRawBalance = dest.nativeRawBalance.plus(payAmt); + return; + } + + const srcLine = source.trustlines.get(assetId); + const destLine = dest.trustlines.get(assetId); + if (srcLine !== undefined) { + srcLine.balance = srcLine.balance.minus(payAmt); + } + if (destLine !== undefined) { + destLine.balance = destLine.balance.plus(payAmt); + } + } + + #getContextData( + ctx: Context, + op: Operation.Payment, + ): { + sourceId: string; + destId: string; + payAmt: BigNumber; + assetId: KnownCaip19ClassicAssetId | KnownCaip19Slip44Id; + source: AccountState; + dest: AccountState; + } { + const { txSource, scope, state } = ctx; + const payment = op; + const sourceId = effectiveSource(payment, txSource); + const destId = this.#paymentDestinationAccountId(payment); + const payAmt = toSmallestUnit(new BigNumber(payment.amount)); + const assetId = this.#paymentAssetToId(payment, scope); + const source = getAccount(state, sourceId); + const dest = getAccount(state, destId); + return { sourceId, destId, payAmt, assetId, source, dest }; + } + + #paymentAssetToId( + op: Operation.Payment, + scope: KnownCaip2ChainId, + ): KnownCaip19ClassicAssetId | KnownCaip19Slip44Id { + const { asset } = op; + if (asset instanceof Asset) { + if (asset.isNative()) { + return getSlip44AssetId(scope); + } + return toCaip19ClassicAssetId(scope, asset.getCode(), asset.getIssuer()); + } + throw new TransactionValidationException( + 'Only native or alphanum Asset payments are supported for sequential validation', + ); + } + + #paymentDestinationAccountId(op: Operation.Payment): string { + const { destination } = op; + if (typeof destination === 'string') { + return destination; + } + throw new TransactionValidationException( + 'Unsupported payment destination type', + ); + } +} + +export class CreateAccountOPSimulator implements OperationSimulator { + validate(ctx: Context, op: Operation.CreateAccount): void { + const { state, opIndex } = ctx; + if (typeof op.destination !== 'string' || op.destination.length === 0) { + throw new TransactionValidationException( + `CreateAccount at index ${opIndex} has no destination`, + ); + } + const { source, destId, startingBalance } = this.#getContextData(ctx, op); + + // Minimum starting balance is 1 XLM if we are not sponsoring the account + const minCreate = toSmallestUnit(new BigNumber(1)); + + if (startingBalance.isLessThan(minCreate)) { + throw new InvalidAmountForCreateAccountException( + startingBalance.toString(), + ); + } + + const spendable = getSpendableNative(source); + if (spendable.isLessThan(startingBalance)) { + throw new InsufficientBalanceException( + spendable.toString(), + startingBalance.toString(), + ); + } + + const existing = state.accounts.get(destId); + if (existing !== undefined) { + throw new TransactionValidationException( + `CreateAccount destination already exists in simulation: ${destId}`, + ); + } + } + + apply(ctx: Context, op: Operation.CreateAccount): void { + const { state } = ctx; + const { source, destId, startingBalance } = this.#getContextData(ctx, op); + + source.nativeRawBalance = source.nativeRawBalance.minus(startingBalance); + + state.accounts.set(destId, { + nativeRawBalance: startingBalance, + subentryCount: 0, + numSponsoring: 0, + numSponsored: 0, + trustlines: new Map(), + }); + } + + #getContextData( + ctx: Context, + op: Operation.CreateAccount, + ): { source: AccountState; destId: string; startingBalance: BigNumber } { + const { txSource, state } = ctx; + const funderId = effectiveSource(op, txSource); + const destId = op.destination; + const startingBalance = toSmallestUnit(new BigNumber(op.startingBalance)); + const source = getAccount(state, funderId); + return { source, destId, startingBalance }; + } +} + +export class ChangeTrustOPSimulator implements OperationSimulator { + validate(ctx: Context, op: Operation.ChangeTrust): void { + const { opIndex } = ctx; + if ( + op.limit === undefined || + op.limit === null || + op.line === undefined || + op.line === null + ) { + throw new InvalidTrustlineException( + `ChangeTrust at index ${opIndex} is incomplete`, + ); + } + + const { source, sourceId, assetId, trustlineLimit } = this.#getContextData( + ctx, + op, + ); + + const sourceTrustline = source.trustlines.get(assetId); + const isRemove = trustlineLimit.isZero(); + + // if it is removing an existing trustline, verify if the trustline exists and if the balance is zero + if (isRemove) { + if (sourceTrustline === undefined) { + throw new TrustlineNotFoundException(assetId, sourceId); + } + if (sourceTrustline.balance.isGreaterThan(0)) { + throw new RemoveTrustlineWithNonZeroBalanceException( + `Cannot remove trustline for ${assetId}: balance must be zero`, + ); + } + return; + } + + // if it is adding a new trustline, verify if the source has enough balance to cover the base reserve + if (sourceTrustline === undefined) { + const spendable = getSpendableNative(source); + const reserve = new BigNumber(BASE_RESERVE_STROOPS); + if (spendable.isLessThan(reserve)) { + throw new InsufficientBalanceToCoverBaseReserveException( + spendable.toString(), + reserve.toString(), + ); + } + return; + } + + // if it is updating an existing trustline, verify if the limit is lower than the current balance + if (trustlineLimit.isLessThan(sourceTrustline.balance)) { + throw new UpdateTrustlineException( + `ChangeTrust limit cannot be below current balance for ${assetId}`, + ); + } + } + + apply(ctx: Context, op: Operation.ChangeTrust): void { + const { source, assetId, trustlineLimit } = this.#getContextData(ctx, op); + + const sourceTrustline = source.trustlines.get(assetId); + const isRemove = trustlineLimit.isZero(); + + // if it is removing an existing trustline, we need to update the source account subentry and numSponsored for spendable balance calculation: + // - decrease the subentry count by 1 + // - decrease the numSponsored by 1 if the trustline is sponsored + if (isRemove) { + // Safe guard + if (sourceTrustline !== undefined) { + source.subentryCount = Math.max(0, source.subentryCount - 1); + if (sourceTrustline.sponsored) { + source.numSponsored = Math.max(0, source.numSponsored - 1); + } + source.trustlines.delete(assetId); + } + return; + } + + // if it is adding a new trustline, we need to update the source account subentry for spendable balance calculation: + // - increase the subentry count by 1 + if (sourceTrustline === undefined) { + source.trustlines.set(assetId, { + balance: new BigNumber(0), + limit: trustlineLimit, + // assume we always authorize the trustline for source account. + authorized: true, + // assume we only support enable the trustline for source account, but not sponsor to other accounts + sponsored: false, + }); + source.subentryCount += 1; + return; + } + + sourceTrustline.limit = trustlineLimit; + } + + #getContextData( + ctx: Context, + op: Operation.ChangeTrust, + ): { + source: AccountState; + sourceId: string; + assetId: KnownCaip19ClassicAssetId; + trustlineLimit: BigNumber; + } { + const { txSource, state, scope } = ctx; + const sourceId = effectiveSource(op, txSource); + const source = getAccount(state, sourceId); + const asset = op.line; + if (!(asset instanceof Asset)) { + throw new InvalidTrustlineException( + `ChangeTrust line must be Stellar SAC Asset or Stellar Classic Asset, ${asset.constructor.name} is not supported`, + ); + } + + const assetId = toCaip19ClassicAssetId( + scope, + asset.getCode(), + asset.getIssuer(), + ); + + // Operation limit is in human-readable form; convert to stroops like Horizon balances. + const limit = new BigNumber(op.limit); + const trustlineLimit = limit.isZero() + ? new BigNumber(0) + : toSmallestUnit(limit); + + return { source, sourceId, assetId, trustlineLimit }; + } +} + +export class InvokeHostFunctionOPSimulator implements OperationSimulator { + validate(ctx: Context, op: Operation.InvokeHostFunction): void { + const { txSource, state, scope } = ctx; + const sourceId = effectiveSource(op, txSource); + // Contract transaction should always be sourced from the user wallet account + // `getAccount` will throw if the source account is not found in the simulation state, + // hence, it should protect if the actual source account is not same as user wallet account + getAccount(state, sourceId); + + // handle the SEP-41 transfer operation + const parsed = tryParseSep41TransferInvoke(op, scope); + if (parsed === null) { + // Not a SEP-41 `transfer`; skip contract-token balance validation (other invokes ignore preloaded map). + return; + } + + const { fromAccountId, assetId, amount } = parsed; + + // safe guard to prevent the from account is different from the source account. + if (fromAccountId !== sourceId) { + throw new TransactionValidationException( + 'SEP-41 transfer requires the sender account to be the same as the source account', + ); + } + + const sep41TokenBalanceMap = state.preloadedTokenBalance; + const onChainBalance = sep41TokenBalanceMap?.get( + toSep41TokenBalanceMapKey(sourceId, assetId), + ); + if (onChainBalance === undefined) { + throw new TransactionValidationException( + 'SEP-41 transfer requires a preloaded token balance for the sender and contract', + ); + } + + if (onChainBalance.isLessThan(amount)) { + throw new InsufficientBalanceException( + onChainBalance.toString(), + amount.toString(), + ); + } + } + + apply(_ctx: Context, _op: Operation.InvokeHostFunction): void { + // InvokeHostFunction is a single operation transaction, + // hence we don't need to apply any balance or trustline effects for Soroban invoke during simulation. + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/utils.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/utils.ts new file mode 100644 index 00000000..c9a8a257 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/utils.ts @@ -0,0 +1,133 @@ +import type { Operation } from '@stellar/stellar-sdk'; +import { Address, scValToNative } from '@stellar/stellar-sdk'; + +import { TransactionValidationException } from '../exceptions'; +import type { + AccountState, + Sep41TokenBalanceMapKey, + SimulationState, +} from './api'; +import type { KnownCaip19Sep41AssetId, KnownCaip2ChainId } from '../../../api'; +import { toCaip19Sep41AssetId } from '../../../utils'; +import { parseScValToNative } from '../../network/utils'; +import { calculateSpendableBalance } from '../../on-chain-account/utils'; +/** + * Gets the effective source account ID from the operation. + * Returns the source account ID from the operation if it is set, otherwise returns the transaction source. + * + * @param op - The operation. + * @param txSource - The transaction source. + * @returns Effective source account public key. + */ +export function effectiveSource(op: Operation, txSource: string): string { + return op.source ?? txSource; +} + +/** + * Calculates the spendable native balance for an account. + * + * @param account - The account state. + * @returns Spendable native balance in stroops. + */ +export function getSpendableNative(account: AccountState): BigNumber { + return calculateSpendableBalance({ + nativeBalance: account.nativeRawBalance, + subentryCount: account.subentryCount, + numSponsoring: account.numSponsoring, + numSponsored: account.numSponsored, + }); +} + +/** + * Gets the account state from the simulation state. + * + * @param state - The simulation state. + * @param accountId - The account ID. + * @returns Mutable account state for the given id. + */ +export function getAccount( + state: SimulationState, + accountId: string, +): AccountState { + const account = state.accounts.get(accountId); + if (account === undefined) { + throw new TransactionValidationException( + `Account not loaded: ${accountId}`, + ); + } + return account; +} + +export type ParsedSep41TransferInvoke = { + /** + * Canonical SEP-41 CAIP-19 id for the **invoked contract** — not a separate XDR field. + * Same encoding as {@link TransactionBuilder.sep41Transfer}: `toCaip19Sep41AssetId(scope, contractId)`. + */ + assetId: KnownCaip19Sep41AssetId; + fromAccountId: string; + amount: BigNumber; +}; + +/** + * When the op is a single-contract `transfer(from, to, amount)` (SEP-41 token shape), + * reads **contract address** from the invoke target and **derives** the CAIP-19 asset id with `scope` + * (the envelope never embeds a CAIP string — only `C…` like `Contract.call`). + * {@link TransactionBuilder.sep41Transfer} is the same function that is used to build the transaction. + * + * @param op - Parsed `invokeHostFunction` operation. + * @param scope - CAIP-2 chain id (must match the envelope network when matching preload keys). + * @returns Parsed transfer metadata, or `null` if the shape does not match. + */ +export function tryParseSep41TransferInvoke( + op: Operation.InvokeHostFunction, + scope: KnownCaip2ChainId, +): ParsedSep41TransferInvoke | null { + const { func } = op; + if (!func || func.switch().name !== 'hostFunctionTypeInvokeContract') { + return null; + } + const ic = func.invokeContract(); + // if it is not a transfer function, we can skip parsing the transfer metadata + if (ic.functionName().toString() !== 'transfer') { + return null; + } + + const args = ic.args(); + if (args.length !== 3 || args[0] === undefined || args[2] === undefined) { + throw new TransactionValidationException( + 'Invalid transfer function arguments', + ); + } + // First argument is the from address + const fromArg = args[0]; + // Third argument is the amount + const amountArg = args[2]; + + const contractAddr = Address.fromScAddress(ic.contractAddress()).toString(); + + const fromNative = scValToNative(fromArg); + const amountNative = scValToNative(amountArg); + if (typeof fromNative !== 'string' || !fromNative.startsWith('G')) { + throw new TransactionValidationException('Invalid from address'); + } + + return { + assetId: toCaip19Sep41AssetId(scope, contractAddr), + fromAccountId: fromNative, + amount: parseScValToNative(amountNative), + }; +} + +/** + * Map key for {@link SimulationState.preloadedTokenBalance}: `accountId` and SEP-41 `assetId` (order matters). + * + * @param accountId - Stellar account id of the token holder (`G…`). + * @param assetId - SEP-41 CAIP-19 asset id for the token contract. + * @returns Opaque composite map key. + */ +export function toSep41TokenBalanceMapKey( + accountId: string, + assetId: KnownCaip19Sep41AssetId, +): Sep41TokenBalanceMapKey { + return `${accountId}-${assetId}`; +} From e04fb412a180c916aa274454b2cda2a1a9b142b6 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Wed, 29 Apr 2026 11:25:39 +0200 Subject: [PATCH 137/384] perf(snap): persist asset metadata cache via setKey instead of manageState --- .../stellar-wallet-snap/snap.manifest.json | 22 +++++++++---- .../AssetMetadataRepository.test.ts | 32 ++++++++++++++++++- .../asset-metadata/AssetMetadataRepository.ts | 23 ++++++++++--- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index eddde23d..bb417094 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.0.1", + "version": "0.0.1-dev.124", "description": "Manage Stellar using MetaMask", "proposedName": "Stellar", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "VwPCt/flfhcOfZV3UGt/6tvA7+FJJM5wCMrlxDfMxBs=", + "shasum": "Mk8Fc/fQFRnj80xHOIgnLcb5wOc3OVU3oRypnk2PjLE=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -16,18 +16,26 @@ "registry": "https://registry.npmjs.org/" } }, - "locales": ["locales/en.json"] + "locales": [ + "locales/en.json" + ] }, "initialConnections": { "https://portfolio.metamask.io": {} }, "initialPermissions": { "endowment:keyring": { - "allowedOrigins": ["https://portfolio.metamask.io"] + "allowedOrigins": [ + "https://portfolio.metamask.io" + ] }, "snap_getBip32Entropy": [ { - "path": ["m", "44'", "148'"], + "path": [ + "m", + "44'", + "148'" + ], "curve": "ed25519" } ], @@ -38,7 +46,9 @@ "snap_getPreferences": {}, "endowment:cronjob": {}, "endowment:assets": { - "scopes": ["stellar:pubnet"] + "scopes": [ + "stellar:pubnet" + ] } }, "platformVersion": "10.3.0", diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts index abb5141e..dc154455 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts @@ -48,7 +48,11 @@ function createMockStateManager( } return undefined; }, - setKey: jest.fn(async () => Promise.resolve()), + setKey: jest.fn(async (key: string, value: unknown) => { + if (key === 'assets') { + state.assets = cloneDeep(value) as AssetMetadataState['assets']; + } + }), update: async (updater) => { state = updater(cloneDeep(state)); return cloneDeep(state); @@ -134,4 +138,30 @@ describe('AssetMetadataRepository', () => { expect(await repo.getByAssetId(classicId)).toStrictEqual(row); expect(await repo.getByAssetId(sep41Id)).toBeNull(); }); + + it('preserves all rows when saveMany runs concurrently for disjoint asset ids', async () => { + const manager = createMockStateManager({ assets: {} }); + const repo = new AssetMetadataRepository(manager); + const classic = generateAssetData( + classicId, + AssetType.Token, + KnownCaip2ChainId.Testnet, + ); + const sep41 = generateAssetData( + sep41Id, + AssetType.Sep41, + KnownCaip2ChainId.Mainnet, + ); + + await Promise.all([repo.saveMany([classic]), repo.saveMany([sep41])]); + + expect(await repo.getByAssetId(classicId)).toMatchObject({ + assetId: classicId, + persistedAt: expect.any(Number), + }); + expect(await repo.getByAssetId(sep41Id)).toMatchObject({ + assetId: sep41Id, + persistedAt: expect.any(Number), + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts index c0e7268f..c0fa03c2 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts @@ -1,3 +1,4 @@ +import { Mutex } from 'async-mutex'; import { cloneDeep } from 'lodash'; import type { @@ -17,6 +18,13 @@ export class AssetMetadataRepository { readonly #stateKey = 'assets'; + /** + * Serializes read-merge-write on the `assets` map so concurrent `saveMany` + * calls cannot drop each other's rows (lost update), without using + * `snap_manageState` on the entire snap state blob. + */ + readonly #saveMutex = new Mutex(); + constructor(state: IStateManager) { this.#state = state; } @@ -117,6 +125,9 @@ export class AssetMetadataRepository { * Upserts rows by `assetId`. Stamps `persistedAt` (same value for all rows in this call) * for future staleness / TTL logic. * + * Uses `snap_setState` on the `assets` key (via `setKey`) instead of `snap_manageState` + * so imports and lookups avoid rewriting the entire encrypted state blob. + * * @param assets - Full metadata rows; `assetId` must match the CAIP-19 key for that network. */ async saveMany(assets: StellarAssetMetadata[]): Promise { @@ -124,16 +135,20 @@ export class AssetMetadataRepository { return; } const persistedAt = Date.now(); - await this.#state.update((stateValue) => { - const newState = cloneDeep(stateValue); + await this.#saveMutex.runExclusive(async () => { + const current = + (await this.#state.getKey(this.#stateKey)) ?? + {}; + const merged = cloneDeep(current); for (const asset of assets) { - newState.assets[asset.assetId] = { + merged[asset.assetId] = { ...asset, persistedAt, }; } - return newState; + + await this.#state.setKey(this.#stateKey, merged); }); } } From 3119f409969d294b2bd19dbe4d736bd476f6e6a6 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Wed, 29 Apr 2026 12:27:10 +0200 Subject: [PATCH 138/384] fix: fix comments --- .../src/handlers/keyring/api.test.ts | 10 +++++----- .../src/handlers/keyring/keyring.ts | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts index 7a12e94b..5a477cf4 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts @@ -79,7 +79,7 @@ describe('ResolveAccountAddressRequestStruct', () => { id: '1', method: MultichainMethod.SignMessage, params: { - message: btoa('Hello, world!'), + message: 'Hello, world!', opts: { address: account.address, networkPassphrase: 'Public Global Stellar Network ; September 2015', @@ -146,7 +146,7 @@ describe('ResolveAccountAddressRequestStruct', () => { jsonrpc: '2.0', id: '1', method: MultichainMethod.SignMessage, - params: { message: btoa('Hello') }, + params: { message: 'Hello' }, }, scope: KnownCaip2ChainId.Mainnet, }, @@ -211,7 +211,7 @@ describe('SignMessageRequestStruct', () => { account: account.id, request: { method: MultichainMethod.SignMessage, - params: { message: btoa('Hello, world!') }, + params: { message: 'Hello, world!' }, }, }; @@ -244,7 +244,7 @@ describe('SignMessageRequestStruct', () => { request: { method: MultichainMethod.SignMessage, params: { - message: btoa('Hello, world!'), + message: 'Hello, world!', opts: { address: account.address, networkPassphrase: @@ -263,7 +263,7 @@ describe('SignMessageRequestStruct', () => { ...validSignMessageRequest, request: { method: MultichainMethod.SignTransaction, - params: { message: btoa('Hello') }, + params: { message: 'Hello' }, }, }, { diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index c3c620f6..2e78795a 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -477,8 +477,8 @@ export class KeyringHandler implements Keyring { return { address: `${scope}:${account.address}` }; } catch (error: unknown) { // Per the keyring API, returning `null` signals "this snap does not - // own the requested address" so MetaMask's routing layer can try the - // next snap. Throwing here would be treated as a hard routing error. + // own the requested address" so MetaMask's routing layer will fallback to + // the current connected account. if (error instanceof AccountNotFoundException) { return null; } From e7701807ea3a2c9563f130bd314ff9072c5085d1 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Thu, 30 Apr 2026 13:02:00 +0200 Subject: [PATCH 139/384] chore: missing perm for createAccounts --- .../stellar-wallet-snap/src/permissions.ts | 1 + .../AssetMetadataRepository.test.ts | 32 +------------------ .../asset-metadata/AssetMetadataRepository.ts | 23 +++---------- 3 files changed, 6 insertions(+), 50 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/permissions.ts b/merged-packages/stellar-wallet-snap/src/permissions.ts index 6a69aa73..0438f7cd 100644 --- a/merged-packages/stellar-wallet-snap/src/permissions.ts +++ b/merged-packages/stellar-wallet-snap/src/permissions.ts @@ -14,6 +14,7 @@ const dappPermissions = isDev KeyringRpcMethod.ListAccounts, KeyringRpcMethod.GetAccount, KeyringRpcMethod.CreateAccount, + KeyringRpcMethod.CreateAccounts, KeyringRpcMethod.DeleteAccount, KeyringRpcMethod.DiscoverAccounts, KeyringRpcMethod.GetAccountBalances, diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts index dc154455..abb5141e 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts @@ -48,11 +48,7 @@ function createMockStateManager( } return undefined; }, - setKey: jest.fn(async (key: string, value: unknown) => { - if (key === 'assets') { - state.assets = cloneDeep(value) as AssetMetadataState['assets']; - } - }), + setKey: jest.fn(async () => Promise.resolve()), update: async (updater) => { state = updater(cloneDeep(state)); return cloneDeep(state); @@ -138,30 +134,4 @@ describe('AssetMetadataRepository', () => { expect(await repo.getByAssetId(classicId)).toStrictEqual(row); expect(await repo.getByAssetId(sep41Id)).toBeNull(); }); - - it('preserves all rows when saveMany runs concurrently for disjoint asset ids', async () => { - const manager = createMockStateManager({ assets: {} }); - const repo = new AssetMetadataRepository(manager); - const classic = generateAssetData( - classicId, - AssetType.Token, - KnownCaip2ChainId.Testnet, - ); - const sep41 = generateAssetData( - sep41Id, - AssetType.Sep41, - KnownCaip2ChainId.Mainnet, - ); - - await Promise.all([repo.saveMany([classic]), repo.saveMany([sep41])]); - - expect(await repo.getByAssetId(classicId)).toMatchObject({ - assetId: classicId, - persistedAt: expect.any(Number), - }); - expect(await repo.getByAssetId(sep41Id)).toMatchObject({ - assetId: sep41Id, - persistedAt: expect.any(Number), - }); - }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts index c0fa03c2..c0e7268f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts +++ b/merged-packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts @@ -1,4 +1,3 @@ -import { Mutex } from 'async-mutex'; import { cloneDeep } from 'lodash'; import type { @@ -18,13 +17,6 @@ export class AssetMetadataRepository { readonly #stateKey = 'assets'; - /** - * Serializes read-merge-write on the `assets` map so concurrent `saveMany` - * calls cannot drop each other's rows (lost update), without using - * `snap_manageState` on the entire snap state blob. - */ - readonly #saveMutex = new Mutex(); - constructor(state: IStateManager) { this.#state = state; } @@ -125,9 +117,6 @@ export class AssetMetadataRepository { * Upserts rows by `assetId`. Stamps `persistedAt` (same value for all rows in this call) * for future staleness / TTL logic. * - * Uses `snap_setState` on the `assets` key (via `setKey`) instead of `snap_manageState` - * so imports and lookups avoid rewriting the entire encrypted state blob. - * * @param assets - Full metadata rows; `assetId` must match the CAIP-19 key for that network. */ async saveMany(assets: StellarAssetMetadata[]): Promise { @@ -135,20 +124,16 @@ export class AssetMetadataRepository { return; } const persistedAt = Date.now(); - await this.#saveMutex.runExclusive(async () => { - const current = - (await this.#state.getKey(this.#stateKey)) ?? - {}; - const merged = cloneDeep(current); + await this.#state.update((stateValue) => { + const newState = cloneDeep(stateValue); for (const asset of assets) { - merged[asset.assetId] = { + newState.assets[asset.assetId] = { ...asset, persistedAt, }; } - - await this.#state.setKey(this.#stateKey, merged); + return newState; }); } } From ab29a43e4bee027a2e54f89e386e5aeb1b6ed47c Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Thu, 30 Apr 2026 13:13:05 +0200 Subject: [PATCH 140/384] fix: makes PriceService use inMemoryCache --- merged-packages/stellar-wallet-snap/src/context.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 2ec87b64..22ccdbd1 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -19,7 +19,7 @@ import { AssetMetadataRepository, AssetMetadataService, } from './services/asset-metadata'; -import { StateCache } from './services/cache'; +import { InMemoryCache, StateCache } from './services/cache'; import { NetworkService } from './services/network'; import type { OnChainAccountState } from './services/on-chain-account'; import { @@ -35,7 +35,7 @@ import { } from './services/transaction'; import { WalletService } from './services/wallet'; import { ConfirmationUXController } from './ui/confirmation/controller'; -import { logger } from './utils'; +import { logger, noOpLogger } from './utils'; assert(AppConfig, object()); @@ -91,7 +91,7 @@ const transactionService = new TransactionService({ }); const priceService = new PriceService({ - cache: new StateCache(state, logger, '__cache__price'), + cache: new InMemoryCache(noOpLogger), logger, }); From 314c0b52dd8282fd9d82191751a3a3005fe20a9b Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Thu, 30 Apr 2026 13:24:55 +0200 Subject: [PATCH 141/384] chore: lint --- merged-packages/stellar-wallet-snap/src/utils/currency.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.ts index 4cf17e68..6b5ef534 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/currency.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.ts @@ -46,6 +46,7 @@ export function normalizeAmount( * * @param amountInSmallestUnit - Balance in the asset's smallest unit (e.g. stroops). * @param decimalPlaces - Asset decimals (e.g. 7 for XLM / classic Stellar assets). + * @returns Decimal string suitable for the keyring / multichain balance APIs. */ export function formatBalanceAmountForKeyringApi( amountInSmallestUnit: BigNumber, From f92783b517ce96eeab70a7a6b8fd1358c2fa6448 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 4 May 2026 12:26:57 +0800 Subject: [PATCH 142/384] fix: price service cache for spot price --- .../cache/__mocks__/cache.fixtures.ts | 24 ++- .../src/services/price/PriceService.test.ts | 177 ++++++++++++++++-- .../src/services/price/PriceService.ts | 87 +++++++-- .../src/services/price/utils.ts | 40 ++++ 4 files changed, 300 insertions(+), 28 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/services/price/utils.ts diff --git a/merged-packages/stellar-wallet-snap/src/services/cache/__mocks__/cache.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/cache/__mocks__/cache.fixtures.ts index 147c74f7..7b98aadd 100644 --- a/merged-packages/stellar-wallet-snap/src/services/cache/__mocks__/cache.fixtures.ts +++ b/merged-packages/stellar-wallet-snap/src/services/cache/__mocks__/cache.fixtures.ts @@ -24,8 +24,28 @@ export function createMemoryCache(): { keys: jest.fn(async () => [...store.keys()]), size: jest.fn(async () => store.size), peek: jest.fn(async (key: string) => store.get(key)), - mget: jest.fn(async () => ({})), - mset: jest.fn(async () => undefined), + mget: jest.fn(async (keys: string[]) => { + const result: Record = {}; + for (const key of keys) { + result[key] = store.get(key); + } + return result; + }), + mset: jest.fn( + async ( + entries: { + key: string; + value: Serializable; + ttlMilliseconds?: number; + }[], + ) => { + for (const { key, value } of entries) { + if (value !== undefined) { + store.set(key, value); + } + } + }, + ), mdelete: jest.fn(async () => ({})), } as unknown as ICache; return { cache, store }; diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts index f01d3c16..357d30df 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts @@ -15,6 +15,7 @@ import type { SpotPrice, } from './price-api/api'; import { PriceApiClient } from './price-api/PriceApiClient'; +import { toCacheKey } from './utils'; import { createMemoryCache } from '../cache/__mocks__/cache.fixtures'; jest.mock('../../utils/logger'); @@ -53,11 +54,10 @@ const minimalSpot = (id: string, price: number): SpotPrice => ({ price, }); -const cacheKeySpotPrices = ( - assetIds: KnownCaip19AssetIdOrSlip44Id[], - vsCurrency: string, -) => - `PriceService:getSpotPrices:${JSON.stringify(serialize(assetIds))}:${JSON.stringify(serialize(vsCurrency))}`; +const SPOT_PRICES_CACHE_KEY_PREFIX = 'PriceApiClient:getSpotPrices' as const; + +const cacheKeySpotPrice = (assetId: CaipAssetType, vsCurrency: string) => + toCacheKey(SPOT_PRICES_CACHE_KEY_PREFIX, assetId, vsCurrency); const cacheKeyFiatExchangeRates = () => 'PriceService:getFiatExchangeRates:'; @@ -113,21 +113,21 @@ describe('PriceService', () => { [stellarClassicUsdc], 'usd', ); - const key = cacheKeySpotPrices([stellarClassicUsdc], 'usd'); - expect(cache.set).toHaveBeenCalledWith( - key, - spotResult, - AppConfig.cache.ttlMilliseconds.spotPrices, - ); + expect(cache.mset).toHaveBeenCalledWith([ + { + key: cacheKeySpotPrice(stellarClassicUsdc, 'usd'), + value: null, + ttlMilliseconds: AppConfig.cache.ttlMilliseconds.spotPrices, + }, + ]); }); it('returns cached spot prices without calling PriceApiClient', async () => { const { cache, store } = createMemoryCache(); const service = new PriceService({ cache, logger }); const cached = { [stellarClassicUsdc]: null }; - const key = cacheKeySpotPrices([stellarClassicUsdc], 'eur'); - store.set(key, cached); + store.set(cacheKeySpotPrice(stellarClassicUsdc, 'eur'), null); expect( await service.getSpotPrices({ @@ -142,9 +142,8 @@ describe('PriceService', () => { it('calls PriceApiClient when refreshCache is true', async () => { const { cache, store } = createMemoryCache(); const service = new PriceService({ cache, logger }); - const key = cacheKeySpotPrices([stellarClassicUsdc], 'usd'); - store.set(key, { [stellarClassicUsdc]: null }); + store.set(cacheKeySpotPrice(stellarClassicUsdc, 'usd'), null); await service.getSpotPrices( { assetIds: [stellarClassicUsdc], vsCurrency: 'usd' }, @@ -153,6 +152,154 @@ describe('PriceService', () => { expect(getSpotPricesSpy).toHaveBeenCalledTimes(1); }); + + it('returns empty object without calling PriceApiClient when assetIds is empty', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + + expect(await service.getSpotPrices({ assetIds: [] })).toStrictEqual({}); + + expect(getSpotPricesSpy).not.toHaveBeenCalled(); + expect(cache.mget).toHaveBeenCalledWith([]); + expect(cache.mset).not.toHaveBeenCalled(); + }); + + it('deduplicates assetIds before calling PriceApiClient', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + const spotResult = { + [stellarClassicUsdc]: minimalSpot(stellarClassicUsdc, 1), + }; + + getSpotPricesSpy.mockResolvedValueOnce(spotResult); + + expect( + await service.getSpotPrices({ + assetIds: [stellarClassicUsdc, stellarClassicUsdc], + vsCurrency: 'usd', + }), + ).toStrictEqual(spotResult); + + expect(getSpotPricesSpy).toHaveBeenCalledTimes(1); + expect(getSpotPricesSpy).toHaveBeenCalledWith( + [stellarClassicUsdc], + 'usd', + ); + expect(cache.mset).toHaveBeenCalledWith([ + { + key: cacheKeySpotPrice(stellarClassicUsdc, 'usd'), + value: spotResult[stellarClassicUsdc], + ttlMilliseconds: AppConfig.cache.ttlMilliseconds.spotPrices, + }, + ]); + }); + + it('uses default vsCurrency usd when vsCurrency is omitted', async () => { + const { cache } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + const spotResult = { [stellarClassicUsdc]: null }; + + getSpotPricesSpy.mockResolvedValueOnce(spotResult); + + await service.getSpotPrices({ assetIds: [stellarClassicUsdc] }); + + expect(getSpotPricesSpy).toHaveBeenCalledWith( + [stellarClassicUsdc], + 'usd', + ); + expect(cache.mset).toHaveBeenCalledWith([ + { + key: cacheKeySpotPrice(stellarClassicUsdc, 'usd'), + value: null, + ttlMilliseconds: AppConfig.cache.ttlMilliseconds.spotPrices, + }, + ]); + }); + + it('fetches only assets missing from cache on partial hit', async () => { + const { cache, store } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + const cachedPrice = minimalSpot(stellarClassicUsdc, 0.99); + const mockPrice = minimalSpot(stellarTestnetMockAsset, 2); + + store.set(cacheKeySpotPrice(stellarClassicUsdc, 'usd'), cachedPrice); + + getSpotPricesSpy.mockResolvedValueOnce({ + [stellarTestnetMockAsset]: mockPrice, + }); + + expect( + await service.getSpotPrices({ + assetIds: [stellarClassicUsdc, stellarTestnetMockAsset], + vsCurrency: 'usd', + }), + ).toStrictEqual({ + [stellarClassicUsdc]: cachedPrice, + [stellarTestnetMockAsset]: mockPrice, + }); + + expect(getSpotPricesSpy).toHaveBeenCalledTimes(1); + expect(getSpotPricesSpy).toHaveBeenCalledWith( + [stellarTestnetMockAsset], + 'usd', + ); + expect(cache.mset).toHaveBeenCalledWith([ + { + key: cacheKeySpotPrice(stellarTestnetMockAsset, 'usd'), + value: mockPrice, + ttlMilliseconds: AppConfig.cache.ttlMilliseconds.spotPrices, + }, + ]); + }); + + it('returns all assets from cache when every asset is cached', async () => { + const { cache, store } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + const usdcPrice = minimalSpot(stellarClassicUsdc, 1); + const mockPrice = minimalSpot(stellarTestnetMockAsset, 3); + + store.set(cacheKeySpotPrice(stellarClassicUsdc, 'usd'), usdcPrice); + store.set(cacheKeySpotPrice(stellarTestnetMockAsset, 'usd'), mockPrice); + + expect( + await service.getSpotPrices({ + assetIds: [stellarClassicUsdc, stellarTestnetMockAsset], + vsCurrency: 'usd', + }), + ).toStrictEqual({ + [stellarClassicUsdc]: usdcPrice, + [stellarTestnetMockAsset]: mockPrice, + }); + + expect(getSpotPricesSpy).not.toHaveBeenCalled(); + expect(cache.mset).not.toHaveBeenCalled(); + }); + + it('does not reuse cache across different vsCurrency values', async () => { + const { cache, store } = createMemoryCache(); + const service = new PriceService({ cache, logger }); + const usdPrice = minimalSpot(stellarClassicUsdc, 1); + + store.set(cacheKeySpotPrice(stellarClassicUsdc, 'usd'), usdPrice); + getSpotPricesSpy.mockResolvedValueOnce({ + [stellarClassicUsdc]: minimalSpot(stellarClassicUsdc, 0.9), + }); + + expect( + await service.getSpotPrices({ + assetIds: [stellarClassicUsdc], + vsCurrency: 'eur', + }), + ).toStrictEqual({ + [stellarClassicUsdc]: minimalSpot(stellarClassicUsdc, 0.9), + }); + + expect(getSpotPricesSpy).toHaveBeenCalledTimes(1); + expect(getSpotPricesSpy).toHaveBeenCalledWith( + [stellarClassicUsdc], + 'eur', + ); + }); }); describe('getFiatExchangeRates', () => { diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts index 2b24bb29..05d88ae6 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts @@ -6,7 +6,7 @@ import type { import type { CaipAssetType } from '@metamask/utils'; import { parseCaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { pick } from 'lodash'; +import { mapKeys, pick } from 'lodash'; import { createPrefixedLogger, @@ -28,6 +28,7 @@ import type { VsCurrencyParam, } from './price-api/api'; import { PriceApiClient } from './price-api/PriceApiClient'; +import { parseCacheKey, toCacheKey } from './utils'; import { AppConfig } from '../../config'; /** @@ -95,16 +96,80 @@ export class PriceService { vsCurrency?: VsCurrencyParam | string; }, refreshCache: boolean = false, - ): Promise> { - return useCache( - this.#priceApiClient.getSpotPrices.bind(this.#priceApiClient), - this.#cache, - { - functionName: 'PriceService:getSpotPrices', - ttlMilliseconds: AppConfig.cache.ttlMilliseconds.spotPrices, - refreshCache, - }, - )(assetIds, vsCurrency); + ): Promise { + return this.#getCachedSpotPrices(assetIds, vsCurrency, refreshCache); + } + + /** + * Internal caching for {@link PriceService.getSpotPrices}: + * - Uses `mget` / `mset` for batch reads and writes. + * - One cache entry per asset and quote currency. + * - On partial hits, fetches only assets missing from the cache. + * + * @param tokenCaip19Types - CAIP-19 asset IDs to quote. + * @param vsCurrency - Quote currency. + * @param refreshCache - When true, bypasses the cache for this call. + * @returns Spot prices keyed by asset ID. + */ + async #getCachedSpotPrices( + tokenCaip19Types: CaipAssetType[], + vsCurrency: VsCurrencyParam | string = 'usd', + refreshCache: boolean = false, + ): Promise { + const uniqueTokenCaip19Types = [...new Set(tokenCaip19Types)]; + + const cacheKeyPrefix = 'PriceApiClient:getSpotPrices'; + + // Get the cached spot prices + const cachedSpotPricesRecord = refreshCache + ? {} + : await this.#cache.mget( + uniqueTokenCaip19Types.map((tokenCaip19Type: CaipAssetType) => + toCacheKey(cacheKeyPrefix, tokenCaip19Type, vsCurrency), + ), + ); + + // `mget` keys results by full cache keys (`PriceApiClient:getSpotPrices:…`), not by CAIP asset ID; map back to asset IDs. + const cachedSpotPricesRecordWithParsedKeys = mapKeys( + cachedSpotPricesRecord, + (_value, key) => parseCacheKey(cacheKeyPrefix, key)[1], + ); + + // We still need to fetch the spot prices for the tokens that are not cached + const nonCachedTokenCaip19Types = uniqueTokenCaip19Types.filter( + (tokenCaip19Type) => + cachedSpotPricesRecordWithParsedKeys[tokenCaip19Type] === undefined, + ); + + if (nonCachedTokenCaip19Types.length === 0) { + return cachedSpotPricesRecordWithParsedKeys as SpotPrices; + } + + // Fetch the spot prices for the tokens that are not cached + const nonCachedSpotPrices = await this.#priceApiClient.getSpotPrices( + nonCachedTokenCaip19Types, + vsCurrency, + ); + + // Cache the data + await this.#cache.mset( + Object.entries(nonCachedSpotPrices).map( + ([tokenCaipAssetType, spotPrice]) => ({ + key: toCacheKey( + cacheKeyPrefix, + tokenCaipAssetType as CaipAssetType, + vsCurrency, + ), + value: spotPrice, + ttlMilliseconds: AppConfig.cache.ttlMilliseconds.spotPrices, + }), + ), + ); + + return { + ...cachedSpotPricesRecordWithParsedKeys, + ...nonCachedSpotPrices, + }; } /** diff --git a/merged-packages/stellar-wallet-snap/src/services/price/utils.ts b/merged-packages/stellar-wallet-snap/src/services/price/utils.ts new file mode 100644 index 00000000..dc39e587 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/price/utils.ts @@ -0,0 +1,40 @@ +import type { CaipAssetType } from '@metamask/utils'; + +import type { VsCurrencyParam } from './price-api/api'; + +/** + * Shorthand method to generate the cache key + * + * @param cacheKeyPrefix - The prefix of the cache key. + * @param tokenCaipAssetType - The CAIP asset type. + * @param vsCurrency - The currency to convert the prices to. + * @returns The cache key. + */ +export function toCacheKey( + cacheKeyPrefix: string, + tokenCaipAssetType: CaipAssetType, + vsCurrency: VsCurrencyParam | string, +): string { + return `${cacheKeyPrefix}:${tokenCaipAssetType}:${vsCurrency}`; +} + +/** + * Parses back the cache key + * + * @param cacheKeyPrefix - The prefix of the cache key. + * @param key - The cache key to parse. + * @returns The parsed cache key. + */ +export function parseCacheKey( + cacheKeyPrefix: string, + key: string, +): RegExpMatchArray { + const regex = new RegExp(`^${cacheKeyPrefix}:(.+):(.+)$`, 'u'); + const match = key.match(regex); + + if (!match) { + throw new Error('Invalid cache key'); + } + + return match; +} From 41ad26326549b9f2a273b684e8133e34cf66aab4 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 4 May 2026 12:35:32 +0800 Subject: [PATCH 143/384] chore: update code --- .../src/services/price/PriceService.test.ts | 9 ++++- .../src/services/price/PriceService.ts | 31 ++++++++------ .../src/services/price/utils.ts | 40 ------------------- 3 files changed, 26 insertions(+), 54 deletions(-) delete mode 100644 merged-packages/stellar-wallet-snap/src/services/price/utils.ts diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts index 357d30df..d5c058e1 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts @@ -15,7 +15,6 @@ import type { SpotPrice, } from './price-api/api'; import { PriceApiClient } from './price-api/PriceApiClient'; -import { toCacheKey } from './utils'; import { createMemoryCache } from '../cache/__mocks__/cache.fixtures'; jest.mock('../../utils/logger'); @@ -56,8 +55,14 @@ const minimalSpot = (id: string, price: number): SpotPrice => ({ const SPOT_PRICES_CACHE_KEY_PREFIX = 'PriceApiClient:getSpotPrices' as const; +/** + * Mirrors {@link PriceService} spot-price cache keys: `prefix:caipAsset:vsCurrency`. + * + * @param assetId + * @param vsCurrency + */ const cacheKeySpotPrice = (assetId: CaipAssetType, vsCurrency: string) => - toCacheKey(SPOT_PRICES_CACHE_KEY_PREFIX, assetId, vsCurrency); + `${SPOT_PRICES_CACHE_KEY_PREFIX}:${assetId}:${vsCurrency}`; const cacheKeyFiatExchangeRates = () => 'PriceService:getFiatExchangeRates:'; diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts index 05d88ae6..e5cde2bb 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts @@ -28,7 +28,6 @@ import type { VsCurrencyParam, } from './price-api/api'; import { PriceApiClient } from './price-api/PriceApiClient'; -import { parseCacheKey, toCacheKey } from './utils'; import { AppConfig } from '../../config'; /** @@ -120,19 +119,31 @@ export class PriceService { const cacheKeyPrefix = 'PriceApiClient:getSpotPrices'; + // Shorthand method to generate the cache key + const toCacheKey = (tokenCaipAssetType: CaipAssetType): string => + `${cacheKeyPrefix}:${tokenCaipAssetType}:${vsCurrency}`; + + // Parses back the cache key + const parseCacheKey = (key: string): RegExpMatchArray => { + const regex = new RegExp(`^${cacheKeyPrefix}:(.+):(.+)$`, 'u'); + const match = key.match(regex); + + if (!match) { + throw new Error('Invalid cache key'); + } + + return match; + }; + // Get the cached spot prices const cachedSpotPricesRecord = refreshCache ? {} - : await this.#cache.mget( - uniqueTokenCaip19Types.map((tokenCaip19Type: CaipAssetType) => - toCacheKey(cacheKeyPrefix, tokenCaip19Type, vsCurrency), - ), - ); + : await this.#cache.mget(uniqueTokenCaip19Types.map(toCacheKey)); // `mget` keys results by full cache keys (`PriceApiClient:getSpotPrices:…`), not by CAIP asset ID; map back to asset IDs. const cachedSpotPricesRecordWithParsedKeys = mapKeys( cachedSpotPricesRecord, - (_value, key) => parseCacheKey(cacheKeyPrefix, key)[1], + (_value, key) => parseCacheKey(key)[1], ); // We still need to fetch the spot prices for the tokens that are not cached @@ -155,11 +166,7 @@ export class PriceService { await this.#cache.mset( Object.entries(nonCachedSpotPrices).map( ([tokenCaipAssetType, spotPrice]) => ({ - key: toCacheKey( - cacheKeyPrefix, - tokenCaipAssetType as CaipAssetType, - vsCurrency, - ), + key: toCacheKey(tokenCaipAssetType as CaipAssetType), value: spotPrice, ttlMilliseconds: AppConfig.cache.ttlMilliseconds.spotPrices, }), diff --git a/merged-packages/stellar-wallet-snap/src/services/price/utils.ts b/merged-packages/stellar-wallet-snap/src/services/price/utils.ts deleted file mode 100644 index dc39e587..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/price/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { CaipAssetType } from '@metamask/utils'; - -import type { VsCurrencyParam } from './price-api/api'; - -/** - * Shorthand method to generate the cache key - * - * @param cacheKeyPrefix - The prefix of the cache key. - * @param tokenCaipAssetType - The CAIP asset type. - * @param vsCurrency - The currency to convert the prices to. - * @returns The cache key. - */ -export function toCacheKey( - cacheKeyPrefix: string, - tokenCaipAssetType: CaipAssetType, - vsCurrency: VsCurrencyParam | string, -): string { - return `${cacheKeyPrefix}:${tokenCaipAssetType}:${vsCurrency}`; -} - -/** - * Parses back the cache key - * - * @param cacheKeyPrefix - The prefix of the cache key. - * @param key - The cache key to parse. - * @returns The parsed cache key. - */ -export function parseCacheKey( - cacheKeyPrefix: string, - key: string, -): RegExpMatchArray { - const regex = new RegExp(`^${cacheKeyPrefix}:(.+):(.+)$`, 'u'); - const match = key.match(regex); - - if (!match) { - throw new Error('Invalid cache key'); - } - - return match; -} From 3ab75682750b8fa2615836699b253adf94d83b9b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 4 May 2026 12:36:12 +0800 Subject: [PATCH 144/384] chore: remove js doc --- .../src/services/price/PriceService.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts index d5c058e1..e1370033 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts @@ -55,12 +55,6 @@ const minimalSpot = (id: string, price: number): SpotPrice => ({ const SPOT_PRICES_CACHE_KEY_PREFIX = 'PriceApiClient:getSpotPrices' as const; -/** - * Mirrors {@link PriceService} spot-price cache keys: `prefix:caipAsset:vsCurrency`. - * - * @param assetId - * @param vsCurrency - */ const cacheKeySpotPrice = (assetId: CaipAssetType, vsCurrency: string) => `${SPOT_PRICES_CACHE_KEY_PREFIX}:${assetId}:${vsCurrency}`; From 25fc050cbfafaa189dc4c4ae49312bdfd61642e6 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 4 May 2026 12:45:44 +0800 Subject: [PATCH 145/384] chore: update test --- .../src/services/price/PriceService.test.ts | 84 ++++++++++++++----- 1 file changed, 61 insertions(+), 23 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts index e1370033..fe6fcbee 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.test.ts @@ -69,32 +69,40 @@ const cacheKeyHistoricalPrices = (params: { }) => `PriceService:getHistoricalPrices:${JSON.stringify(serialize(params))}`; describe('PriceService', () => { - let getSpotPricesSpy: jest.SpiedFunction; - let getFiatExchangeRatesSpy: jest.SpiedFunction< - PriceApiClient['getFiatExchangeRates'] - >; - let getHistoricalPricesSpy: jest.SpiedFunction< - PriceApiClient['getHistoricalPrices'] - >; - - beforeEach(() => { - getSpotPricesSpy = jest - .spyOn(PriceApiClient.prototype, 'getSpotPrices') - .mockResolvedValue({ [stellarClassicUsdc]: null }); - getFiatExchangeRatesSpy = jest - .spyOn(PriceApiClient.prototype, 'getFiatExchangeRates') - .mockResolvedValue(fiatExchangeRatesBody); - getHistoricalPricesSpy = jest - .spyOn(PriceApiClient.prototype, 'getHistoricalPrices') - .mockResolvedValue(GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); + const setupTest = () => { + const getSpotPricesSpy = jest.spyOn( + PriceApiClient.prototype, + 'getSpotPrices', + ); + getSpotPricesSpy.mockReset(); + getSpotPricesSpy.mockResolvedValue({ [stellarClassicUsdc]: null }); + + const getFiatExchangeRatesSpy = jest.spyOn( + PriceApiClient.prototype, + 'getFiatExchangeRates', + ); + getFiatExchangeRatesSpy.mockReset(); + getFiatExchangeRatesSpy.mockResolvedValue(fiatExchangeRatesBody); + + const getHistoricalPricesSpy = jest.spyOn( + PriceApiClient.prototype, + 'getHistoricalPrices', + ); + getHistoricalPricesSpy.mockReset(); + getHistoricalPricesSpy.mockResolvedValue( + GET_HISTORICAL_PRICES_RESPONSE_NULL_OBJECT, + ); + + return { + getSpotPricesSpy, + getFiatExchangeRatesSpy, + getHistoricalPricesSpy, + }; + }; describe('getSpotPrices', () => { it('calls PriceApiClient and stores result in cache', async () => { + const { getSpotPricesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); const spotResult = { [stellarClassicUsdc]: null }; @@ -122,6 +130,7 @@ describe('PriceService', () => { }); it('returns cached spot prices without calling PriceApiClient', async () => { + const { getSpotPricesSpy } = setupTest(); const { cache, store } = createMemoryCache(); const service = new PriceService({ cache, logger }); const cached = { [stellarClassicUsdc]: null }; @@ -139,6 +148,7 @@ describe('PriceService', () => { }); it('calls PriceApiClient when refreshCache is true', async () => { + const { getSpotPricesSpy } = setupTest(); const { cache, store } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -153,6 +163,7 @@ describe('PriceService', () => { }); it('returns empty object without calling PriceApiClient when assetIds is empty', async () => { + const { getSpotPricesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -164,6 +175,7 @@ describe('PriceService', () => { }); it('deduplicates assetIds before calling PriceApiClient', async () => { + const { getSpotPricesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); const spotResult = { @@ -194,6 +206,7 @@ describe('PriceService', () => { }); it('uses default vsCurrency usd when vsCurrency is omitted', async () => { + const { getSpotPricesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); const spotResult = { [stellarClassicUsdc]: null }; @@ -216,6 +229,7 @@ describe('PriceService', () => { }); it('fetches only assets missing from cache on partial hit', async () => { + const { getSpotPricesSpy } = setupTest(); const { cache, store } = createMemoryCache(); const service = new PriceService({ cache, logger }); const cachedPrice = minimalSpot(stellarClassicUsdc, 0.99); @@ -252,6 +266,7 @@ describe('PriceService', () => { }); it('returns all assets from cache when every asset is cached', async () => { + const { getSpotPricesSpy } = setupTest(); const { cache, store } = createMemoryCache(); const service = new PriceService({ cache, logger }); const usdcPrice = minimalSpot(stellarClassicUsdc, 1); @@ -275,6 +290,7 @@ describe('PriceService', () => { }); it('does not reuse cache across different vsCurrency values', async () => { + const { getSpotPricesSpy } = setupTest(); const { cache, store } = createMemoryCache(); const service = new PriceService({ cache, logger }); const usdPrice = minimalSpot(stellarClassicUsdc, 1); @@ -303,6 +319,7 @@ describe('PriceService', () => { describe('getFiatExchangeRates', () => { it('calls PriceApiClient and stores result in cache', async () => { + const { getFiatExchangeRatesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -320,6 +337,7 @@ describe('PriceService', () => { }); it('returns cached fiat rates without calling PriceApiClient', async () => { + const { getFiatExchangeRatesSpy } = setupTest(); const { cache, store } = createMemoryCache(); const service = new PriceService({ cache, logger }); const key = cacheKeyFiatExchangeRates(); @@ -334,6 +352,7 @@ describe('PriceService', () => { }); it('calls PriceApiClient when refreshCache is true', async () => { + const { getFiatExchangeRatesSpy } = setupTest(); const { cache, store } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -363,6 +382,7 @@ describe('PriceService', () => { }; it('calls PriceApiClient and stores result in cache', async () => { + const { getHistoricalPricesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -382,6 +402,7 @@ describe('PriceService', () => { }); it('returns cached historical prices without calling PriceApiClient', async () => { + const { getHistoricalPricesSpy } = setupTest(); const { cache, store } = createMemoryCache(); const service = new PriceService({ cache, logger }); const key = cacheKeyHistoricalPrices(historicalRequestPayload); @@ -396,6 +417,7 @@ describe('PriceService', () => { }); it('calls PriceApiClient when refreshCache is true', async () => { + const { getHistoricalPricesSpy } = setupTest(); const { cache, store } = createMemoryCache(); const service = new PriceService({ cache, logger }); const key = cacheKeyHistoricalPrices(historicalRequestPayload); @@ -408,6 +430,7 @@ describe('PriceService', () => { }); it('defaults vsCurrency and forwards zero from and to', async () => { + const { getHistoricalPricesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -430,6 +453,7 @@ describe('PriceService', () => { describe('getHistoricalPriceWithAllTimePeriods', () => { it('requests each configured time period with vsCurrency from quote asset', async () => { + const { getHistoricalPricesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -458,6 +482,7 @@ describe('PriceService', () => { }); it('returns intervals keyed by ISO 8601 durations with stringified prices', async () => { + const { getHistoricalPricesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -496,6 +521,7 @@ describe('PriceService', () => { }); it('sets updateTime and expirationTime using historical prices cache TTL', async () => { + const { getHistoricalPricesSpy } = setupTest(); jest.useFakeTimers(); jest.setSystemTime(new Date('2024-06-01T12:00:00.000Z')); @@ -523,6 +549,7 @@ describe('PriceService', () => { }); it('uses empty price series for a period when the historical request fails', async () => { + const { getHistoricalPricesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -561,6 +588,7 @@ describe('PriceService', () => { describe('getMultipleTokenConversions', () => { it('returns empty record when conversions list is empty', async () => { + setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -568,6 +596,7 @@ describe('PriceService', () => { }); it('derives crypto to crypto rate from USD spot prices', async () => { + const { getSpotPricesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -592,6 +621,7 @@ describe('PriceService', () => { }); it('returns null when a crypto leg has no usable USD price', async () => { + const { getSpotPricesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -607,6 +637,7 @@ describe('PriceService', () => { }); it('derives fiat to fiat rate using inverted exchange rate values', async () => { + const { getSpotPricesSpy, getFiatExchangeRatesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -625,6 +656,7 @@ describe('PriceService', () => { }); it('sets expirationTime from the shorter spot or fiat cache TTL', async () => { + const { getSpotPricesSpy } = setupTest(); jest.useFakeTimers(); jest.setSystemTime(new Date('2024-01-15T00:00:00.000Z')); @@ -658,6 +690,7 @@ describe('PriceService', () => { describe('getMultipleTokensMarketData', () => { it('returns empty record when assets list is empty', async () => { + setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -665,6 +698,7 @@ describe('PriceService', () => { }); it('omits rows when the base asset has no spot entry', async () => { + const { getSpotPricesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -678,6 +712,7 @@ describe('PriceService', () => { }); it('omits rows when the unit has no usable conversion rate', async () => { + const { getSpotPricesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -693,6 +728,7 @@ describe('PriceService', () => { }); it('scales USD monetary fields to the quote unit without converting circulating supply', async () => { + const { getSpotPricesSpy, getFiatExchangeRatesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -724,6 +760,7 @@ describe('PriceService', () => { }); it('includes pricePercentChange when spot returns percent fields', async () => { + const { getSpotPricesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); @@ -749,6 +786,7 @@ describe('PriceService', () => { }); it('uses string zero for circulating supply when spot omits, nulls, or sends zero', async () => { + const { getSpotPricesSpy } = setupTest(); const { cache } = createMemoryCache(); const service = new PriceService({ cache, logger }); From 796e6d06230fd52932a70c3bb5bfbdbf74629bd1 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 4 May 2026 15:20:07 +0800 Subject: [PATCH 146/384] chore: address comment --- .../src/services/price/PriceService.ts | 52 +++++++------------ 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts index e5cde2bb..edd75028 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts @@ -6,7 +6,7 @@ import type { import type { CaipAssetType } from '@metamask/utils'; import { parseCaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { mapKeys, pick } from 'lodash'; +import { pick } from 'lodash'; import { createPrefixedLogger, @@ -95,7 +95,7 @@ export class PriceService { vsCurrency?: VsCurrencyParam | string; }, refreshCache: boolean = false, - ): Promise { + ): Promise> { return this.#getCachedSpotPrices(assetIds, vsCurrency, refreshCache); } @@ -114,55 +114,39 @@ export class PriceService { tokenCaip19Types: CaipAssetType[], vsCurrency: VsCurrencyParam | string = 'usd', refreshCache: boolean = false, - ): Promise { - const uniqueTokenCaip19Types = [...new Set(tokenCaip19Types)]; + ): Promise> { + const uniqueAssetTypes = [...new Set(tokenCaip19Types)]; const cacheKeyPrefix = 'PriceApiClient:getSpotPrices'; - // Shorthand method to generate the cache key const toCacheKey = (tokenCaipAssetType: CaipAssetType): string => `${cacheKeyPrefix}:${tokenCaipAssetType}:${vsCurrency}`; - // Parses back the cache key - const parseCacheKey = (key: string): RegExpMatchArray => { - const regex = new RegExp(`^${cacheKeyPrefix}:(.+):(.+)$`, 'u'); - const match = key.match(regex); - - if (!match) { - throw new Error('Invalid cache key'); - } - - return match; - }; - - // Get the cached spot prices const cachedSpotPricesRecord = refreshCache ? {} - : await this.#cache.mget(uniqueTokenCaip19Types.map(toCacheKey)); + : await this.#cache.mget(uniqueAssetTypes.map(toCacheKey)); - // `mget` keys results by full cache keys (`PriceApiClient:getSpotPrices:…`), not by CAIP asset ID; map back to asset IDs. - const cachedSpotPricesRecordWithParsedKeys = mapKeys( - cachedSpotPricesRecord, - (_value, key) => parseCacheKey(key)[1], - ); + const cachedSpotPricesByAssetId: Partial = {}; + for (const assetType of uniqueAssetTypes) { + const value = cachedSpotPricesRecord[toCacheKey(assetType)]; + if (value !== undefined) { + cachedSpotPricesByAssetId[assetType] = value as SpotPrice | null; + } + } - // We still need to fetch the spot prices for the tokens that are not cached - const nonCachedTokenCaip19Types = uniqueTokenCaip19Types.filter( - (tokenCaip19Type) => - cachedSpotPricesRecordWithParsedKeys[tokenCaip19Type] === undefined, + const nonCachedAssetTypes = uniqueAssetTypes.filter( + (assetType) => cachedSpotPricesByAssetId[assetType] === undefined, ); - if (nonCachedTokenCaip19Types.length === 0) { - return cachedSpotPricesRecordWithParsedKeys as SpotPrices; + if (nonCachedAssetTypes.length === 0) { + return cachedSpotPricesByAssetId; } - // Fetch the spot prices for the tokens that are not cached const nonCachedSpotPrices = await this.#priceApiClient.getSpotPrices( - nonCachedTokenCaip19Types, + nonCachedAssetTypes, vsCurrency, ); - // Cache the data await this.#cache.mset( Object.entries(nonCachedSpotPrices).map( ([tokenCaipAssetType, spotPrice]) => ({ @@ -174,7 +158,7 @@ export class PriceService { ); return { - ...cachedSpotPricesRecordWithParsedKeys, + ...cachedSpotPricesByAssetId, ...nonCachedSpotPrices, }; } From d12e7d9042353689568dbbfc9d8a032f1eedb393 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 4 May 2026 15:27:52 +0800 Subject: [PATCH 147/384] chore: optimize code --- .../src/services/price/PriceService.ts | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts index edd75028..d94eee11 100644 --- a/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/price/PriceService.ts @@ -122,22 +122,34 @@ export class PriceService { const toCacheKey = (tokenCaipAssetType: CaipAssetType): string => `${cacheKeyPrefix}:${tokenCaipAssetType}:${vsCurrency}`; - const cachedSpotPricesRecord = refreshCache - ? {} - : await this.#cache.mget(uniqueAssetTypes.map(toCacheKey)); + let cachedSpotPricesRecord: Record = {}; + // Continue even if there is an error fetching the cached spot prices + try { + cachedSpotPricesRecord = refreshCache + ? {} + : await this.#cache.mget(uniqueAssetTypes.map(toCacheKey)); + } catch (error) { + this.#logger.logErrorWithDetails( + 'Error fetching cached spot prices', + error, + ); + } const cachedSpotPricesByAssetId: Partial = {}; + const nonCachedAssetTypes: CaipAssetType[] = []; for (const assetType of uniqueAssetTypes) { const value = cachedSpotPricesRecord[toCacheKey(assetType)]; - if (value !== undefined) { + // Not found in cache + if (value === undefined) { + // Add to query list + nonCachedAssetTypes.push(assetType); + } else { + // Add to result cachedSpotPricesByAssetId[assetType] = value as SpotPrice | null; } } - const nonCachedAssetTypes = uniqueAssetTypes.filter( - (assetType) => cachedSpotPricesByAssetId[assetType] === undefined, - ); - + // if there are no assets to query, return the cached results if (nonCachedAssetTypes.length === 0) { return cachedSpotPricesByAssetId; } @@ -147,15 +159,20 @@ export class PriceService { vsCurrency, ); - await this.#cache.mset( - Object.entries(nonCachedSpotPrices).map( - ([tokenCaipAssetType, spotPrice]) => ({ - key: toCacheKey(tokenCaipAssetType as CaipAssetType), - value: spotPrice, - ttlMilliseconds: AppConfig.cache.ttlMilliseconds.spotPrices, - }), - ), - ); + // Continue even if there is an error caching the spot prices + try { + await this.#cache.mset( + Object.entries(nonCachedSpotPrices).map( + ([tokenCaipAssetType, spotPrice]) => ({ + key: toCacheKey(tokenCaipAssetType as CaipAssetType), + value: spotPrice, + ttlMilliseconds: AppConfig.cache.ttlMilliseconds.spotPrices, + }), + ), + ); + } catch (error) { + this.#logger.logErrorWithDetails('Error caching spot prices', error); + } return { ...cachedSpotPricesByAssetId, From b7c0a246d1bd4550b9118cc4049931de8f92f008 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Mon, 4 May 2026 10:01:24 +0200 Subject: [PATCH 148/384] chore: rename formatBalanceAm.. --- .../stellar-wallet-snap/src/handlers/keyring/keyring.ts | 4 ++-- .../stellar-wallet-snap/src/utils/currency.test.ts | 8 ++++---- merged-packages/stellar-wallet-snap/src/utils/currency.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index ff114ca5..7a9ff2a5 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -79,7 +79,7 @@ import { getSnapProvider, isSep41Id, isSlip44Id, - formatBalanceAmountForKeyringApi, + toDisplayBalance, rethrowIfInstanceElseThrow, validateOrigin, validateRequest, @@ -479,7 +479,7 @@ export class KeyringHandler implements Keyring { const decimal = assetMetadata.units[0].decimals; assetBalances[assetId] = { unit: asset.symbol ?? '', - amount: formatBalanceAmountForKeyringApi(asset.balance, decimal), + amount: toDisplayBalance(asset.balance, decimal), }; } return assetBalances; diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts index 2f4462dd..e4c5db43 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts @@ -2,7 +2,7 @@ import type { CaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { - formatBalanceAmountForKeyringApi, + toDisplayBalance, formatFiat, getFiatTicker, isFiat, @@ -39,16 +39,16 @@ describe('normalizeAmount', () => { }); }); -describe('formatBalanceAmountForKeyringApi', () => { +describe('toDisplayBalance', () => { it('avoids scientific notation for one stroop', () => { - expect(formatBalanceAmountForKeyringApi(new BigNumber(1), 7)).toBe( + expect(toDisplayBalance(new BigNumber(1), 7)).toBe( '0.0000001', ); expect(normalizeAmount(new BigNumber(1), 7).toString()).toBe('1e-7'); }); it('trims trailing zeros while keeping significant fractional digits', () => { - expect(formatBalanceAmountForKeyringApi(new BigNumber(10), 7)).toBe( + expect(toDisplayBalance(new BigNumber(10), 7)).toBe( '0.000001', ); }); diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.ts index 6b5ef534..98b3cbd3 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/currency.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.ts @@ -48,7 +48,7 @@ export function normalizeAmount( * @param decimalPlaces - Asset decimals (e.g. 7 for XLM / classic Stellar assets). * @returns Decimal string suitable for the keyring / multichain balance APIs. */ -export function formatBalanceAmountForKeyringApi( +export function toDisplayBalance( amountInSmallestUnit: BigNumber, decimalPlaces: number, ): string { From ad41f5da30186945fa482aa22a8d01d0643c4427 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Mon, 4 May 2026 10:12:14 +0200 Subject: [PATCH 149/384] chore: lint --- .../stellar-wallet-snap/src/utils/currency.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts index e4c5db43..88729ef7 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts @@ -41,16 +41,12 @@ describe('normalizeAmount', () => { describe('toDisplayBalance', () => { it('avoids scientific notation for one stroop', () => { - expect(toDisplayBalance(new BigNumber(1), 7)).toBe( - '0.0000001', - ); + expect(toDisplayBalance(new BigNumber(1), 7)).toBe('0.0000001'); expect(normalizeAmount(new BigNumber(1), 7).toString()).toBe('1e-7'); }); it('trims trailing zeros while keeping significant fractional digits', () => { - expect(toDisplayBalance(new BigNumber(10), 7)).toBe( - '0.000001', - ); + expect(toDisplayBalance(new BigNumber(10), 7)).toBe('0.000001'); }); }); From 21aaa6df571dace09330580ac27d15cd88dde3bf Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 4 May 2026 17:30:09 +0800 Subject: [PATCH 150/384] fix: type issue --- .../src/ui/confirmation/controller.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 69699ce8..9db1711b 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -54,20 +54,25 @@ type RenderConfirmationDialogCommon = { }; /** - * Discriminated union: confirmations that have a fee (sign transaction) + * Discriminated union: confirmations that have a fee (example: sign transaction) * MUST provide one; fee-less confirmations (sign message, etc.) MUST NOT. * Prevents callers from forgetting `fee` for SignTransaction (would yield * `feeData: {}` and crash the view at `feeData.assetId`). */ type RenderConfirmationDialogParams = | (RenderConfirmationDialogCommon & { - interfaceKey: ConfirmationInterfaceKey.SignTransaction; + interfaceKey: + | ConfirmationInterfaceKey.SignTransaction + | ConfirmationInterfaceKey.ChangeTrustlineOptIn + | ConfirmationInterfaceKey.ChangeTrustlineOptOut; fee: string; }) | (RenderConfirmationDialogCommon & { interfaceKey: Exclude< ConfirmationInterfaceKey, - ConfirmationInterfaceKey.SignTransaction + | ConfirmationInterfaceKey.SignTransaction + | ConfirmationInterfaceKey.ChangeTrustlineOptIn + | ConfirmationInterfaceKey.ChangeTrustlineOptOut >; fee?: never; }); @@ -93,8 +98,7 @@ export class ConfirmationUXController { * @param params - The parameters for the render. * @param params.scope - The scope of the confirmation. * @param params.renderContext - The context for the render. - * @param params.interfaceKey - The key of the interface to render. When this is - * {@link ConfirmationInterfaceKey.SignTransaction}, `fee` is required. + * @param params.interfaceKey - The key of the interface to render. * @param params.fee - Fee in stroops, REQUIRED for SignTransaction, forbidden otherwise. * @param params.origin - [Optional] The origin of the confirmation. Defaults to 'metamask'. * @param params.renderOptions - [Optional] The options for the render. Defaults to {@link #defaultRenderOptions}. From 6812e28f258b0adc4a0dfd1367dac19199e726f9 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Mon, 4 May 2026 15:30:54 +0200 Subject: [PATCH 151/384] feat: add SEP-43 signAuthEntry keyring method --- .../stellar-wallet-snap/locales/es.json | 24 ++ .../stellar-wallet-snap/snap.manifest.json | 2 +- .../stellar-wallet-snap/src/api/xdr.ts | 25 ++ .../stellar-wallet-snap/src/context.ts | 10 + .../src/handlers/keyring/api.ts | 66 +++- .../src/handlers/keyring/index.ts | 1 + .../src/handlers/keyring/keyring.test.ts | 3 + .../handlers/keyring/signAuthEntry.test.ts | 290 ++++++++++++++++++ .../src/handlers/keyring/signAuthEntry.ts | 194 ++++++++++++ .../src/handlers/user-input/userInput.ts | 2 + .../stellar-wallet-snap/src/index.ts | 3 + .../services/account/AccountService.test.ts | 4 +- .../src/services/account/AccountService.ts | 6 +- .../src/services/wallet/Wallet.test.ts | 46 ++- .../src/services/wallet/Wallet.ts | 32 ++ .../src/services/wallet/exceptions.ts | 10 + .../src/ui/confirmation/api.ts | 1 + .../src/ui/confirmation/controller.tsx | 10 + .../ConfirmSignAuthEntry.tsx | 167 ++++++++++ .../views/ConfirmSignAuthEntry/events.tsx | 50 +++ 20 files changed, 941 insertions(+), 5 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.ts create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/events.tsx diff --git a/merged-packages/stellar-wallet-snap/locales/es.json b/merged-packages/stellar-wallet-snap/locales/es.json index 741163f9..81ee8331 100644 --- a/merged-packages/stellar-wallet-snap/locales/es.json +++ b/merged-packages/stellar-wallet-snap/locales/es.json @@ -49,6 +49,30 @@ "confirmation.signMessage.message": { "message": "Message" }, + "confirmation.signAuthEntry.title": { + "message": "Authorize smart contract" + }, + "confirmation.signAuthEntry.warning": { + "message": "You are authorizing a smart contract to act on your behalf. Only approve if you trust this site." + }, + "confirmation.signAuthEntry.contract": { + "message": "Contract" + }, + "confirmation.signAuthEntry.function": { + "message": "Function" + }, + "confirmation.signAuthEntry.expiresAt": { + "message": "Expires at ledger" + }, + "confirmation.signAuthEntry.nonce": { + "message": "Nonce" + }, + "confirmation.signAuthEntry.subInvocations": { + "message": "Nested authorizations" + }, + "confirmation.signAuthEntry.createContract": { + "message": "Deploy contract" + }, "confirmation.account": { "message": "Account" }, diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index dde1e5f5..513f33e6 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "Hi6aSk/GDRc5ThG13cAe8VuYgkNdcNIYe6jWlqLPu7I=", + "shasum": "bEsJi+ez3xSysjUpbD2XUya8bBAEyqpNeHhoj7Xe7DU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/api/xdr.ts b/merged-packages/stellar-wallet-snap/src/api/xdr.ts index 2ec38152..5ea58e21 100644 --- a/merged-packages/stellar-wallet-snap/src/api/xdr.ts +++ b/merged-packages/stellar-wallet-snap/src/api/xdr.ts @@ -19,3 +19,28 @@ export const XdrStruct = refine( } }, ); + +/** + * Validation struct for a SEP-43 `signAuthEntry` payload: a base64-encoded + * `HashIdPreimage` whose discriminant is `envelopeTypeSorobanAuthorization` + * (i.e. a Soroban auth-entry preimage). Anything else is rejected at the + * struct level so the handler can return -3 InvalidRequest. + */ +export const HashIdPreimageXdrStruct = refine( + nonempty(base64(string())), + 'valid_soroban_auth_preimage', + (value: string) => { + try { + const preimage = xdr.HashIdPreimage.fromXDR(value, 'base64'); + if ( + preimage.switch() !== + xdr.EnvelopeType.envelopeTypeSorobanAuthorization() + ) { + return 'HashIdPreimage is not a Soroban authorization preimage'; + } + return true; + } catch { + return 'Invalid HashIdPreimage XDR'; + } + }, +); diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 2ec87b64..de113076 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -10,6 +10,7 @@ import { TrackTransactionHandler } from './handlers/cronjob/trackTransaction'; import type { IKeyringRequestHandler } from './handlers/keyring'; import { MultichainMethod, + SignAuthEntryHandler, SignMessageHandler, SignTransactionHandler, } from './handlers/keyring'; @@ -117,10 +118,18 @@ const signMessageHandler = new SignMessageHandler({ confirmationUIController, }); +const signAuthEntryHandler = new SignAuthEntryHandler({ + logger, + accountService, + walletService, + confirmationUIController, +}); + const keyringMethodHandlers: Record = { [MultichainMethod.SignTransaction]: signTransactionHandler, [MultichainMethod.SignMessage]: signMessageHandler, + [MultichainMethod.SignAuthEntry]: signAuthEntryHandler, }; const keyringHandler = new KeyringHandler({ @@ -176,5 +185,6 @@ export { userInputHandler, signTransactionHandler, signMessageHandler, + signAuthEntryHandler, confirmationUIController, }; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts index 3f432167..7f58fe6f 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts @@ -29,13 +29,14 @@ import { StellarAddressStruct } from '../../api/address'; import { KnownCaip2ChainId, KnownCaip2ChainIdStruct } from '../../api/network'; import { Utf8StringStruct } from '../../api/string'; import { UuidStruct } from '../../api/uuid'; -import { XdrStruct } from '../../api/xdr'; +import { HashIdPreimageXdrStruct, XdrStruct } from '../../api/xdr'; import { networkToCaip2ChainId } from '../../services/network/utils'; /** JSON-RPC methods supported by this snap's multichain keyring. */ export enum MultichainMethod { SignMessage = 'signMessage', SignTransaction = 'signTransaction', + SignAuthEntry = 'signAuthEntry', } /** Superstruct validator for {@link MultichainMethod} string values. */ @@ -257,6 +258,59 @@ export const SignTransactionResponseStruct = union([ SignTransactionResponseStructWithoutError, ]); +/** + * Validation struct for the signAuthEntry request. + * + * Params follow the SEP-43 `SignAuthEntry` shape: a base64-encoded + * `HashIdPreimage` (Soroban authorization preimage) and the optional + * `opts` bag (`address`, `networkPassphrase`). + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md + */ +export const SignAuthEntryRequestStruct = assign( + KeyringRequestStruct, + object({ + request: object({ + method: literal(MultichainMethod.SignAuthEntry), + params: object({ + authEntry: HashIdPreimageXdrStruct, + opts: optional(Sep43OptsStruct), + }), + }), + scope: literal(KnownCaip2ChainId.Mainnet), + account: UuidStruct, + }), +); + +/** + * Error-shape of the signAuthEntry response: an `error` envelope is present. + * Success fields are kept loose to allow partial data alongside the error. + */ +export const SignAuthEntryResponseStructWithError = object({ + signedAuthEntry: union([nonempty(base64(string())), literal('')]), + signerAddress: union([StellarAddressStruct, literal('')]), + error: Sep43ErrorEnvelopeStruct, +}); + +/** + * Success-shape of the signAuthEntry response: signature present, no `error`. + */ +export const SignAuthEntryResponseStructWithoutError = object({ + signedAuthEntry: nonempty(base64(string())), + signerAddress: StellarAddressStruct, +}); + +/** + * Validation struct for the signAuthEntry response. + * + * Modeled as a discriminated union: a response either has an `error` + * envelope or the success fields — never neither. + */ +export const SignAuthEntryResponseStruct = union([ + SignAuthEntryResponseStructWithError, + SignAuthEntryResponseStructWithoutError, +]); + /** * Validation struct for the getAccount request. */ @@ -338,3 +392,13 @@ export type SignTransactionRequest = Infer; export type SignTransactionResponse = Infer< typeof SignTransactionResponseStruct >; + +/** + * Type for the signAuthEntry request. + */ +export type SignAuthEntryRequest = Infer; + +/** + * Type for the signAuthEntry response. + */ +export type SignAuthEntryResponse = Infer; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/index.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/index.ts index 68bba9c8..d4a920a2 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/index.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/index.ts @@ -1,5 +1,6 @@ export * from './api'; export * from './base'; +export * from './signAuthEntry'; export * from './signMessage'; export * from './signTransaction'; export * from './keyring'; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index a2f27393..dd325dc5 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -74,6 +74,7 @@ describe('KeyringHandler', () => { let mockAccountId: string; let mockSignMessageHandler: IKeyringRequestHandler; let mockSignTransactionHandler: IKeyringRequestHandler; + let mockSignAuthEntryHandler: IKeyringRequestHandler; const toKeyringAccount = (account: StellarKeyringAccount): KeyringAccount => { const { id, address, type, options, methods, scopes } = account; @@ -101,6 +102,7 @@ describe('KeyringHandler', () => { mockSignMessageHandler = { handle: jest.fn() }; mockSignTransactionHandler = { handle: jest.fn() }; + mockSignAuthEntryHandler = { handle: jest.fn() }; const { accountService, onChainAccountService } = mockOnChainAccountService(); @@ -115,6 +117,7 @@ describe('KeyringHandler', () => { handlers: { [MultichainMethod.SignMessage]: mockSignMessageHandler, [MultichainMethod.SignTransaction]: mockSignTransactionHandler, + [MultichainMethod.SignAuthEntry]: mockSignAuthEntryHandler, }, }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.test.ts new file mode 100644 index 00000000..49b6d949 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.test.ts @@ -0,0 +1,290 @@ +import { Address, Keypair, Networks, hash, xdr } from '@stellar/stellar-sdk'; + +import { MultichainMethod, type SignAuthEntryRequest } from './api'; +import { Sep43ErrorCode } from './exceptions'; +import { SignAuthEntryHandler } from './signAuthEntry'; +import { KnownCaip2ChainId } from '../../api'; +import { AccountService } from '../../services/account'; +import { + generateStellarKeyringAccount, + mockAccountService, +} from '../../services/account/__mocks__/account.fixtures'; +import { WalletService } from '../../services/wallet'; +import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; +import type { ConfirmationUXController } from '../../ui/confirmation/controller'; +import { bufferToUint8Array } from '../../utils/buffer'; +import { logger } from '../../utils/logger'; + +jest.mock('../../utils/logger'); + +/** + * Builds a minimal but valid base64-encoded `HashIdPreimage` + * (envelopeTypeSorobanAuthorization) for tests. The field values are + * arbitrary — we only need the XDR to round-trip through superstruct + * validation and the handler's preimage decoder. + * + * @returns Base64 XDR of a Soroban authorization preimage. + */ +function buildAuthEntryPreimageXdr(): string { + const contractIdBytes = new Uint8Array(32).fill(1); + const contractAddress = Address.contract( + bufferToUint8Array(contractIdBytes), + ).toScAddress(); + const invokeContractArgs = new xdr.InvokeContractArgs({ + contractAddress, + functionName: 'transfer', + args: [], + }); + const fn = + xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + invokeContractArgs, + ); + const invocation = new xdr.SorobanAuthorizedInvocation({ + function: fn, + subInvocations: [], + }); + const sorobanAuth = new xdr.HashIdPreimageSorobanAuthorization({ + networkId: hash(bufferToUint8Array(Networks.PUBLIC, 'utf8')), + nonce: xdr.Int64.fromString('123456789'), + signatureExpirationLedger: 1_000_000, + invocation, + }); + return xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(sorobanAuth) + .toXDR() + .toString('base64'); +} + +describe('SignAuthEntryHandler', () => { + /** + * Builds a {@link SignAuthEntryHandler} with mocked account / wallet + * resolution and a stubbed `ConfirmationUXController`. + * + * @returns Handler instance and the test doubles needed by each spec. + */ + function setupHandler() { + const wallet = getTestWallet(); + const accountId = globalThis.crypto.randomUUID(); + const mockAccount = generateStellarKeyringAccount( + accountId, + wallet.address, + 'entropy-source-1', + 0, + ); + + const { accountService, walletService } = mockAccountService(); + + jest + .spyOn(AccountService.prototype, 'resolveAccount') + .mockResolvedValue({ account: mockAccount }); + + jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + + const renderConfirmationDialog = jest.fn(); + const confirmationUIController = { + renderConfirmationDialog, + } as Pick< + ConfirmationUXController, + 'renderConfirmationDialog' + > as unknown as ConfirmationUXController; + + const handler = new SignAuthEntryHandler({ + logger, + accountService, + walletService, + confirmationUIController, + }); + + return { + handler, + mockAccount, + wallet, + renderConfirmationDialog, + }; + } + + const validAuthEntry = buildAuthEntryPreimageXdr(); + + const buildRequest = ( + accountId: string, + overrides: Partial = {}, + ): SignAuthEntryRequest => ({ + id: '11111111-1111-4111-8111-111111111111', + origin: 'https://example.com', + scope: KnownCaip2ChainId.Mainnet, + account: accountId, + request: { + method: MultichainMethod.SignAuthEntry, + params: { + authEntry: validAuthEntry, + ...overrides, + }, + }, + }); + + it('returns signedAuthEntry and signerAddress on confirm', async () => { + const { handler, mockAccount, wallet, renderConfirmationDialog } = + setupHandler(); + renderConfirmationDialog.mockResolvedValue(true); + + const result = await handler.handle(buildRequest(mockAccount.id)); + + const expected = await wallet.signAuthEntry(validAuthEntry); + expect(result).toStrictEqual({ + signedAuthEntry: expected, + signerAddress: wallet.address, + }); + }); + + it('passes a decoded readable preimage to the confirmation dialog', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + renderConfirmationDialog.mockResolvedValue(true); + + await handler.handle(buildRequest(mockAccount.id)); + + expect(renderConfirmationDialog).toHaveBeenCalledWith( + expect.objectContaining({ + renderContext: expect.objectContaining({ + readableAuthEntry: expect.objectContaining({ + functionType: 'invoke', + functionName: 'transfer', + signatureExpirationLedger: 1_000_000, + nonce: '123456789', + subInvocationsCount: 0, + contractAddress: expect.stringMatching(/^C[A-Z2-7]+$/u), + }), + }), + }), + ); + }); + + it('returns error -4 when user rejects', async () => { + const { handler, mockAccount, wallet, renderConfirmationDialog } = + setupHandler(); + renderConfirmationDialog.mockResolvedValue(false); + + const result = await handler.handle(buildRequest(mockAccount.id)); + + expect(result).toMatchObject({ + signedAuthEntry: '', + signerAddress: wallet.address, + error: { code: Sep43ErrorCode.UserRejected }, + }); + }); + + it('returns error -3 when scope is testnet', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const result = await handler.handle({ + ...buildRequest(mockAccount.id), + scope: KnownCaip2ChainId.Testnet, + }); + + expect(result).toMatchObject({ + signedAuthEntry: '', + signerAddress: '', + error: { code: Sep43ErrorCode.InvalidRequest }, + }); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -3 when opts.networkPassphrase is not the mainnet passphrase', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const result = await handler.handle( + buildRequest(mockAccount.id, { + opts: { networkPassphrase: Networks.TESTNET }, + }), + ); + + expect(result).toMatchObject({ + error: { + code: Sep43ErrorCode.InvalidRequest, + ext: [expect.stringContaining('mainnet')], + }, + }); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it.each([ + ['opts.submit', { submit: true }], + ['opts.submitUrl', { submitUrl: 'https://horizon.stellar.org' }], + ])('returns error -3 when %s is provided', async (_label, forbiddenOpts) => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const base = buildRequest(mockAccount.id); + // Inject the forbidden opt bypassing the struct type so we can assert the + // handler rejects it at runtime with -3 InvalidRequest. + (base.request.params as unknown as { opts: Record }).opts = + forbiddenOpts; + + const result = await handler.handle(base); + + expect(result).toMatchObject({ + error: { code: Sep43ErrorCode.InvalidRequest }, + }); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -3 when authEntry is not valid base64 XDR', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + const result = await handler.handle( + buildRequest(mockAccount.id, { authEntry: 'not-base64-xdr' }), + ); + + expect(result).toMatchObject({ + error: { code: Sep43ErrorCode.InvalidRequest }, + }); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('returns error -3 when authEntry is a non-Soroban HashIdPreimage', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + // A HashIdPreimage of a different envelope type (envelopeTypeContractId) + // must be rejected — only Soroban authorization preimages are signable + // here. We pick this variant because its inner shape only needs a + // network ID + a contract ID preimage, no account/sequence types. + const wrongPreimage = xdr.HashIdPreimage.envelopeTypeContractId( + new xdr.HashIdPreimageContractId({ + networkId: hash(bufferToUint8Array(Networks.PUBLIC, 'utf8')), + contractIdPreimage: xdr.ContractIdPreimage.contractIdPreimageFromAsset( + xdr.Asset.assetTypeNative(), + ), + }), + ) + .toXDR() + .toString('base64'); + + const result = await handler.handle( + buildRequest(mockAccount.id, { authEntry: wrongPreimage }), + ); + + expect(result).toMatchObject({ + error: { code: Sep43ErrorCode.InvalidRequest }, + }); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + + it('ignores opts.address: signer is always determined by the keyring account UUID', async () => { + const { handler, mockAccount, wallet, renderConfirmationDialog } = + setupHandler(); + renderConfirmationDialog.mockResolvedValue(true); + + // Different valid Stellar G-address — MetaMask already routed to + // `mockAccount` via the UUID, so this MUST be ignored. + const otherAddress = Keypair.random().publicKey(); + + const result = await handler.handle( + buildRequest(mockAccount.id, { opts: { address: otherAddress } }), + ); + + const expected = await wallet.signAuthEntry(validAuthEntry); + expect(result).toStrictEqual({ + signedAuthEntry: expected, + signerAddress: wallet.address, + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.ts new file mode 100644 index 00000000..4b421fc1 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.ts @@ -0,0 +1,194 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; +import { Address, xdr } from '@stellar/stellar-sdk'; + +import type { SignAuthEntryRequest, SignAuthEntryResponse } from './api'; +import { SignAuthEntryRequestStruct, SignAuthEntryResponseStruct } from './api'; +import { BaseSep43KeyringHandler } from './base'; +import type { Sep43Error } from './exceptions'; +import type { + AccountService, + StellarKeyringAccount, +} from '../../services/account'; +import type { Wallet, WalletService } from '../../services/wallet'; +import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import type { ConfirmationUXController } from '../../ui/confirmation/controller'; +import type { ILogger } from '../../utils'; + +/** + * Human-readable Soroban auth entry summary rendered in the confirmation + * dialog. The struct guarantees the preimage parses and is the Soroban + * authorization variant; this shape extracts only the fields a user can + * meaningfully verify. + */ +export type ReadableAuthEntry = { + /** `'invoke'` for direct contract calls, `'createContract'` / `'createContractV2'` for deployments. */ + functionType: 'invoke' | 'createContract' | 'createContractV2'; + /** Strkey-encoded contract `C…` address being invoked, or `null` for contract-creation entries. */ + contractAddress: string | null; + /** Function being invoked, or `null` for contract-creation entries. */ + functionName: string | null; + /** Ledger sequence at which this authorization expires (exclusive). */ + signatureExpirationLedger: number; + /** Replay-protection nonce. */ + nonce: string; + /** Count of nested invocations the user is also authorizing. */ + subInvocationsCount: number; +}; + +/** + * SEP-43 `signAuthEntry` keyring handler. + * + * The dapp passes a base64-encoded `HashIdPreimage` + * (envelopeTypeSorobanAuthorization). The handler decodes it for display in + * the confirmation dialog and, on confirm, asks the wallet to + * `sha256(preimage) → ed25519 sign`. The network passphrase is already + * baked into the preimage's `networkId`, so no additional prefix is applied. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md + */ +export class SignAuthEntryHandler extends BaseSep43KeyringHandler< + SignAuthEntryRequest, + SignAuthEntryResponse +> { + readonly #confirmationUIController: ConfirmationUXController; + + constructor({ + logger, + accountService, + walletService, + confirmationUIController, + }: { + logger: ILogger; + accountService: AccountService; + walletService: WalletService; + confirmationUIController: ConfirmationUXController; + }) { + super({ + logger, + accountService, + walletService, + loggerPrefix: '[🛂 SignAuthEntryHandler]', + requestStruct: SignAuthEntryRequestStruct, + responseStruct: SignAuthEntryResponseStruct, + }); + this.#confirmationUIController = confirmationUIController; + } + + protected async execute( + request: SignAuthEntryRequest, + resolved: { account: StellarKeyringAccount; wallet: Wallet }, + ): Promise { + const { account, wallet } = resolved; + const { authEntry } = request.request.params; + + const readableAuthEntry = decodeSorobanAuthPreimage(authEntry); + + if (!(await this.#confirm(request, account, readableAuthEntry))) { + throw new UserRejectedRequestError() as unknown as Error; + } + + const signedAuthEntry = await wallet.signAuthEntry(authEntry); + + return { + signedAuthEntry, + signerAddress: account.address, + }; + } + + protected toErrorResponse( + signerAddress: string, + error: Sep43Error, + ): SignAuthEntryResponse { + return { + // SEP-43 schema requires the field even on error; keep it empty when unknown. + signedAuthEntry: '', + signerAddress, + error: error.toJSON(), + }; + } + + async #confirm( + request: SignAuthEntryRequest, + account: StellarKeyringAccount, + readableAuthEntry: ReadableAuthEntry, + ): Promise { + return ( + (await this.#confirmationUIController.renderConfirmationDialog({ + scope: request.scope, + renderContext: { + account, + readableAuthEntry, + }, + origin: request.origin, + interfaceKey: ConfirmationInterfaceKey.SignAuthEntry, + })) === true + ); + } +} + +/** + * Decodes a SEP-43 `signAuthEntry` payload into the user-facing summary. + * The struct has already validated that the input parses as + * `HashIdPreimage.envelopeTypeSorobanAuthorization`, so the cast is safe. + * + * @param authEntry - Base64-encoded `HashIdPreimage` XDR. + * @returns Fields displayed in the confirmation dialog. + */ +function decodeSorobanAuthPreimage(authEntry: string): ReadableAuthEntry { + const preimage = xdr.HashIdPreimage.fromXDR(authEntry, 'base64'); + const sorobanAuth = preimage.sorobanAuthorization(); + const fn = sorobanAuth.invocation().function(); + + let functionType: ReadableAuthEntry['functionType']; + let contractAddress: string | null; + let functionName: string | null; + switch (fn.switch()) { + case xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeContractFn(): { + const args = fn.contractFn(); + functionType = 'invoke'; + contractAddress = Address.fromScAddress( + args.contractAddress(), + ).toString(); + functionName = readFunctionName(args.functionName()); + break; + } + case xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeCreateContractHostFn(): + functionType = 'createContract'; + contractAddress = null; + functionName = null; + break; + case xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeCreateContractV2HostFn(): + functionType = 'createContractV2'; + contractAddress = null; + functionName = null; + break; + /* istanbul ignore next — exhaustive switch over an SDK enum */ + default: + functionType = 'invoke'; + contractAddress = null; + functionName = null; + } + + return { + functionType, + contractAddress, + functionName, + signatureExpirationLedger: sorobanAuth.signatureExpirationLedger(), + nonce: sorobanAuth.nonce().toString(), + subInvocationsCount: sorobanAuth.invocation().subInvocations().length, + }; +} + +/** + * `functionName` is typed `string | Buffer` by the SDK because the XDR + * field carries raw bytes. Normalize to a UTF-8 string for display. + * + * @param fnName - Function name as returned by the SDK. + * @returns The function name as a UTF-8 string. + */ +function readFunctionName(fnName: string | Buffer): string { + return typeof fnName === 'string' ? fnName : fnName.toString('utf8'); +} + +/* istanbul ignore next — re-export for tests */ +export { decodeSorobanAuthPreimage }; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts index 2f85c365..c77a97e7 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts @@ -1,6 +1,7 @@ import type { InterfaceContext, UserInputEvent } from '@metamask/snaps-sdk'; import type { UserInputUiEventHandler } from './api'; +import { createEventHandlers as createSignAuthEntryEvents } from '../../ui/confirmation/views/ConfirmSignAuthEntry/events'; import { createEventHandlers as createSignMessageEvents } from '../../ui/confirmation/views/ConfirmSignMessage/events'; import { createEventHandlers as createSignTransactionEvents } from '../../ui/confirmation/views/ConfirmSignTransaction/events'; import { @@ -43,6 +44,7 @@ export class UserInputHandler { const uiEventHandlers: Record = { ...createSignMessageEvents(), ...createSignTransactionEvents(), + ...createSignAuthEntryEvents(), }; /** diff --git a/merged-packages/stellar-wallet-snap/src/index.ts b/merged-packages/stellar-wallet-snap/src/index.ts index 0b32eec7..3a95d204 100644 --- a/merged-packages/stellar-wallet-snap/src/index.ts +++ b/merged-packages/stellar-wallet-snap/src/index.ts @@ -16,6 +16,7 @@ import { userInputHandler, cronjobHandler, assetsHandler, + signAuthEntryHandler, signMessageHandler, signTransactionHandler, } from './context'; @@ -80,6 +81,8 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { return signMessageHandler.handle(request.params as Json); case 'stellar_signTransaction': return signTransactionHandler.handle(request.params as Json); + case 'stellar_signAuthEntry': + return signAuthEntryHandler.handle(request.params as Json); default: throw new MethodNotFoundError() as Error; } diff --git a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.test.ts b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.test.ts index 58847e83..f7659fdc 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.test.ts @@ -71,7 +71,7 @@ describe('AccountService', () => { type: KEYRING_ACCOUNT_TYPE, address: expect.any(String), scopes: [KnownCaip2ChainId.Mainnet], - methods: ['signMessage', 'signTransaction'], + methods: ['signMessage', 'signTransaction', 'signAuthEntry'], options: { entropy: { type: 'mnemonic', @@ -106,6 +106,7 @@ describe('AccountService', () => { methods: [ MultichainMethod.SignMessage, MultichainMethod.SignTransaction, + MultichainMethod.SignAuthEntry, ], options: { entropy: { @@ -148,6 +149,7 @@ describe('AccountService', () => { methods: [ MultichainMethod.SignMessage, MultichainMethod.SignTransaction, + MultichainMethod.SignAuthEntry, ], options: { entropy: { diff --git a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts index f3c8490b..f60f0405 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts @@ -337,7 +337,11 @@ export class AccountService { }, exportable: true, }, - methods: [MultichainMethod.SignMessage, MultichainMethod.SignTransaction], + methods: [ + MultichainMethod.SignMessage, + MultichainMethod.SignTransaction, + MultichainMethod.SignAuthEntry, + ], }; } } diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts index 2192eb32..2f093220 100644 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts @@ -1,4 +1,4 @@ -import { hexToBytes } from '@metamask/utils'; +import { hexToBytes, sha256 } from '@metamask/utils'; import { Keypair } from '@stellar/stellar-sdk'; import { getTestWallet } from './__mocks__/wallet.fixtures'; @@ -147,6 +147,50 @@ describe('Wallet', () => { }); }); + describe('signAuthEntry', () => { + // Arbitrary 56-byte buffer mimicking a HashIdPreimage XDR payload — we + // only care that signAuthEntry hashes the bytes and signs the digest. + const preimageBytes = bufferToUint8Array( + 'AAAACQAAAAAAAAAAAAAAAQAAAAEAAAAAAAAAAQAAAAAAAAAAAAAAZAAAAAA=', + 'base64', + ); + const preimageBase64 = preimageBytes.toString('base64'); + + it('returns a base64-encoded signature', async () => { + const wallet = getTestWallet({ seed }); + const signature = await wallet.signAuthEntry(preimageBase64); + expect(signature).toMatch(/^[A-Za-z0-9+/]+=*$/u); + expect(signature.length).toBeGreaterThan(0); + }); + + it('signs sha256(preimage bytes) — verifiable with the signer public key', async () => { + const wallet = getTestWallet({ seed }); + const signature = await wallet.signAuthEntry(preimageBase64); + + const digest = bufferToUint8Array(await sha256(preimageBytes)); + const keypair = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + expect( + keypair.verify(digest, bufferToUint8Array(signature, 'base64')), + ).toBe(true); + }); + + it('does NOT prepend the SEP-53 "Stellar Signed Message" prefix', async () => { + // signAuthEntry must hash the raw preimage bytes only — adding a + // SEP-53 prefix would invalidate Soroban auth-entry signatures. + const wallet = getTestWallet({ seed }); + const authSignature = await wallet.signAuthEntry(preimageBase64); + const messageSignature = await wallet.signMessage(preimageBase64); + expect(authSignature).not.toStrictEqual(messageSignature); + }); + + it('supports hex encoding for the returned signature', async () => { + const wallet = getTestWallet({ seed }); + const hexSignature = await wallet.signAuthEntry(preimageBase64, 'hex'); + expect(hexSignature).toMatch(/^[0-9a-f]+$/u); + expect(hexSignature).toHaveLength(128); + }); + }); + describe('signTransaction', () => { it('signs a transaction built for the same source account', () => { const wallet = getTestWallet({ seed }); diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.ts index 8e21c66f..75ed7e3b 100644 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.ts +++ b/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.ts @@ -2,6 +2,7 @@ import { sha256 } from '@metamask/utils'; import type { Keypair } from '@stellar/stellar-sdk'; import { + SignAuthEntryException, SignMessageException, SignTransactionException, VerifyMessageException, @@ -102,6 +103,37 @@ export class Wallet { } } + /** + * Signs a SEP-43 Soroban auth entry preimage. The dapp passes the + * `HashIdPreimage` (envelopeTypeSorobanAuthorization) as base64 XDR — the + * wallet hashes the bytes with SHA-256 and signs the digest. No + * "Stellar Signed Message" prefix is applied: the network ID is already + * embedded inside the preimage. + * + * @param authEntry - The base64-encoded XDR `HashIdPreimage` to sign. + * @param encode - The encoding to use for the signature. Defaults to 'base64'. + * @returns A promise that resolves to the signature. + * @throws {SignAuthEntryException} If signing fails (details are not exposed). + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md + */ + async signAuthEntry( + authEntry: string, + encode: 'hex' | 'base64' = 'base64', + ): Promise { + try { + const preimageBuffer = bufferToUint8Array(authEntry, 'base64'); + const preimageHash = await sha256(preimageBuffer); + + const signature = this.#signer + .sign(bufferToUint8Array(preimageHash)) + .toString(encode); + + return signature; + } catch { + throw new SignAuthEntryException(); + } + } + #encodeMessage(message: string | Uint8Array): Uint8Array { const messagePrefix = 'Stellar Signed Message:\n'; let messageBuffer: Uint8Array; diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/exceptions.ts index 55dbc416..89967fd1 100644 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/wallet/exceptions.ts @@ -26,6 +26,16 @@ export class SignMessageException extends Error { } } +/** + * Thrown when the SEP-43 auth entry cannot be signed. + */ +export class SignAuthEntryException extends Error { + constructor() { + super('Failed to sign auth entry'); + this.name = 'SignAuthEntryException'; + } +} + /** * Thrown when the message cannot be verified. */ diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts index 9716b18c..23dc1a34 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts @@ -55,6 +55,7 @@ export enum ConfirmationInterfaceKey { ChangeTrustlineOptOut = 'ChangeTrustlineOptOut', SignMessage = 'SignMessage', SignTransaction = 'SignTransaction', + SignAuthEntry = 'SignAuthEntry', } export const ConfirmationInterfaceKeyStruct = enums( diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 901a0eb1..0822b747 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -22,6 +22,10 @@ import { updateInterfaceIfExists, } from '../../utils'; import { STELLAR_IMAGE } from '../images/icon'; +import { + ConfirmSignAuthEntry, + type ConfirmSignAuthEntryProps, +} from './views/ConfirmSignAuthEntry/ConfirmSignAuthEntry'; import { ConfirmSignMessage, type ConfirmSignMessageProps, @@ -245,6 +249,12 @@ export class ConfirmationUXController { ); case ConfirmationInterfaceKey.SignMessage: return ; + case ConfirmationInterfaceKey.SignAuthEntry: + return ( + + ); default: { const exhaustive: never = interfaceKey; throw new Error(`Unsupported interface key: ${String(exhaustive)}`); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx new file mode 100644 index 00000000..c2178aac --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx @@ -0,0 +1,167 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { + Address, + Banner, + Box, + Button, + Container, + Footer, + Heading, + Icon, + Image, + Section, + Text as SnapText, + Tooltip, +} from '@metamask/snaps-sdk/jsx'; + +import { ConfirmSignAuthEntryFormNames } from './events'; +import type { ReadableAuthEntry } from '../../../../handlers/keyring/signAuthEntry'; +import type { StellarKeyringAccount } from '../../../../services/account'; +import type { Locale } from '../../../../utils'; +import { i18n } from '../../../../utils'; +import { STELLAR_IMAGE } from '../../../images/icon'; +import type { ConfirmationBaseProps } from '../../api'; +import { getAccountName, getNetworkName } from '../../utils'; + +export type ConfirmSignAuthEntryProps = Pick< + ConfirmationBaseProps, + 'scope' | 'locale' | 'networkImage' | 'origin' +> & { + readableAuthEntry: ReadableAuthEntry; + account: StellarKeyringAccount; +}; + +export const ConfirmSignAuthEntry = ({ + readableAuthEntry, + account, + scope, + locale, + networkImage, + origin, +}: ConfirmSignAuthEntryProps): ComponentOrElement => { + const translate = i18n(locale as Locale); + const { address } = account; + const addressCaip10 = getAccountName(scope, address); + const { + functionType, + contractAddress, + functionName, + signatureExpirationLedger, + nonce, + subInvocationsCount, + } = readableAuthEntry; + + return ( + + + + {null} + + {translate('confirmation.signAuthEntry.title')} + + {null} + + + + {translate('confirmation.signAuthEntry.warning')} + + +
+ {functionType === 'invoke' && contractAddress !== null ? ( + + + {translate('confirmation.signAuthEntry.contract')} + +
+ + ) : ( + + + {translate('confirmation.signAuthEntry.contract')} + + + {translate('confirmation.signAuthEntry.createContract')} + + + )} + + {functionName === null ? null : ( + + + {translate('confirmation.signAuthEntry.function')} + + {functionName} + + )} + + + + {translate('confirmation.signAuthEntry.expiresAt')} + + {String(signatureExpirationLedger)} + + + + + {translate('confirmation.signAuthEntry.nonce')} + + {nonce} + + + {subInvocationsCount > 0 ? ( + + + {translate('confirmation.signAuthEntry.subInvocations')} + + {String(subInvocationsCount)} + + ) : null} +
+ +
+ {origin ? ( + + + + {translate('confirmation.origin')} + + + + + + {origin} + + ) : null} + + + {translate('confirmation.account')} + +
+ + + + {translate('confirmation.network')} + + + + {getNetworkName(scope)} + + +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/events.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/events.tsx new file mode 100644 index 00000000..8e7e0b10 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/events.tsx @@ -0,0 +1,50 @@ +import type { + UserInputUiEventHandler, + UserInputUiEventHandlerContext, +} from '../../../../handlers/user-input/api'; +import { resolveInterface } from '../../../../utils'; + +/** + * Handles the click event for the cancel button. + * + * @param options - The user input handler context from `onUserInput`. + * @returns A promise that resolves when the interface has been updated. + */ +async function onCancelButtonClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id } = options; + await resolveInterface(id, false); +} + +/** + * Handles the click event for the confirm button. + * + * @param options - The user input handler context from `onUserInput`. + * @returns A promise that resolves when the interface has been updated. + */ +async function onConfirmButtonClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id } = options; + await resolveInterface(id, true); +} + +export enum ConfirmSignAuthEntryFormNames { + Cancel = 'confirm-sign-auth-entry-cancel', + Confirm = 'confirm-sign-auth-entry-confirm', +} + +/** + * Create event handlers bound to a SnapClient instance. + * + * @returns Object containing event handlers. + */ +export function createEventHandlers(): Record { + return { + [ConfirmSignAuthEntryFormNames.Cancel]: async (options) => + onCancelButtonClick(options), + [ConfirmSignAuthEntryFormNames.Confirm]: async (options) => + onConfirmButtonClick(options), + }; +} From d5d5faf265a64a6af1c1e07f1fe0fc71f5fea43d Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Mon, 4 May 2026 16:20:06 +0200 Subject: [PATCH 152/384] chore: add more tests --- .../stellar-wallet-snap/locales/en.json | 24 +++ .../stellar-wallet-snap/messages.json | 24 +++ .../src/handlers/keyring/api.test.ts | 162 +++++++++++++++++- .../src/handlers/keyring/base.ts | 4 +- .../src/handlers/keyring/keyring.test.ts | 74 ++++++++ 5 files changed, 280 insertions(+), 8 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/locales/en.json b/merged-packages/stellar-wallet-snap/locales/en.json index d3bd239a..b9591c60 100644 --- a/merged-packages/stellar-wallet-snap/locales/en.json +++ b/merged-packages/stellar-wallet-snap/locales/en.json @@ -52,6 +52,30 @@ "confirmation.signMessage.message": { "message": "Message" }, + "confirmation.signAuthEntry.title": { + "message": "Authorize smart contract" + }, + "confirmation.signAuthEntry.warning": { + "message": "You are authorizing a smart contract to act on your behalf. Only approve if you trust this site." + }, + "confirmation.signAuthEntry.contract": { + "message": "Contract" + }, + "confirmation.signAuthEntry.function": { + "message": "Function" + }, + "confirmation.signAuthEntry.expiresAt": { + "message": "Expires at ledger" + }, + "confirmation.signAuthEntry.nonce": { + "message": "Nonce" + }, + "confirmation.signAuthEntry.subInvocations": { + "message": "Nested authorizations" + }, + "confirmation.signAuthEntry.createContract": { + "message": "Deploy contract" + }, "confirmation.account": { "message": "Account" }, diff --git a/merged-packages/stellar-wallet-snap/messages.json b/merged-packages/stellar-wallet-snap/messages.json index 197fb17d..70c50bd0 100644 --- a/merged-packages/stellar-wallet-snap/messages.json +++ b/merged-packages/stellar-wallet-snap/messages.json @@ -50,6 +50,30 @@ "confirmation.signMessage.message": { "message": "Message" }, + "confirmation.signAuthEntry.title": { + "message": "Authorize smart contract" + }, + "confirmation.signAuthEntry.warning": { + "message": "You are authorizing a smart contract to act on your behalf. Only approve if you trust this site." + }, + "confirmation.signAuthEntry.contract": { + "message": "Contract" + }, + "confirmation.signAuthEntry.function": { + "message": "Function" + }, + "confirmation.signAuthEntry.expiresAt": { + "message": "Expires at ledger" + }, + "confirmation.signAuthEntry.nonce": { + "message": "Nonce" + }, + "confirmation.signAuthEntry.subInvocations": { + "message": "Nested authorizations" + }, + "confirmation.signAuthEntry.createContract": { + "message": "Deploy contract" + }, "confirmation.account": { "message": "Account" }, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts index 5a477cf4..f9a31204 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts @@ -7,6 +7,8 @@ import { ListAccountTransactionsRequestStruct, MultichainMethod, MultichainMethodStruct, + SignAuthEntryRequestStruct, + SignAuthEntryResponseStruct, SignMessageRequestStruct, SignMessageResponseStruct, SignTransactionRequestStruct, @@ -20,14 +22,19 @@ const mockAccounts = generateMockStellarKeyringAccounts(1, 'entropy-source-1'); const account = mockAccounts[0] as StellarKeyringAccount; const keyringRequestId = '11111111-1111-4111-8111-111111111111'; const xdr = `AAAAAgAAAADjngeX0YTNoQ15A0xC83aMm/sDnXrmLF+apmXvdmkUugAAAGQAC3gAAAAAQQAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAOZfkjSFZ31vI/Nx28cC6iAFWLWcPIvJhM2NVoxmfgVTAAAAAAAAAAAAmJaAAAAAAAAAAAA=`; +// Mainnet HashIdPreimage(envelopeTypeSorobanAuthorization), `transfer` invoke +// against a deterministic 32-byte contract id, no sub-invocations. Round-trips +// through `xdr.HashIdPreimage.fromXDR(..., 'base64')`. +const authEntry = `AAAACXrDOZdUTjF10ma9AiQ5sizbFlCMARY/JuXLKj4QRal5AAAAAAdbzRUAD0JAAAAAAAAAAAECAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgAAAAh0cmFuc2ZlcgAAAAAAAAAA`; describe('MultichainMethodStruct', () => { - it.each([MultichainMethod.SignMessage, MultichainMethod.SignTransaction])( - 'accepts a supported multichain method', - (method) => { - expect(() => assert(method, MultichainMethodStruct)).not.toThrow(); - }, - ); + it.each([ + MultichainMethod.SignMessage, + MultichainMethod.SignTransaction, + MultichainMethod.SignAuthEntry, + ])('accepts a supported multichain method', (method) => { + expect(() => assert(method, MultichainMethodStruct)).not.toThrow(); + }); it('rejects an unsupported method string', () => { expect(() => assert('eth_sendTransaction', MultichainMethodStruct)).toThrow( @@ -420,6 +427,149 @@ describe('SignTransactionResponseStruct', () => { }); }); +describe('SignAuthEntryRequestStruct', () => { + const validSignAuthEntryRequest = { + id: keyringRequestId, + origin: 'https://example.com', + scope: KnownCaip2ChainId.Mainnet, + account: account.id, + request: { + method: MultichainMethod.SignAuthEntry, + params: { authEntry }, + }, + }; + + it('accepts a valid signAuthEntry keyring request', () => { + expect(() => + assert(validSignAuthEntryRequest, SignAuthEntryRequestStruct), + ).not.toThrow(); + }); + + it('accepts an SEP-43 opts bag with address and networkPassphrase', () => { + expect(() => + assert( + { + ...validSignAuthEntryRequest, + request: { + method: MultichainMethod.SignAuthEntry, + params: { + authEntry, + opts: { + address: account.address, + networkPassphrase: + 'Public Global Stellar Network ; September 2015', + }, + }, + }, + }, + SignAuthEntryRequestStruct, + ), + ).not.toThrow(); + }); + + it.each([ + // Wrong method discriminator + { + ...validSignAuthEntryRequest, + request: { + method: MultichainMethod.SignTransaction, + params: { authEntry }, + }, + }, + // Garbage base64 -> fails XdrStruct base64 refinement + { + ...validSignAuthEntryRequest, + request: { + method: MultichainMethod.SignAuthEntry, + params: { authEntry: 'not-base64-xdr' }, + }, + }, + // Valid base64 but not a Soroban authorization preimage + { + ...validSignAuthEntryRequest, + request: { + method: MultichainMethod.SignAuthEntry, + params: { authEntry: btoa('not a HashIdPreimage') }, + }, + }, + // Forbidden opt: snap is sign-only, never submits + { + ...validSignAuthEntryRequest, + request: { + method: MultichainMethod.SignAuthEntry, + params: { authEntry, opts: { submit: true } }, + }, + }, + // Wrong network passphrase (testnet) + { + ...validSignAuthEntryRequest, + request: { + method: MultichainMethod.SignAuthEntry, + params: { + authEntry, + opts: { + networkPassphrase: 'Test SDF Network ; September 2015', + }, + }, + }, + }, + // Non-mainnet scope + { + ...validSignAuthEntryRequest, + scope: 'invalid:scope' as KnownCaip2ChainId, + }, + // Bad UUID + { + ...validSignAuthEntryRequest, + account: 'not-a-uuid', + }, + ])('rejects an invalid signAuthEntry request', (request) => { + expect(() => assert(request, SignAuthEntryRequestStruct)).toThrow( + StructError, + ); + }); +}); + +describe('SignAuthEntryResponseStruct', () => { + it('accepts a successful signAuthEntry envelope', () => { + expect(() => + assert( + { + signedAuthEntry: btoa('signed'), + signerAddress: account.address, + }, + SignAuthEntryResponseStruct, + ), + ).not.toThrow(); + }); + + it('accepts an error envelope with empty success fields', () => { + expect(() => + assert( + { + signedAuthEntry: '', + signerAddress: '', + error: { message: 'rejected', code: -4 }, + }, + SignAuthEntryResponseStruct, + ), + ).not.toThrow(); + }); + + it.each([ + // Garbage base64 + { signedAuthEntry: 'not!!!valid-base64', signerAddress: account.address }, + // Bad signer address + { signedAuthEntry: btoa('signed'), signerAddress: 'invalid-address' }, + // Missing both error and success fields + { signedAuthEntry: '', signerAddress: '' }, + ])('rejects an invalid signAuthEntry response', (response) => { + expect(() => assert(response, SignAuthEntryResponseStruct)).toThrow( + StructError, + ); + }); +}); + describe('ListAccountTransactionsRequestStruct', () => { it('accepts a valid listAccountTransactions request', () => { const request = { diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts index a69df45b..0c80458d 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/base.ts @@ -23,8 +23,8 @@ export type IKeyringRequestHandler = { }; /** - * Base class shared by the SEP-43 SignMessage and SignTransaction keyring - * handlers. + * Base class shared by the SEP-43 SignMessage, SignTransaction, and + * SignAuthEntry keyring handlers. * * Extends {@link BaseHandler} for codebase consistency (inherits * `logger` / `requestStruct` / `responseStruct`) but overrides `handle()`: diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index dd325dc5..612874d2 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -15,6 +15,7 @@ import { BigNumber } from 'bignumber.js'; import { MultichainMethod, + SignAuthEntryResponseStruct, SignMessageResponseStruct, SignTransactionResponseStruct, } from './api'; @@ -785,6 +786,46 @@ describe('KeyringHandler', () => { signTransactionPayload, ); expect(mockSignMessageHandler.handle).not.toHaveBeenCalled(); + expect(mockSignAuthEntryHandler.handle).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + pending: false, + result: expectedResult, + }); + }); + + it('submits a sign auth entry request', async () => { + const authEntry = `AAAACXrDOZdUTjF10ma9AiQ5sizbFlCMARY/JuXLKj4QRal5AAAAAAdbzRUAD0JAAAAAAAAAAAECAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgAAAAh0cmFuc2ZlcgAAAAAAAAAA`; + + const expectedResult = { + signedAuthEntry: bufferToUint8Array('signed', 'utf8').toString( + 'base64', + ), + signerAddress: mockAccount.address, + }; + + jest + .mocked(mockSignAuthEntryHandler.handle) + .mockResolvedValue(expectedResult); + + const signAuthEntryPayload = { + id: keyringRequestId, + origin: 'metamask', + request: { + method: MultichainMethod.SignAuthEntry, + params: { authEntry }, + }, + scope: KnownCaip2ChainId.Mainnet, + account: mockAccountId, + }; + + const result = await keyringHandler.submitRequest(signAuthEntryPayload); + + expect(mockSignAuthEntryHandler.handle).toHaveBeenCalledTimes(1); + expect(mockSignAuthEntryHandler.handle).toHaveBeenCalledWith( + signAuthEntryPayload, + ); + expect(mockSignMessageHandler.handle).not.toHaveBeenCalled(); + expect(mockSignTransactionHandler.handle).not.toHaveBeenCalled(); expect(result).toStrictEqual({ pending: false, result: expectedResult, @@ -807,6 +848,7 @@ describe('KeyringHandler', () => { expect(mockSignMessageHandler.handle).not.toHaveBeenCalled(); expect(mockSignTransactionHandler.handle).not.toHaveBeenCalled(); + expect(mockSignAuthEntryHandler.handle).not.toHaveBeenCalled(); }); it('exposes a submitRequest result that satisfies the SEP-43 response struct', async () => { @@ -878,5 +920,37 @@ describe('KeyringHandler', () => { ), ).not.toThrow(); }); + + it('exposes a sign-auth-entry submitRequest result that satisfies the SEP-43 response struct', async () => { + const authEntry = `AAAACXrDOZdUTjF10ma9AiQ5sizbFlCMARY/JuXLKj4QRal5AAAAAAdbzRUAD0JAAAAAAAAAAAECAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgAAAAh0cmFuc2ZlcgAAAAAAAAAA`; + const expectedWithError = { + signedAuthEntry: '', + signerAddress: mockAccount.address, + error: { message: 'x', code: -3 }, + }; + jest + .mocked(mockSignAuthEntryHandler.handle) + .mockResolvedValue(expectedWithError); + + const signAuthEntryPayload = { + id: keyringRequestId, + origin: 'metamask', + request: { + method: MultichainMethod.SignAuthEntry, + params: { authEntry }, + }, + scope: KnownCaip2ChainId.Mainnet, + account: mockAccountId, + }; + + const response = await keyringHandler.submitRequest(signAuthEntryPayload); + expect(response).toMatchObject({ pending: false }); + expect(() => + create( + (response as { pending: false; result: Json }).result, + SignAuthEntryResponseStruct, + ), + ).not.toThrow(); + }); }); }); From 63b810a0b3cd40569e4dd4235473696c7a54035f Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Mon, 4 May 2026 17:50:03 +0200 Subject: [PATCH 153/384] fix: fix copilot comment --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../stellar-wallet-snap/src/api/xdr.ts | 30 ++++++++++++++++-- .../src/handlers/keyring/api.test.ts | 14 +++++++++ .../handlers/keyring/signAuthEntry.test.ts | 31 +++++++++++++++++-- 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 513f33e6..c79c4db4 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "bEsJi+ez3xSysjUpbD2XUya8bBAEyqpNeHhoj7Xe7DU=", + "shasum": "S7p0EGGsZRz/nZelt2k4jWJUvb9hEOzgzIAVZD20ygU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/api/xdr.ts b/merged-packages/stellar-wallet-snap/src/api/xdr.ts index 5ea58e21..80ea37f8 100644 --- a/merged-packages/stellar-wallet-snap/src/api/xdr.ts +++ b/merged-packages/stellar-wallet-snap/src/api/xdr.ts @@ -1,6 +1,8 @@ import { nonempty, refine, string } from '@metamask/superstruct'; import { base64 } from '@metamask/utils'; -import { xdr } from '@stellar/stellar-sdk'; +import { Networks, hash, xdr } from '@stellar/stellar-sdk'; + +import { bufferToUint8Array } from '../utils/buffer'; /** * Validation struct for XDR: must be a valid base64 encoded XDR string. @@ -20,11 +22,26 @@ export const XdrStruct = refine( }, ); +// SHA-256 of the Stellar mainnet passphrase. Cached so the refine below +// doesn't re-hash on every validation. This is the value the network compares +// against when verifying a Soroban authorization signature, so the embedded +// `networkId` of any preimage we agree to sign must equal it. +const MAINNET_NETWORK_ID_HEX = hash( + bufferToUint8Array(Networks.PUBLIC, 'utf8'), +).toString('hex'); + /** * Validation struct for a SEP-43 `signAuthEntry` payload: a base64-encoded * `HashIdPreimage` whose discriminant is `envelopeTypeSorobanAuthorization` - * (i.e. a Soroban auth-entry preimage). Anything else is rejected at the - * struct level so the handler can return -3 InvalidRequest. + * AND whose embedded `networkId` matches Stellar mainnet. Anything else is + * rejected at the struct level so the handler can return -3 InvalidRequest. + * + * The `networkId` check matters because — unlike `signTransaction`, where the + * network passphrase is supplied by the signer — `signAuthEntry` SHA-256s the + * raw preimage and signs the digest as-is. The dapp therefore controls the + * network the resulting signature is valid against, and a mainnet-only snap + * must reject preimages bound to any other network even if the keyring `scope` + * and `opts.networkPassphrase` look mainnet-y. */ export const HashIdPreimageXdrStruct = refine( nonempty(base64(string())), @@ -38,6 +55,13 @@ export const HashIdPreimageXdrStruct = refine( ) { return 'HashIdPreimage is not a Soroban authorization preimage'; } + const embeddedNetworkId = preimage + .sorobanAuthorization() + .networkId() + .toString('hex'); + if (embeddedNetworkId !== MAINNET_NETWORK_ID_HEX) { + return 'HashIdPreimage networkId is not Stellar mainnet'; + } return true; } catch { return 'Invalid HashIdPreimage XDR'; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts index f9a31204..1d91ce74 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.test.ts @@ -26,6 +26,10 @@ const xdr = `AAAAAgAAAADjngeX0YTNoQ15A0xC83aMm/sDnXrmLF+apmXvdmkUugAAAGQAC3gAAAA // against a deterministic 32-byte contract id, no sub-invocations. Round-trips // through `xdr.HashIdPreimage.fromXDR(..., 'base64')`. const authEntry = `AAAACXrDOZdUTjF10ma9AiQ5sizbFlCMARY/JuXLKj4QRal5AAAAAAdbzRUAD0JAAAAAAAAAAAECAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgAAAAh0cmFuc2ZlcgAAAAAAAAAA`; +// Same shape, but with the embedded `networkId` bound to testnet — used to +// assert `HashIdPreimageXdrStruct` rejects preimages whose networkId does not +// match Stellar mainnet. +const testnetAuthEntry = `AAAACc7gMC1ZhE0yvcqRXIID3USzP7t+3BkFHqN6vt8o7NRyAAAAAAdbzRUAD0JAAAAAAAAAAAECAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgAAAAh0cmFuc2ZlcgAAAAAAAAAA`; describe('MultichainMethodStruct', () => { it.each([ @@ -492,6 +496,16 @@ describe('SignAuthEntryRequestStruct', () => { params: { authEntry: btoa('not a HashIdPreimage') }, }, }, + // Valid Soroban authorization preimage but bound to testnet networkId — + // mainnet-only snap must reject so the resulting signature can't be + // smuggled across networks. + { + ...validSignAuthEntryRequest, + request: { + method: MultichainMethod.SignAuthEntry, + params: { authEntry: testnetAuthEntry }, + }, + }, // Forbidden opt: snap is sign-only, never submits { ...validSignAuthEntryRequest, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.test.ts index 49b6d949..4cb43ec2 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.test.ts @@ -23,9 +23,14 @@ jest.mock('../../utils/logger'); * arbitrary — we only need the XDR to round-trip through superstruct * validation and the handler's preimage decoder. * + * @param networkPassphrase - Network passphrase to embed as `networkId`. + * Defaults to mainnet so happy-path tests pass `HashIdPreimageXdrStruct`'s + * mainnet-only check. * @returns Base64 XDR of a Soroban authorization preimage. */ -function buildAuthEntryPreimageXdr(): string { +function buildAuthEntryPreimageXdr( + networkPassphrase: string = Networks.PUBLIC, +): string { const contractIdBytes = new Uint8Array(32).fill(1); const contractAddress = Address.contract( bufferToUint8Array(contractIdBytes), @@ -44,7 +49,7 @@ function buildAuthEntryPreimageXdr(): string { subInvocations: [], }); const sorobanAuth = new xdr.HashIdPreimageSorobanAuthorization({ - networkId: hash(bufferToUint8Array(Networks.PUBLIC, 'utf8')), + networkId: hash(bufferToUint8Array(networkPassphrase, 'utf8')), nonce: xdr.Int64.fromString('123456789'), signatureExpirationLedger: 1_000_000, invocation, @@ -240,6 +245,28 @@ describe('SignAuthEntryHandler', () => { expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); + it("returns error -3 when authEntry's embedded networkId is not mainnet", async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + + // Same shape as the mainnet fixture, but with the embedded `networkId` + // bound to testnet. The keyring `scope`/`opts.networkPassphrase` look + // mainnet-y, so without the networkId check the snap would happily sign + // a Soroban auth signature valid only against testnet. + const testnetAuthEntry = buildAuthEntryPreimageXdr(Networks.TESTNET); + + const result = await handler.handle( + buildRequest(mockAccount.id, { authEntry: testnetAuthEntry }), + ); + + expect(result).toMatchObject({ + error: { + code: Sep43ErrorCode.InvalidRequest, + ext: [expect.stringContaining('networkId')], + }, + }); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + }); + it('returns error -3 when authEntry is a non-Soroban HashIdPreimage', async () => { const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); From d1ad0fe25c7ed3f576094e33a15f05cc4d65350a Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Mon, 4 May 2026 23:08:16 +0200 Subject: [PATCH 154/384] feat: trustline operation status tracking --- .../stellar-wallet-snap/src/context.ts | 4 + .../clientRequest/changeTrustOpt.test.ts | 25 ++ .../handlers/clientRequest/changeTrustOpt.ts | 7 + .../src/handlers/cronjob/api.ts | 5 + .../handlers/cronjob/trackTransaction.test.ts | 256 ++++++++++++++++++ .../src/handlers/cronjob/trackTransaction.ts | 153 ++++++++++- .../src/services/account/AccountService.ts | 10 + .../services/network/NetworkService.test.ts | 57 ++++ .../src/services/network/NetworkService.ts | 32 ++- .../transaction/TransactionRepository.ts | 29 +- .../transaction/TransactionService.test.ts | 128 ++++++++- .../transaction/TransactionService.ts | 48 ++++ 12 files changed, 744 insertions(+), 10 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 24abb76d..7f46348e 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -154,6 +154,10 @@ const refreshConfirmationPricesHandler = new RefreshConfirmationPricesHandler({ const trackTransactionHandler = new TrackTransactionHandler({ logger, + networkService, + onChainAccountService, + accountService, + transactionService, }); const cronjobMethodHandlers: Record< diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts index 9c04bbfd..c5cd4654 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts @@ -38,6 +38,7 @@ import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; import { ConfirmationUXController } from '../../ui/confirmation/controller'; import { logger } from '../../utils/logger'; +import { TrackTransactionHandler } from '../cronjob/trackTransaction'; jest.mock('../../utils/logger'); jest.mock('@metamask/keyring-snap-sdk', () => ({ @@ -48,6 +49,9 @@ describe('ChangeTrustOptHandler', () => { beforeEach(() => { jest.mocked(emitSnapKeyringEvent).mockReset(); jest.mocked(emitSnapKeyringEvent).mockResolvedValue(undefined); + jest + .spyOn(TrackTransactionHandler, 'scheduleBackgroundEvent') + .mockResolvedValue(undefined); }); const accountId = '11111111-1111-4111-8111-111111111111'; @@ -247,6 +251,13 @@ describe('ChangeTrustOptHandler', () => { }, }, }); + expect( + TrackTransactionHandler.scheduleBackgroundEvent, + ).toHaveBeenCalledWith({ + txId: 'dGVzdC10eC1pZA==', + scope, + accountIds: [account.id], + }); }); it('returns success early for opt-in when trustline already exists', async () => { @@ -269,6 +280,9 @@ describe('ChangeTrustOptHandler', () => { expect(signTransactionSpy).not.toHaveBeenCalled(); expect(sendTransaction).not.toHaveBeenCalled(); expect(savePendingKeyringTransaction).not.toHaveBeenCalled(); + expect( + TrackTransactionHandler.scheduleBackgroundEvent, + ).not.toHaveBeenCalled(); }); it('throws TrustlineNotFoundException for opt-out when trustline does not exist', async () => { @@ -336,6 +350,13 @@ describe('ChangeTrustOptHandler', () => { }, }, }); + expect( + TrackTransactionHandler.scheduleBackgroundEvent, + ).toHaveBeenCalledWith({ + txId: 'dGVzdC10eC1pZA==', + scope, + accountIds: [account.id], + }); }); it('throws UserRejectedRequestError when confirmation is rejected', async () => { @@ -357,6 +378,9 @@ describe('ChangeTrustOptHandler', () => { expect(sendTransaction).not.toHaveBeenCalled(); expect(networkSendSpy).not.toHaveBeenCalled(); expect(savePendingKeyringTransaction).not.toHaveBeenCalled(); + expect( + TrackTransactionHandler.scheduleBackgroundEvent, + ).not.toHaveBeenCalled(); }); it('continues successfully when saving pending transaction fails', async () => { @@ -373,5 +397,6 @@ describe('ChangeTrustOptHandler', () => { transactionId: 'dGVzdC10eC1pZA==', }); expect(sendTransaction).toHaveBeenCalledTimes(1); + expect(TrackTransactionHandler.scheduleBackgroundEvent).toHaveBeenCalled(); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts index 7acc018d..03578441 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts @@ -41,6 +41,7 @@ import type { WalletService } from '../../services/wallet'; import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; import type { ConfirmationUXController } from '../../ui/confirmation/controller'; import { createPrefixedLogger, type ILogger } from '../../utils/logger'; +import { TrackTransactionHandler } from '../cronjob/trackTransaction'; export class ChangeTrustOptHandler extends WithClientRequestActiveAccountResolve< ChangeTrustOptJsonRpcRequest, @@ -164,6 +165,12 @@ export class ChangeTrustOptHandler extends WithClientRequestActiveAccountResolve action, }); + await TrackTransactionHandler.scheduleBackgroundEvent({ + txId: transactionId, + scope, + accountIds: [account.id], + }); + return { status: true, transactionId, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts index 79766a8c..c1806c1a 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts @@ -4,9 +4,12 @@ import { assign, boolean, enums, + integer, literal, nonempty, object, + optional, + size, string, type, } from '@metamask/superstruct'; @@ -49,6 +52,8 @@ export const TrackTransactionParamsStruct = type({ txId: nonempty(string()), scope: KnownCaip2ChainIdStruct, accountIds: nonempty(array(UuidStruct)), + /** Reschedule counter; omitted on first schedule (treated as 0). */ + attempt: optional(size(integer(), 0, 30)), }); export const RefreshConfirmationPricesJsonRpcRequestStruct = assign( diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts new file mode 100644 index 00000000..dbf70dac --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts @@ -0,0 +1,256 @@ +import { TransactionStatus } from '@metamask/keyring-api'; + +import { BackgroundEventMethod } from './api'; +import { TrackTransactionHandler } from './trackTransaction'; +import { KnownCaip2ChainId } from '../../api'; +import { AccountService } from '../../services/account'; +import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; +import { NetworkService } from '../../services/network'; +import { OnChainAccountService } from '../../services/on-chain-account'; +import { TransactionService } from '../../services/transaction'; +import { createMockTransactionService } from '../../services/transaction/__mocks__/transaction.fixtures'; +import { logger } from '../../utils/logger'; +import { scheduleBackgroundEvent } from '../../utils/snap'; + +jest.mock('../../utils/logger'); +jest.mock('../../utils/snap', () => ({ + scheduleBackgroundEvent: jest.fn().mockResolvedValue('scheduled'), + getClientStatus: jest.fn().mockResolvedValue({ active: true, locked: false }), +})); + +describe('TrackTransactionHandler', () => { + const txId = 'abc123'; + const scope = KnownCaip2ChainId.Testnet; + const accountId = '22222222-2222-4222-8222-222222222222'; + + beforeEach(() => { + jest.mocked(scheduleBackgroundEvent).mockClear(); + jest.mocked(scheduleBackgroundEvent).mockResolvedValue('scheduled'); + }); + + function setup() { + const account = generateStellarKeyringAccount( + accountId, + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + 'entropy-source-1', + 0, + ); + + const findByIds = jest + .spyOn(AccountService.prototype, 'findByIds') + .mockResolvedValue([account]); + + const getHorizonTransactionInclusionStatus = jest.spyOn( + NetworkService.prototype, + 'getHorizonTransactionInclusionStatus', + ); + + const synchronize = jest + .spyOn(OnChainAccountService.prototype, 'synchronize') + .mockResolvedValue(undefined); + + const applyKeyringTransactionSettlement = jest + .spyOn(TransactionService.prototype, 'applyKeyringTransactionSettlement') + .mockResolvedValue(undefined); + + const { transactionService } = createMockTransactionService(); + + const handler = new TrackTransactionHandler({ + logger, + networkService: new NetworkService({ logger }), + onChainAccountService: new OnChainAccountService({ + logger, + networkService: new NetworkService({ logger }), + onChainAccountRepository: {} as never, + assetMetadataService: {} as never, + }), + accountService: new AccountService({ + logger, + accountsRepository: {} as never, + walletService: {} as never, + }), + transactionService, + }); + + return { + handler, + account, + findByIds, + getHorizonTransactionInclusionStatus, + synchronize, + applyKeyringTransactionSettlement, + }; + } + + it('reschedules when Horizon reports pending', async () => { + const { handler, getHorizonTransactionInclusionStatus, synchronize } = + setup(); + getHorizonTransactionInclusionStatus.mockResolvedValue('pending'); + + await handler.handleCronJobRequest({ + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIds: [accountId], + attempt: 0, + }, + }); + + expect(synchronize).not.toHaveBeenCalled(); + expect(scheduleBackgroundEvent).toHaveBeenCalledTimes(1); + expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIds: [accountId], + attempt: 1, + }, + duration: TrackTransactionHandler.duration, + }); + }); + + it('synchronizes when Horizon reports success', async () => { + const { + handler, + account, + getHorizonTransactionInclusionStatus, + synchronize, + applyKeyringTransactionSettlement, + } = setup(); + getHorizonTransactionInclusionStatus.mockResolvedValue('success'); + + await handler.handleCronJobRequest({ + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIds: [accountId], + }, + }); + + expect(applyKeyringTransactionSettlement).toHaveBeenCalledWith({ + txId, + accountIds: [accountId], + status: TransactionStatus.Confirmed, + }); + expect(synchronize).toHaveBeenCalledTimes(1); + expect(synchronize).toHaveBeenCalledWith([account], scope); + expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); + }); + + it('synchronizes when Horizon reports failed', async () => { + const { + handler, + getHorizonTransactionInclusionStatus, + synchronize, + applyKeyringTransactionSettlement, + } = setup(); + getHorizonTransactionInclusionStatus.mockResolvedValue('failed'); + + await handler.handleCronJobRequest({ + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIds: [accountId], + }, + }); + + expect(applyKeyringTransactionSettlement).toHaveBeenCalledWith({ + txId, + accountIds: [accountId], + status: TransactionStatus.Failed, + }); + expect(synchronize).toHaveBeenCalledTimes(1); + expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); + }); + + it('synchronizes on max attempts without reschedule', async () => { + const { + handler, + getHorizonTransactionInclusionStatus, + synchronize, + applyKeyringTransactionSettlement, + } = setup(); + getHorizonTransactionInclusionStatus.mockResolvedValue('pending'); + + await handler.handleCronJobRequest({ + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIds: [accountId], + attempt: 15, + }, + }); + + expect(getHorizonTransactionInclusionStatus).toHaveBeenCalledTimes(1); + expect(applyKeyringTransactionSettlement).not.toHaveBeenCalled(); + expect(synchronize).toHaveBeenCalledTimes(1); + expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); + }); + + it('settles keyring row on max attempts when final Horizon poll succeeds', async () => { + const { + handler, + getHorizonTransactionInclusionStatus, + synchronize, + applyKeyringTransactionSettlement, + } = setup(); + getHorizonTransactionInclusionStatus.mockResolvedValue('success'); + + await handler.handleCronJobRequest({ + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIds: [accountId], + attempt: 15, + }, + }); + + expect(applyKeyringTransactionSettlement).toHaveBeenCalledWith({ + txId, + accountIds: [accountId], + status: TransactionStatus.Confirmed, + }); + expect(synchronize).toHaveBeenCalledTimes(1); + }); + + it('returns early when no accounts match', async () => { + const { + handler, + findByIds, + getHorizonTransactionInclusionStatus, + synchronize, + } = setup(); + findByIds.mockResolvedValue([]); + + await handler.handleCronJobRequest({ + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIds: [accountId], + }, + }); + + expect(getHorizonTransactionInclusionStatus).not.toHaveBeenCalled(); + expect(synchronize).not.toHaveBeenCalled(); + expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts index eaa21889..8a0ba481 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts @@ -1,3 +1,5 @@ +import { TransactionStatus } from '@metamask/keyring-api'; + import type { TrackTransactionJsonRpcRequest, TrackTransactionParams, @@ -7,10 +9,17 @@ import { TrackTransactionJsonRpcRequestStruct, } from './api'; import { CronjobBaseHandler } from './base'; +import type { AccountService } from '../../services/account'; +import type { NetworkService } from '../../services/network'; +import type { OnChainAccountService } from '../../services/on-chain-account'; +import type { TransactionService } from '../../services/transaction'; import type { ILogger } from '../../utils/logger'; import { createPrefixedLogger } from '../../utils/logger'; import { scheduleBackgroundEvent } from '../../utils/snap'; +/** Poll budget for Horizon inclusion after submit (matches Tron snap pattern). */ +const TRACK_TRANSACTION_MAX_ATTEMPTS = 15; + export class TrackTransactionHandler extends CronjobBaseHandler { static readonly duration = 'PT1S'; @@ -25,7 +34,27 @@ export class TrackTransactionHandler extends CronjobBaseHandler { - // TODO: Implement transaction tracking. - this.logger.info('Tracking transaction...'); + const { txId, scope, accountIds, attempt: attemptRaw } = request.params; + const attempt = attemptRaw ?? 0; + + this.logger.info('Tracking transaction', { + txId, + scope, + attempt: attempt + 1, + maxAttempts: TRACK_TRANSACTION_MAX_ATTEMPTS, + }); + + const accounts = await this.#accountService.findByIds(accountIds); + if (accounts.length === 0) { + this.logger.warn('TrackTransaction: no matching accounts; stopping', { + accountIds, + }); + return; + } + + const synchronizeAccounts = async (): Promise => { + await this.#onChainAccountService.synchronize(accounts, scope); + }; + + const settleKeyringRow = async ( + keyringStatus: TransactionStatus.Confirmed | TransactionStatus.Failed, + ): Promise => { + await this.#transactionService.applyKeyringTransactionSettlement({ + txId, + accountIds, + status: keyringStatus, + }); + }; + + if (attempt >= TRACK_TRANSACTION_MAX_ATTEMPTS) { + this.logger.warn( + 'TrackTransaction: max attempts reached; synchronizing accounts', + { txId, scope }, + ); + try { + const lastStatus = + await this.#networkService.getHorizonTransactionInclusionStatus( + txId, + scope, + ); + if (lastStatus === 'success') { + await settleKeyringRow(TransactionStatus.Confirmed); + } else if (lastStatus === 'failed') { + await settleKeyringRow(TransactionStatus.Failed); + } + } catch (error: unknown) { + this.logger.logErrorWithDetails( + 'TrackTransaction: final Horizon poll failed', + error, + ); + } + await synchronizeAccounts(); + return; + } + + try { + const status = + await this.#networkService.getHorizonTransactionInclusionStatus( + txId, + scope, + ); + + if (status === 'pending') { + await TrackTransactionHandler.scheduleBackgroundEvent( + { + txId, + scope, + accountIds, + attempt: attempt + 1, + }, + TrackTransactionHandler.duration, + ); + return; + } + + this.logger.info('TrackTransaction: Horizon settled; synchronizing', { + txId, + scope, + status, + }); + if (status === 'success') { + await settleKeyringRow(TransactionStatus.Confirmed); + } else { + await settleKeyringRow(TransactionStatus.Failed); + } + await synchronizeAccounts(); + } catch (error: unknown) { + this.logger.logErrorWithDetails( + 'TrackTransaction: Horizon poll error; will retry', + error, + ); + if (attempt < TRACK_TRANSACTION_MAX_ATTEMPTS - 1) { + await TrackTransactionHandler.scheduleBackgroundEvent( + { + txId, + scope, + accountIds, + attempt: attempt + 1, + }, + TrackTransactionHandler.duration, + ); + } else { + await synchronizeAccounts(); + } + } } } diff --git a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts index f3c8490b..1e14cc94 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts @@ -229,6 +229,16 @@ export class AccountService { return (await this.#accountsRepository.findById(id)) ?? undefined; } + /** + * Finds Stellar keyring accounts matching the given ids (order not preserved). + * + * @param ids - Keyring account UUIDs. + * @returns Accounts that exist in snap state for those ids. + */ + async findByIds(ids: string[]): Promise { + return await this.#accountsRepository.findByIds(ids); + } + async #resolveKeyringAccountByAddress({ scope, address, diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts index 6317f5e2..f4f3b169 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts @@ -436,6 +436,63 @@ describe('NetworkService', () => { }); }); + describe('getHorizonTransactionInclusionStatus', () => { + it('returns pending when Horizon returns NotFoundError', async () => { + const transactionsSpy = jest + .spyOn(StellarHorizon.Server.prototype, 'transactions') + .mockReturnValue({ + transaction: jest.fn().mockReturnValue({ + call: jest + .fn() + .mockRejectedValue(new NotFoundError('not found', {})), + }), + } as never); + + const result = await networkService.getHorizonTransactionInclusionStatus( + testTransactionHash, + scope, + ); + + expect(result).toBe('pending'); + transactionsSpy.mockRestore(); + }); + + it('returns success when Horizon record is successful', async () => { + const call = jest.fn().mockResolvedValue({ successful: true }); + const transactionsSpy = jest + .spyOn(StellarHorizon.Server.prototype, 'transactions') + .mockReturnValue({ + transaction: jest.fn().mockReturnValue({ call }), + } as never); + + const result = await networkService.getHorizonTransactionInclusionStatus( + testTransactionHash, + scope, + ); + + expect(result).toBe('success'); + expect(call).toHaveBeenCalledTimes(1); + transactionsSpy.mockRestore(); + }); + + it('returns failed when Horizon record is not successful', async () => { + const call = jest.fn().mockResolvedValue({ successful: false }); + const transactionsSpy = jest + .spyOn(StellarHorizon.Server.prototype, 'transactions') + .mockReturnValue({ + transaction: jest.fn().mockReturnValue({ call }), + } as never); + + const result = await networkService.getHorizonTransactionInclusionStatus( + testTransactionHash, + scope, + ); + + expect(result).toBe('failed'); + transactionsSpy.mockRestore(); + }); + }); + describe('send', () => { it('returns transaction hash when pollTransaction is false', async () => { const { sendTransactionSpy, pollTransactionSpy } = getRpcServerSpies(); diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index 3b600d46..2fd90d1e 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -1,4 +1,4 @@ -import { parseCaipAssetType } from '@metamask/utils'; +import { ensureError, parseCaipAssetType } from '@metamask/utils'; import { Account as StellarAccount, Address, @@ -136,6 +136,36 @@ export class NetworkService { * @throws {TransactionPollException} When the terminal status is not SUCCESS, or polling fails * (uses {@link AppConfig.transaction.pollingAttempts} as the attempt budget). */ + /** + * Whether a transaction has been ingested by Horizon and its ledger outcome. + * + * @param transactionHash - Transaction hash from submission (hex). + * @param scope - CAIP-2 chain id (Horizon endpoint). + * @returns `pending` when the tx is not yet available (404); `success` / `failed` when present. + */ + async getHorizonTransactionInclusionStatus( + transactionHash: string, + scope: KnownCaip2ChainId, + ): Promise<'pending' | 'success' | 'failed'> { + try { + const client = this.#getHorizonClient(scope); + const record = await client + .transactions() + .transaction(transactionHash) + .call(); + return record.successful ? 'success' : 'failed'; + } catch (error: unknown) { + if (error instanceof NotFoundError) { + return 'pending'; + } + this.#logger.logErrorWithDetails( + 'Failed to load transaction from Horizon', + error, + ); + throw ensureError(error); + } + } + async pollTransaction( transactionHash: string, scope: KnownCaip2ChainId, diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts index fd8598b7..6ed2cfd1 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts @@ -26,11 +26,32 @@ export class TransactionRepository { } async findByAccountId(accountId: string): Promise { - const transactions = await this.#state.getKey( - `${this.#stateKey}.${accountId}`, - ); + const transactionsByAccount = await this.#state.getKey< + TransactionStateValue['transactions'] + >(this.#stateKey); - return transactions ?? []; + return transactionsByAccount?.[accountId] ?? []; + } + + /** + * Finds a persisted keyring transaction by hash among the given accounts. + * + * @param txId - Stellar transaction hash (keyring `Transaction.id`). + * @param accountIds - Account ids to search (same order as the track job). + * @returns The transaction when found; otherwise `undefined`. + */ + async findByIdAmongAccounts( + txId: string, + accountIds: readonly string[], + ): Promise { + for (const accountId of accountIds) { + const list = await this.findByAccountId(accountId); + const found = list.find((t) => t.id === txId); + if (found) { + return found; + } + } + return undefined; } async save(transaction: KeyringTransaction): Promise { diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts index 710b5067..b17b0d1a 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts @@ -1,5 +1,6 @@ import { KeyringEvent, + type Transaction as KeyringTransaction, TransactionStatus, TransactionType, } from '@metamask/keyring-api'; @@ -11,11 +12,15 @@ import { TransactionBuilder } from './TransactionBuilder'; import type { KnownCaip19ClassicAssetId } from '../../api'; import { KnownCaip2ChainId } from '../../api'; import { getSlip44AssetId, getSnapProvider } from '../../utils'; -import { createMockTransactionService } from './__mocks__/transaction.fixtures'; +import { + createMockTransactionService, + generateMockTransactions, +} from './__mocks__/transaction.fixtures'; import { generateMockStellarKeyringAccounts } from '../account/__mocks__/account.fixtures'; import type { StellarKeyringAccount } from '../account/api'; import { NetworkService, TransactionRetryableException } from '../network'; import { TransactionScopeNotMatchException } from './exceptions'; +import { TransactionRepository } from './TransactionRepository'; import { OnChainAccount } from '../on-chain-account'; import { createMockAccountWithBalances, @@ -117,6 +122,127 @@ describe('TransactionService', () => { }); }); + describe('applyKeyringTransactionSettlement', () => { + let findByIdAmongAccountsSpy: jest.SpiedFunction< + TransactionRepository['findByIdAmongAccounts'] + >; + + beforeEach(() => { + findByIdAmongAccountsSpy = jest.spyOn( + TransactionRepository.prototype, + 'findByIdAmongAccounts', + ); + }); + + afterEach(() => { + findByIdAmongAccountsSpy.mockRestore(); + }); + + it('updates persisted transaction to confirmed and emits keyring event', async () => { + const { transactionService } = createMockTransactionService(); + const [account] = generateMockStellarKeyringAccounts( + 1, + 'settle-entropy', + ) as [StellarKeyringAccount]; + + const txId = 'settle-tx-hash-1'; + const existing = generateMockTransactions(1, { + id: txId, + account: account.id, + scope: KnownCaip2ChainId.Mainnet, + status: TransactionStatus.Unconfirmed, + })[0] as KeyringTransaction; + + findByIdAmongAccountsSpy.mockResolvedValue(existing); + + jest.mocked(emitSnapKeyringEvent).mockClear(); + + await transactionService.applyKeyringTransactionSettlement({ + txId, + accountIds: [account.id], + status: TransactionStatus.Confirmed, + }); + + expect(jest.mocked(emitSnapKeyringEvent)).toHaveBeenCalledTimes(1); + expect(jest.mocked(emitSnapKeyringEvent)).toHaveBeenCalledWith( + getSnapProvider(), + KeyringEvent.AccountTransactionsUpdated, + { + transactions: { + [account.id]: [ + expect.objectContaining({ + id: txId, + status: TransactionStatus.Confirmed, + events: expect.arrayContaining([ + expect.objectContaining({ + status: TransactionStatus.Unconfirmed, + }), + expect.objectContaining({ + status: TransactionStatus.Confirmed, + }), + ]), + }), + ], + }, + }, + ); + }); + + it('does nothing when no transaction matches txId', async () => { + const { transactionService } = createMockTransactionService(); + + findByIdAmongAccountsSpy.mockResolvedValue(undefined); + + jest.mocked(emitSnapKeyringEvent).mockClear(); + + await transactionService.applyKeyringTransactionSettlement({ + txId: 'missing-hash', + accountIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'], + status: TransactionStatus.Confirmed, + }); + + expect(jest.mocked(emitSnapKeyringEvent)).not.toHaveBeenCalled(); + }); + + it('does not emit twice when already confirmed', async () => { + const { transactionService } = createMockTransactionService(); + const [account] = generateMockStellarKeyringAccounts( + 1, + 'settle-entropy-2', + ) as [StellarKeyringAccount]; + + const txId = 'settle-tx-hash-2'; + const confirmed = generateMockTransactions(1, { + id: txId, + account: account.id, + scope: KnownCaip2ChainId.Mainnet, + status: TransactionStatus.Confirmed, + events: [ + { + status: TransactionStatus.Unconfirmed, + timestamp: 1, + }, + { + status: TransactionStatus.Confirmed, + timestamp: 2, + }, + ], + })[0] as KeyringTransaction; + + findByIdAmongAccountsSpy.mockResolvedValue(confirmed); + + jest.mocked(emitSnapKeyringEvent).mockClear(); + + await transactionService.applyKeyringTransactionSettlement({ + txId, + accountIds: [account.id], + status: TransactionStatus.Confirmed, + }); + + expect(jest.mocked(emitSnapKeyringEvent)).not.toHaveBeenCalled(); + }); + }); + describe('sendTransaction', () => { const seed = hexToBytes( '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index 1dd305ac..e1be2c30 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -1,5 +1,6 @@ import { KeyringEvent, + TransactionStatus, type Transaction as KeyringTransaction, } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; @@ -138,6 +139,53 @@ export class TransactionService { return transaction; } + /** + * Updates a persisted keyring transaction to a terminal status and emits + * {@link KeyringEvent.AccountTransactionsUpdated} so the extension Activity list can leave + * the "pending" state after Horizon inclusion (or failure). + * + * @param params - Settlement parameters. + * @param params.txId - Transaction hash (`Transaction.id`). + * @param params.accountIds - Accounts that may hold the tx (from the track job). + * @param params.status - {@link TransactionStatus.Confirmed} or {@link TransactionStatus.Failed}. + */ + async applyKeyringTransactionSettlement(params: { + txId: string; + accountIds: readonly string[]; + status: TransactionStatus.Confirmed | TransactionStatus.Failed; + }): Promise { + const { txId, accountIds, status } = params; + + const existing = await this.#transactionRepository.findByIdAmongAccounts( + txId, + accountIds, + ); + + if (!existing) { + this.#logger.debug( + 'applyKeyringTransactionSettlement: no matching persisted transaction', + { txId, accountIds }, + ); + return; + } + + if ( + existing.status === TransactionStatus.Confirmed || + existing.status === TransactionStatus.Failed + ) { + return; + } + + const timestamp = Math.floor(Date.now() / 1000); + const updated: KeyringTransaction = { + ...existing, + status, + events: [...existing.events, { status, timestamp }], + }; + + await this.save(updated); + } + /** * Computes the fee for a transaction. * From 509d3040f67715a9192971caa03128b1e5b4dab6 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 5 May 2026 10:38:33 +0800 Subject: [PATCH 155/384] chore: add code comment --- .../src/handlers/cronjob/cronjob.ts | 15 +++++++++------ .../src/handlers/cronjob/syncAccounts.ts | 2 ++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts index df1fa597..1708c482 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/cronjob.ts @@ -3,6 +3,7 @@ import type { JsonRpcRequest } from '@metamask/snaps-sdk'; import type { BackgroundEventMethod, ICronjobRequestHandler } from './api'; import { BackgroundEventMethodStruct } from './api'; import { CronjobMethodNotFoundError } from './exceptions'; +import { withCatchAndThrowSnapError } from '../../utils'; import { getClientStatus } from '../../utils/snap'; export class CronjobHandler { @@ -17,14 +18,16 @@ export class CronjobHandler { } async handle(request: JsonRpcRequest): Promise { - const { active, locked } = await getClientStatus(); + await withCatchAndThrowSnapError(async () => { + const { active, locked } = await getClientStatus(); - // if the client is not active or locked, we dont execute the cronjob - if (!active || locked) { - return; - } + // if the client is not active or locked, we dont execute the cronjob + if (!active || locked) { + return; + } - await this.#handleRequest(request); + await this.#handleRequest(request); + }); } async #handleRequest(request: JsonRpcRequest): Promise { diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/syncAccounts.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/syncAccounts.ts index ea296496..61d428b9 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/syncAccounts.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/syncAccounts.ts @@ -68,5 +68,7 @@ export class SyncAccountsHandler extends CronjobBaseHandler Date: Tue, 5 May 2026 10:41:01 +0800 Subject: [PATCH 156/384] chore: remove account balance --- .../stellar-wallet-snap/src/context.ts | 2 -- .../src/services/account-balance/api.ts | 34 ------------------- .../src/services/account-balance/index.ts | 1 - 3 files changed, 37 deletions(-) delete mode 100644 merged-packages/stellar-wallet-snap/src/services/account-balance/api.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/services/account-balance/index.ts diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index a19f76c8..4b506126 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -15,7 +15,6 @@ import { SignTransactionHandler, } from './handlers/keyring'; import { AccountService, AccountsRepository } from './services/account'; -import type { AccountBalanceState } from './services/account-balance'; import { AssetMetadataRepository, AssetMetadataService, @@ -46,7 +45,6 @@ const state = new State({ keyringAccounts: {}, assets: {}, transactions: {}, - accountBalances: {} as AccountBalanceState['accountBalances'], onChainAccounts: {} as OnChainAccountState['onChainAccounts'], }, }); diff --git a/merged-packages/stellar-wallet-snap/src/services/account-balance/api.ts b/merged-packages/stellar-wallet-snap/src/services/account-balance/api.ts deleted file mode 100644 index 347c777b..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/account-balance/api.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Balance } from '@metamask/keyring-api'; - -import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; - -export type BaseAssetBalance = Balance; - -export type TrustLineAssetBalance = BaseAssetBalance & { - /** The limit of the balance. */ - limit: string; - /** Horizon `is_authorized` for this trustline (optional for legacy persisted rows). */ - authorized?: boolean; - /** The sponsored balance. */ - sponsored?: boolean; -}; - -/** - * Per-account balances keyed by native slip44, classic, or SEP-41 CAIP-19 asset id. - * - * For the slip44 native entry, `amount` is the **total** balance in stroops (same as Horizon native before reserve subtraction). Spendable XLM is derived at bind time from this value plus account metadata (subentries / sponsoring). - */ -export type AccountBalance = Partial< - Record ->; - -/** Wrapper persisted under `accountBalances[accountId]` with a single write timestamp for the row. */ -export type AccountBalanceRecord = { - balances: AccountBalance; - persistedAt: number; -}; - -/** Snap state slice: `accountBalances[accountId]` → per-asset balances for that keyring account. */ -export type AccountBalanceState = { - accountBalances: Record; -}; diff --git a/merged-packages/stellar-wallet-snap/src/services/account-balance/index.ts b/merged-packages/stellar-wallet-snap/src/services/account-balance/index.ts deleted file mode 100644 index 6561443d..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/account-balance/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type * from './api'; From 8327e493b455776851594ca9e5b2f033d1c4fcfe Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 5 May 2026 16:36:14 +0200 Subject: [PATCH 157/384] chore: handle comment --- .../handlers/cronjob/trackTransaction.test.ts | 22 ++--- .../src/handlers/cronjob/trackTransaction.ts | 83 +++++++++++++------ .../transaction/TransactionRepository.ts | 6 +- .../transaction/TransactionService.test.ts | 8 +- .../transaction/TransactionService.ts | 6 +- 5 files changed, 80 insertions(+), 45 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts index dbf70dac..cc69fb80 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts @@ -49,8 +49,8 @@ describe('TrackTransactionHandler', () => { .spyOn(OnChainAccountService.prototype, 'synchronize') .mockResolvedValue(undefined); - const applyKeyringTransactionSettlement = jest - .spyOn(TransactionService.prototype, 'applyKeyringTransactionSettlement') + const updateKeyringTransactionStatus = jest + .spyOn(TransactionService.prototype, 'updateKeyringTransactionStatus') .mockResolvedValue(undefined); const { transactionService } = createMockTransactionService(); @@ -78,7 +78,7 @@ describe('TrackTransactionHandler', () => { findByIds, getHorizonTransactionInclusionStatus, synchronize, - applyKeyringTransactionSettlement, + updateKeyringTransactionStatus, }; } @@ -119,7 +119,7 @@ describe('TrackTransactionHandler', () => { account, getHorizonTransactionInclusionStatus, synchronize, - applyKeyringTransactionSettlement, + updateKeyringTransactionStatus, } = setup(); getHorizonTransactionInclusionStatus.mockResolvedValue('success'); @@ -134,7 +134,7 @@ describe('TrackTransactionHandler', () => { }, }); - expect(applyKeyringTransactionSettlement).toHaveBeenCalledWith({ + expect(updateKeyringTransactionStatus).toHaveBeenCalledWith({ txId, accountIds: [accountId], status: TransactionStatus.Confirmed, @@ -149,7 +149,7 @@ describe('TrackTransactionHandler', () => { handler, getHorizonTransactionInclusionStatus, synchronize, - applyKeyringTransactionSettlement, + updateKeyringTransactionStatus, } = setup(); getHorizonTransactionInclusionStatus.mockResolvedValue('failed'); @@ -164,7 +164,7 @@ describe('TrackTransactionHandler', () => { }, }); - expect(applyKeyringTransactionSettlement).toHaveBeenCalledWith({ + expect(updateKeyringTransactionStatus).toHaveBeenCalledWith({ txId, accountIds: [accountId], status: TransactionStatus.Failed, @@ -178,7 +178,7 @@ describe('TrackTransactionHandler', () => { handler, getHorizonTransactionInclusionStatus, synchronize, - applyKeyringTransactionSettlement, + updateKeyringTransactionStatus, } = setup(); getHorizonTransactionInclusionStatus.mockResolvedValue('pending'); @@ -195,7 +195,7 @@ describe('TrackTransactionHandler', () => { }); expect(getHorizonTransactionInclusionStatus).toHaveBeenCalledTimes(1); - expect(applyKeyringTransactionSettlement).not.toHaveBeenCalled(); + expect(updateKeyringTransactionStatus).not.toHaveBeenCalled(); expect(synchronize).toHaveBeenCalledTimes(1); expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); }); @@ -205,7 +205,7 @@ describe('TrackTransactionHandler', () => { handler, getHorizonTransactionInclusionStatus, synchronize, - applyKeyringTransactionSettlement, + updateKeyringTransactionStatus, } = setup(); getHorizonTransactionInclusionStatus.mockResolvedValue('success'); @@ -221,7 +221,7 @@ describe('TrackTransactionHandler', () => { }, }); - expect(applyKeyringTransactionSettlement).toHaveBeenCalledWith({ + expect(updateKeyringTransactionStatus).toHaveBeenCalledWith({ txId, accountIds: [accountId], status: TransactionStatus.Confirmed, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts index 8a0ba481..d0464354 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts @@ -9,7 +9,11 @@ import { TrackTransactionJsonRpcRequestStruct, } from './api'; import { CronjobBaseHandler } from './base'; -import type { AccountService } from '../../services/account'; +import type { KnownCaip2ChainId } from '../../api'; +import type { + AccountService, + StellarKeyringAccount, +} from '../../services/account'; import type { NetworkService } from '../../services/network'; import type { OnChainAccountService } from '../../services/on-chain-account'; import type { TransactionService } from '../../services/transaction'; @@ -20,6 +24,13 @@ import { scheduleBackgroundEvent } from '../../utils/snap'; /** Poll budget for Horizon inclusion after submit (matches Tron snap pattern). */ const TRACK_TRANSACTION_MAX_ATTEMPTS = 15; +/** + * Polls Horizon for transaction inclusion; on settlement or max attempts runs + * {@link OnChainAccountService.synchronize} so keyring asset/balance events emit. + * + * TODO: Revisit unifying this Horizon loop with {@link NetworkService.pollTransaction} (RPC), + * outcome mapping, and scheduling shape after cronjob-related work stabilizes. + */ export class TrackTransactionHandler extends CronjobBaseHandler { static readonly duration = 'PT1S'; @@ -70,9 +81,6 @@ export class TrackTransactionHandler extends CronjobBaseHandler => { - await this.#onChainAccountService.synchronize(accounts, scope); - }; - - const settleKeyringRow = async ( - keyringStatus: TransactionStatus.Confirmed | TransactionStatus.Failed, - ): Promise => { - await this.#transactionService.applyKeyringTransactionSettlement({ - txId, - accountIds, - status: keyringStatus, - }); - }; - + // Not another scheduled retry: one-off Horizon read to classify the tx before sync + // after scheduled attempt budget is exhausted. if (attempt >= TRACK_TRANSACTION_MAX_ATTEMPTS) { this.logger.warn( 'TrackTransaction: max attempts reached; synchronizing accounts', @@ -122,9 +118,17 @@ export class TrackTransactionHandler extends CronjobBaseHandler { + await this.#onChainAccountService.synchronize(accounts, scope); + } + + async #settleKeyringRow( + txId: string, + accountIds: readonly string[], + keyringStatus: TransactionStatus.Confirmed | TransactionStatus.Failed, + ): Promise { + await this.#transactionService.updateKeyringTransactionStatus({ + txId, + accountIds, + status: keyringStatus, + }); + } } diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts index 6ed2cfd1..7fef2b61 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts @@ -44,8 +44,12 @@ export class TransactionRepository { txId: string, accountIds: readonly string[], ): Promise { + const transactionsByAccount = await this.#state.getKey< + TransactionStateValue['transactions'] + >(this.#stateKey); + for (const accountId of accountIds) { - const list = await this.findByAccountId(accountId); + const list = transactionsByAccount?.[accountId] ?? []; const found = list.find((t) => t.id === txId); if (found) { return found; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts index b17b0d1a..dad96a22 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts @@ -122,7 +122,7 @@ describe('TransactionService', () => { }); }); - describe('applyKeyringTransactionSettlement', () => { + describe('updateKeyringTransactionStatus', () => { let findByIdAmongAccountsSpy: jest.SpiedFunction< TransactionRepository['findByIdAmongAccounts'] >; @@ -157,7 +157,7 @@ describe('TransactionService', () => { jest.mocked(emitSnapKeyringEvent).mockClear(); - await transactionService.applyKeyringTransactionSettlement({ + await transactionService.updateKeyringTransactionStatus({ txId, accountIds: [account.id], status: TransactionStatus.Confirmed, @@ -195,7 +195,7 @@ describe('TransactionService', () => { jest.mocked(emitSnapKeyringEvent).mockClear(); - await transactionService.applyKeyringTransactionSettlement({ + await transactionService.updateKeyringTransactionStatus({ txId: 'missing-hash', accountIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'], status: TransactionStatus.Confirmed, @@ -233,7 +233,7 @@ describe('TransactionService', () => { jest.mocked(emitSnapKeyringEvent).mockClear(); - await transactionService.applyKeyringTransactionSettlement({ + await transactionService.updateKeyringTransactionStatus({ txId, accountIds: [account.id], status: TransactionStatus.Confirmed, diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index e1be2c30..e756a24c 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -144,12 +144,12 @@ export class TransactionService { * {@link KeyringEvent.AccountTransactionsUpdated} so the extension Activity list can leave * the "pending" state after Horizon inclusion (or failure). * - * @param params - Settlement parameters. + * @param params - Status update parameters. * @param params.txId - Transaction hash (`Transaction.id`). * @param params.accountIds - Accounts that may hold the tx (from the track job). * @param params.status - {@link TransactionStatus.Confirmed} or {@link TransactionStatus.Failed}. */ - async applyKeyringTransactionSettlement(params: { + async updateKeyringTransactionStatus(params: { txId: string; accountIds: readonly string[]; status: TransactionStatus.Confirmed | TransactionStatus.Failed; @@ -163,7 +163,7 @@ export class TransactionService { if (!existing) { this.#logger.debug( - 'applyKeyringTransactionSettlement: no matching persisted transaction', + 'updateKeyringTransactionStatus: no matching persisted transaction', { txId, accountIds }, ); return; From 5b5b681d6f3346ce7d422b482c67dcbded7c3774 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Tue, 5 May 2026 18:46:09 +0200 Subject: [PATCH 158/384] fix: address signAuthEntry review nits (wallet test, networkId compare, UI args) --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../stellar-wallet-snap/src/api/xdr.ts | 11 +- .../handlers/keyring/signAuthEntry.test.ts | 91 ++++++++++++-- .../src/handlers/keyring/signAuthEntry.ts | 112 ++++++++++++++--- .../src/services/wallet/Wallet.test.ts | 9 -- .../ConfirmSignAuthEntry.tsx | 115 ++++++++++++++++-- 6 files changed, 285 insertions(+), 55 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 82ed6751..fca39e3c 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "rs7iGfb4WOfGVOcNXIiaKX4cKsATUJkJ+YeU+i2scJw=", + "shasum": "eXd0il4mdjFcVw8/G17QsrvBIWstClXC1qgLe6JrjC4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/api/xdr.ts b/merged-packages/stellar-wallet-snap/src/api/xdr.ts index 80ea37f8..3fa27745 100644 --- a/merged-packages/stellar-wallet-snap/src/api/xdr.ts +++ b/merged-packages/stellar-wallet-snap/src/api/xdr.ts @@ -26,9 +26,7 @@ export const XdrStruct = refine( // doesn't re-hash on every validation. This is the value the network compares // against when verifying a Soroban authorization signature, so the embedded // `networkId` of any preimage we agree to sign must equal it. -const MAINNET_NETWORK_ID_HEX = hash( - bufferToUint8Array(Networks.PUBLIC, 'utf8'), -).toString('hex'); +const MAINNET_NETWORK_ID = hash(bufferToUint8Array(Networks.PUBLIC, 'utf8')); /** * Validation struct for a SEP-43 `signAuthEntry` payload: a base64-encoded @@ -55,11 +53,8 @@ export const HashIdPreimageXdrStruct = refine( ) { return 'HashIdPreimage is not a Soroban authorization preimage'; } - const embeddedNetworkId = preimage - .sorobanAuthorization() - .networkId() - .toString('hex'); - if (embeddedNetworkId !== MAINNET_NETWORK_ID_HEX) { + const embeddedNetworkId = preimage.sorobanAuthorization().networkId(); + if (!MAINNET_NETWORK_ID.equals(embeddedNetworkId)) { return 'HashIdPreimage networkId is not Stellar mainnet'; } return true; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.test.ts index 4cb43ec2..c5fab758 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.test.ts @@ -23,14 +23,25 @@ jest.mock('../../utils/logger'); * arbitrary — we only need the XDR to round-trip through superstruct * validation and the handler's preimage decoder. * - * @param networkPassphrase - Network passphrase to embed as `networkId`. - * Defaults to mainnet so happy-path tests pass `HashIdPreimageXdrStruct`'s - * mainnet-only check. + * @param options - Optional overrides for the generated preimage. + * @param options.networkPassphrase - Network passphrase to embed as + * `networkId`. Defaults to mainnet so happy-path tests pass + * `HashIdPreimageXdrStruct`'s mainnet-only check. + * @param options.args - `ScVal` arguments to attach to the contract function. + * Defaults to no arguments (matching a `transfer` with empty args list). + * @param options.subInvocations - Nested authorized invocations to attach. + * Defaults to an empty list. * @returns Base64 XDR of a Soroban authorization preimage. */ -function buildAuthEntryPreimageXdr( - networkPassphrase: string = Networks.PUBLIC, -): string { +function buildAuthEntryPreimageXdr({ + networkPassphrase = Networks.PUBLIC, + args = [], + subInvocations = [], +}: { + networkPassphrase?: string; + args?: xdr.ScVal[]; + subInvocations?: xdr.SorobanAuthorizedInvocation[]; +} = {}): string { const contractIdBytes = new Uint8Array(32).fill(1); const contractAddress = Address.contract( bufferToUint8Array(contractIdBytes), @@ -38,7 +49,7 @@ function buildAuthEntryPreimageXdr( const invokeContractArgs = new xdr.InvokeContractArgs({ contractAddress, functionName: 'transfer', - args: [], + args, }); const fn = xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( @@ -46,7 +57,7 @@ function buildAuthEntryPreimageXdr( ); const invocation = new xdr.SorobanAuthorizedInvocation({ function: fn, - subInvocations: [], + subInvocations, }); const sorobanAuth = new xdr.HashIdPreimageSorobanAuthorization({ networkId: hash(bufferToUint8Array(networkPassphrase, 'utf8')), @@ -156,7 +167,8 @@ describe('SignAuthEntryHandler', () => { functionName: 'transfer', signatureExpirationLedger: 1_000_000, nonce: '123456789', - subInvocationsCount: 0, + args: [], + subInvocations: [], contractAddress: expect.stringMatching(/^C[A-Z2-7]+$/u), }), }), @@ -164,6 +176,63 @@ describe('SignAuthEntryHandler', () => { ); }); + it('decodes function arguments and nested sub-invocations into the readable preimage', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + renderConfirmationDialog.mockResolvedValue(true); + + // transfer(to: G…, amount: 10) wrapped around a single nested call to + // verify both args decoding (address strkey + i128) and recursive + // sub-invocation decoding. + const recipient = Keypair.random().publicKey(); + const args = [ + xdr.ScVal.scvAddress(Address.fromString(recipient).toScAddress()), + xdr.ScVal.scvI128( + new xdr.Int128Parts({ + hi: xdr.Int64.fromString('0'), + lo: xdr.Uint64.fromString('10'), + }), + ), + ]; + const nestedInvocation = new xdr.SorobanAuthorizedInvocation({ + function: + xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: Address.contract( + bufferToUint8Array(new Uint8Array(32).fill(2)), + ).toScAddress(), + functionName: 'approve', + args: [], + }), + ), + subInvocations: [], + }); + const authEntry = buildAuthEntryPreimageXdr({ + args, + subInvocations: [nestedInvocation], + }); + + await handler.handle(buildRequest(mockAccount.id, { authEntry })); + + expect(renderConfirmationDialog).toHaveBeenCalledWith( + expect.objectContaining({ + renderContext: expect.objectContaining({ + readableAuthEntry: expect.objectContaining({ + functionName: 'transfer', + args: [JSON.stringify(recipient), JSON.stringify('10')], + subInvocations: [ + expect.objectContaining({ + functionType: 'invoke', + functionName: 'approve', + args: [], + subInvocations: [], + }), + ], + }), + }), + }), + ); + }); + it('returns error -4 when user rejects', async () => { const { handler, mockAccount, wallet, renderConfirmationDialog } = setupHandler(); @@ -252,7 +321,9 @@ describe('SignAuthEntryHandler', () => { // bound to testnet. The keyring `scope`/`opts.networkPassphrase` look // mainnet-y, so without the networkId check the snap would happily sign // a Soroban auth signature valid only against testnet. - const testnetAuthEntry = buildAuthEntryPreimageXdr(Networks.TESTNET); + const testnetAuthEntry = buildAuthEntryPreimageXdr({ + networkPassphrase: Networks.TESTNET, + }); const result = await handler.handle( buildRequest(mockAccount.id, { authEntry: testnetAuthEntry }), diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.ts index 4b421fc1..6caebc4c 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signAuthEntry.ts @@ -1,5 +1,5 @@ import { UserRejectedRequestError } from '@metamask/snaps-sdk'; -import { Address, xdr } from '@stellar/stellar-sdk'; +import { Address, scValToNative, xdr } from '@stellar/stellar-sdk'; import type { SignAuthEntryRequest, SignAuthEntryResponse } from './api'; import { SignAuthEntryRequestStruct, SignAuthEntryResponseStruct } from './api'; @@ -13,26 +13,40 @@ import type { Wallet, WalletService } from '../../services/wallet'; import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; import type { ConfirmationUXController } from '../../ui/confirmation/controller'; import type { ILogger } from '../../utils'; +import { bufferToUint8Array } from '../../utils/buffer'; /** - * Human-readable Soroban auth entry summary rendered in the confirmation - * dialog. The struct guarantees the preimage parses and is the Soroban - * authorization variant; this shape extracts only the fields a user can - * meaningfully verify. + * Decoded summary of a single Soroban authorized invocation — used both for + * the root call the user is authorizing and, recursively, for every nested + * call the same authorization implicitly covers. */ -export type ReadableAuthEntry = { +export type ReadableInvocation = { /** `'invoke'` for direct contract calls, `'createContract'` / `'createContractV2'` for deployments. */ functionType: 'invoke' | 'createContract' | 'createContractV2'; /** Strkey-encoded contract `C…` address being invoked, or `null` for contract-creation entries. */ contractAddress: string | null; /** Function being invoked, or `null` for contract-creation entries. */ functionName: string | null; + /** + * Decoded function arguments as user-readable JSON strings, in declaration + * order. Empty array for contract-creation entries (which carry no args). + */ + args: string[]; + /** Nested invocations this authorization also covers. */ + subInvocations: ReadableInvocation[]; +}; + +/** + * Human-readable Soroban auth entry summary rendered in the confirmation + * dialog. The struct guarantees the preimage parses and is the Soroban + * authorization variant; this shape extracts only the fields a user can + * meaningfully verify. + */ +export type ReadableAuthEntry = ReadableInvocation & { /** Ledger sequence at which this authorization expires (exclusive). */ signatureExpirationLedger: number; /** Replay-protection nonce. */ nonce: string; - /** Count of nested invocations the user is also authorizing. */ - subInvocationsCount: number; }; /** @@ -137,45 +151,69 @@ export class SignAuthEntryHandler extends BaseSep43KeyringHandler< function decodeSorobanAuthPreimage(authEntry: string): ReadableAuthEntry { const preimage = xdr.HashIdPreimage.fromXDR(authEntry, 'base64'); const sorobanAuth = preimage.sorobanAuthorization(); - const fn = sorobanAuth.invocation().function(); - let functionType: ReadableAuthEntry['functionType']; + return { + ...decodeInvocation(sorobanAuth.invocation()), + signatureExpirationLedger: sorobanAuth.signatureExpirationLedger(), + nonce: sorobanAuth.nonce().toString(), + }; +} + +/** + * Recursively decodes a single Soroban authorized invocation (the root call + * or any nested sub-invocation) into a UI-friendly shape. The same data + * matters at every depth: which contract, which function, what arguments, + * what's nested below. + * + * @param invocation - The `SorobanAuthorizedInvocation` to decode. + * @returns A {@link ReadableInvocation} for display. + */ +function decodeInvocation( + invocation: xdr.SorobanAuthorizedInvocation, +): ReadableInvocation { + const fn = invocation.function(); + + let functionType: ReadableInvocation['functionType']; let contractAddress: string | null; let functionName: string | null; + let args: string[]; switch (fn.switch()) { case xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeContractFn(): { - const args = fn.contractFn(); + const contractFn = fn.contractFn(); functionType = 'invoke'; contractAddress = Address.fromScAddress( - args.contractAddress(), + contractFn.contractAddress(), ).toString(); - functionName = readFunctionName(args.functionName()); + functionName = readFunctionName(contractFn.functionName()); + args = readScVals(contractFn.args()); break; } case xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeCreateContractHostFn(): functionType = 'createContract'; contractAddress = null; functionName = null; + args = []; break; case xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeCreateContractV2HostFn(): functionType = 'createContractV2'; contractAddress = null; functionName = null; + args = []; break; /* istanbul ignore next — exhaustive switch over an SDK enum */ default: functionType = 'invoke'; contractAddress = null; functionName = null; + args = []; } return { functionType, contractAddress, functionName, - signatureExpirationLedger: sorobanAuth.signatureExpirationLedger(), - nonce: sorobanAuth.nonce().toString(), - subInvocationsCount: sorobanAuth.invocation().subInvocations().length, + args, + subInvocations: invocation.subInvocations().map(decodeInvocation), }; } @@ -190,5 +228,47 @@ function readFunctionName(fnName: string | Buffer): string { return typeof fnName === 'string' ? fnName : fnName.toString('utf8'); } +/** + * Decodes the contract-function `ScVal[]` arguments into a list of + * user-readable JSON strings. Each value is run through `scValToNative` + * (the SDK's canonical XDR-to-JS converter) and then JSON-serialized with + * a replacer that rescues `bigint` and `Uint8Array`/`Buffer` values that + * `JSON.stringify` cannot represent natively. + * + * @param scVals - Function arguments as raw `ScVal`s. + * @returns One JSON string per argument, in declaration order. + */ +function readScVals(scVals: xdr.ScVal[]): string[] { + return scVals.map((scv) => { + try { + return jsonStringifyArgValue(scValToNative(scv)); + } catch { + // Some custom contract types may not have a native projection. + // Fall back to the raw XDR base64 so the user still sees something. + return scv.toXDR().toString('base64'); + } + }); +} + +/** + * `JSON.stringify` cannot natively serialize `bigint` (used for i128/u128 + * SCVals) or `Uint8Array`/`Buffer` (ScBytes). Render them as their string + * / hex representations so the dialog never throws on a contract argument. + * + * @param value - Native value produced by `scValToNative`. + * @returns Stable JSON representation suitable for display. + */ +function jsonStringifyArgValue(value: unknown): string { + return JSON.stringify(value, (_key, raw: unknown) => { + if (typeof raw === 'bigint') { + return raw.toString(); + } + if (raw instanceof Uint8Array) { + return bufferToUint8Array(raw).toString('hex'); + } + return raw; + }); +} + /* istanbul ignore next — re-export for tests */ export { decodeSorobanAuthPreimage }; diff --git a/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts b/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts index 2f093220..9f006c2f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/wallet/Wallet.test.ts @@ -174,15 +174,6 @@ describe('Wallet', () => { ).toBe(true); }); - it('does NOT prepend the SEP-53 "Stellar Signed Message" prefix', async () => { - // signAuthEntry must hash the raw preimage bytes only — adding a - // SEP-53 prefix would invalidate Soroban auth-entry signatures. - const wallet = getTestWallet({ seed }); - const authSignature = await wallet.signAuthEntry(preimageBase64); - const messageSignature = await wallet.signMessage(preimageBase64); - expect(authSignature).not.toStrictEqual(messageSignature); - }); - it('supports hex encoding for the returned signature', async () => { const wallet = getTestWallet({ seed }); const hexSignature = await wallet.signAuthEntry(preimageBase64, 'hex'); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx index c2178aac..473ddc9d 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx @@ -5,6 +5,7 @@ import { Box, Button, Container, + Divider, Footer, Heading, Icon, @@ -15,7 +16,11 @@ import { } from '@metamask/snaps-sdk/jsx'; import { ConfirmSignAuthEntryFormNames } from './events'; -import type { ReadableAuthEntry } from '../../../../handlers/keyring/signAuthEntry'; +import type { KnownCaip2ChainId } from '../../../../api'; +import type { + ReadableAuthEntry, + ReadableInvocation, +} from '../../../../handlers/keyring/signAuthEntry'; import type { StellarKeyringAccount } from '../../../../services/account'; import type { Locale } from '../../../../utils'; import { i18n } from '../../../../utils'; @@ -31,6 +36,73 @@ export type ConfirmSignAuthEntryProps = Pick< account: StellarKeyringAccount; }; +// Compact summary of one nested authorized invocation: contract, function, +// decoded args, and a count of any deeper invocations beneath it. Rendered +// only one level deep to keep the dialog scannable; deeper nesting is +// surfaced as a count and is still bound by the user's signature. +const SubInvocationSummary = ({ + invocation, + scope, + translate, +}: { + invocation: ReadableInvocation; + scope: KnownCaip2ChainId; + translate: ReturnType; +}): ComponentOrElement => { + const { contractAddress, functionName, args, subInvocations } = invocation; + + return ( + + {contractAddress === null ? ( + + + {translate('confirmation.signAuthEntry.contract')} + + + {translate('confirmation.signAuthEntry.createContract')} + + + ) : ( + + + {translate('confirmation.signAuthEntry.contract')} + +
+ + )} + + {functionName === null ? null : ( + + + {translate('confirmation.signAuthEntry.function')} + + {functionName} + + )} + + {args.length > 0 ? ( + + + {translate('confirmation.transaction.param.arguments')} + + {args.map((arg, index) => ( + {arg} + ))} + + ) : null} + + {subInvocations.length > 0 ? ( + + + {translate('confirmation.signAuthEntry.subInvocations')} + + {String(subInvocations.length)} + + ) : null} + + ); +}; + export const ConfirmSignAuthEntry = ({ readableAuthEntry, account, @@ -46,9 +118,10 @@ export const ConfirmSignAuthEntry = ({ functionType, contractAddress, functionName, + args, signatureExpirationLedger, nonce, - subInvocationsCount, + subInvocations, } = readableAuthEntry; return ( @@ -94,6 +167,17 @@ export const ConfirmSignAuthEntry = ({ )} + {args.length > 0 ? ( + + + {translate('confirmation.transaction.param.arguments')} + + {args.map((arg, index) => ( + {arg} + ))} + + ) : null} + {translate('confirmation.signAuthEntry.expiresAt')} @@ -107,17 +191,26 @@ export const ConfirmSignAuthEntry = ({ {nonce} - - {subInvocationsCount > 0 ? ( - - - {translate('confirmation.signAuthEntry.subInvocations')} - - {String(subInvocationsCount)} - - ) : null}
+ {subInvocations.length > 0 ? ( +
+ + {translate('confirmation.signAuthEntry.subInvocations')} + + {subInvocations.map((sub, index) => ( + + {index > 0 ? : null} + + + ))} +
+ ) : null} +
{origin ? ( From 824caee48351f3a1a0c9702611b4b38ef222d368 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 6 May 2026 14:25:15 +0800 Subject: [PATCH 159/384] chore: add preload accounts --- .../services/network/NetworkService.test.ts | 84 +++++++++++++++++++ .../src/services/network/NetworkService.ts | 39 +++++++++ .../src/services/transaction/Transaction.ts | 21 +++++ .../transaction/TransactionService.ts | 56 +++++++++++++ .../src/services/transaction/utils.ts | 2 +- 5 files changed, 201 insertions(+), 1 deletion(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts index 6317f5e2..d9b1e901 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts @@ -168,6 +168,90 @@ describe('NetworkService', () => { }); }); + describe('loadOnChainAccounts', () => { + const addrA = generateStellarAddress(); + const addrB = generateStellarAddress(); + + it('returns empty array without calling Horizon when addresses is empty', async () => { + const { loadAccountSpy } = getHorizonClientSpies(); + + const result = await networkService.loadOnChainAccounts([], scope); + + expect(result).toStrictEqual([]); + expect(loadAccountSpy).not.toHaveBeenCalled(); + }); + + it('returns loaded accounts in the same order as the input addresses', async () => { + const { loadAccountSpy } = getHorizonClientSpies(); + loadAccountSpy + .mockResolvedValueOnce( + createMockAccountWithBalances(addrA, '10', { + nativeBalance: 1, + assets: [], + }) as unknown as StellarHorizon.AccountResponse, + ) + .mockResolvedValueOnce( + createMockAccountWithBalances(addrB, '20', { + nativeBalance: 1, + assets: [], + }) as unknown as StellarHorizon.AccountResponse, + ); + + const result = await networkService.loadOnChainAccounts( + [addrA, addrB], + scope, + ); + + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(OnChainAccount); + expect(result[1]).toBeInstanceOf(OnChainAccount); + expect(result[0]?.accountId).toStrictEqual(addrA); + expect(result[0]?.sequenceNumber).toBe('10'); + expect(result[1]?.accountId).toStrictEqual(addrB); + expect(result[1]?.sequenceNumber).toBe('20'); + expect(loadAccountSpy).toHaveBeenCalledTimes(2); + }); + + it('maps failures to null, preserves order, and logs a warning', async () => { + const { loadAccountSpy } = getHorizonClientSpies(); + loadAccountSpy + .mockRejectedValueOnce(new NotFoundError('not found', {})) + .mockResolvedValueOnce( + createMockAccountWithBalances(addrA, '1', { + nativeBalance: 1, + assets: [], + }) as unknown as StellarHorizon.AccountResponse, + ); + + const result = await networkService.loadOnChainAccounts( + [addrB, addrA], + scope, + ); + + expect(result).toHaveLength(2); + expect(result[0]).toBeNull(); + expect(result[1]).toBeInstanceOf(OnChainAccount); + expect(logger.warn).toHaveBeenCalledWith( + expect.any(String), + 'Failed to preload participating account', + expect.objectContaining({ + accountId: addrB, + error: expect.any(AccountNotActivatedException), + }), + ); + }); + + it('throws NetworkServiceException when batchSize is less than one', async () => { + const { loadAccountSpy } = getHorizonClientSpies(); + + await expect( + networkService.loadOnChainAccounts([addrA], scope, 0), + ).rejects.toThrow(NetworkServiceException); + + expect(loadAccountSpy).not.toHaveBeenCalled(); + }); + }); + describe('loadActivatedAccountOrNull', () => { const testAddress = 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG'; diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index 3b600d46..dd9dc2ad 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -54,6 +54,7 @@ import { toCaip19ClassicAssetId, toCaip19Sep41AssetId, rethrowIfInstanceElseThrow, + batchesAllSettled, } from '../../utils'; import { OnChainAccount } from '../on-chain-account/OnChainAccount'; import { Transaction } from '../transaction/Transaction'; @@ -187,6 +188,44 @@ export class NetworkService { } } + async loadOnChainAccounts( + accountAddress: string[], + scope: KnownCaip2ChainId, + // Hardcoded to 5 to avoid overwhelming the network + batchSize: number = 5, + ): Promise<(OnChainAccount | null)[]> { + try { + const settled = await batchesAllSettled( + accountAddress, + batchSize, + async (accountId) => this.loadOnChainAccount(accountId, scope), // Assume the onChainAccount scope is the same as the transaction scope + ); + + const onChainAccounts: (OnChainAccount | null)[] = []; + let idx = 0; + for (const result of settled) { + if (result.status === 'fulfilled') { + onChainAccounts.push(result.value); + } else { + this.#logger.warn('Failed to preload participating account', { + accountId: accountAddress[idx], + error: result.reason, + }); + onChainAccounts.push(null); + } + idx += 1; + } + + return onChainAccounts; + } catch (error: unknown) { + return rethrowIfInstanceElseThrow( + error, + [AccountLoadException, AccountNotActivatedException], + new NetworkServiceException('Failed to load accounts'), + ); + } + } + /** * Fetches the account via Soroban RPC (`getAccountEntry`) and wraps it as {@link OnChainAccount}. * The underlying SDK `Account` has **id and sequence only** (no Horizon `balances`); use diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts index 2267f607..55d5ad7b 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts @@ -20,17 +20,28 @@ export class Transaction { readonly #participatingAccounts: Set = new Set(); + readonly #invokedByAccounts: Set = new Set(); + constructor(inner: StellarTransaction | FeeBumpTransaction) { this.#inner = inner; this.#initialize(); } #initialize(): void { + this.#invokedByAccounts.add(this.sourceAccount); + this.#invokedByAccounts.add(this.feeSourceAccount); + this.#participatingAccounts.add(this.sourceAccount); this.#participatingAccounts.add(this.feeSourceAccount); for (const operation of this.transactionOperations) { this.#participatingAccounts.add(operation.source ?? this.sourceAccount); + if (operation.type === 'pathPaymentStrictSend') { + this.#participatingAccounts.add(operation.destination); + } + if (operation.type === 'pathPaymentStrictReceive') { + this.#participatingAccounts.add(operation.destination); + } this.#operationTypes.add(operation.type); } } @@ -201,6 +212,16 @@ export class Transaction { return this.#participatingAccounts.has(accountId); } + /** + * Checks if the transaction is invoked by the given account. + * + * @param accountId - The account ID to check. + * @returns True if the transaction is invoked by the given account, false otherwise. + */ + isInvokedByAccount(accountId: string): boolean { + return this.#invokedByAccounts.has(accountId); + } + /** * The raw SDK transaction. Prefer the wrapped API where possible; use this for signing and submission. * diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index 1dd305ac..8da8f6c3 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -117,6 +117,62 @@ export class TransactionService { return transaction; } + /** + * Creates a validated swap transaction from a Base64 encoded XDR. + * + * @param params - The parameters for the transaction. + * @param params.onChainAccount - The on-chain account. + * @param params.scope - The CAIP-2 chain ID. + * @param params.xdr - The Base64 encoded XDR of the transaction. + * @returns A promise that resolves to the validated transaction. + */ + async createValidatedSwapTransaction(params: { + onChainAccount: OnChainAccount; + scope: KnownCaip2ChainId; + xdr: string; + }): Promise { + const { onChainAccount, scope, xdr } = params; + + const transaction = this.#transactionBuilder.deserialize({ + xdr, + scope, + }); + + const transactionWithFee = await this.computingFee(transaction); + + const preloadedAccounts = await this.#getPreloadedAccounts( + transactionWithFee, + onChainAccount, + ); + + this.validateTransaction(transactionWithFee, onChainAccount, { + expectedOPTypes: [SupportedOperations.InvokeHostFunction], + preloadedAccounts, + }); + + return transaction; + } + + async #getPreloadedAccounts( + transaction: Transaction, + onChainAccount: OnChainAccount, + ): Promise { + // get the participating accounts Id that are not the source account, + // as we already preloaded the source account + const participatingAccounts: string[] = transaction.hasInvokeHostFunction + ? [] + : transaction.participatingAccounts.filter( + (accountId) => accountId !== onChainAccount.accountId, + ); + + const preloadedAccounts = await this.#networkService.loadOnChainAccounts( + participatingAccounts, + transaction.scope, + ); + + return preloadedAccounts.filter((account) => account !== null); + } + /** * Create and save a pending keyring transaction. * diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts index 0dd32d8d..66991fc3 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts @@ -95,7 +95,7 @@ export function assertAccountInvolvesTransaction( transaction: Transaction, accountId: string, ): void { - if (transaction.hasParticipatingAccount(accountId)) { + if (transaction.isInvokedByAccount(accountId)) { return; } throw new TransactionValidationException( From 6b5ed91ccef0dae203972c357d15b1a096d6c52f Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 6 May 2026 14:26:00 +0800 Subject: [PATCH 160/384] feat: add client request to support swap --- .../stellar-wallet-snap/src/context.ts | 20 +++ .../src/handlers/clientRequest/api.ts | 95 ++++++++++++ .../src/handlers/clientRequest/computeFee.ts | 99 +++++++++++++ .../clientRequest/signAndSendTransaction.ts | 140 ++++++++++++++++++ 4 files changed, 354 insertions(+) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 24abb76d..b480a1bf 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -9,6 +9,8 @@ import { ClientRequestHandler, ClientRequestMethod, } from './handlers/clientRequest'; +import { ComputeFeeHandler } from './handlers/clientRequest/computeFee'; +import { SignAndSendTransactionHandler } from './handlers/clientRequest/signAndSendTransaction'; import type { ICronjobRequestHandler } from './handlers/cronjob/api'; import { BackgroundEventMethod } from './handlers/cronjob/api'; import { RefreshConfirmationPricesHandler } from './handlers/cronjob/refreshConfirmationPrices'; @@ -187,11 +189,29 @@ const changeTrustOptHandler = new ChangeTrustOptHandler({ confirmationUIController, }); +const signAndSendTransactionHandler = new SignAndSendTransactionHandler({ + logger, + accountService, + onChainAccountService, + walletService, + transactionService, +}); + +const computeFeeHandler = new ComputeFeeHandler({ + logger, + accountService, + onChainAccountService, + walletService, + transactionService, +}); + const clientRequestMethodHandlers: Record< ClientRequestMethod, IClientRequestHandler > = { [ClientRequestMethod.ChangeTrustOpt]: changeTrustOptHandler, + [ClientRequestMethod.SignAndSendTransaction]: signAndSendTransactionHandler, + [ClientRequestMethod.ComputeFee]: computeFeeHandler, }; const clientRequestHandler = new ClientRequestHandler({ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts index a56ed83e..70779ad8 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts @@ -1,3 +1,4 @@ +import { AssetStruct, FeeType } from '@metamask/keyring-api'; import type { Infer } from '@metamask/superstruct'; import { enums, @@ -10,6 +11,9 @@ import { type, union, refine, + integer, + min, + array, } from '@metamask/superstruct'; import type { JsonRpcRequest } from '@metamask/utils'; import { base64, parseCaipAssetType } from '@metamask/utils'; @@ -20,12 +24,17 @@ import { KnownCaip19ClassicAssetStruct, UuidStruct, NonZeroValidAmountStruct, + XdrStruct, } from '../../api'; /** * Enum for the client request method. */ export enum ClientRequestMethod { + /** -------------------------------- Wallet Standard -------------------------------- */ + // Standard multichain workflow for bridge + SignAndSendTransaction = 'signAndSendTransaction', + ComputeFee = 'computeFee', /** -------------------------------- Stellar Specific -------------------------------- */ ChangeTrustOpt = 'changeTrustOpt', } @@ -109,6 +118,64 @@ export const ChangeTrustOptJsonRpcResponseStruct = object({ transactionId: optional(base64(string())), }); +/** + * Validation struct for the sendTransaction JSON-RPC request. + */ +export const SignAndSendTransactionJsonRpcRequestStruct = assign( + JsonRpcRequestStruct, + object({ + method: literal(ClientRequestMethod.SignAndSendTransaction), + params: object({ + // TODO: try not to accept any XDR + transaction: XdrStruct, + accountId: UuidStruct, + scope: KnownCaip2ChainIdStruct, + options: object({ + visible: optional(boolean()), + type: string(), + }), + }), + }), +); + +/** + * Validation struct for the sendTransaction JSON-RPC response. + */ +export const SignAndSendTransactionJsonRpcResponseStruct = object({ + transactionId: base64(string()), +}); + +/** + * Validation struct for the computeFee JSON-RPC request. + */ +export const ComputeFeeJsonRpcRequestStruct = assign( + JsonRpcRequestStruct, + object({ + method: literal(ClientRequestMethod.ComputeFee), + params: object({ + // TODO: try not to accept any XDR + transaction: XdrStruct, + accountId: UuidStruct, + scope: KnownCaip2ChainIdStruct, + options: object({ + visible: optional(boolean()), + type: string(), + feeLimit: optional(min(integer(), 0)), + }), + }), + }), +); + +/** + * Validation struct for the computeFee JSON-RPC response. + */ +export const ComputeFeeJsonRpcResponseStruct = array( + object({ + type: enums(Object.values(FeeType)), + asset: AssetStruct, + }), +); + /** * A JSON-RPC request with an account resolve parameter. */ @@ -130,3 +197,31 @@ export type ChangeTrustOptJsonRpcRequest = Infer< export type ChangeTrustOptJsonRpcResponse = Infer< typeof ChangeTrustOptJsonRpcResponseStruct >; + +/** + * Type for the sendTransaction JSON-RPC request. + */ +export type SignAndSendTransactionJsonRpcRequest = Infer< + typeof SignAndSendTransactionJsonRpcRequestStruct +>; + +/** + * Type for the sendTransaction JSON-RPC response. + */ +export type SignAndSendTransactionJsonRpcResponse = Infer< + typeof SignAndSendTransactionJsonRpcResponseStruct +>; + +/** + * Type for the computeFee JSON-RPC request. + */ +export type ComputeFeeJsonRpcRequest = Infer< + typeof ComputeFeeJsonRpcRequestStruct +>; + +/** + * Type for the computeFee JSON-RPC response. + */ +export type ComputeFeeJsonRpcResponse = Infer< + typeof ComputeFeeJsonRpcResponseStruct +>; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.ts new file mode 100644 index 00000000..65cb3936 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.ts @@ -0,0 +1,99 @@ +import { FeeType } from '@metamask/keyring-api'; + +import type { + ComputeFeeJsonRpcRequest, + ComputeFeeJsonRpcResponse, +} from './api'; +import { + ComputeFeeJsonRpcRequestStruct, + ComputeFeeJsonRpcResponseStruct, +} from './api'; +import type { ResolvedActivatedAccount } from '../base'; +import { WithClientRequestActiveAccountResolve } from './base'; +import { KnownCaip19Slip44IdMap } from '../../api'; +import type { AccountService } from '../../services/account'; +import type { OnChainAccountService } from '../../services/on-chain-account'; +import type { TransactionService } from '../../services/transaction/TransactionService'; +import type { WalletService } from '../../services/wallet'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; + +export class ComputeFeeHandler extends WithClientRequestActiveAccountResolve< + ComputeFeeJsonRpcRequest, + ComputeFeeJsonRpcResponse +> { + readonly #transactionService: TransactionService; + + constructor({ + logger, + accountService, + onChainAccountService, + walletService, + transactionService, + }: { + logger: ILogger; + accountService: AccountService; + onChainAccountService: OnChainAccountService; + walletService: WalletService; + transactionService: TransactionService; + }) { + const prefixedLogger = createPrefixedLogger( + logger, + '[💰 ComputeFeeHandler]', + ); + super({ + accountService, + onChainAccountService, + walletService, + logger: prefixedLogger, + requestStruct: ComputeFeeJsonRpcRequestStruct, + responseStruct: ComputeFeeJsonRpcResponseStruct, + }); + this.#transactionService = transactionService; + } + + /** + * Computes the fee for a swap envelope built by MetaMask CrossChain API and consumed by {@link SignAndSendTransactionHandler}. + * + * **Client workflow** + * 1. After the user selects a quote, obtain the unsigned XDR from MetaMask CrossChain API. + * 2. Call **computeFee** with that XDR and `scope` so the user can review fees in stroops. + * 3. After approval in your UI, call **signAndSendTransaction** with the **same** `transaction` XDR and `scope`. + * + * Uses {@link TransactionService.createValidatedSwapTransaction} — the same decode, validation, and fee + * simulation path as sign-and-send (including Soroban simulation when the envelope uses contract calls) + * — then reads {@link Transaction.totalFee} on the wrapped transaction. + * + * @param resolved - The resolved activated account and wallet ({@link ResolvedActivatedAccount}). + * @param request - The JSON-RPC request containing transaction details. + * @param request.params.transaction - The Base64 encoded XDR of the transaction. + * @param request.params.scope - The CAIP-2 chain ID. + * @returns Fee entries for the client ({@link ComputeFeeJsonRpcResponse}). + */ + async _handle( + resolved: ResolvedActivatedAccount, + request: ComputeFeeJsonRpcRequest, + ): Promise { + const { onChainAccount } = resolved; + const { transaction: transactionBase64Xdr, scope } = request.params; + + const transaction = + await this.#transactionService.createValidatedSwapTransaction({ + xdr: transactionBase64Xdr, + scope, + onChainAccount, + }); + + return [ + { + type: FeeType.Base, + asset: { + unit: 'Stroop', + type: KnownCaip19Slip44IdMap[scope], + amount: transaction.totalFee.toString(), + fungible: true as const, + }, + }, + ]; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts new file mode 100644 index 00000000..c76d2e13 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts @@ -0,0 +1,140 @@ +import type { + SignAndSendTransactionJsonRpcRequest, + SignAndSendTransactionJsonRpcResponse, +} from './api'; +import { + SignAndSendTransactionJsonRpcRequestStruct, + SignAndSendTransactionJsonRpcResponseStruct, +} from './api'; +import type { KnownCaip2ChainId } from '../../api'; +import type { ResolvedActivatedAccount } from '../base'; +import { WithClientRequestActiveAccountResolve } from './base'; +import type { + AccountService, + StellarKeyringAccount, +} from '../../services/account'; +import type { OnChainAccountService } from '../../services/on-chain-account'; +import type { TransactionService } from '../../services/transaction/TransactionService'; +import type { WalletService } from '../../services/wallet'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import { TrackTransactionHandler } from '../cronjob/trackTransaction'; + +export class SignAndSendTransactionHandler extends WithClientRequestActiveAccountResolve< + SignAndSendTransactionJsonRpcRequest, + SignAndSendTransactionJsonRpcResponse +> { + readonly #transactionService: TransactionService; + + constructor({ + logger, + accountService, + onChainAccountService, + walletService, + transactionService, + }: { + logger: ILogger; + accountService: AccountService; + onChainAccountService: OnChainAccountService; + walletService: WalletService; + transactionService: TransactionService; + }) { + const prefixedLogger = createPrefixedLogger( + logger, + '[👋 SignAndSendTransactionHandler]', + ); + super({ + accountService, + onChainAccountService, + walletService, + logger: prefixedLogger, + requestStruct: SignAndSendTransactionJsonRpcRequestStruct, + responseStruct: SignAndSendTransactionJsonRpcResponseStruct, + }); + this.#transactionService = transactionService; + } + + /** + * Signs and submits the envelope built by MetaMask CrossChain API and quoted by {@link ComputeFeeHandler}. + * + * Use the **same** `params.transaction` and `params.scope` as **computeFee** so the signed submission + * matches the quoted envelope. The user must remain the transaction source. Decoding and validation + * use {@link TransactionService.createValidatedSwapTransaction}. + * + * CRITICAL SECURITY REQUIREMENT: + * This method does NOT request user confirmation. The caller is responsible + * for obtaining explicit user consent before invoking this method. + * + * The caller MUST: + * - Display transaction details (recipient, amount, fees) to the user + * - Obtain explicit user approval before calling this method + * - Validate transaction authenticity and integrity + * + * Failure to implement caller-side consent will result in transactions being + * signed and broadcast without user knowledge, creating a critical security + * vulnerability. + * + * @param resolved - The resolved and activated account and wallet ({@link ResolvedActivatedAccount}). + * @param request - The JSON-RPC request containing transaction details. + * @param request.params.transaction - The Base64 encoded XDR of the transaction. + * @param request.params.scope - The CAIP-2 chain ID. + * @returns A promise that resolves to the JSON-RPC response ({@link SignAndSendTransactionJsonRpcResponse}). + */ + async _handle( + resolved: ResolvedActivatedAccount, + request: SignAndSendTransactionJsonRpcRequest, + ): Promise { + const { wallet, onChainAccount, account } = resolved; + const { transaction: transactionBase64Xdr, scope } = request.params; + + const transaction = + await this.#transactionService.createValidatedSwapTransaction({ + xdr: transactionBase64Xdr, + scope, + onChainAccount, + }); + + wallet.signTransaction(transaction); + + const transactionHash = await this.#transactionService.sendTransaction({ + wallet, + onChainAccount, + scope, + transaction, + pollTransaction: false, + }); + + await this.#savePendingTransaction({ + transactionId: transactionHash, + account, + scope, + }); + + // Track the transaction after a transaction + await TrackTransactionHandler.scheduleBackgroundEvent({ + scope, + txId: transactionHash, + accountIds: [account.id], + }); + + return { + transactionId: transactionHash, + }; + } + + async #savePendingTransaction(_params: { + transactionId: string; + scope: KnownCaip2ChainId; + account: StellarKeyringAccount; + }): Promise { + try { + // TODO: save a SWAP transaction + } catch (error: unknown) { + this.logger.logErrorWithDetails( + 'Failed to save pending transaction', + error, + ); + // we should not throw error here, as we want to continue the flow even if the pending transaction is not saved + } + } +} From 3983c31e4f0acda1e84344f25c113a1222cad049 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 6 May 2026 14:28:45 +0800 Subject: [PATCH 161/384] chore: use in mem cache --- merged-packages/stellar-wallet-snap/src/context.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 24abb76d..6c3f13c5 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -25,7 +25,7 @@ import { AssetMetadataRepository, AssetMetadataService, } from './services/asset-metadata'; -import { InMemoryCache, StateCache } from './services/cache'; +import { InMemoryCache } from './services/cache'; import { NetworkService } from './services/network'; import type { OnChainAccountState } from './services/on-chain-account'; import { @@ -94,7 +94,7 @@ const transactionService = new TransactionService({ transactionRepository, networkService, transactionBuilder, - cache: new StateCache(state, logger, '__cache__transaction'), + cache: new InMemoryCache(noOpLogger), }); const priceService = new PriceService({ From 9bfe13d342a54b61c8c1f1b6745858ea5accd75d Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 6 May 2026 15:34:40 +0800 Subject: [PATCH 162/384] fix: comment --- .../src/services/transaction/Transaction.ts | 24 ++++++++++++++----- .../transaction/TransactionService.ts | 6 +++-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts index 55d5ad7b..3a92da32 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts @@ -35,13 +35,21 @@ export class Transaction { this.#participatingAccounts.add(this.feeSourceAccount); for (const operation of this.transactionOperations) { - this.#participatingAccounts.add(operation.source ?? this.sourceAccount); + const source = operation.source ?? this.sourceAccount; + // Source of the operation should count as invoked by the account + this.#invokedByAccounts.add(source); + this.#participatingAccounts.add(source); + + // Destination of the operation should count as participating in the transaction. + // For now, we only support payment related operations if (operation.type === 'pathPaymentStrictSend') { this.#participatingAccounts.add(operation.destination); - } - if (operation.type === 'pathPaymentStrictReceive') { + } else if (operation.type === 'pathPaymentStrictReceive') { + this.#participatingAccounts.add(operation.destination); + } else if (operation.type === 'payment') { this.#participatingAccounts.add(operation.destination); } + this.#operationTypes.add(operation.type); } } @@ -182,7 +190,11 @@ export class Transaction { } /** - * Accounts that participate in the envelope: tx source, fee source, and each operation’s effective source. + * Accounts that participate in the envelope: + * - tx source, + * - fee source + * - each operation’s effective source + * - destination of the payment related operations * * @returns Participating account ids (`G…`). */ @@ -203,10 +215,10 @@ export class Transaction { } /** - * Whether the account is among {@link Transaction.hasParticipatingAccount} (source, fee source, or op source). + * Whether the account is among {@link Transaction.hasParticipatingAccount}. * * @param accountId - The account ID to check. - * @returns True if the account participates in the envelope. + * @returns True if the account participates in the envelope, false otherwise. */ hasParticipatingAccount(accountId: string): boolean { return this.#participatingAccounts.has(accountId); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index 8da8f6c3..e2fbeda9 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -150,7 +150,7 @@ export class TransactionService { preloadedAccounts, }); - return transaction; + return transactionWithFee; } async #getPreloadedAccounts( @@ -170,7 +170,9 @@ export class TransactionService { transaction.scope, ); - return preloadedAccounts.filter((account) => account !== null); + return preloadedAccounts.filter( + (account): account is OnChainAccount => account !== null, + ); } /** From d9df37862031d2b39e60aac4eeb56333ec7722d3 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Wed, 6 May 2026 11:13:09 +0200 Subject: [PATCH 163/384] fix: make synchronizeAccounts param optional --- .../stellar-wallet-snap/snap.manifest.json | 5 +---- .../src/handlers/cronjob/api.test.ts | 12 ++++++++++++ .../src/handlers/cronjob/api.ts | 4 +++- .../src/handlers/cronjob/syncAccounts.ts | 15 ++++++++++++--- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 3c283160..e30263b1 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -41,10 +41,7 @@ { "duration": "PT30S", "request": { - "method": "synchronizeAccounts", - "params": { - "accountIds": "selected" - } + "method": "synchronizeAccounts" } } ] diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts index 4076431a..24f128b8 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts @@ -80,6 +80,18 @@ describe('Cronjob API structs', () => { }); }); + it('accepts synchronize accounts requests without params for declarative cron', () => { + const value = { + ...jsonRpcBase, + method: BackgroundEventMethod.SynchronizeAccounts, + }; + assert(value, SyncAccountJsonRpcRequestStruct); + expect(value).toStrictEqual({ + ...jsonRpcBase, + method: BackgroundEventMethod.SynchronizeAccounts, + }); + }); + it('rejects wrong method for synchronize accounts request', () => { expect(() => assert( diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts index f90791ce..1c2b45f8 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts @@ -78,7 +78,9 @@ export const SyncAccountJsonRpcRequestStruct = assign( JsonRpcRequestStruct, object({ method: literal(BackgroundEventMethod.SynchronizeAccounts), - params: SyncAccountParamsStruct, + // Omitted in declarative manifest cron jobs (matches Bitcoin wallet snap); + // runtime defaults to synchronizing selected accounts. + params: optional(SyncAccountParamsStruct), }), ); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/syncAccounts.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/syncAccounts.ts index ea296496..52ab1f17 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/syncAccounts.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/syncAccounts.ts @@ -48,13 +48,22 @@ export class SyncAccountsHandler extends CronjobBaseHandler { const scope = AppConfig.selectedNetwork; - const { - params: { accountIds }, - } = request; + const accountIds = + request.params === undefined + ? ('selected' as const) + : request.params.accountIds; let accounts: StellarKeyringAccount[] = []; if (accountIds === 'selected') { From b555e9cce3250ffeaa0cb11581063fcfcb754930 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Wed, 6 May 2026 11:13:31 +0200 Subject: [PATCH 164/384] fix: align signAuthEntry confirmation dialog with Freighter --- .../stellar-wallet-snap/locales/en.json | 7 +- .../stellar-wallet-snap/messages.json | 7 +- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../ConfirmSignAuthEntry.tsx | 149 +++++++----------- 4 files changed, 67 insertions(+), 98 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/locales/en.json b/merged-packages/stellar-wallet-snap/locales/en.json index b9591c60..1a851fd4 100644 --- a/merged-packages/stellar-wallet-snap/locales/en.json +++ b/merged-packages/stellar-wallet-snap/locales/en.json @@ -59,10 +59,13 @@ "message": "You are authorizing a smart contract to act on your behalf. Only approve if you trust this site." }, "confirmation.signAuthEntry.contract": { - "message": "Contract" + "message": "Contract ID" }, "confirmation.signAuthEntry.function": { - "message": "Function" + "message": "Function Name" + }, + "confirmation.signAuthEntry.parameters": { + "message": "Parameters" }, "confirmation.signAuthEntry.expiresAt": { "message": "Expires at ledger" diff --git a/merged-packages/stellar-wallet-snap/messages.json b/merged-packages/stellar-wallet-snap/messages.json index 70c50bd0..36f56678 100644 --- a/merged-packages/stellar-wallet-snap/messages.json +++ b/merged-packages/stellar-wallet-snap/messages.json @@ -57,10 +57,13 @@ "message": "You are authorizing a smart contract to act on your behalf. Only approve if you trust this site." }, "confirmation.signAuthEntry.contract": { - "message": "Contract" + "message": "Contract ID" }, "confirmation.signAuthEntry.function": { - "message": "Function" + "message": "Function Name" + }, + "confirmation.signAuthEntry.parameters": { + "message": "Parameters" }, "confirmation.signAuthEntry.expiresAt": { "message": "Expires at ledger" diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index fca39e3c..d270e16b 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "eXd0il4mdjFcVw8/G17QsrvBIWstClXC1qgLe6JrjC4=", + "shasum": "O0S+ynXAUjJVdndgqLIczhCH52WmBBUf9PZPfed1N4A=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx index 473ddc9d..ff680ca4 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignAuthEntry/ConfirmSignAuthEntry.tsx @@ -36,25 +36,41 @@ export type ConfirmSignAuthEntryProps = Pick< account: StellarKeyringAccount; }; -// Compact summary of one nested authorized invocation: contract, function, -// decoded args, and a count of any deeper invocations beneath it. Rendered -// only one level deep to keep the dialog scannable; deeper nesting is -// surfaced as a count and is still bound by the user's signature. -const SubInvocationSummary = ({ +// Vertical, full-width summary of one Soroban authorized invocation. Used both +// for the root call the user is authorizing and recursively (one level deep) +// for any nested calls. Layout follows Freighter: function name as a heading +// at the top, then Contract ID, Function Name, Parameters stacked vertically +// so long values (G/C addresses, i128 amounts) never get squeezed into a +// right-aligned column and wrap badly. +// +// `showNestedCount` controls whether to render a "Nested authorizations: N" +// row inside this card. Disabled for the root and direct sub-invocations +// (whose children we expand into their own card right below) and enabled +// only for deeper nesting where we don't recurse — there the count is the +// only signal the user gets that more calls exist beneath. +const InvocationSummary = ({ invocation, scope, translate, + showHeading, + showNestedCount, }: { invocation: ReadableInvocation; scope: KnownCaip2ChainId; translate: ReturnType; + showHeading: boolean; + showNestedCount: boolean; }): ComponentOrElement => { const { contractAddress, functionName, args, subInvocations } = invocation; return ( + {showHeading && functionName !== null ? ( + {functionName} + ) : null} + {contractAddress === null ? ( - + {translate('confirmation.signAuthEntry.contract')} @@ -63,7 +79,7 @@ const SubInvocationSummary = ({ ) : ( - + {translate('confirmation.signAuthEntry.contract')} @@ -72,7 +88,7 @@ const SubInvocationSummary = ({ )} {functionName === null ? null : ( - + {translate('confirmation.signAuthEntry.function')} @@ -83,16 +99,16 @@ const SubInvocationSummary = ({ {args.length > 0 ? ( - {translate('confirmation.transaction.param.arguments')} + {translate('confirmation.signAuthEntry.parameters')} {args.map((arg, index) => ( - {arg} + {arg} ))} ) : null} - {subInvocations.length > 0 ? ( - + {showNestedCount && subInvocations.length > 0 ? ( + {translate('confirmation.signAuthEntry.subInvocations')} @@ -114,15 +130,7 @@ export const ConfirmSignAuthEntry = ({ const translate = i18n(locale as Locale); const { address } = account; const addressCaip10 = getAccountName(scope, address); - const { - functionType, - contractAddress, - functionName, - args, - signatureExpirationLedger, - nonce, - subInvocations, - } = readableAuthEntry; + const { subInvocations } = readableAuthEntry; return ( @@ -139,78 +147,6 @@ export const ConfirmSignAuthEntry = ({ {translate('confirmation.signAuthEntry.warning')} -
- {functionType === 'invoke' && contractAddress !== null ? ( - - - {translate('confirmation.signAuthEntry.contract')} - -
- - ) : ( - - - {translate('confirmation.signAuthEntry.contract')} - - - {translate('confirmation.signAuthEntry.createContract')} - - - )} - - {functionName === null ? null : ( - - - {translate('confirmation.signAuthEntry.function')} - - {functionName} - - )} - - {args.length > 0 ? ( - - - {translate('confirmation.transaction.param.arguments')} - - {args.map((arg, index) => ( - {arg} - ))} - - ) : null} - - - - {translate('confirmation.signAuthEntry.expiresAt')} - - {String(signatureExpirationLedger)} - - - - - {translate('confirmation.signAuthEntry.nonce')} - - {nonce} - -
- - {subInvocations.length > 0 ? ( -
- - {translate('confirmation.signAuthEntry.subInvocations')} - - {subInvocations.map((sub, index) => ( - - {index > 0 ? : null} - - - ))} -
- ) : null} -
{origin ? ( @@ -246,6 +182,33 @@ export const ConfirmSignAuthEntry = ({
+ +
+ +
+ + {subInvocations.length > 0 ? ( +
+ {subInvocations.map((sub, index) => ( + + {index > 0 ? : null} + + + ))} +
+ ) : null}
-
diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx index 03c80e43..ca958bf2 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx @@ -26,10 +26,11 @@ import type { FeeData, } from '../../api'; import { FetchStatus } from '../../api'; -import { Asset, AssetIcon, FeeRow } from '../../components'; +import { Asset, AssetIcon, FeeRow, TransactionAlert } from '../../components'; import { getAccountName, getClassicAssetExplorerUrl, + isConfirmDisabledByScan, getNetworkName, } from '../../utils'; @@ -51,12 +52,30 @@ export const ConfirmSignChangeTrustOptOut = ({ origin, preferences, tokenPricesFetchStatus = FetchStatus.Initial, + scan, + scanFetchStatus = FetchStatus.Initial, }: ConfirmSignChangeTrustOptOutProps): ComponentOrElement => { const t = i18n(locale); const { address } = account; + const shouldDisableConfirmButton = isConfirmDisabledByScan({ + preferences, + scan, + scanFetchStatus, + }); + return ( + {preferences.useSecurityAlerts || preferences.simulateOnChainActions ? ( + + ) : null} {null} @@ -141,7 +160,10 @@ export const ConfirmSignChangeTrustOptOut = ({ -
diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx index f213c128..5fefd279 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -27,9 +27,11 @@ import type { ConfirmationBaseProps, FeeData } from '../../api'; import { FetchStatus } from '../../api'; import { Asset } from '../../components/Asset'; import { FeeRow } from '../../components/Fee'; +import { TransactionAlert } from '../../components/TransactionAlert'; import { getAccountName, getNetworkName, + isConfirmDisabledByScan, resolveAssetDisplay, } from '../../utils'; @@ -166,16 +168,33 @@ export const ConfirmSignTransaction = ({ feeData, tokenPrices, tokenPricesFetchStatus = FetchStatus.Initial, + scan, + scanFetchStatus = FetchStatus.Initial, }: ConfirmSignTransactionProps): ComponentOrElement => { const t = i18n(locale as Locale); const { address } = account; const addressCaip10 = getAccountName(scope, address); const priceLoading = tokenPricesFetchStatus === FetchStatus.Fetching; const feePrice = tokenPrices?.[feeData.assetId] ?? null; + const shouldDisableConfirmButton = isConfirmDisabledByScan({ + preferences, + scan, + scanFetchStatus, + }); return ( + {preferences.useSecurityAlerts || preferences.simulateOnChainActions ? ( + + ) : null} {null} {t('confirmation.signTransaction.title')} @@ -294,7 +313,10 @@ export const ConfirmSignTransaction = ({ - From 4ff5169c108d7f1f56b939c699b4c23a4eb32123 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Tue, 19 May 2026 18:16:46 +0200 Subject: [PATCH 232/384] fix: fix copilot comments --- .../stellar-wallet-snap/locales/en.json | 6 +++ .../stellar-wallet-snap/locales/es.json | 6 +++ .../stellar-wallet-snap/messages.json | 6 +++ .../stellar-wallet-snap/snap.manifest.json | 2 +- .../refreshConfirmationSecurityScan.test.ts | 54 +++++++++++++++++-- .../refreshConfirmationSecurityScan.ts | 28 +++++----- .../src/ui/confirmation/api.ts | 4 +- .../components/TransactionAlert.test.tsx | 21 ++++++++ .../components/TransactionAlert.tsx | 39 +++++++++----- .../src/ui/confirmation/controller.test.tsx | 22 ++++++++ .../src/ui/confirmation/controller.tsx | 6 +++ 11 files changed, 162 insertions(+), 32 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.test.tsx diff --git a/merged-packages/stellar-wallet-snap/locales/en.json b/merged-packages/stellar-wallet-snap/locales/en.json index bd66c58c..9b0fe132 100644 --- a/merged-packages/stellar-wallet-snap/locales/en.json +++ b/merged-packages/stellar-wallet-snap/locales/en.json @@ -124,6 +124,12 @@ "confirmation.validationErrorSubtitle": { "message": "If you approve this request, a third party known for scams will take all your assets." }, + "confirmation.validationWarningTitle": { + "message": "This request may be risky" + }, + "confirmation.validationWarningSubtitle": { + "message": "Security Alerts found potential risk. Only continue if you trust this site and every address involved." + }, "confirmation.validationErrorLearnMore": { "message": "Learn more" }, diff --git a/merged-packages/stellar-wallet-snap/locales/es.json b/merged-packages/stellar-wallet-snap/locales/es.json index d77f286c..43af768e 100644 --- a/merged-packages/stellar-wallet-snap/locales/es.json +++ b/merged-packages/stellar-wallet-snap/locales/es.json @@ -112,6 +112,12 @@ "confirmation.validationErrorSubtitle": { "message": "If you approve this request, a third party known for scams will take all your assets." }, + "confirmation.validationWarningTitle": { + "message": "This request may be risky" + }, + "confirmation.validationWarningSubtitle": { + "message": "Security Alerts found potential risk. Only continue if you trust this site and every address involved." + }, "confirmation.validationErrorLearnMore": { "message": "Learn more" }, diff --git a/merged-packages/stellar-wallet-snap/messages.json b/merged-packages/stellar-wallet-snap/messages.json index 7aa38afa..ded6528c 100644 --- a/merged-packages/stellar-wallet-snap/messages.json +++ b/merged-packages/stellar-wallet-snap/messages.json @@ -122,6 +122,12 @@ "confirmation.validationErrorSubtitle": { "message": "If you approve this request, a third party known for scams will take all your assets." }, + "confirmation.validationWarningTitle": { + "message": "This request may be risky" + }, + "confirmation.validationWarningSubtitle": { + "message": "Security Alerts found potential risk. Only continue if you trust this site and every address involved." + }, "confirmation.validationErrorLearnMore": { "message": "Learn more" }, diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 24768029..2e8c4cf7 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "nxBeYWDT7qML7GQ7CwZj5JfK7DdxzaLoU3joHPYtwjc=", + "shasum": "sg83PDRKkxPwTe3RSQKPkmJ8qOLGPz4kMuKyiVDjSdw=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationSecurityScan.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationSecurityScan.test.ts index 42712e49..837ebe1f 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationSecurityScan.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationSecurityScan.test.ts @@ -100,7 +100,11 @@ describe('RefreshConfirmationSecurityScanHandler', () => { it('refreshes the scan and schedules the next refresh', async () => { const { handler, transactionScanService, confirmationUIController } = setup(); - jest.mocked(getInterfaceContextIfExists).mockResolvedValue(baseContext); + const fetchedContext = { + ...baseContext, + scanFetchStatus: FetchStatus.Fetched, + }; + jest.mocked(getInterfaceContextIfExists).mockResolvedValue(fetchedContext); await handler.handle(request); @@ -115,7 +119,7 @@ describe('RefreshConfirmationSecurityScanHandler', () => { interfaceId, interfaceKey, updatedContext: { - ...baseContext, + ...fetchedContext, scanFetchStatus: FetchStatus.Fetching, }, }); @@ -123,7 +127,7 @@ describe('RefreshConfirmationSecurityScanHandler', () => { interfaceId, interfaceKey, updatedContext: { - ...baseContext, + ...fetchedContext, scan, scanFetchStatus: FetchStatus.Fetched, }, @@ -139,6 +143,26 @@ describe('RefreshConfirmationSecurityScanHandler', () => { }); }); + it('does not rewrite fetching status when the scan is already fetching', async () => { + const { handler, confirmationUIController } = setup(); + jest.mocked(getInterfaceContextIfExists).mockResolvedValue(baseContext); + + await handler.handle(request); + + expect(confirmationUIController.updateConfirmation).toHaveBeenCalledTimes( + 1, + ); + expect(confirmationUIController.updateConfirmation).toHaveBeenCalledWith({ + interfaceId, + interfaceKey, + updatedContext: { + ...baseContext, + scan, + scanFetchStatus: FetchStatus.Fetched, + }, + }); + }); + it('does not scan when security preferences are disabled', async () => { const { handler, transactionScanService, confirmationUIController } = setup(); @@ -224,6 +248,30 @@ describe('RefreshConfirmationSecurityScanHandler', () => { expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); }); + it('marks the scan as error when security scan preferences are malformed', async () => { + const { handler, transactionScanService, confirmationUIController } = + setup(); + const context = { + ...baseContext, + preferences: {}, + }; + jest.mocked(getInterfaceContextIfExists).mockResolvedValue(context); + + await handler.handle(request); + + expect(transactionScanService.scanTransaction).not.toHaveBeenCalled(); + expect(confirmationUIController.updateConfirmation).toHaveBeenCalledWith({ + interfaceId, + interfaceKey, + updatedContext: { + ...context, + scan: null, + scanFetchStatus: FetchStatus.Error, + }, + }); + expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); + }); + it('marks the scan as error when the service returns null', async () => { const { handler, transactionScanService, confirmationUIController } = setup(); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationSecurityScan.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationSecurityScan.ts index 89f3809e..d96c89a8 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationSecurityScan.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationSecurityScan.ts @@ -29,8 +29,8 @@ import { } from '../../utils/snap'; type SecurityScanPreferences = { - useSecurityAlerts?: boolean; - simulateOnChainActions?: boolean; + useSecurityAlerts: boolean; + simulateOnChainActions: boolean; }; type SecurityScanInterfaceContext = Record & @@ -121,14 +121,16 @@ export class RefreshConfirmationSecurityScanHandler extends CronjobBaseHandler; const SecurityScanPreferencesStruct = type({ - useSecurityAlerts: optional(boolean()), - simulateOnChainActions: optional(boolean()), + useSecurityAlerts: boolean(), + simulateOnChainActions: boolean(), }); export const ContextWithSecurityScanStruct = type({ diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.test.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.test.tsx index 228b725b..bf0d70c7 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.test.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.test.tsx @@ -112,6 +112,27 @@ describe('TransactionAlert', () => { }); }); + it('renders warning validation alerts with softer warning copy', () => { + const component = TransactionAlert({ + preferences, + validation: { + type: 'Warning', + reason: 'suspicious_request', + description: null, + }, + error: null, + scanFetchStatus: FetchStatus.Fetched, + showValidationAlert: true, + showSimulationError: false, + }); + + expect(getType(component)).toBe('Banner'); + expect(getProps(component)).toMatchObject({ + severity: 'warning', + title: 'This request may be risky', + }); + }); + it('renders API scan failures as danger banners', () => { const component = TransactionAlert({ preferences, diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx index 21bfb0c5..6cdd30d7 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx @@ -26,14 +26,26 @@ type TransactionAlertProps = { showSimulationError: boolean; }; -const VALIDATION_TYPE_TO_SEVERITY: Partial< +const VALIDATION_TYPE_TO_ALERT: Partial< Record< NonNullable, - BannerProps['severity'] + { + severity: BannerProps['severity']; + title: LocalizedMessage; + subtitle: LocalizedMessage; + } > > = { - Malicious: 'danger', - Warning: 'warning', + Malicious: { + severity: 'danger', + title: 'confirmation.validationErrorTitle', + subtitle: 'confirmation.validationErrorSubtitle', + }, + Warning: { + severity: 'warning', + title: 'confirmation.validationWarningTitle', + subtitle: 'confirmation.validationWarningSubtitle', + }, }; const ERROR_MESSAGE_IDS: Record = { @@ -81,17 +93,18 @@ export const TransactionAlert = ({ } if (validation?.type && showValidationAlert) { - const severity = VALIDATION_TYPE_TO_SEVERITY[validation.type]; + const alert = VALIDATION_TYPE_TO_ALERT[validation.type]; + + if (alert) { + const description = validation.description?.trim(); + const subtitle = + description === undefined || description.length === 0 + ? translate(alert.subtitle) + : description; - if (severity) { return ( - - - {translate('confirmation.validationErrorSubtitle')} - + + {subtitle} {translate('confirmation.validationErrorLearnMore')} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.test.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.test.tsx new file mode 100644 index 00000000..4a2f9769 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.test.tsx @@ -0,0 +1,22 @@ +import { ConfirmationInterfaceKey } from './api'; +import { ConfirmationUXController } from './controller'; +import { KnownCaip2ChainId } from '../../api'; +import { noOpLogger } from '../../utils/logger'; + +describe('ConfirmationUXController', () => { + it('throws when transaction scanning is enabled without a security scan request', async () => { + const controller = new ConfirmationUXController({ logger: noOpLogger }); + + await expect( + controller.renderConfirmationDialog({ + scope: KnownCaip2ChainId.Mainnet, + interfaceKey: ConfirmationInterfaceKey.SignTransaction, + fee: '100', + renderContext: {}, + renderOptions: { scanTxn: true }, + }), + ).rejects.toThrow( + 'Cannot scan a transaction confirmation without a security scan request.', + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 058aaa82..0274afcf 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -128,6 +128,12 @@ export class ConfirmationUXController { ...params.renderOptions, }; + if (renderOptions.scanTxn && params.securityScanRequest === undefined) { + throw new Error( + 'Cannot scan a transaction confirmation without a security scan request.', + ); + } + const preferences = await getPreferencesWithFallback(); const defaultTokenPrices = fee From 31bce8393db45acd62d4123801aa61be7a023a96 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 20 May 2026 08:36:34 +0800 Subject: [PATCH 233/384] fix: accept contract address --- .../src/api/address.test.ts | 31 ++++++++++++++++++- .../stellar-wallet-snap/src/api/address.ts | 24 ++++++++++++++ .../services/transaction/simulation/utils.ts | 12 +++++-- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/api/address.test.ts b/merged-packages/stellar-wallet-snap/src/api/address.test.ts index 591158ee..377a4b06 100644 --- a/merged-packages/stellar-wallet-snap/src/api/address.test.ts +++ b/merged-packages/stellar-wallet-snap/src/api/address.test.ts @@ -1,6 +1,9 @@ import { assert, StructError } from '@metamask/superstruct'; -import { StellarAddressStruct } from './address'; +import { + StellarAddressOrContractStruct, + StellarAddressStruct, +} from './address'; describe('StellarAddressStruct', () => { it('accepts a valid Stellar address', () => { @@ -17,3 +20,29 @@ describe('StellarAddressStruct', () => { expect(() => assert(address, StellarAddressStruct)).toThrow(StructError); }); }); + +describe('StellarAddressOrContractStruct', () => { + it('accepts a valid Stellar address', () => { + expect(() => + assert( + 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + StellarAddressOrContractStruct, + ), + ).not.toThrow(); + }); + + it('accepts a valid Stellar contract', () => { + expect(() => + assert( + 'CASUP2OPFVEHCWGP2XLBXOV7DQIQIT42AQISG4MXAZGNLVFFN63X7WRT', + StellarAddressOrContractStruct, + ), + ).not.toThrow(); + }); + + it('rejects an invalid Stellar address or contract', () => { + expect(() => + assert('invalid-address', StellarAddressOrContractStruct), + ).toThrow(StructError); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/api/address.ts b/merged-packages/stellar-wallet-snap/src/api/address.ts index 5a2bf5f3..b3811efc 100644 --- a/merged-packages/stellar-wallet-snap/src/api/address.ts +++ b/merged-packages/stellar-wallet-snap/src/api/address.ts @@ -20,7 +20,31 @@ export const StellarAddressStruct = refine( }, ); +export const StellarAddressOrContractStruct = refine( + nonempty(string()), + 'stellar_contract_or_address', + (value: string) => { + try { + if ( + !StrKey.isValidContract(value) && + !StrKey.isValidEd25519PublicKey(value) + ) { + return 'Invalid Stellar address or contract'; + } + return true; + } catch { + return 'Invalid Stellar address or contract'; + } + }, +); /** * Type for a Stellar address. */ export type StellarAddress = Infer; + +/** + * Type for a Stellar address or contract. + */ +export type StellarAddressOrContract = Infer< + typeof StellarAddressOrContractStruct +>; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/utils.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/utils.ts index ed0b8157..a60e7416 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/utils.ts @@ -4,7 +4,7 @@ import { Address, scValToNative } from '@stellar/stellar-sdk'; import { TransactionValidationException } from '../exceptions'; import type { AccountState, SimulationState } from './api'; import { - StellarAddressStruct, + StellarAddressOrContractStruct, type KnownCaip19Sep41AssetId, type KnownCaip2ChainId, } from '../../../api'; @@ -116,10 +116,16 @@ export function tryParseSep41TransferInvoke( const fromNative = scValToNative(fromArg); const toNative = scValToNative(toArg); const amountNative = scValToNative(amountArg); - if (typeof fromNative !== 'string' || !StellarAddressStruct.is(fromNative)) { + if ( + typeof fromNative !== 'string' || + !StellarAddressOrContractStruct.is(fromNative) + ) { throw new TransactionValidationException('Invalid from address'); } - if (typeof toNative !== 'string' || !StellarAddressStruct.is(toNative)) { + if ( + typeof toNative !== 'string' || + !StellarAddressOrContractStruct.is(toNative) + ) { throw new TransactionValidationException('Invalid to address'); } From 5abcd31fc85dab095fbb350cfe695462e926deac Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 20 May 2026 08:39:29 +0800 Subject: [PATCH 234/384] chore: add env example --- .../stellar-wallet-snap/.env.example | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/merged-packages/stellar-wallet-snap/.env.example b/merged-packages/stellar-wallet-snap/.env.example index 1201c2c8..8c63d16d 100644 --- a/merged-packages/stellar-wallet-snap/.env.example +++ b/merged-packages/stellar-wallet-snap/.env.example @@ -40,3 +40,23 @@ STATIC_API_BASE_URL=https://static.api.cx.metamask.io # Price API Base URL PRICE_API_BASE_URL=https://price.api.cx.metamask.io +# Cache TTL Milliseconds for base fee +#BASE_FEE_TTL_MILLISECONDS= + +# Cache TTL Milliseconds for load on chain account +#LOAD_ON_CHAIN_ACCOUNT_TTL_MILLISECONDS= + +# Cache TTL Milliseconds for SEP41 transfer simulate transaction +#SIMULATE_TRANSACTION_TTL_MILLISECONDS= + +# Cache TTL Milliseconds for sep41 asset balance +#SEP41_ASSET_BALANCE_TTL_MILLISECONDS= + +# Cache TTL Milliseconds for spot prices +#SPOT_PRICES_TTL_MILLISECONDS= + +# Cache TTL Milliseconds for fiat exchange rates +#FIAT_EXCHANGE_RATES_TTL_MILLISECONDS= + +# Cache TTL Milliseconds for historical prices +#HISTORICAL_PRICES_TTL_MILLISECONDS= From e25d4cf5813cedb851af992c8d2e6d574b607c8a Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 20 May 2026 08:46:54 +0800 Subject: [PATCH 235/384] fix: keying test --- .../src/handlers/keyring/keyring.test.ts | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index e3e161e8..b621256e 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -12,7 +12,6 @@ import { import { InvalidParamsError, type JsonRpcRequest } from '@metamask/snaps-sdk'; import { create } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; import { MultichainMethod, @@ -45,8 +44,14 @@ import { } from '../../services/account/__mocks__/account.fixtures'; import { AccountNotFoundException } from '../../services/account/exceptions'; import { OnChainAccountService } from '../../services/on-chain-account'; -import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; -import type { OnChainAccount } from '../../services/on-chain-account/OnChainAccount'; +import { + createMockAccountWithBalances, + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + horizonSource, + mockOnChainAccountService, + type MockAccountWithBalancesData, +} from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; +import { OnChainAccount } from '../../services/on-chain-account/OnChainAccount'; import { createMockTransactionService, generateMockTransactions, @@ -103,6 +108,18 @@ describe('KeyringHandler', () => { findByIdsSpy: jest.spyOn(AccountService.prototype, 'findByIds'), }); + const createTestOnChainAccount = ( + address: string, + data: MockAccountWithBalancesData = DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ): OnChainAccount => { + const stellarAccount = createMockAccountWithBalances(address, '1', data); + return new OnChainAccount( + stellarAccount, + KnownCaip2ChainId.Mainnet, + horizonSource(stellarAccount, KnownCaip2ChainId.Mainnet), + ); + }; + beforeEach(() => { jest.clearAllMocks(); jest.mocked(getDefaultEntropySource).mockResolvedValue(entropySourceId); @@ -399,21 +416,19 @@ describe('KeyringHandler', () => { describe('listAccountAssets', () => { it('returns on-chain asset ids for the account', async () => { - const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); jest .spyOn( OnChainAccountService.prototype, 'resolveOnChainAccountByKeyringAccountId', ) - .mockResolvedValue({ - assetIds: [slipId], - } as unknown as OnChainAccount); + .mockResolvedValue(onChainAccount); const result = await keyringHandler.listAccountAssets(mockAccountId); - expect(result).toStrictEqual([slipId]); + expect(result).toStrictEqual(onChainAccount.assetIds); }); it('returns native asset id when the account is not activated on-chain', async () => { @@ -612,18 +627,16 @@ describe('KeyringHandler', () => { const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address, { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 1.000001, + }); jest .spyOn( OnChainAccountService.prototype, 'resolveOnChainAccountByKeyringAccountId', ) - .mockResolvedValue({ - assetIds: [slipId], - getAsset: () => ({ - balance: new BigNumber('10'), - symbol: 'XLM', - }), - } as unknown as OnChainAccount); + .mockResolvedValue(onChainAccount); const result = await keyringHandler.getAccountBalances(mockAccountId, [ slipId, From f5e87510dd27f045d8726d6ec44c40b0f772b833 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 20 May 2026 16:58:58 +0800 Subject: [PATCH 236/384] chore: update code comment --- .../src/services/network/NetworkService.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index 75309935..34eb4b9e 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -701,16 +701,20 @@ export class NetworkService { } /** - * Simulates a SEP-41 transfer transaction via RPC and returns a new {@link Transaction} with updated fee. + * Simulates a SEP-41 transfer via RPC and returns an assembled {@link Transaction} with fee and footprint. * - * @param params - The parameters for simulating a SEP-41 transfer transaction. - * @param params.transaction - The transaction to simulate. + * Results are cached by `assetId`, `fromAccountId`, `toAccountId`, and `scope` so repeated simulations + * for the same transfer context (e.g. different amounts during fee estimation) avoid extra RPC calls. + * Pass `refreshCache: true` when simulating the transaction that will be signed and submitted. + * + * @param params - Simulation parameters. + * @param params.transaction - SEP-41 transfer envelope with exactly one `invokeHostFunction` operation. * @param params.scope - The CAIP-2 chain ID. * @param params.assetId - The CAIP-19 SEP-41 asset ID. - * @param params.fromAccountId - The from account ID. - * @param params.toAccountId - The to account ID. - * @param params.refreshCache - Whether to refresh the cache. - * @returns A promise that resolves to a new {@link Transaction} with updated fee. + * @param params.fromAccountId - Sender account ID. + * @param params.toAccountId - Recipient account ID. + * @param params.refreshCache - When true, bypasses the cache and stores a fresh simulation result. + * @returns A promise that resolves to an assembled {@link Transaction}. */ async simulateSep41TransferWithCache({ transaction, From b6c77e3c331979fa67d49e60ccbd1aca7ced8915 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 20 May 2026 17:27:30 +0800 Subject: [PATCH 237/384] chore: code comment --- .../src/services/network/NetworkService.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index 34eb4b9e..fae86783 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -753,8 +753,9 @@ export class NetworkService { ttlMilliseconds: AppConfig.cache.ttlMilliseconds.simulateTransaction, refreshCache, generateCacheKey: (functionName: string, _args: Serializable[]) => { - // Simulation result for Soroban Contract token transfer is only impacted by: - // the assetId, fromAccountId, toAccountId, and scope. + // This cache is intended for preflight/fee estimation only. The returned XDR + // may contain stale transaction fields such as amount or sequence number. + // Final signing/submission must call this with refreshCache: true. return `${functionName}:${assetId}:${fromAccountId}:${toAccountId}:${scope}`; }, }, From 20c9a1db7abc285fd6f469cc666036e2957e8975 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 20 May 2026 17:51:13 +0800 Subject: [PATCH 238/384] fix: code comment --- .../on-chain-account/OnChainAccountSynchronizeService.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts index 005918df..0be621a4 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts @@ -669,7 +669,8 @@ describe('OnChainAccountSynchronizeService', () => { // Trustline removal happened on sync 2 and was missed by client. // Classic removals persist as tombstone entries (`limit` 0), so sync 4 still sends balance 0. expect(simulatedClientBalances[USDC_CLASSIC]?.amount).toBe('0'); - // SEP-41 zero entries are persisted and continue to be sent, so sync 4 corrects the client. + // SEP-41 zero entries are persisted and continue to be sent by sync balance update events, + // so sync 4 corrects the client if it missed a previous update. expect(simulatedClientBalances[sep41Id]).toStrictEqual({ unit: 'USDC', amount: '0', From 09f244cf8dcbde70cbdf2cb3417692ec2c3cd588 Mon Sep 17 00:00:00 2001 From: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Date: Thu, 21 May 2026 09:18:39 +0100 Subject: [PATCH 239/384] ci: migrate publish-preview workflow to MetaMask/github-tools (#73) ## Explanation Replace the bespoke publish-preview workflow and its supporting scripts with a call to the reusable workflow from MetaMask/github-tools. The reusable workflow (with is-snap: true) now handles manifest renaming, publishing, and PR comment generation, so the local helper scripts are no longer needed. Build-time configuration is forwarded to the reusable workflow via the new BUILD_ENV secret. Note: the workflow is temporarily pinned to the mikesposito/snap-previews branch of github-tools for testing. This should be updated to a tagged release before merging. ## References N/A ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- merged-packages/stellar-wallet-snap/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/merged-packages/stellar-wallet-snap/package.json b/merged-packages/stellar-wallet-snap/package.json index 6440dd79..411c1e7f 100644 --- a/merged-packages/stellar-wallet-snap/package.json +++ b/merged-packages/stellar-wallet-snap/package.json @@ -35,7 +35,6 @@ "lint:types": "tsc --noEmit", "format": "prettier '**/*.ts' '**/*.tsx' --write", "prepublishOnly": "mm-snap manifest", - "publish:preview": "yarn npm publish --tag preview", "serve": "mm-snap serve", "start": "node scripts/update-manifest-local.js && concurrently \"mm-snap watch\" \"yarn build:locale:watch\"", "test": "jest --passWithNoTests --coverage=false", From 979fe130c4613d4efd68489567af3afa83dc261b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 21 May 2026 21:06:46 +0800 Subject: [PATCH 240/384] fix: add fee multiplier (#75) ## Explanation Adds configurable fee multipliers to increase Stellar base fees and Soroban smart-contract resource fees, aiming to reduce underfeeing in higher-demand conditions. ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- .../stellar-wallet-snap/.env.example | 6 +++ .../stellar-wallet-snap/snap.config.ts | 2 + .../stellar-wallet-snap/src/config.ts | 13 +++++ .../services/network/NetworkService.test.ts | 52 +++++++++++++++++-- .../src/services/network/NetworkService.ts | 28 +++++++++- 5 files changed, 95 insertions(+), 6 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/.env.example b/merged-packages/stellar-wallet-snap/.env.example index 8c63d16d..a6caa1b2 100644 --- a/merged-packages/stellar-wallet-snap/.env.example +++ b/merged-packages/stellar-wallet-snap/.env.example @@ -60,3 +60,9 @@ PRICE_API_BASE_URL=https://price.api.cx.metamask.io # Cache TTL Milliseconds for historical prices #HISTORICAL_PRICES_TTL_MILLISECONDS= + +# Base fee multiplier +#BASE_FEE_MULTIPLIER= + +# Simulation fee multiplier +#SIMULATION_FEE_MULTIPLIER= diff --git a/merged-packages/stellar-wallet-snap/snap.config.ts b/merged-packages/stellar-wallet-snap/snap.config.ts index 1b86545f..bdaa0087 100644 --- a/merged-packages/stellar-wallet-snap/snap.config.ts +++ b/merged-packages/stellar-wallet-snap/snap.config.ts @@ -39,6 +39,8 @@ const config: SnapConfig = { process.env.SIMULATE_TRANSACTION_TTL_MILLISECONDS ?? '', SEP41_ASSET_BALANCE_TTL_MILLISECONDS: process.env.SEP41_ASSET_BALANCE_TTL_MILLISECONDS ?? '', + BASE_FEE_MULTIPLIER: process.env.BASE_FEE_MULTIPLIER ?? '', + SIMULATION_FEE_MULTIPLIER: process.env.SIMULATION_FEE_MULTIPLIER ?? '', }, polyfills: true, }; diff --git a/merged-packages/stellar-wallet-snap/src/config.ts b/merged-packages/stellar-wallet-snap/src/config.ts index f978458d..e84b5c8d 100644 --- a/merged-packages/stellar-wallet-snap/src/config.ts +++ b/merged-packages/stellar-wallet-snap/src/config.ts @@ -76,6 +76,17 @@ const ConfigStruct = object({ transaction: object({ timeout: parseIntegerStruct(100, 180), pollingAttempts: parseIntegerStruct(0, 10), + /** + * The base fee multiplier for the Stellar network. + */ + baseFeeMultiplier: parseIntegerStruct(1, 1.2), + /** + * The smart contract transaction fee multiplier for the Stellar network. + * The multiplier is higher because smart contract transactions have tighter ledger limits than normal transactions. + * + * @see https://developers.stellar.org/docs/learn/fundamentals/fees-resource-limits-metering#ledger-limits + */ + simulationFeeMultiplier: parseIntegerStruct(1, 1.5), }), api: object({ tokenApi: object({ @@ -145,6 +156,8 @@ export const AppConfig = create( transaction: { timeout: process.env.TRANSACTION_TIMEOUT, pollingAttempts: process.env.TRANSACTION_POLLING_ATTEMPTS, + baseFeeMultiplier: process.env.BASE_FEE_MULTIPLIER, + simulationFeeMultiplier: process.env.SIMULATION_FEE_MULTIPLIER, }, api: { tokenApi: { diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts index 79ae3337..e8739e31 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts @@ -6,6 +6,7 @@ import { nativeToScVal, NotFoundError, rpc as StellarRpc, + SorobanDataBuilder, TransactionBuilder as StellarTransactionBuilder, } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; @@ -137,7 +138,11 @@ describe('NetworkService', () => { const result = await networkService.getBaseFee(scope); - expect(result).toStrictEqual(new BigNumber(100)); + expect(result).toStrictEqual( + new BigNumber(100).multipliedBy( + AppConfig.transaction.baseFeeMultiplier, + ), + ); expect(fetchBaseFeeSpy).toHaveBeenCalled(); }); @@ -161,8 +166,12 @@ describe('NetworkService', () => { await Promise.resolve(); const second = await networkService.getBaseFeeWithCache(scope); - expect(first).toStrictEqual(new BigNumber(55)); - expect(second).toStrictEqual(new BigNumber(55)); + expect(first).toStrictEqual( + new BigNumber(55).multipliedBy(AppConfig.transaction.baseFeeMultiplier), + ); + expect(second).toStrictEqual( + new BigNumber(55).multipliedBy(AppConfig.transaction.baseFeeMultiplier), + ); expect(fetchBaseFeeSpy).toHaveBeenCalledTimes(1); }); @@ -175,7 +184,12 @@ describe('NetworkService', () => { await Promise.resolve(); const refreshed = await networkService.getBaseFeeWithCache(scope, true); - expect(refreshed).toStrictEqual(new BigNumber(11)); + expect(refreshed).toStrictEqual( + // expected value is 11 * 1.2 = 13.2, rounded up to 14 + new BigNumber(11) + .multipliedBy(AppConfig.transaction.baseFeeMultiplier) + .integerValue(BigNumber.ROUND_CEIL), + ); expect(fetchBaseFeeSpy).toHaveBeenCalledTimes(2); }); }); @@ -896,6 +910,36 @@ describe('NetworkService', () => { isSimErrorSpy.mockRestore(); }); + it('applies simulationFeeMultiplier to minResourceFee before assembling', async () => { + const { simulateTransactionSpy } = getRpcServerSpies(); + const mockInvoke = createMockInvokeHostFunctionTransaction(); + const minResourceFee = '1000'; + const transactionData = new SorobanDataBuilder(); + const setResourceFeeSpy = jest.spyOn(transactionData, 'setResourceFee'); + simulateTransactionSpy.mockResolvedValue({ + // eslint-disable-next-line @typescript-eslint/naming-convention + _parsed: true, + id: '1', + latestLedger: 1, + events: [], + minResourceFee, + transactionData, + result: { auth: [] }, + } as never); + + const result = await networkService.simulateTransaction( + mockInvoke, + scope, + ); + + expect(result).toBeInstanceOf(Transaction); + expect(setResourceFeeSpy).toHaveBeenCalledWith( + new BigNumber(minResourceFee) + .multipliedBy(AppConfig.transaction.simulationFeeMultiplier) + .toString(), + ); + }); + it('calls RPC simulateTransaction with the wrapped envelope getRaw()', async () => { const { simulateTransactionSpy } = getRpcServerSpies(); const mockInvoke = createMockInvokeHostFunctionTransaction(); diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index fae86783..ddb1e6b2 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -128,7 +128,9 @@ export class NetworkService { try { const client = this.#getHorizonClient(scope); const baseFee = await client.fetchBaseFee(); - return new BigNumber(baseFee); + return new BigNumber(baseFee) + .multipliedBy(AppConfig.transaction.baseFeeMultiplier) + .integerValue(BigNumber.ROUND_CEIL); } catch (error: unknown) { this.#logger.logErrorWithDetails('Failed to fetch base fee', error); throw new BaseFeeFetchException(scope); @@ -665,7 +667,7 @@ export class NetworkService { try { if (!transaction.hasInvokeHostFunction) { throw new NetworkServiceException( - 'Transaction is not a valid SEP-41 transfer transaction', + 'Transaction is not a valid contract invoke transaction', ); } @@ -683,10 +685,32 @@ export class NetworkService { ); } + // Get the min resource fee from the simulation response. + const resourceFee = new BigNumber(simulateResponse.minResourceFee); + + if ( + resourceFee.isNaN() || + !resourceFee.isFinite() || + resourceFee.isNegative() + ) { + throw new SimulationException('Invalid resource fee'); + } + + // Set the resource fee to the multiplied value. + // simulateResponse.transactionData will be used to assemble the transaction. + // @link https://github.com/stellar/stellar-sdk/blob/main/packages/stellar-base/src/rpc/transaction.ts + simulateResponse.transactionData.setResourceFee( + resourceFee + .multipliedBy(AppConfig.transaction.simulationFeeMultiplier) + .integerValue(BigNumber.ROUND_CEIL) + .toString(), + ); + const simulatedTransaction = rpc.assembleTransaction( rawTransaction, simulateResponse, ); + return new Transaction(simulatedTransaction.build()); } catch (error: unknown) { this.#logger.logErrorWithDetails('Failed to simulate transaction', error); From bbe661c8cb486c6f87ab5b69329f47da1eb7f3c5 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 21 May 2026 21:07:08 +0800 Subject: [PATCH 241/384] feat: add `onAmountInput ` and `onAddressInput` RPC (#63) ## Explanation Adds Wallet Standard-style client request RPCs for validating destination addresses and send amounts, backed by a new TransactionService.createValidatedSendTransaction flow that supports slip44/native, classic, and SEP-41 transfers. Pre-requirement (require below PRs to be merged): - [ ] #69 - [ ] #68 - [ ] #67 ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../src/api/integer.test.ts | 80 +++-- .../stellar-wallet-snap/src/api/integer.ts | 52 ++-- .../stellar-wallet-snap/src/context.ts | 15 + .../src/handlers/clientRequest/api.test.ts | 243 ++++++++++++++- .../src/handlers/clientRequest/api.ts | 120 +++++++- .../src/handlers/clientRequest/base.ts | 28 +- .../clientRequest/onAddressInput.test.ts | 58 ++++ .../handlers/clientRequest/onAddressInput.ts | 50 +++ .../clientRequest/onAmountInput.test.ts | 287 ++++++++++++++++++ .../handlers/clientRequest/onAmountInput.ts | 165 ++++++++++ .../transaction/TransactionService.test.ts | 267 +++++++++++++++- .../transaction/TransactionService.ts | 265 +++++++++++++++- .../stellar-wallet-snap/src/utils/currency.ts | 14 + 14 files changed, 1542 insertions(+), 104 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAddressInput.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAddressInput.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index b25ff5e7..6f96fef2 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "Dp1z+A6EyeAuhPrVfuxaoAzDlUA9Wdua6+wtUPyAf+Q=", + "shasum": "jF7tBkmaNRfHE6oqO7dsGLbTxRry+GLlC0p44exGKuY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/api/integer.test.ts b/merged-packages/stellar-wallet-snap/src/api/integer.test.ts index 804d94e4..d10f155a 100644 --- a/merged-packages/stellar-wallet-snap/src/api/integer.test.ts +++ b/merged-packages/stellar-wallet-snap/src/api/integer.test.ts @@ -1,79 +1,69 @@ import { assert, StructError } from '@metamask/superstruct'; import { - NonZeroValidAmountStruct, - PositiveNumberStringStruct, + NonZeroValidStellarAmountStruct, + ValidStellarAmountStruct, ValidAmountStruct, } from './integer'; -describe('PositiveNumberStringStruct', () => { - it('accepts a valid positive integer string', () => { - expect(() => - assert('1000000000', PositiveNumberStringStruct), - ).not.toThrow(); - }); - - it('accepts a valid positive float string', () => { - expect(() => assert('1.5', PositiveNumberStringStruct)).not.toThrow(); - }); - - it('rejects JavaScript bigint', () => { - expect(() => assert(BigInt(100), PositiveNumberStringStruct)).toThrow( - StructError, - ); +describe('ValidAmountStruct', () => { + it.each([ + // MAX_INT64 stroops converted to XLM-style amount. + '922337203685.4775807', + '123.1212331321231', + '0.1', + '0', + '0.000000023', + ])('accepts a valid amount', (value: string) => { + expect(() => assert(value, ValidAmountStruct)).not.toThrow(); }); - it('rejects a negative numeric string', () => { - expect(() => assert('-1', PositiveNumberStringStruct)).toThrow(StructError); + it('rejects a negative amount', () => { + expect(() => assert('-0.1', ValidAmountStruct)).toThrow(StructError); }); - it('rejects a non-numeric string', () => { - expect(() => assert('not-a-number', PositiveNumberStringStruct)).toThrow( - StructError, - ); + it('rejects non-finite numeric values', () => { + expect(() => assert('Infinity', ValidAmountStruct)).toThrow(StructError); + expect(() => assert('NaN', ValidAmountStruct)).toThrow(StructError); }); }); -describe('ValidAmountStruct', () => { +describe('ValidStellarAmountStruct', () => { it('accepts a valid amount with up to 7 decimal places', () => { - expect(() => assert('12.3456789', ValidAmountStruct)).not.toThrow(); + expect(() => assert('12.3456789', ValidStellarAmountStruct)).not.toThrow(); }); - it('accepts max int64 represented in 7-decimal Stellar units', () => { - // MAX_INT64 stroops converted to XLM-style amount. + it('accepts an amount just below max int64', () => { expect(() => - assert('922337203685.4775807', ValidAmountStruct), + assert('922337203685.4775807', ValidStellarAmountStruct), ).not.toThrow(); }); it('rejects an amount above max int64 when converted to stroops', () => { - expect(() => assert('922337203685.4775808', ValidAmountStruct)).toThrow( - StructError, - ); + expect(() => + assert('922337203685.4775808', ValidStellarAmountStruct), + ).toThrow(StructError); }); it('rejects an amount with more than 7 decimal places', () => { - expect(() => assert('1.00000001', ValidAmountStruct)).toThrow(StructError); - }); - - it('rejects a negative amount', () => { - expect(() => assert('-0.1', ValidAmountStruct)).toThrow(StructError); - }); - - it('rejects non-finite numeric values', () => { - expect(() => assert('Infinity', ValidAmountStruct)).toThrow(StructError); - expect(() => assert('NaN', ValidAmountStruct)).toThrow(StructError); + expect(() => assert('1.00000001', ValidStellarAmountStruct)).toThrow( + StructError, + ); }); }); -describe('NonZeroValidAmountStruct', () => { +describe('NonZeroValidStellarAmountStruct', () => { it('accepts a valid non-zero amount', () => { - expect(() => assert('0.0000001', NonZeroValidAmountStruct)).not.toThrow(); + expect(() => + assert('0.0000001', NonZeroValidStellarAmountStruct), + ).not.toThrow(); }); it('rejects zero', () => { - expect(() => assert('0', NonZeroValidAmountStruct)).toThrow(StructError); - expect(() => assert('0.0000000', NonZeroValidAmountStruct)).toThrow( + expect(() => assert('0', NonZeroValidStellarAmountStruct)).toThrow( + StructError, + ); + expect(() => assert('0.0000000', NonZeroValidStellarAmountStruct)).toThrow( StructError, ); }); diff --git a/merged-packages/stellar-wallet-snap/src/api/integer.ts b/merged-packages/stellar-wallet-snap/src/api/integer.ts index 6ed6cd38..0798a1bb 100644 --- a/merged-packages/stellar-wallet-snap/src/api/integer.ts +++ b/merged-packages/stellar-wallet-snap/src/api/integer.ts @@ -5,50 +5,45 @@ import { MAX_INT64, STELLAR_DECIMAL_PLACES } from '../constants'; import { toSmallestUnit } from '../utils/currency'; /** - * Non-empty string that parses to a finite, non-negative {@link BigNumber} (stroops or human-readable amounts). - * Uses `refine` so `assert` / `validate` enforce this; not only `create` with coercion. + * Non-empty string that parses to a finite, non-negative {@link BigNumber}. */ -export const PositiveNumberStringStruct = refine( +export const ValidAmountStruct = refine( nonempty(string()), - 'positive_number_string', + 'valid_amount', (value: string) => { try { - const bn = new BigNumber(value); - if (bn.isNaN() || !bn.isFinite()) { - return 'Invalid positive number'; - } - if (bn.isLessThan(0)) { - return 'Not a positive number'; + const amount = new BigNumber(value); + if ( + // < 0 + amount.isNegative() || + // NaN or Infinity + amount.isNaN() || + !amount.isFinite() + ) { + return 'Invalid amount'; } return true; } catch { - return 'Invalid positive number'; + return 'Invalid amount'; } }, ); /** - * Non-empty string that parses to a finite, non-negative {@link BigNumber}. + * Non-empty string that parses to a finite, non-negative {@link BigNumber} and is not above the maximum int64. * The amount is converted to the smallest unit of the asset and validated against the maximum int64. - * Uses `refine` so `assert` / `validate` enforce this; not only `create` with coercion. */ -export const ValidAmountStruct = refine( - nonempty(string()), - 'valid_amount', +export const ValidStellarAmountStruct = refine( + ValidAmountStruct, + 'valid_stellar_amount', (value: string) => { try { const amount = new BigNumber(value); const decimalPlaces = amount.decimalPlaces(); if ( - // < 0 - amount.isNegative() || - // > Max value - toSmallestUnit(amount).gt(new BigNumber(MAX_INT64).toString()) || - // Decimal places (max 7) (decimalPlaces && decimalPlaces > STELLAR_DECIMAL_PLACES) || - // NaN or Infinity - amount.isNaN() || - !amount.isFinite() + // > Max value + toSmallestUnit(amount).gt(new BigNumber(MAX_INT64).toString()) ) { return 'Invalid amount'; } @@ -62,10 +57,9 @@ export const ValidAmountStruct = refine( /** * Non-empty string that parses to a finite, non-negative {@link BigNumber} and is not zero. * The amount is converted to the smallest unit of the asset and validated against the maximum int64. - * Uses `refine` so `assert` / `validate` enforce this; not only `create` with coercion. */ -export const NonZeroValidAmountStruct = refine( - ValidAmountStruct, +export const NonZeroValidStellarAmountStruct = refine( + ValidStellarAmountStruct, 'non_zero_valid_amount', (value: string) => { const amount = new BigNumber(value); @@ -76,8 +70,8 @@ export const NonZeroValidAmountStruct = refine( }, ); -export type NonZeroValidAmount = Infer; +export type NonZeroValidAmount = Infer; export type ValidAmount = Infer; -export type PositiveNumberString = Infer; +export type ValidStellarAmount = Infer; diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 31aed267..a437c0aa 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -11,6 +11,8 @@ import { ClientRequestMethod, } from './handlers/clientRequest'; import { ComputeFeeHandler } from './handlers/clientRequest/computeFee'; +import { OnAddressInputHandler } from './handlers/clientRequest/onAddressInput'; +import { OnAmountInputHandler } from './handlers/clientRequest/onAmountInput'; import { SignAndSendTransactionHandler } from './handlers/clientRequest/signAndSendTransaction'; import type { ICronjobRequestHandler } from './handlers/cronjob/api'; import { BackgroundEventMethod } from './handlers/cronjob/api'; @@ -210,6 +212,17 @@ const changeTrustOptHandler = new ChangeTrustOptHandler({ confirmationUIController, }); +const onAddressInputHandler = new OnAddressInputHandler({ + logger, +}); + +const onAmountInputHandler = new OnAmountInputHandler({ + logger, + accountResolver, + assetMetadataService, + transactionService, +}); + const signAndSendTransactionHandler = new SignAndSendTransactionHandler({ logger, accountResolver, @@ -227,6 +240,8 @@ const clientRequestMethodHandlers: Record< IClientRequestHandler > = { [ClientRequestMethod.ChangeTrustOpt]: changeTrustOptHandler, + [ClientRequestMethod.OnAddressInput]: onAddressInputHandler, + [ClientRequestMethod.OnAmountInput]: onAmountInputHandler, [ClientRequestMethod.SignAndSendTransaction]: signAndSendTransactionHandler, [ClientRequestMethod.ComputeFee]: computeFeeHandler, }; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts index febbff22..1d18ba9b 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts @@ -9,8 +9,14 @@ import { import { ChangeTrustOptJsonRpcRequestStruct, ChangeTrustOptJsonRpcResponseStruct, - ComputeFeeJsonRpcRequestStruct, + ClientRequestMethod, + ClientRequestMethodStruct, JsonRpcRequestWithAccountStruct, + OnAddressInputJsonRpcRequestStruct, + OnAddressInputJsonRpcResponseStruct, + OnAmountInputJsonRpcRequestStruct, + OnAmountInputJsonRpcResponseStruct, + ComputeFeeJsonRpcRequestStruct, SignAndSendTransactionJsonRpcRequestStruct, SignAndSendTransactionJsonRpcResponseStruct, } from './api'; @@ -36,6 +42,14 @@ const buildTestInvokeXdr = () => { .toXDR(); }; +const classicAssetId = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; +const sep41AssetId = + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J'; +const slip44AssetId = 'stellar:pubnet/slip44:148'; +const stellarAddress = + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + describe('JsonRpcRequestWithAccountStruct', () => { it.each([ { @@ -377,3 +391,230 @@ describe('ChangeTrustOptJsonRpcRequestStruct', () => { ); }); }); + +describe('ClientRequestMethodStruct', () => { + it.each(Object.values(ClientRequestMethod))( + 'accepts known client request method %s', + (method) => { + expect(() => assert(method, ClientRequestMethodStruct)).not.toThrow(); + }, + ); + + it('rejects an unknown method string', () => { + expect(() => + assert('notAClientRequestMethod', ClientRequestMethodStruct), + ).toThrow(StructError); + }); +}); + +describe('OnAddressInputJsonRpcRequestStruct', () => { + it.each([ + { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAddressInput, + params: { value: stellarAddress }, + }, + { + jsonrpc: '2.0' as const, + id: 'request-id', + method: ClientRequestMethod.OnAddressInput, + params: { value: stellarAddress }, + }, + ])('accepts a valid onAddressInput JSON-RPC request', (request) => { + expect(() => + assert(request, OnAddressInputJsonRpcRequestStruct), + ).not.toThrow(); + }); + + it.each([ + { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAmountInput, + params: { value: stellarAddress }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAddressInput, + params: { value: 'not-a-stellar-address' }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAddressInput, + params: {}, + }, + ])('rejects an invalid onAddressInput JSON-RPC request', (request) => { + expect(() => assert(request, OnAddressInputJsonRpcRequestStruct)).toThrow( + StructError, + ); + }); +}); + +describe('OnAddressInputJsonRpcResponseStruct', () => { + it.each([ + { valid: true, errors: [] }, + { + valid: false, + errors: [{ code: 'Invalid' }], + }, + ])('accepts a valid onAddressInput JSON-RPC response', (response) => { + expect(() => + assert(response, OnAddressInputJsonRpcResponseStruct), + ).not.toThrow(); + }); + + it.each([{}, { valid: true }, { valid: true, errors: [{ code: 1 }] }])( + 'rejects an invalid onAddressInput JSON-RPC response', + (response) => { + expect(() => + assert(response, OnAddressInputJsonRpcResponseStruct), + ).toThrow(StructError); + }, + ); +}); + +describe('OnAmountInputJsonRpcRequestStruct', () => { + it.each([ + { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAmountInput, + params: { + accountId, + assetId: classicAssetId, + value: '10', + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAmountInput, + params: { + accountId, + assetId: slip44AssetId, + value: '1.0000001', + to: stellarAddress, + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAmountInput, + params: { + accountId, + assetId: sep41AssetId, + value: '1.12345678', + }, + }, + ])('accepts a valid onAmountInput JSON-RPC request', (request) => { + expect(() => + assert(request, OnAmountInputJsonRpcRequestStruct), + ).not.toThrow(); + }); + + it.each([ + { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAddressInput, + params: { + accountId, + assetId: classicAssetId, + value: '10', + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAmountInput, + params: { + accountId: 'not-a-uuid', + assetId: classicAssetId, + value: '10', + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAmountInput, + params: { + accountId, + assetId: 'stellar:pubnet/asset:INVALID', + value: '10', + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAmountInput, + params: { + accountId, + assetId: classicAssetId, + value: '', + }, + }, + ])( + 'rejects an onAmountInput JSON-RPC request with invalid shape', + (request) => { + expect(() => assert(request, OnAmountInputJsonRpcRequestStruct)).toThrow( + StructError, + ); + }, + ); + + it.each([ + { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAmountInput, + params: { + accountId, + assetId: sep41AssetId, + value: '-1', + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAmountInput, + params: { + accountId, + assetId: classicAssetId, + value: '1.00000001', + }, + }, + ])( + 'rejects an onAmountInput JSON-RPC request when amount rules fail refinement', + (request) => { + expect(() => assert(request, OnAmountInputJsonRpcRequestStruct)).toThrow( + StructError, + ); + }, + ); +}); + +describe('OnAmountInputJsonRpcResponseStruct', () => { + it.each([ + { valid: true, errors: [] }, + { + valid: false, + errors: [{ code: 'InsufficientBalance' }], + }, + ])('accepts a valid onAmountInput JSON-RPC response', (response) => { + expect(() => + assert(response, OnAmountInputJsonRpcResponseStruct), + ).not.toThrow(); + }); + + it.each([{}, { valid: true }, { valid: true, errors: [{ code: true }] }])( + 'rejects an invalid onAmountInput JSON-RPC response', + (response) => { + expect(() => + assert(response, OnAmountInputJsonRpcResponseStruct), + ).toThrow(StructError); + }, + ); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts index 37d0c042..eccaeced 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts @@ -11,9 +11,10 @@ import { type, union, refine, + array, + nonempty, integer, min, - array, } from '@metamask/superstruct'; import type { JsonRpcRequest } from '@metamask/utils'; import { parseCaipAssetType } from '@metamask/utils'; @@ -24,15 +25,23 @@ import { KnownCaip19ClassicAssetStruct, StellarTransactionHashStruct, UuidStruct, - NonZeroValidAmountStruct, + NonZeroValidStellarAmountStruct, + KnownCaip19Sep41AssetStruct, + KnownCaip19Slip44IdStruct, + StellarAddressStruct, + ValidAmountStruct, + ValidStellarAmountStruct, SwapTransactionXdrStruct, } from '../../api'; +import { isSep41Id } from '../../utils'; /** * Enum for the client request method. */ export enum ClientRequestMethod { /** -------------------------------- Wallet Standard -------------------------------- */ + OnAddressInput = 'onAddressInput', + OnAmountInput = 'onAmountInput', // Standard multichain workflow for bridge SignAndSendTransaction = 'signAndSendTransaction', ComputeFee = 'computeFee', @@ -40,6 +49,14 @@ export enum ClientRequestMethod { ChangeTrustOpt = 'changeTrustOpt', } +export enum MultiChainSendErrorCodes { + // eslint-disable-next-line @typescript-eslint/no-shadow + Required = 'Required', + Invalid = 'Invalid', + InsufficientBalance = 'InsufficientBalance', + InsufficientBalanceToCoverFee = 'InsufficientBalanceToCoverFee', +} + /** * Trustline change intent for {@link ClientRequestMethod.ChangeTrustOpt}. */ @@ -78,7 +95,7 @@ const ChangeTrustAddStruct = assign( ChangeTrustBaseParamsStruct, object({ action: literal(ChangeTrustOptAction.Add), - limit: optional(NonZeroValidAmountStruct), + limit: optional(NonZeroValidStellarAmountStruct), }), ); @@ -145,6 +162,75 @@ export const SignAndSendTransactionJsonRpcResponseStruct = object({ transactionId: StellarTransactionHashStruct, }); +/* + * Validation struct for the onAddressInput JSON-RPC request. + */ +export const OnAddressInputJsonRpcRequestStruct = assign( + JsonRpcRequestStruct, + object({ + method: literal(ClientRequestMethod.OnAddressInput), + params: object({ + value: StellarAddressStruct, + }), + }), +); + +/** + * Validation struct for the onAddressInput JSON-RPC response. + */ +export const OnAddressInputJsonRpcResponseStruct = object({ + valid: boolean(), + errors: array( + object({ + code: string(), + }), + ), +}); + +/** + * Validation struct for the onAmountInput JSON-RPC request. + */ +export const OnAmountInputJsonRpcRequestStruct = refine( + assign( + JsonRpcRequestStruct, + object({ + method: literal(ClientRequestMethod.OnAmountInput), + params: object({ + accountId: UuidStruct, + assetId: union([ + KnownCaip19ClassicAssetStruct, + KnownCaip19Sep41AssetStruct, + KnownCaip19Slip44IdStruct, + ]), + value: nonempty(string()), + to: optional(StellarAddressStruct), + }), + }), + ), + 'on-amount-input-request', + ({ params }) => { + if ( + (isSep41Id(params.assetId) && ValidAmountStruct.is(params.value)) || + (!isSep41Id(params.assetId) && ValidStellarAmountStruct.is(params.value)) + ) { + return true; + } + return 'Invalid amount'; + }, +); + +/** + * Validation struct for the onAmountInput JSON-RPC response. + */ +export const OnAmountInputJsonRpcResponseStruct = object({ + valid: boolean(), + errors: array( + object({ + code: string(), + }), + ), +}); + /** * Validation struct for the computeFee JSON-RPC request. */ @@ -197,6 +283,34 @@ export type ChangeTrustOptJsonRpcResponse = Infer< typeof ChangeTrustOptJsonRpcResponseStruct >; +/** + * Type for the onAddressInput JSON-RPC request. + */ +export type OnAddressInputJsonRpcRequest = Infer< + typeof OnAddressInputJsonRpcRequestStruct +>; + +/** + * Type for the onAddressInput JSON-RPC response. + */ +export type OnAddressInputJsonRpcResponse = Infer< + typeof OnAddressInputJsonRpcResponseStruct +>; + +/** + * Type for the onAmountInput JSON-RPC request. + */ +export type OnAmountInputJsonRpcRequest = Infer< + typeof OnAmountInputJsonRpcRequestStruct +>; + +/** + * Type for the onAmountInput JSON-RPC response. + */ +export type OnAmountInputJsonRpcResponse = Infer< + typeof OnAmountInputJsonRpcResponseStruct +>; + /** * Type for the sendTransaction JSON-RPC request. */ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/base.ts index 8657f4d6..f21cd39f 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/base.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/base.ts @@ -88,27 +88,27 @@ export abstract class BaseClientRequestHandler< protected async handleRequest( request: RequestType, ): Promise { - const resolvedAccount = await this.resolveAccount(request); - return await this.execute(resolvedAccount, request); - } - - protected async resolveAccount( - request: RequestType, - ): Promise { try { - return await this.#accountResolver.resolveAccount({ - accountId: this.getAccountId(request), - scope: this.getScope(request), - options: this.#resolveAccountOptions, - }); + const resolvedAccount = await this.resolveAccount(request); + return await this.execute(resolvedAccount, request); } catch (error: unknown) { if (error instanceof AccountNotActivatedException) { - await this.handleAccountNotActivatedError(error); + return this.handleAccountNotActivatedError(error); } throw error; } } + protected async resolveAccount( + request: RequestType, + ): Promise { + return this.#accountResolver.resolveAccount({ + accountId: this.getAccountId(request), + scope: this.getScope(request), + options: this.#resolveAccountOptions, + }); + } + async #showAccountNotActivatedAlert(address: string): Promise { await renderAccountActivationPrompt(address); } @@ -121,7 +121,7 @@ export abstract class BaseClientRequestHandler< */ protected async handleAccountNotActivatedError( error: AccountNotActivatedException, - ): Promise { + ): Promise { await this.#showAccountNotActivatedAlert(error.address); throw error; } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAddressInput.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAddressInput.test.ts new file mode 100644 index 00000000..f2bd3acf --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAddressInput.test.ts @@ -0,0 +1,58 @@ +import type { JsonRpcRequest } from '@metamask/utils'; + +import { + ClientRequestMethod, + MultiChainSendErrorCodes, + type OnAddressInputJsonRpcRequest, +} from './api'; +import { OnAddressInputHandler } from './onAddressInput'; +import { logger } from '../../utils/logger'; + +jest.mock('../../utils/logger'); + +const stellarAddress = + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + +describe('OnAddressInputHandler', () => { + const handler = new OnAddressInputHandler({ logger }); + + it('returns valid when the address passes validation', async () => { + const request: OnAddressInputJsonRpcRequest = { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.OnAddressInput, + params: { value: stellarAddress }, + }; + + expect(await handler.handle(request)).toStrictEqual({ + valid: true, + errors: [], + }); + }); + + it.each([ + { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.OnAddressInput, + params: { value: 'not-a-stellar-address' }, + }, + { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.OnAmountInput, + params: { value: stellarAddress }, + }, + { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.OnAddressInput, + params: {}, + }, + ])('returns invalid when the request fails validation', async (request) => { + expect(await handler.handle(request)).toStrictEqual({ + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAddressInput.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAddressInput.ts new file mode 100644 index 00000000..3cde6c3f --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAddressInput.ts @@ -0,0 +1,50 @@ +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import type { + OnAddressInputJsonRpcRequest, + OnAddressInputJsonRpcResponse, +} from './api'; +import { + MultiChainSendErrorCodes, + OnAddressInputJsonRpcRequestStruct, +} from './api'; +import type { IClientRequestHandler } from './base'; +import type { ILogger } from '../../utils'; +import { createPrefixedLogger, validateRequest } from '../../utils'; + +export class OnAddressInputHandler implements IClientRequestHandler { + readonly #logger: ILogger; + + constructor({ logger }: { logger: ILogger }) { + const prefixedLogger = createPrefixedLogger( + logger, + '[📮 OnAddressInputHandler]', + ); + this.#logger = prefixedLogger; + } + + /** + * Handles the input of an address. + * + * @param request - The JSON-RPC request containing the method and parameters. + * @param request.params.value - The address to validate. + * @returns The response to the JSON-RPC request. + */ + async handle( + request: OnAddressInputJsonRpcRequest | JsonRpcRequest, + ): Promise { + try { + validateRequest(request, OnAddressInputJsonRpcRequestStruct); + return { + valid: true, + errors: [], + }; + } catch (error: unknown) { + this.#logger.logErrorWithDetails('Invalid address', error); + return { + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }; + } + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts new file mode 100644 index 00000000..6d068381 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts @@ -0,0 +1,287 @@ +import { InvalidParamsError } from '@metamask/snaps-sdk'; +import { BigNumber } from 'bignumber.js'; + +import { + ClientRequestMethod, + MultiChainSendErrorCodes, + type OnAmountInputJsonRpcRequest, +} from './api'; +import { OnAmountInputHandler } from './onAmountInput'; +import { + KnownCaip2ChainId, + type KnownCaip19ClassicAssetId, + type KnownCaip19Sep41AssetId, +} from '../../api'; +import { AccountService } from '../../services/account'; +import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; +import type { StellarAssetMetadata } from '../../services/asset-metadata'; +import { AssetMetadataService } from '../../services/asset-metadata'; +import { + createMockAssetMetadataService, + generateMockStellarAssetMetadata, + USDC_CLASSIC, + USDC_SEP41, +} from '../../services/asset-metadata/__mocks__/assets.fixtures'; +import { AccountNotActivatedException } from '../../services/network'; +import { + OnChainAccount, + OnChainAccountService, +} from '../../services/on-chain-account'; +import { + createMockAccountWithBalances, + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + horizonSource, + mockOnChainAccountService, +} from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; +import type { Transaction } from '../../services/transaction'; +import { TransactionService } from '../../services/transaction'; +import { createMockTransactionService } from '../../services/transaction/__mocks__/transaction.fixtures'; +import { + InsufficientBalanceException, + InsufficientBalanceToCoverFeeException, + TransactionValidationException, +} from '../../services/transaction/exceptions'; +import { WalletService } from '../../services/wallet'; +import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; +import { logger } from '../../utils/logger'; +import { AccountResolver } from '../accountResolver'; + +jest.mock('../../utils/logger'); +jest.mock('../../utils/snap'); +jest.mock('../../ui/confirmation/views/AccountActivationPrompt/render', () => ({ + render: jest.fn().mockResolvedValue(undefined), +})); + +const destinationAddress = + 'GDTF7ERUQVTX23ZD6NY5XRYC5IQAKWFVTQ6IXSMEZWGVNDDGPYCVHRZP'; + +describe('OnAmountInputHandler', () => { + const accountId = '11111111-1111-4111-8111-111111111111'; + const assetId = USDC_CLASSIC as KnownCaip19ClassicAssetId; + const scope = KnownCaip2ChainId.Mainnet; + + function setup() { + const wallet = getTestWallet(); + const account = generateStellarKeyringAccount( + accountId, + wallet.address, + 'entropy-source-1', + 0, + ); + const mockRawAccount = createMockAccountWithBalances(wallet.address, '1', { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 10, + assets: [], + }); + const onChainAccount = new OnChainAccount( + mockRawAccount, + scope, + horizonSource(mockRawAccount, scope), + ); + + const { accountService, onChainAccountService, walletService } = + mockOnChainAccountService(); + jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ + account, + }); + const resolveOnChainAccountByKeyringAccountIdSpy = jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue(onChainAccount); + jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + + const { transactionService } = createMockTransactionService(); + const createValidatedSendTransaction = jest + .spyOn(TransactionService.prototype, 'createValidatedSendTransaction') + .mockResolvedValue({} as Transaction); + + const { service: assetMetadataService } = createMockAssetMetadataService(); + const assetMetadata = generateMockStellarAssetMetadata()[ + assetId + ] as StellarAssetMetadata; + jest + .spyOn(AssetMetadataService.prototype, 'resolve') + .mockResolvedValue(assetMetadata); + + const accountResolver = new AccountResolver({ + accountService, + onChainAccountService, + walletService, + }); + + const handler = new OnAmountInputHandler({ + logger, + accountResolver, + assetMetadataService, + transactionService, + }); + + return { + handler, + account, + onChainAccount, + wallet, + createValidatedSendTransaction, + resolveOnChainAccountByKeyringAccountIdSpy, + }; + } + + function baseRequest( + overrides: Partial = {}, + ): OnAmountInputJsonRpcRequest { + return { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.OnAmountInput, + params: { + accountId, + assetId, + value: '1', + ...overrides, + }, + }; + } + + it('returns invalid when value has more decimal places than the asset supports', async () => { + const sep41AssetId = USDC_SEP41 as KnownCaip19Sep41AssetId; + const { handler, createValidatedSendTransaction } = setup(); + const assetMetadata = generateMockStellarAssetMetadata()[ + sep41AssetId + ] as StellarAssetMetadata; + jest + .spyOn(AssetMetadataService.prototype, 'resolve') + .mockResolvedValue(assetMetadata); + + expect( + await handler.handle( + baseRequest({ assetId: sep41AssetId, value: '1.12345678' }), + ), + ).toStrictEqual({ + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }); + expect(createValidatedSendTransaction).not.toHaveBeenCalled(); + }); + + it('returns valid when send validation succeeds', async () => { + const { handler, onChainAccount, createValidatedSendTransaction } = setup(); + + const result = await handler.handle(baseRequest()); + + expect(result).toStrictEqual({ valid: true, errors: [] }); + expect(createValidatedSendTransaction).toHaveBeenCalledWith({ + onChainAccount, + scope, + assetId, + amount: new BigNumber('10000000'), + destination: onChainAccount.accountId, + useCache: true, + }); + }); + + it('passes explicit destination when params.to is set', async () => { + const { handler, onChainAccount, createValidatedSendTransaction } = setup(); + + await handler.handle(baseRequest({ to: destinationAddress })); + + expect(createValidatedSendTransaction).toHaveBeenCalledWith({ + onChainAccount, + scope, + assetId, + amount: new BigNumber('10000000'), + destination: destinationAddress, + useCache: true, + }); + }); + + it('returns insufficient balance when createValidatedSendTransaction throws InsufficientBalanceException', async () => { + const { handler, createValidatedSendTransaction } = setup(); + createValidatedSendTransaction.mockRejectedValueOnce( + new InsufficientBalanceException('0', '1'), + ); + + expect(await handler.handle(baseRequest())).toStrictEqual({ + valid: false, + errors: [{ code: MultiChainSendErrorCodes.InsufficientBalance }], + }); + }); + + it('returns insufficient balance to cover fee when createValidatedSendTransaction throws InsufficientBalanceToCoverFeeException', async () => { + const { handler, createValidatedSendTransaction } = setup(); + createValidatedSendTransaction.mockRejectedValueOnce( + new InsufficientBalanceToCoverFeeException('0', '1'), + ); + + expect(await handler.handle(baseRequest())).toStrictEqual({ + valid: false, + errors: [ + { code: MultiChainSendErrorCodes.InsufficientBalanceToCoverFee }, + ], + }); + }); + + it('returns invalid when createValidatedSendTransaction throws TransactionValidationException', async () => { + const { handler, createValidatedSendTransaction } = setup(); + createValidatedSendTransaction.mockRejectedValueOnce( + new TransactionValidationException('x'), + ); + + expect(await handler.handle(baseRequest())).toStrictEqual({ + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }); + }); + + it('returns invalid when createValidatedSendTransaction throws AccountNotActivatedException', async () => { + const { handler, createValidatedSendTransaction, wallet } = setup(); + createValidatedSendTransaction.mockRejectedValueOnce( + new AccountNotActivatedException(wallet.address, scope), + ); + + expect(await handler.handle(baseRequest())).toStrictEqual({ + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }); + }); + + it('returns invalid when keyring state has no on-chain snapshot', async () => { + const { handler, resolveOnChainAccountByKeyringAccountIdSpy } = setup(); + resolveOnChainAccountByKeyringAccountIdSpy.mockResolvedValueOnce(null); + + expect(await handler.handle(baseRequest())).toStrictEqual({ + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }); + }); + + it('rethrows unexpected errors from createValidatedSendTransaction', async () => { + const { handler, createValidatedSendTransaction } = setup(); + createValidatedSendTransaction.mockRejectedValueOnce( + new Error('unexpected'), + ); + + await expect(handler.handle(baseRequest())).rejects.toThrow('unexpected'); + }); + + it('throws InvalidParamsError when the request fails struct validation', async () => { + const { handler } = setup(); + const badRequest = { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAmountInput, + params: { + accountId, + assetId, + value: '', + }, + }; + + await expect(handler.handle(badRequest)).rejects.toThrow( + InvalidParamsError, + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts new file mode 100644 index 00000000..cbbf2453 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts @@ -0,0 +1,165 @@ +import { parseCaipAssetType } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { + OnAmountInputJsonRpcRequest, + OnAmountInputJsonRpcResponse, +} from './api'; +import { + MultiChainSendErrorCodes, + OnAmountInputJsonRpcRequestStruct, + OnAmountInputJsonRpcResponseStruct, +} from './api'; +import { BaseClientRequestHandler } from './base'; +import type { KnownCaip2ChainId } from '../../api'; +import type { AssetMetadataService } from '../../services/asset-metadata'; +import { AccountNotActivatedException } from '../../services/network/exceptions'; +import type { TransactionService } from '../../services/transaction'; +import { + InsufficientBalanceException, + InsufficientBalanceToCoverFeeException, + TransactionValidationException, +} from '../../services/transaction/exceptions'; +import { hasDecimals, toSmallestUnit } from '../../utils'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { + AccountResolver, + ResolvedActivatedAccount, +} from '../accountResolver'; +import { RESOLVE_ACCOUNT_FULL_FROM_KEYRING_STATE } from '../accountResolver'; + +export class OnAmountInputHandler extends BaseClientRequestHandler< + OnAmountInputJsonRpcRequest, + OnAmountInputJsonRpcResponse +> { + readonly #logger: ILogger; + + readonly #assetMetadataService: AssetMetadataService; + + readonly #transactionService: TransactionService; + + constructor({ + logger, + accountResolver, + assetMetadataService, + transactionService, + }: { + logger: ILogger; + accountResolver: AccountResolver; + assetMetadataService: AssetMetadataService; + transactionService: TransactionService; + }) { + const prefixedLogger = createPrefixedLogger( + logger, + '[💰 OnAmountInputHandler]', + ); + super({ + accountResolver, + logger: prefixedLogger, + requestStruct: OnAmountInputJsonRpcRequestStruct, + responseStruct: OnAmountInputJsonRpcResponseStruct, + resolveAccountOptions: RESOLVE_ACCOUNT_FULL_FROM_KEYRING_STATE, + }); + this.#assetMetadataService = assetMetadataService; + this.#transactionService = transactionService; + this.#logger = prefixedLogger; + } + + /** + * Preflight-validates a send amount for an asset transfer by building a + * validated send transaction (balance and fee checks only; nothing is signed + * or submitted). Uses cached network reads for SEP-41 fee simulation so + * repeated amount checks stay responsive. + * + * @param resolved - Keyring account, persisted on-chain snapshot, and wallet. + * @param request - JSON-RPC request with `assetId`, `value` (positive amount string), and optional `to`. + * @returns Validation result with `valid` and optional error codes. + */ + protected async execute( + resolved: ResolvedActivatedAccount, + request: OnAmountInputJsonRpcRequest, + ): Promise { + try { + const { onChainAccount } = resolved; + const { assetId, value, to } = request.params; + + const scope = parseCaipAssetType(assetId).chainId as KnownCaip2ChainId; + const { units } = await this.#assetMetadataService.resolve(assetId); + const { decimals } = units[0]; + + const amountInSmallestUnit = toSmallestUnit( + new BigNumber(value), + decimals, + ); + + if (hasDecimals(amountInSmallestUnit)) { + return { + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }; + } + + await this.#transactionService.createValidatedSendTransaction({ + onChainAccount, + scope, + assetId, + amount: amountInSmallestUnit, + // If no destination is provided, validate a self-transfer to the sender. + destination: to ?? onChainAccount.accountId, + // Use cached network reads so repeated amount checks stay fast. + useCache: true, + }); + + return { + valid: true, + errors: [], + }; + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to validate amount input', + error, + ); + if (error instanceof InsufficientBalanceException) { + return { + valid: false, + errors: [{ code: MultiChainSendErrorCodes.InsufficientBalance }], + }; + } + if (error instanceof InsufficientBalanceToCoverFeeException) { + return { + valid: false, + errors: [ + { code: MultiChainSendErrorCodes.InsufficientBalanceToCoverFee }, + ], + }; + } + if ( + error instanceof TransactionValidationException || + error instanceof AccountNotActivatedException + ) { + return { + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }; + } + throw error; + } + } + + /** + * Override the base handler to return invalid when the account is not activated. + * Instead of showing the account not activated alert, it returns an invalid response. + * + * @param _error - The error to handle. + * @returns The invalid response when the account is not activated. + */ + protected override async handleAccountNotActivatedError( + _error: AccountNotActivatedException, + ): Promise { + return { + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts index 616b9e51..c6e6c209 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts @@ -7,10 +7,13 @@ import { import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import { hexToBytes } from '@metamask/utils'; import { Networks } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; +import { TransactionScopeNotMatchException } from './exceptions'; import { KeyringTransactionType } from './KeyringTransactionBuilder'; import type { Transaction } from './Transaction'; import { TransactionBuilder } from './TransactionBuilder'; +import { TransactionRepository } from './TransactionRepository'; import type { KnownCaip19ClassicAssetId } from '../../api'; import { KnownCaip2ChainId } from '../../api'; import { getSlip44AssetId, getSnapProvider } from '../../utils'; @@ -22,9 +25,15 @@ import { } from './__mocks__/transaction.fixtures'; import { generateMockStellarKeyringAccounts } from '../account/__mocks__/account.fixtures'; import type { StellarKeyringAccount } from '../account/api'; -import { NetworkService, TransactionRetryableException } from '../network'; -import { TransactionScopeNotMatchException } from './exceptions'; -import { TransactionRepository } from './TransactionRepository'; +import { + USDC_CLASSIC, + USDC_SEP41, +} from '../asset-metadata/__mocks__/assets.fixtures'; +import { + AccountNotActivatedException, + NetworkService, + TransactionRetryableException, +} from '../network'; import { OnChainAccount } from '../on-chain-account'; import { createMockAccountWithBalances, @@ -41,11 +50,6 @@ jest.mock('@metamask/keyring-snap-sdk', () => ({ })); describe('TransactionService', () => { - beforeEach(() => { - jest.mocked(emitSnapKeyringEvent).mockReset(); - jest.mocked(emitSnapKeyringEvent).mockResolvedValue(undefined); - }); - describe('savePendingKeyringTransaction', () => { it('creates and saves a pending send transaction', async () => { const { transactionService, transactionRepositorySaveManySpy } = @@ -600,4 +604,251 @@ describe('TransactionService', () => { expect(rebuildTxnWithNewSeqSpy).not.toHaveBeenCalled(); }); }); + + describe('createValidatedSendTransaction', () => { + it('returns a payment transaction for native XLM to an activated destination', async () => { + const { transactionService } = createMockTransactionService(); + const sourceWallet = getTestWallet(); + const destWallet = getTestWallet(); + + const sourceAcc = createMockAccountWithBalances( + sourceWallet.address, + '1', + { ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, nativeBalance: 500 }, + ); + const sourceOnChain = new OnChainAccount( + sourceAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(sourceAcc, KnownCaip2ChainId.Mainnet), + ); + + const destAcc = createMockAccountWithBalances(destWallet.address, '1', { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 50, + }); + const destOnChain = new OnChainAccount( + destAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(destAcc, KnownCaip2ChainId.Mainnet), + ); + + jest + .spyOn(NetworkService.prototype, 'loadOnChainAccount') + .mockResolvedValue(destOnChain); + jest + .spyOn(NetworkService.prototype, 'getBaseFee') + .mockResolvedValue(new BigNumber('100')); + + const tx = await transactionService.createValidatedSendTransaction({ + onChainAccount: sourceOnChain, + amount: new BigNumber('1000000'), + scope: KnownCaip2ChainId.Mainnet, + assetId: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + destination: destWallet.address, + }); + + expect(tx.transactionOperations).toHaveLength(1); + expect(tx.transactionOperations[0]?.type).toBe('payment'); + }); + + it('returns a createAccount transaction for native XLM to an unfunded destination', async () => { + const { transactionService } = createMockTransactionService(); + const sourceWallet = getTestWallet(); + const unfundedDestination = getTestWallet().address; + + const sourceAcc = createMockAccountWithBalances( + sourceWallet.address, + '1', + { ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, nativeBalance: 500 }, + ); + const sourceOnChain = new OnChainAccount( + sourceAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(sourceAcc, KnownCaip2ChainId.Mainnet), + ); + + jest + .spyOn(NetworkService.prototype, 'loadOnChainAccount') + .mockRejectedValue( + new AccountNotActivatedException( + unfundedDestination, + KnownCaip2ChainId.Mainnet, + ), + ); + jest + .spyOn(NetworkService.prototype, 'getBaseFee') + .mockResolvedValue(new BigNumber('100')); + + const tx = await transactionService.createValidatedSendTransaction({ + onChainAccount: sourceOnChain, + amount: new BigNumber('20000000'), + scope: KnownCaip2ChainId.Mainnet, + assetId: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + destination: unfundedDestination, + }); + + expect(tx.hasCreateAccount).toBe(true); + }); + + it('returns a SEP-41 transfer transaction when destination is activated', async () => { + const { transactionService } = createMockTransactionService(); + const sourceWallet = getTestWallet(); + const destWallet = getTestWallet(); + + const sourceAcc = createMockAccountWithBalances( + sourceWallet.address, + '1', + { ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, nativeBalance: 500 }, + ); + const sourceOnChain = new OnChainAccount( + sourceAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(sourceAcc, KnownCaip2ChainId.Mainnet), + ); + + const destAcc = createMockAccountWithBalances(destWallet.address, '1', { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 50, + }); + const destOnChain = new OnChainAccount( + destAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(destAcc, KnownCaip2ChainId.Mainnet), + ); + + jest + .spyOn(NetworkService.prototype, 'loadOnChainAccount') + .mockResolvedValue(destOnChain); + jest + .spyOn(NetworkService.prototype, 'simulateTransaction') + .mockImplementation(async (transaction) => transaction); + jest + .spyOn(NetworkService.prototype, 'getSep41AssetBalances') + .mockResolvedValue({ + [sourceWallet.address]: { + [USDC_SEP41]: new BigNumber(1_000_000), + }, + }); + + const tx = await transactionService.createValidatedSendTransaction({ + onChainAccount: sourceOnChain, + amount: new BigNumber('100'), + scope: KnownCaip2ChainId.Mainnet, + assetId: USDC_SEP41, + destination: destWallet.address, + }); + + expect(tx.hasInvokeHostFunction).toBe(true); + }); + + it('throws AccountNotActivatedException when sending a classic asset to an unfunded destination', async () => { + const { transactionService } = createMockTransactionService(); + const sourceWallet = getTestWallet(); + const unfundedDestination = getTestWallet().address; + + const sourceAcc = createMockAccountWithBalances( + sourceWallet.address, + '1', + { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 500, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + balance: 1000, + }, + ], + }, + ); + const sourceOnChain = new OnChainAccount( + sourceAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(sourceAcc, KnownCaip2ChainId.Mainnet), + ); + + jest + .spyOn(NetworkService.prototype, 'loadOnChainAccount') + .mockRejectedValue( + new AccountNotActivatedException( + unfundedDestination, + KnownCaip2ChainId.Mainnet, + ), + ); + + const error = await transactionService + .createValidatedSendTransaction({ + onChainAccount: sourceOnChain, + amount: new BigNumber('1000000'), + scope: KnownCaip2ChainId.Mainnet, + assetId: USDC_CLASSIC, + destination: unfundedDestination, + }) + .then( + () => { + throw new Error('expected rejection'); + }, + (rejection: unknown) => rejection, + ); + + expect(error).toBeInstanceOf(AccountNotActivatedException); + expect((error as AccountNotActivatedException).address).toBe( + unfundedDestination, + ); + expect((error as AccountNotActivatedException).scope).toBe( + KnownCaip2ChainId.Mainnet, + ); + }); + + it('throws AccountNotActivatedException when sending SEP-41 to an unfunded destination', async () => { + const { transactionService } = createMockTransactionService(); + const sourceWallet = getTestWallet(); + const unfundedDestination = getTestWallet().address; + + const sourceAcc = createMockAccountWithBalances( + sourceWallet.address, + '1', + { ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, nativeBalance: 500 }, + ); + const sourceOnChain = new OnChainAccount( + sourceAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(sourceAcc, KnownCaip2ChainId.Mainnet), + ); + + jest + .spyOn(NetworkService.prototype, 'loadOnChainAccount') + .mockRejectedValue( + new AccountNotActivatedException( + unfundedDestination, + KnownCaip2ChainId.Mainnet, + ), + ); + + const error = await transactionService + .createValidatedSendTransaction({ + onChainAccount: sourceOnChain, + amount: new BigNumber('100'), + scope: KnownCaip2ChainId.Mainnet, + assetId: USDC_SEP41, + destination: unfundedDestination, + }) + .then( + () => { + throw new Error('expected rejection'); + }, + (rejection: unknown) => rejection, + ); + + expect(error).toBeInstanceOf(AccountNotActivatedException); + expect((error as AccountNotActivatedException).address).toBe( + unfundedDestination, + ); + expect((error as AccountNotActivatedException).scope).toBe( + KnownCaip2ChainId.Mainnet, + ); + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index 6af7c90b..447fc2cc 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -11,15 +11,25 @@ import { KeyringTransactionBuilder } from './KeyringTransactionBuilder'; import type { Transaction } from './Transaction'; import type { TransactionBuilder } from './TransactionBuilder'; import type { TransactionRepository } from './TransactionRepository'; -import type { KnownCaip19ClassicAssetId, KnownCaip2ChainId } from '../../api'; -import { getSnapProvider } from '../../utils'; +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip19ClassicAssetId, + KnownCaip19Sep41AssetId, + KnownCaip19Slip44Id, + KnownCaip2ChainId, +} from '../../api'; +import { getSnapProvider, isSep41Id, isSlip44Id } from '../../utils'; import type { ILogger } from '../../utils/logger'; import { createPrefixedLogger } from '../../utils/logger'; import type { StellarKeyringAccount } from '../account/api'; import type { NetworkService } from '../network'; -import { TransactionRetryableException } from '../network/exceptions'; +import { + AccountNotActivatedException, + TransactionRetryableException, +} from '../network/exceptions'; import type { OnChainAccount } from '../on-chain-account/OnChainAccount'; import type { Wallet } from '../wallet'; +import { InsufficientBalanceException } from './exceptions'; import { SupportedOperations, TransactionSimulator, @@ -102,6 +112,236 @@ export class TransactionService { return transaction; } + /** + * Creates a validated send transaction. + * + * @param params - The parameters for the transaction. + * @param params.onChainAccount - The on-chain account. + * @param params.amount - The amount to send. + * @param params.scope - The CAIP-2 chain ID. + * @param params.assetId - The CAIP-19 asset ID. + * @param params.destination - The destination address. + * @param params.useCache - Whether to use the cache. + * @returns A promise that resolves to the validated transaction. + */ + async createValidatedSendTransaction(params: { + onChainAccount: OnChainAccount; + amount: BigNumber; + scope: KnownCaip2ChainId; + assetId: KnownCaip19AssetIdOrSlip44Id; + destination: string; + useCache?: boolean; + }): Promise { + const { + onChainAccount, + scope, + assetId, + amount, + destination, + useCache = false, + } = params; + + let destinationAccount: OnChainAccount | null = null; + if (onChainAccount.accountId === destination) { + destinationAccount = onChainAccount; + } else { + destinationAccount = await this.#loadActivatedAccountOrNull( + destination, + scope, + useCache, + ); + } + + const isSep41 = isSep41Id(assetId); + + // If it is SEP-41, run SEP-41 transfer flow to build and validate the transaction + if (isSep41) { + // fail early if the destination account is not activated + if (destinationAccount === null) { + throw new AccountNotActivatedException(destination, scope); + } + + return this.#createValidatedSep41Transfer({ + onChainAccount, + scope, + assetId, + amount, + destination, + destinationAccount, + useCache, + }); + } + + // If it is classic asset, run classic asset transfer flow to build and validate the transaction + return this.#createValidatedClassicAssetTransfer({ + onChainAccount, + scope, + assetId, + amount, + destination, + destinationAccount, + }); + } + + /** + * Creates a validated SEP-41 transfer transaction (Soroban contract transfer). + * + * @param params - The parameters for the transaction. + * @param params.onChainAccount - The on-chain account. + * @param params.scope - The CAIP-2 chain ID. + * @param params.assetId - The CAIP-19 SEP-41 asset ID. + * @param params.amount - The amount to send. + * @param params.destination - The destination address. + * @param params.destinationAccount - The destination account. + * @param params.useCache - When `true`, reuses a cached SEP-41 simulation keyed by + * asset, sender, recipient, and scope (not amount). Use only for preflight checks + * such as amount-input validation, where the caller needs fee/balance feedback on + * every keystroke without an RPC call per amount. Balance is checked locally before + * simulation, so insufficient funds still fail fast. When `false` (default), always + * simulates fresh so the returned transaction is safe to sign and submit. + * @returns A promise that resolves to the validated transaction. + */ + async #createValidatedSep41Transfer(params: { + onChainAccount: OnChainAccount; + scope: KnownCaip2ChainId; + assetId: KnownCaip19Sep41AssetId; + amount: BigNumber; + destination: string; + destinationAccount: OnChainAccount; + useCache: boolean; + }): Promise { + const { + onChainAccount, + scope, + assetId, + amount, + destination, + destinationAccount, + useCache, + } = params; + + let transaction = this.#transactionBuilder.sep41Transfer({ + onChainAccount, + scope, + assetId, + amount, + destination, + }); + + // Use getRawAsset so we only fetch when the asset is absent from the State. + // Use getAsset hides zero-balance SEP-41 entries and would trigger a redundant on-chain fetch. + if (!onChainAccount.getRawAsset(assetId)) { + const onChainBalance = await this.#networkService.getSep41AssetBalances({ + accounts: [onChainAccount.accountId], + assetIds: [assetId], + scope, + }); + onChainAccount.setAsset(assetId, { + balance: + onChainBalance?.[onChainAccount.accountId]?.[assetId] ?? + new BigNumber(0), + // We don't need symbol/decimals for a SEP-41 asset here; simulation + // does not use them. + symbol: '', + }); + } + + // Simulation will throw an error if the balance is less than the sending amount, + // so we can fail early here. + if (onChainAccount.getRawAsset(assetId)?.balance.lt(amount)) { + throw new InsufficientBalanceException( + onChainAccount.accountId, + amount.toString(), + ); + } + + // Simulate the transaction to estimate the network fee for contract call + transaction = await this.#networkService.simulateSep41TransferWithCache({ + transaction, + scope, + assetId, + fromAccountId: onChainAccount.accountId, + toAccountId: destination, + // With useCache=true the cached XDR may carry a stale amount or sequence; + // Callers must only use that path for preflight (e.g. onAmountInput), not signing. + refreshCache: !useCache, + }); + + this.validateTransaction(transaction, onChainAccount, { + expectedOPTypes: [SupportedOperations.InvokeHostFunction], + preloadedAccounts: destinationAccount ? [destinationAccount] : undefined, + }); + + return transaction; + } + + /** + * Creates a validated classic asset transfer transaction. + * Classic assets use the chain's native transfer mechanism. + * If the destination is not activated, a `createAccount` operation can only + * be added for slip44/native asset transfers. For non-slip44 classic assets, + * the destination account must already be activated or an + * `AccountNotActivatedException` will be thrown. + * If the destination is activated, a payment operation will be added to the + * transaction. + * + * @param params - The parameters for the transaction. + * @param params.onChainAccount - The on-chain account. + * @param params.scope - The CAIP-2 chain ID. + * @param params.assetId - The CAIP-19 classic asset ID. + * @param params.amount - The amount to send. + * @param params.destination - The destination address. + * @param params.destinationAccount - The destination account. + * @returns A promise that resolves to the validated transaction. + */ + async #createValidatedClassicAssetTransfer(params: { + onChainAccount: OnChainAccount; + scope: KnownCaip2ChainId; + assetId: KnownCaip19ClassicAssetId | KnownCaip19Slip44Id; + amount: BigNumber; + destination: string; + destinationAccount: OnChainAccount | null; + }): Promise { + const { + onChainAccount, + scope, + assetId, + amount, + destinationAccount, + destination, + } = params; + + const isDestinationActivated = destinationAccount !== null; + + // fail early if the destination account is not activated and the asset is not slip44 + if (!isDestinationActivated && !isSlip44Id(assetId)) { + throw new AccountNotActivatedException(destination, scope); + } + + const baseFee = await this.getBaseFee(scope); + + const transaction = this.#transactionBuilder.transfer({ + onChainAccount, + scope, + assetId, + amount, + destination: { + address: destination, + isActivated: isDestinationActivated, + }, + baseFee, + }); + + this.validateTransaction(transaction, onChainAccount, { + expectedOPTypes: isDestinationActivated + ? [SupportedOperations.Payment] + : [SupportedOperations.CreateAccount], + preloadedAccounts: destinationAccount ? [destinationAccount] : undefined, + }); + + return transaction; + } + /** * Creates a validated swap transaction from a Base64 encoded XDR. * @@ -165,6 +405,25 @@ export class TransactionService { ); } + async #loadActivatedAccountOrNull( + accountAddress: string, + scope: KnownCaip2ChainId, + useCache: boolean = false, + ): Promise { + try { + return await this.#networkService.loadOnChainAccountWithCache( + accountAddress, + scope, + !useCache, + ); + } catch (error: unknown) { + if (error instanceof AccountNotActivatedException) { + return null; + } + throw error; + } + } + /** * Create and save a pending keyring transaction. * diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.ts index 73b3493b..f4b2a2a4 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/currency.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.ts @@ -22,6 +22,20 @@ export function toSmallestUnit( return amount.multipliedBy(BigNumber(10).pow(decimalPlaces)); } +/** + * Checks if an amount has decimal places. + * + * @param amount - The amount to check. + * @returns True if the amount has decimal places, false otherwise. + */ +export function hasDecimals(amount: BigNumber): boolean { + const decimalPlaces = amount.decimalPlaces(); + if (decimalPlaces === null) { + return false; + } + return decimalPlaces > 0; +} + /** * Converts an amount from the smallest unit to a human-readable amount. * From 80a27c3d90b2373065e07faa9f35efd9358796ff Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 21 May 2026 21:49:20 +0800 Subject: [PATCH 242/384] refactor: add confirmation refresher (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Explanation This PR refactors confirmation-dialog background updates into a single “confirmation context refresh” cronjob that orchestrates one or more pluggable refreshers (starting with token spot prices), and wires it into the confirmation UX controller and snap cronjob dispatch. ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- .../stellar-wallet-snap/src/context.ts | 21 +- .../src/handlers/cronjob/api.test.ts | 14 +- .../src/handlers/cronjob/api.ts | 39 +- .../__fixtures__/context.fixtures.ts | 43 ++ .../cronjob/refreshConfirmationContext/api.ts | 65 ++++ .../handler.test.ts | 367 ++++++++++++++++++ .../refreshConfirmationContext/handler.ts | 266 +++++++++++++ .../refreshConfirmationContext/index.ts | 12 + .../priceRefresher.test.ts | 132 +++++++ .../priceRefresher.ts | 112 ++++++ .../cronjob/refreshConfirmationPrices.ts | 199 ---------- .../src/ui/confirmation/controller.tsx | 27 +- 12 files changed, 1057 insertions(+), 240 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/__fixtures__/context.fixtures.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/handler.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/handler.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/index.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/priceRefresher.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/priceRefresher.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index a437c0aa..7fe9b37a 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -16,7 +16,10 @@ import { OnAmountInputHandler } from './handlers/clientRequest/onAmountInput'; import { SignAndSendTransactionHandler } from './handlers/clientRequest/signAndSendTransaction'; import type { ICronjobRequestHandler } from './handlers/cronjob/api'; import { BackgroundEventMethod } from './handlers/cronjob/api'; -import { RefreshConfirmationPricesHandler } from './handlers/cronjob/refreshConfirmationPrices'; +import { + ConfirmationPriceRefresher, + RefreshConfirmationContextHandler, +} from './handlers/cronjob/refreshConfirmationContext'; import { SyncAccountsHandler } from './handlers/cronjob/syncAccounts'; import { TrackTransactionHandler } from './handlers/cronjob/trackTransaction'; import type { IKeyringRequestHandler } from './handlers/keyring'; @@ -161,13 +164,19 @@ const userInputHandler = new UserInputHandler({ }); /** ------------------------------ Cronjob Handler ------------------------------ */ - -const refreshConfirmationPricesHandler = new RefreshConfirmationPricesHandler({ +const confirmationPriceRefresher = new ConfirmationPriceRefresher({ logger, priceService, - confirmationUIController, }); +const refreshConfirmationContextHandler = new RefreshConfirmationContextHandler( + { + logger, + confirmationUIController, + refreshers: [confirmationPriceRefresher], + }, +); + const trackTransactionHandler = new TrackTransactionHandler({ logger, networkService, @@ -186,8 +195,8 @@ const cronjobMethodHandlers: Record< BackgroundEventMethod, ICronjobRequestHandler > = { - [BackgroundEventMethod.RefreshConfirmationPrices]: - refreshConfirmationPricesHandler, + [BackgroundEventMethod.RefreshConfirmationContext]: + refreshConfirmationContextHandler, [BackgroundEventMethod.TrackTransaction]: trackTransactionHandler, [BackgroundEventMethod.SynchronizeAccounts]: syncAccountsHandler, }; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts index c85ff3cd..510cff8b 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts @@ -4,7 +4,7 @@ import { BackgroundEventMethod, BackgroundEventMethodStruct, CronjobJsonRpcRequestStruct, - RefreshConfirmationPricesJsonRpcRequestStruct, + RefreshConfirmationContextJsonRpcRequestStruct, SyncAccountJsonRpcRequestStruct, SyncAccountParamsStruct, TrackTransactionJsonRpcRequestStruct, @@ -126,25 +126,27 @@ describe('Cronjob API structs', () => { }); }); - describe('RefreshConfirmationPricesJsonRpcRequestStruct', () => { - it('accepts refresh confirmation prices requests', () => { + describe('RefreshConfirmationContextJsonRpcRequestStruct', () => { + it('accepts refresh confirmation context requests', () => { const value = { ...jsonRpcBase, - method: BackgroundEventMethod.RefreshConfirmationPrices, + method: BackgroundEventMethod.RefreshConfirmationContext, params: { scope: KnownCaip2ChainId.Mainnet, interfaceId: 'interface-id', interfaceKey: ConfirmationInterfaceKey.SignTransaction, + refresherKeys: ['prices'], }, }; - assert(value, RefreshConfirmationPricesJsonRpcRequestStruct); + assert(value, RefreshConfirmationContextJsonRpcRequestStruct); expect(value).toStrictEqual({ ...jsonRpcBase, - method: BackgroundEventMethod.RefreshConfirmationPrices, + method: BackgroundEventMethod.RefreshConfirmationContext, params: { scope: KnownCaip2ChainId.Mainnet, interfaceId: 'interface-id', interfaceKey: ConfirmationInterfaceKey.SignTransaction, + refresherKeys: ['prices'], }, }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts index 68f2ef62..0d30bd51 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts @@ -21,6 +21,7 @@ import { KnownCaip2ChainIdStruct, UuidStruct, } from '../../api'; +import { ConfirmationContextRefresherKeyStruct } from './refreshConfirmationContext/api'; import { ConfirmationInterfaceKeyStruct } from '../../ui/confirmation/api'; /** @@ -32,20 +33,30 @@ export type ICronjobRequestHandler = { export enum BackgroundEventMethod { SynchronizeAccounts = 'synchronizeAccounts', - RefreshConfirmationPrices = 'refreshConfirmationPrices', TrackTransaction = 'trackTransaction', + RefreshConfirmationContext = 'refreshConfirmationContext', } export const BackgroundEventMethodStruct = enums( Object.values(BackgroundEventMethod), ); -export const RefreshConfirmationPricesParamsStruct = type({ +export const RefreshConfirmationContextParamsStruct = type({ scope: KnownCaip2ChainIdStruct, interfaceId: nonempty(string()), interfaceKey: ConfirmationInterfaceKeyStruct, + /** Refresher keys to run; omitted keys are skipped for this cycle. */ + refresherKeys: nonempty(array(ConfirmationContextRefresherKeyStruct)), }); +export const RefreshConfirmationContextJsonRpcRequestStruct = assign( + JsonRpcRequestStruct, + object({ + method: literal(BackgroundEventMethod.RefreshConfirmationContext), + params: RefreshConfirmationContextParamsStruct, + }), +); + export const TrackTransactionParamsStruct = type({ txId: nonempty(string()), scope: KnownCaip2ChainIdStruct, @@ -61,14 +72,6 @@ export const SyncAccountParamsStruct = object({ ), }); -export const RefreshConfirmationPricesJsonRpcRequestStruct = assign( - JsonRpcRequestStruct, - object({ - method: literal(BackgroundEventMethod.RefreshConfirmationPrices), - params: RefreshConfirmationPricesParamsStruct, - }), -); - export const TrackTransactionJsonRpcRequestStruct = assign( JsonRpcRequestStruct, object({ @@ -93,14 +96,6 @@ export const CronjobJsonRpcRequestStruct = object({ export type CronjobJsonRpcRequest = Infer; -export type RefreshConfirmationPricesJsonRpcRequest = Infer< - typeof RefreshConfirmationPricesJsonRpcRequestStruct ->; - -export type RefreshConfirmationPricesParams = Infer< - typeof RefreshConfirmationPricesParamsStruct ->; - export type TrackTransactionJsonRpcRequest = Infer< typeof TrackTransactionJsonRpcRequestStruct >; @@ -112,3 +107,11 @@ export type SyncAccountJsonRpcRequest = Infer< >; export type SyncAccountParams = Infer; + +export type RefreshConfirmationContextJsonRpcRequest = Infer< + typeof RefreshConfirmationContextJsonRpcRequestStruct +>; + +export type RefreshConfirmationContextParams = Infer< + typeof RefreshConfirmationContextParamsStruct +>; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/__fixtures__/context.fixtures.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/__fixtures__/context.fixtures.ts new file mode 100644 index 00000000..0a50e5e5 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/__fixtures__/context.fixtures.ts @@ -0,0 +1,43 @@ +import { KnownCaip2ChainId } from '../../../../api'; +import { + ConfirmationInterfaceKey, + type ContextWithPrices, + FetchStatus, +} from '../../../../ui/confirmation/api'; +import { getSlip44AssetId } from '../../../../utils'; +import type { RefreshConfirmationContextParams } from '../../api'; +import { + ConfirmationContextRefresherKey, + type ConfirmationDataContext, +} from '../api'; + +const scope = KnownCaip2ChainId.Testnet; +const nativeAssetId = getSlip44AssetId(scope); + +export const confirmationContextRequestParams: RefreshConfirmationContextParams = + { + scope, + interfaceId: 'interface-id-1', + interfaceKey: ConfirmationInterfaceKey.SignTransaction, + refresherKeys: [ConfirmationContextRefresherKey.Prices], + }; + +/** + * Builds a valid confirmation refresh context for tests. + * + * @param overrides - Partial fields to override on the default context. + * @returns A context that satisfies {@link ContextWithPricesStruct}. + */ +export function createConfirmationDataContext( + overrides: Partial = {}, +): ConfirmationDataContext { + return { + tokenPrices: { + [nativeAssetId]: null, + } as ContextWithPrices['tokenPrices'], + tokenPricesFetchStatus: FetchStatus.Fetching, + currency: 'usd', + preferences: { useExternalPricingData: true }, + ...overrides, + }; +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/api.ts new file mode 100644 index 00000000..8be210ab --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/api.ts @@ -0,0 +1,65 @@ +import { enums } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; + +import type { ContextWithPrices } from '../../../ui/confirmation/api'; + +/** Identifies which confirmation context refreshers to run for a cron cycle. */ +export enum ConfirmationContextRefresherKey { + Prices = 'prices', + /** TODO: Reserved for security scan; wire in context when implemented. */ + Scan = 'scan', +} + +export const ConfirmationContextRefresherKeyStruct = enums( + Object.values(ConfirmationContextRefresherKey), +); + +/** + * Context the handler passes to refreshers. + */ +export type ConfirmationDataContext = Record & ContextWithPrices; + +/** Outcome of one refresher cycle. `null` means no work was needed. */ +export type ConfirmationContextRefreshResult = { + result: Record; + reschedule: boolean; +} | null; + +/** + * Contract for a single background data source (prices, security scan, …). + */ +export type IConfirmationContextRefresher = { + /** Stable id used in cron params to select this refresher. */ + readonly key: ConfirmationContextRefresherKey; + + /** + * Returns whether this cycle should call. + * When false, the handler uses {@link IConfirmationContextRefresher.recoveryResult} instead. + */ + shouldFetch: (ctx: ConfirmationDataContext) => boolean; + + /** + * Patch applied when {@link IConfirmationContextRefresher.shouldFetch} is false + * (e.g. clear a stuck loading state). Return `null` when the context is already settled. + */ + recoveryResult: ( + ctx: ConfirmationDataContext, + ) => ConfirmationContextRefreshResult; + + /** + * Fetches fresh data when {@link IConfirmationContextRefresher.shouldFetch} is true. + */ + refresh: ( + ctx: ConfirmationDataContext, + ) => Promise; + + /** + * Returns false when this refresher cannot safely read `ctx` (missing or + * malformed fields). Only enabled refreshers (by key) are validated and run. + */ + isValidContext: (ctx: Record) => boolean; +}; + +/** Composed refreshers passed into the confirmation context handler. */ +export type ConfirmationContextRefreshers = + readonly IConfirmationContextRefresher[]; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/handler.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/handler.test.ts new file mode 100644 index 00000000..22fdaae0 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/handler.test.ts @@ -0,0 +1,367 @@ +import { BackgroundEventMethod } from '../api'; +import { + confirmationContextRequestParams, + createConfirmationDataContext, +} from './__fixtures__/context.fixtures'; +import type { + ConfirmationContextRefreshResult, + IConfirmationContextRefresher, +} from './api'; +import { ConfirmationContextRefresherKey } from './api'; +import { RefreshConfirmationContextHandler } from './handler'; +import { + ConfirmationInterfaceKey, + FetchStatus, +} from '../../../ui/confirmation/api'; +import type { ConfirmationUXController } from '../../../ui/confirmation/controller'; +import { Duration } from '../../../utils'; +import { logger } from '../../../utils/logger'; +import { + getInterfaceContextIfExists, + scheduleBackgroundEvent, +} from '../../../utils/snap'; + +jest.mock('../../../utils/logger'); +jest.mock('../../../utils/snap', () => { + const actual = jest.requireActual('../../../utils/snap'); + return { + ...actual, + getInterfaceContextIfExists: jest.fn(), + scheduleBackgroundEvent: jest.fn().mockResolvedValue('scheduled'), + }; +}); + +describe('RefreshConfirmationContextHandler', () => { + const baseContext = createConfirmationDataContext(); + + function createMockRefresher( + key: ConfirmationContextRefresherKey, + overrides: Partial = {}, + ): IConfirmationContextRefresher { + return { + key, + shouldFetch: jest.fn().mockReturnValue(true), + recoveryResult: jest.fn().mockReturnValue(null), + refresh: jest.fn().mockResolvedValue(null), + isValidContext: jest.fn().mockReturnValue(true), + ...overrides, + }; + } + + function setup(refreshers: readonly IConfirmationContextRefresher[]) { + const updateConfirmation = jest.fn().mockResolvedValue(undefined); + const confirmationUIController = { + updateConfirmation, + } as unknown as ConfirmationUXController; + + const handler = new RefreshConfirmationContextHandler({ + logger, + confirmationUIController, + refreshers, + }); + + return { handler, updateConfirmation }; + } + + it('schedules refresh confirmation context background event', async () => { + await RefreshConfirmationContextHandler.scheduleBackgroundEvent( + confirmationContextRequestParams, + Duration.FiveSeconds, + ); + + expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.RefreshConfirmationContext, + params: confirmationContextRequestParams, + duration: Duration.FiveSeconds, + }); + }); + + it('returns early when the interface no longer exists', async () => { + jest.mocked(getInterfaceContextIfExists).mockResolvedValue(null); + + const refresher = createMockRefresher( + ConfirmationContextRefresherKey.Prices, + ); + const { handler, updateConfirmation } = setup([refresher]); + + await handler.handle({ + jsonrpc: '2.0', + id: '1', + method: BackgroundEventMethod.RefreshConfirmationContext, + params: confirmationContextRequestParams, + }); + + expect(refresher.refresh).not.toHaveBeenCalled(); + expect(updateConfirmation).not.toHaveBeenCalled(); + expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); + }); + + it('skips refresh when a refresher rejects the context shape', async () => { + jest.mocked(getInterfaceContextIfExists).mockResolvedValue(baseContext); + + const refresher = createMockRefresher( + ConfirmationContextRefresherKey.Prices, + { + isValidContext: jest.fn().mockReturnValue(false), + }, + ); + const { handler, updateConfirmation } = setup([refresher]); + + await handler.handle({ + jsonrpc: '2.0', + id: '1', + method: BackgroundEventMethod.RefreshConfirmationContext, + params: confirmationContextRequestParams, + }); + + expect(refresher.refresh).not.toHaveBeenCalled(); + expect(updateConfirmation).not.toHaveBeenCalled(); + }); + + it('returns early when every refresher is idle', async () => { + jest.mocked(getInterfaceContextIfExists).mockResolvedValue(baseContext); + + const refresher = createMockRefresher( + ConfirmationContextRefresherKey.Prices, + { + refresh: jest.fn().mockResolvedValue(null), + }, + ); + const { handler, updateConfirmation } = setup([refresher]); + + await handler.handle({ + jsonrpc: '2.0', + id: '1', + method: BackgroundEventMethod.RefreshConfirmationContext, + params: confirmationContextRequestParams, + }); + + expect(refresher.shouldFetch).toHaveBeenCalledWith(baseContext); + expect(refresher.refresh).toHaveBeenCalledWith(baseContext); + expect(refresher.recoveryResult).not.toHaveBeenCalled(); + expect(updateConfirmation).not.toHaveBeenCalled(); + expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); + }); + + it('uses recoveryResult without calling refresh when shouldFetch is false', async () => { + jest + .mocked(getInterfaceContextIfExists) + .mockResolvedValueOnce(baseContext) + .mockResolvedValueOnce(baseContext); + + const recoveryPatch: ConfirmationContextRefreshResult = { + result: { tokenPricesFetchStatus: FetchStatus.Fetched }, + reschedule: false, + }; + + const pricesRefresher = createMockRefresher( + ConfirmationContextRefresherKey.Prices, + { + shouldFetch: jest.fn().mockReturnValue(false), + recoveryResult: jest.fn().mockReturnValue(recoveryPatch), + }, + ); + const scanRefresher = createMockRefresher( + ConfirmationContextRefresherKey.Scan, + { + shouldFetch: jest.fn().mockReturnValue(true), + refresh: jest.fn().mockResolvedValue(null), + }, + ); + + const { handler, updateConfirmation } = setup([ + pricesRefresher, + scanRefresher, + ]); + + await handler.handle({ + jsonrpc: '2.0', + id: '1', + method: BackgroundEventMethod.RefreshConfirmationContext, + params: { + ...confirmationContextRequestParams, + refresherKeys: [ + ConfirmationContextRefresherKey.Prices, + ConfirmationContextRefresherKey.Scan, + ], + }, + }); + + expect(pricesRefresher.recoveryResult).toHaveBeenCalledWith(baseContext); + expect(pricesRefresher.refresh).not.toHaveBeenCalled(); + expect(scanRefresher.shouldFetch).toHaveBeenCalledWith(baseContext); + expect(scanRefresher.refresh).toHaveBeenCalledWith(baseContext); + expect(scanRefresher.recoveryResult).not.toHaveBeenCalled(); + expect(updateConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ + updatedContext: expect.objectContaining({ + tokenPricesFetchStatus: FetchStatus.Fetched, + }), + }), + ); + expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); + }); + + it('merges refresher patches, re-renders, and reschedules when requested', async () => { + const latestContext = createConfirmationDataContext({ + tokenPricesFetchStatus: FetchStatus.Fetching, + }); + jest + .mocked(getInterfaceContextIfExists) + .mockResolvedValueOnce(baseContext) + .mockResolvedValueOnce(latestContext); + + const patch: ConfirmationContextRefreshResult = { + result: { + tokenPricesFetchStatus: FetchStatus.Fetched, + extraField: 'patched', + }, + reschedule: true, + }; + + const refresher = createMockRefresher( + ConfirmationContextRefresherKey.Prices, + { + refresh: jest.fn().mockResolvedValue(patch), + }, + ); + const { handler, updateConfirmation } = setup([refresher]); + + await handler.handle({ + jsonrpc: '2.0', + id: '1', + method: BackgroundEventMethod.RefreshConfirmationContext, + params: confirmationContextRequestParams, + }); + + expect(updateConfirmation).toHaveBeenCalledWith({ + interfaceId: confirmationContextRequestParams.interfaceId, + interfaceKey: ConfirmationInterfaceKey.SignTransaction, + updatedContext: { + ...latestContext, + tokenPricesFetchStatus: FetchStatus.Fetched, + extraField: 'patched', + }, + }); + expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.RefreshConfirmationContext, + params: confirmationContextRequestParams, + duration: Duration.TwentySeconds, + }); + }); + + it('re-renders without rescheduling when no refresher requests it', async () => { + jest + .mocked(getInterfaceContextIfExists) + .mockResolvedValueOnce(baseContext) + .mockResolvedValueOnce(baseContext); + + const refresher = createMockRefresher( + ConfirmationContextRefresherKey.Prices, + { + refresh: jest.fn().mockResolvedValue({ + result: { tokenPricesFetchStatus: FetchStatus.Error }, + reschedule: false, + }), + }, + ); + const { handler, updateConfirmation } = setup([refresher]); + + await handler.handle({ + jsonrpc: '2.0', + id: '1', + method: BackgroundEventMethod.RefreshConfirmationContext, + params: confirmationContextRequestParams, + }); + + expect(updateConfirmation).toHaveBeenCalledTimes(1); + expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); + }); + + it('applies patches from fulfilled refreshers when another rejects unexpectedly', async () => { + jest + .mocked(getInterfaceContextIfExists) + .mockResolvedValueOnce(baseContext) + .mockResolvedValueOnce(baseContext); + + const successful = createMockRefresher( + ConfirmationContextRefresherKey.Prices, + { + refresh: jest.fn().mockResolvedValue({ + result: { tokenPricesFetchStatus: FetchStatus.Fetched }, + reschedule: false, + }), + }, + ); + const failing = createMockRefresher(ConfirmationContextRefresherKey.Scan, { + refresh: jest.fn().mockRejectedValue(new Error('unexpected')), + }); + + const { handler, updateConfirmation } = setup([successful, failing]); + + await handler.handle({ + jsonrpc: '2.0', + id: '1', + method: BackgroundEventMethod.RefreshConfirmationContext, + params: { + ...confirmationContextRequestParams, + refresherKeys: [ + ConfirmationContextRefresherKey.Prices, + ConfirmationContextRefresherKey.Scan, + ], + }, + }); + + expect(updateConfirmation).toHaveBeenCalledWith( + expect.objectContaining({ + updatedContext: expect.objectContaining({ + tokenPricesFetchStatus: FetchStatus.Fetched, + }), + }), + ); + }); + + it('does not run a refresher when its key is omitted from refresherKeys', async () => { + jest.mocked(getInterfaceContextIfExists).mockResolvedValue(baseContext); + + const pricesRefresher = createMockRefresher( + ConfirmationContextRefresherKey.Prices, + { + refresh: jest.fn().mockResolvedValue({ + result: { tokenPricesFetchStatus: FetchStatus.Fetched }, + reschedule: false, + }), + }, + ); + const scanRefresher = createMockRefresher( + ConfirmationContextRefresherKey.Scan, + { + refresh: jest.fn().mockResolvedValue({ + result: { scanFetchStatus: FetchStatus.Fetched }, + reschedule: false, + }), + isValidContext: jest.fn().mockReturnValue(false), + }, + ); + + const { handler, updateConfirmation } = setup([ + pricesRefresher, + scanRefresher, + ]); + + await handler.handle({ + jsonrpc: '2.0', + id: '1', + method: BackgroundEventMethod.RefreshConfirmationContext, + params: { + ...confirmationContextRequestParams, + refresherKeys: [ConfirmationContextRefresherKey.Prices], + }, + }); + + expect(pricesRefresher.refresh).toHaveBeenCalled(); + expect(scanRefresher.refresh).not.toHaveBeenCalled(); + expect(scanRefresher.isValidContext).not.toHaveBeenCalled(); + expect(updateConfirmation).toHaveBeenCalled(); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/handler.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/handler.ts new file mode 100644 index 00000000..6be1ebd3 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/handler.ts @@ -0,0 +1,266 @@ +import type { Json } from '@metamask/utils'; + +import type { + ConfirmationContextRefreshResult, + ConfirmationContextRefreshers, + ConfirmationContextRefresherKey, + ConfirmationDataContext, + IConfirmationContextRefresher, +} from './api'; +import type { ConfirmationInterfaceKey } from '../../../ui/confirmation/api'; +import type { ConfirmationUXController } from '../../../ui/confirmation/controller'; +import type { ILogger } from '../../../utils/logger'; +import { createPrefixedLogger } from '../../../utils/logger'; +import { + Duration, + getInterfaceContextIfExists, + scheduleBackgroundEvent, +} from '../../../utils/snap'; +import type { + RefreshConfirmationContextJsonRpcRequest, + RefreshConfirmationContextParams, +} from '../api'; +import { + BackgroundEventMethod, + RefreshConfirmationContextJsonRpcRequestStruct, +} from '../api'; +import { CronjobBaseHandler } from '../base'; + +/** + * Single writer for the confirmation interface context. Orchestrates + * composed refreshers; + * + * The {@link ConfirmationUXController} passes {@link RefreshConfirmationContextParams.refresherKeys} to select which refreshers run per + * cycle. Unlisted keys are not validated or executed. + */ +export class RefreshConfirmationContextHandler extends CronjobBaseHandler { + readonly #refresherByKey: Map< + ConfirmationContextRefresherKey, + IConfirmationContextRefresher + >; + + readonly #confirmationUIController: ConfirmationUXController; + + static async scheduleBackgroundEvent( + params: RefreshConfirmationContextParams, + duration: Duration = Duration.TwentySeconds, + ): Promise { + await scheduleBackgroundEvent({ + method: BackgroundEventMethod.RefreshConfirmationContext, + params, + duration, + }); + } + + constructor({ + logger, + confirmationUIController, + refreshers, + }: { + logger: ILogger; + confirmationUIController: ConfirmationUXController; + refreshers: ConfirmationContextRefreshers; + }) { + const prefixedLogger = createPrefixedLogger( + logger, + '[🔄 RefreshConfirmationContextHandler]', + ); + super({ + logger: prefixedLogger, + requestStruct: RefreshConfirmationContextJsonRpcRequestStruct, + }); + this.#refresherByKey = new Map( + refreshers.map((refresher) => [refresher.key, refresher]), + ); + // A safeguard to ensure all refreshers are registered. + if (this.#refresherByKey.size !== refreshers.length) { + throw new Error( + 'Duplicate confirmation context refresher key registered', + ); + } + this.#confirmationUIController = confirmationUIController; + } + + /** + * Handles the refresh confirmation context cron job request. + * + * @param request - The refresh confirmation context JSON-RPC request. + */ + protected async handleCronJobRequest( + request: RefreshConfirmationContextJsonRpcRequest, + ): Promise { + this.logger.info('Refreshing confirmation context...'); + const { interfaceId, scope, interfaceKey, refresherKeys } = request.params; + + const activeRefreshers = this.#resolveRefreshers(refresherKeys); + if (activeRefreshers.length === 0) { + this.logger.warn('No matching refreshers for requested keys, skipping'); + return; + } + + const interfaceContext = await this.#getInterfaceContextIfExists({ + interfaceId, + activeRefreshers, + }); + if (interfaceContext === null) { + return; + } + + const results = await this.#runRefreshers( + interfaceContext, + activeRefreshers, + ); + + if (results.every((result) => result === null)) { + this.logger.info( + 'No data sources to refresh or recover; cron will not be rescheduled', + ); + return; + } + + const latestContext = await this.#getInterfaceContextIfExists({ + interfaceId, + activeRefreshers, + }); + if (latestContext === null) { + return; + } + + const refresherPatches = results.reduce>( + (acc, result) => ({ ...acc, ...(result?.result ?? {}) }), + {}, + ); + + const updatedContext: ConfirmationDataContext = { + ...latestContext, + ...refresherPatches, + }; + + await this.#reRender({ + interfaceId, + interfaceKey, + updatedContext, + }); + + if (results.some((result) => result?.reschedule)) { + await RefreshConfirmationContextHandler.scheduleBackgroundEvent({ + scope, + interfaceId, + interfaceKey, + refresherKeys, + }); + } + } + + #resolveRefreshers( + keys: ConfirmationContextRefresherKey[], + ): IConfirmationContextRefresher[] { + const resolvedRefreshers: IConfirmationContextRefresher[] = []; + + for (const key of keys) { + const refresher = this.#refresherByKey.get(key); + if (refresher) { + resolvedRefreshers.push(refresher); + } else { + this.logger.warn(`Unknown confirmation context refresher key: ${key}`); + } + } + + return resolvedRefreshers; + } + + /** + * Runs enabled refreshers in parallel. Uses `allSettled` so one rejection + * does not prevent other refreshers from completing. + * + * @param ctx - Confirmation interface context passed to each refresher. + * @param activeRefreshers - Refreshers selected by `refresherKeys`. + * @returns One result per active refresher; rejected refreshers become `null`. + */ + async #runRefreshers( + ctx: ConfirmationDataContext, + activeRefreshers: readonly IConfirmationContextRefresher[], + ): Promise { + const settled = await Promise.allSettled( + activeRefreshers.map(async (refresher) => + this.#runRefresher(refresher, ctx), + ), + ); + + return settled.map((outcome, index) => { + if (outcome.status === 'fulfilled') { + return outcome.value; + } + this.logger.error( + `Refresher "${activeRefreshers[index]?.key}" rejected unexpectedly`, + outcome.reason, + ); + return null; + }); + } + + async #runRefresher( + refresher: IConfirmationContextRefresher, + ctx: ConfirmationDataContext, + ): Promise { + // If the refresher decides there is nothing to fetch right now + // (for example, no eligible assets remain or a prior error state + // means refresh should be skipped), use the recovery result to clear + // any stuck loading state. + if (!refresher.shouldFetch(ctx)) { + return refresher.recoveryResult(ctx); + } + + return refresher.refresh(ctx); + } + + async #reRender(params: { + interfaceId: string; + interfaceKey: ConfirmationInterfaceKey; + updatedContext: Record; + }): Promise { + await this.#confirmationUIController.updateConfirmation(params); + } + + async #getInterfaceContextIfExists(params: { + interfaceId: string; + activeRefreshers: readonly IConfirmationContextRefresher[]; + }): Promise { + const { interfaceId, activeRefreshers } = params; + const interfaceContext = + await getInterfaceContextIfExists(interfaceId); + + if (!interfaceContext) { + this.logger.info('Interface no longer exists, cleaning up'); + return null; + } + + if (!isRecord(interfaceContext)) { + this.logger.warn('Interface context is not an object, skipping refresh'); + return null; + } + + if ( + activeRefreshers.some( + (refresher) => !refresher.isValidContext(interfaceContext), + ) + ) { + this.logger.warn( + 'Interface context does not match an enabled refresher shape, skipping refresh', + ); + return null; + } + + return interfaceContext; + } +} + +/** + * Checks whether a JSON value is an object record. + * + * @param value - The JSON value to check. + * @returns True when the value is a non-array object. + */ +function isRecord(value: Json): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/index.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/index.ts new file mode 100644 index 00000000..336d73a4 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/index.ts @@ -0,0 +1,12 @@ +export { RefreshConfirmationContextHandler } from './handler'; +export { ConfirmationPriceRefresher } from './priceRefresher'; +export { + ConfirmationContextRefresherKey, + ConfirmationContextRefresherKeyStruct, +} from './api'; +export type { + ConfirmationContextRefreshResult, + ConfirmationContextRefreshers, + ConfirmationDataContext, + IConfirmationContextRefresher, +} from './api'; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/priceRefresher.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/priceRefresher.test.ts new file mode 100644 index 00000000..6dd25f28 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/priceRefresher.test.ts @@ -0,0 +1,132 @@ +import { createConfirmationDataContext } from './__fixtures__/context.fixtures'; +import { ConfirmationPriceRefresher } from './priceRefresher'; +import { KnownCaip2ChainId } from '../../../api'; +import type { PriceService } from '../../../services/price'; +import { + type ContextWithPrices, + FetchStatus, +} from '../../../ui/confirmation/api'; +import { getSlip44AssetId } from '../../../utils'; +import { logger } from '../../../utils/logger'; + +jest.mock('../../../utils/logger'); + +describe('ConfirmationPriceRefresher', () => { + const scope = KnownCaip2ChainId.Testnet; + const nativeAssetId = getSlip44AssetId(scope); + + function setup() { + const getSpotPrices = jest.fn(); + const priceService = { getSpotPrices } as unknown as PriceService; + const refresher = new ConfirmationPriceRefresher({ + logger, + priceService, + }); + return { refresher, getSpotPrices }; + } + + describe('shouldFetch', () => { + it('returns false when tokenPrices is empty', () => { + const { refresher } = setup(); + + expect( + refresher.shouldFetch( + createConfirmationDataContext({ + tokenPrices: {} as ContextWithPrices['tokenPrices'], + }), + ), + ).toBe(false); + }); + + it('returns false when status is Error', () => { + const { refresher } = setup(); + + expect( + refresher.shouldFetch( + createConfirmationDataContext({ + tokenPricesFetchStatus: FetchStatus.Error, + }), + ), + ).toBe(false); + }); + + it('returns true when assets exist and status is not Error', () => { + const { refresher } = setup(); + + expect(refresher.shouldFetch(createConfirmationDataContext())).toBe(true); + }); + }); + + describe('recoveryResult', () => { + it('returns null when status is not Fetching', () => { + const { refresher } = setup(); + + expect( + refresher.recoveryResult( + createConfirmationDataContext({ + tokenPricesFetchStatus: FetchStatus.Fetched, + }), + ), + ).toBeNull(); + }); + + it('clears Fetching to Fetched when fetch is skipped', () => { + const { refresher } = setup(); + + expect( + refresher.recoveryResult( + createConfirmationDataContext({ + tokenPricesFetchStatus: FetchStatus.Fetching, + }), + ), + ).toStrictEqual({ + result: { tokenPricesFetchStatus: FetchStatus.Fetched }, + reschedule: false, + }); + }); + }); + + describe('refresh', () => { + it('fetches spot prices and requests reschedule on success', async () => { + const { refresher, getSpotPrices } = setup(); + getSpotPrices.mockResolvedValue({ + [nativeAssetId]: { price: 1.25 }, + }); + + const result = await refresher.refresh(createConfirmationDataContext()); + + expect(getSpotPrices).toHaveBeenCalledWith({ + assetIds: [nativeAssetId], + vsCurrency: 'usd', + }); + expect(result).toStrictEqual({ + result: { + tokenPrices: { [nativeAssetId]: '1.25' }, + tokenPricesFetchStatus: FetchStatus.Fetched, + }, + reschedule: true, + }); + }); + + it('returns error patch without reschedule when price fetch fails', async () => { + const { refresher, getSpotPrices } = setup(); + getSpotPrices.mockRejectedValue(new Error('network error')); + + const result = await refresher.refresh(createConfirmationDataContext()); + + expect(result).toStrictEqual({ + result: { tokenPricesFetchStatus: FetchStatus.Error }, + reschedule: false, + }); + }); + }); + + it('validates context with ContextWithPricesStruct', () => { + const { refresher } = setup(); + + expect(refresher.isValidContext(createConfirmationDataContext())).toBe( + true, + ); + expect(refresher.isValidContext({})).toBe(false); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/priceRefresher.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/priceRefresher.ts new file mode 100644 index 00000000..122a7be1 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/priceRefresher.ts @@ -0,0 +1,112 @@ +import type { Json } from '@metamask/utils'; + +import { + ConfirmationContextRefresherKey, + type ConfirmationContextRefreshResult, + type ConfirmationDataContext, + type IConfirmationContextRefresher, +} from './api'; +import type { KnownCaip19AssetIdOrSlip44Id } from '../../../api'; +import type { PriceService } from '../../../services/price'; +import type { ContextWithPrices } from '../../../ui/confirmation/api'; +import { + ContextWithPricesStruct, + FetchStatus, +} from '../../../ui/confirmation/api'; +import type { ILogger } from '../../../utils/logger'; +import { createPrefixedLogger } from '../../../utils/logger'; + +/** + * Refreshes token spot prices in the confirmation dialog context. + * Price slice of the confirmation context refresh pipeline; + */ +export class ConfirmationPriceRefresher implements IConfirmationContextRefresher { + readonly key = ConfirmationContextRefresherKey.Prices; + + readonly #priceService: PriceService; + + readonly #logger: ILogger; + + constructor({ + logger, + priceService, + }: { + logger: ILogger; + priceService: PriceService; + }) { + this.#priceService = priceService; + this.#logger = createPrefixedLogger( + logger, + '[🔄 ConfirmationPriceRefresher]', + ); + } + + shouldFetch(ctx: ConfirmationDataContext): boolean { + if (Object.keys(ctx.tokenPrices).length === 0) { + return false; + } + if (ctx.tokenPricesFetchStatus === FetchStatus.Error) { + return false; + } + return true; + } + + recoveryResult( + ctx: ConfirmationDataContext, + ): ConfirmationContextRefreshResult { + // If we are not in a loading state, there is nothing to fix. + // We return null so the handler does not change tokenPricesFetchStatus on the dialog. + if (ctx.tokenPricesFetchStatus !== FetchStatus.Fetching) { + return null; + } + + // We set the status to fetched so the loading UI stops, even though we have no new prices. + return { + result: { tokenPricesFetchStatus: FetchStatus.Fetched }, + reschedule: false, + }; + } + + async refresh( + ctx: ConfirmationDataContext, + ): Promise { + try { + const uniqueAssetCaipIds = [ + ...Object.keys(ctx.tokenPrices), + ] as KnownCaip19AssetIdOrSlip44Id[]; + + const prices = await this.#priceService.getSpotPrices({ + assetIds: uniqueAssetCaipIds, + vsCurrency: ctx.currency, + }); + + const updatedTokenPrices = uniqueAssetCaipIds.reduce< + ContextWithPrices['tokenPrices'] + >( + (acc, assetId) => { + acc[assetId] = prices[assetId]?.price.toString() ?? null; + return acc; + }, + {} as ContextWithPrices['tokenPrices'], + ); + + return { + result: { + tokenPrices: updatedTokenPrices, + tokenPricesFetchStatus: FetchStatus.Fetched, + }, + reschedule: true, + }; + } catch (error) { + this.#logger.error('Error refreshing confirmation prices:', error); + return { + result: { tokenPricesFetchStatus: FetchStatus.Error }, + reschedule: false, + }; + } + } + + isValidContext(ctx: Record): boolean { + return ContextWithPricesStruct.is(ctx); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts deleted file mode 100644 index 98553960..00000000 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationPrices.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { Json } from '@metamask/utils'; - -import { - BackgroundEventMethod, - RefreshConfirmationPricesJsonRpcRequestStruct, -} from './api'; -import type { - RefreshConfirmationPricesJsonRpcRequest, - RefreshConfirmationPricesParams, -} from './api'; -import { CronjobBaseHandler } from './base'; -import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; -import type { PriceService } from '../../services/price'; -import type { - ConfirmationInterfaceKey, - ContextWithPrices, -} from '../../ui/confirmation/api'; -import { - ContextWithPricesStruct, - FetchStatus, -} from '../../ui/confirmation/api'; -import type { ConfirmationUXController } from '../../ui/confirmation/controller'; -import type { ILogger } from '../../utils/logger'; -import { createPrefixedLogger } from '../../utils/logger'; -import { - Duration, - getInterfaceContextIfExists, - scheduleBackgroundEvent, -} from '../../utils/snap'; - -export class RefreshConfirmationPricesHandler extends CronjobBaseHandler { - readonly #priceService: PriceService; - - static async scheduleBackgroundEvent( - params: RefreshConfirmationPricesParams, - duration: Duration = Duration.TwentySeconds, - ): Promise { - await scheduleBackgroundEvent({ - method: BackgroundEventMethod.RefreshConfirmationPrices, - params, - duration, - }); - } - - readonly #confirmationUIController: ConfirmationUXController; - - constructor({ - logger, - priceService, - confirmationUIController, - }: { - logger: ILogger; - priceService: PriceService; - confirmationUIController: ConfirmationUXController; - }) { - const prefixedLogger = createPrefixedLogger( - logger, - '[🔄 RefreshConfirmationPricesHandler]', - ); - super({ - logger: prefixedLogger, - requestStruct: RefreshConfirmationPricesJsonRpcRequestStruct, - }); - this.#priceService = priceService; - this.#confirmationUIController = confirmationUIController; - } - - /** - * Handles the refresh confirmation prices cron job request. - * - * @param request - The refresh confirmation prices JSON-RPC request. - */ - protected async handleCronJobRequest( - request: RefreshConfirmationPricesJsonRpcRequest, - ): Promise { - this.logger.info('Refreshing confirmation prices...'); - const { interfaceId, scope, interfaceKey } = request.params; - - // Find the interface context - const interfaceContext = - await this.#getInterfaceContextIfExists(interfaceId); - if (interfaceContext === null) { - return; - } - - try { - // Extract CAIP IDs from context - const uniqueAssetCaipIds = [ - ...Object.keys(interfaceContext.tokenPrices), - ] as KnownCaip19AssetIdOrSlip44Id[]; - - // Fetch fresh prices via lazy cache mechanism - const prices = await this.#priceService.getSpotPrices({ - assetIds: uniqueAssetCaipIds, - vsCurrency: interfaceContext.currency, - }); - - // Fill the context with the new prices - const updatedTokenPrices = uniqueAssetCaipIds.reduce< - ContextWithPrices['tokenPrices'] - >( - (acc, assetId) => { - if (prices[assetId]) { - acc[assetId] = prices[assetId]?.price.toString() ?? null; - } else { - acc[assetId] = null; - } - return acc; - }, - {} as ContextWithPrices['tokenPrices'], - ); - - // Get the latest context, to ensure the interface is still visible after the price fetch - const latestContext = - await this.#getInterfaceContextIfExists(interfaceId); - if (latestContext === null) { - return; - } - - // Update the context with the new prices - const updatedContext: ContextWithPrices = { - ...latestContext, - tokenPrices: updatedTokenPrices, - tokenPricesFetchStatus: FetchStatus.Fetched, - }; - - // Re-render the Component based on the interface key - await this.#reRenderConfirmationPrices({ - interfaceId, - updatedContext, - interfaceKey, - }); - - // Schedule the next background event - await RefreshConfirmationPricesHandler.scheduleBackgroundEvent( - { - scope, - interfaceId, - interfaceKey, - }, - Duration.TwentySeconds, - ); - } catch (error) { - this.logger.error('Error refreshing confirmation prices:', error); - - const currentContext = - await this.#getInterfaceContextIfExists(interfaceId); - if (currentContext !== null) { - // Update the context with the error status - const errorContext: ContextWithPrices = { - ...currentContext, - tokenPricesFetchStatus: FetchStatus.Error, - }; - - await this.#reRenderConfirmationPrices({ - interfaceId, - updatedContext: errorContext, - interfaceKey, - }); - // Don't schedule another refresh on error - } - } - } - - async #reRenderConfirmationPrices(params: { - interfaceId: string; - updatedContext: ContextWithPrices; - interfaceKey: ConfirmationInterfaceKey; - }): Promise { - const { interfaceId, interfaceKey, updatedContext } = params; - - await this.#confirmationUIController.updateConfirmation({ - interfaceId, - updatedContext, - interfaceKey, - }); - } - - async #getInterfaceContextIfExists( - interfaceId: string, - ): Promise { - const interfaceContext = - await getInterfaceContextIfExists(interfaceId); - - if (!interfaceContext) { - this.logger.info('Interface no longer exists, cleaning up'); - return null; - } - - if (!ContextWithPricesStruct.is(interfaceContext)) { - this.logger.warn( - 'Interface context does not match the ContextWithPrices interface, skipping refresh', - ); - return null; - } - - return interfaceContext; - } -} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 90499b91..d6bd1d91 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -18,7 +18,6 @@ import { createPrefixedLogger, Duration, getSlip44AssetId, - scheduleBackgroundEvent, showDialog, updateInterfaceIfExists, } from '../../utils'; @@ -39,7 +38,10 @@ import { ConfirmSignTransaction, type ConfirmSignTransactionProps, } from './views/ConfirmSignTransaction/ConfirmSignTransaction'; -import { BackgroundEventMethod } from '../../handlers/cronjob/api'; +import { + ConfirmationContextRefresherKey, + RefreshConfirmationContextHandler, +} from '../../handlers/cronjob/refreshConfirmationContext'; /** Serializable props bag stored on the interface and merged into each view. */ type ConfirmationViewProps = Record; @@ -192,22 +194,25 @@ export class ConfirmationUXController { return dialogPromise; } - // 5. Schedule background jobs only after confirming the interface is still alive + // 5. Schedule background context refresh for enabled refreshers only + const refresherKeys: ConfirmationContextRefresherKey[] = []; if (enablePricing) { - // Trigger immediate price fetch (1 second), then continue every 20 seconds - await scheduleBackgroundEvent({ - method: BackgroundEventMethod.RefreshConfirmationPrices, - duration: Duration.OneSecond, // Start immediately - params: { + refresherKeys.push(ConfirmationContextRefresherKey.Prices); + } + // TODO: if (renderOptions.scanTxn) { refresherKeys.push(ConfirmationContextRefresherKey.Scan); } + + if (refresherKeys.length > 0) { + await RefreshConfirmationContextHandler.scheduleBackgroundEvent( + { scope, interfaceId: id, interfaceKey, + refresherKeys, }, - }); + Duration.OneSecond, + ); } - // TODO: Schedule security scan background refresh (every 20 seconds) - // 6. Return the dialog promise immediately (don't await it!) // Cleanup happens in the background refresh handler when it detects the interface is gone return dialogPromise; From 84ae64553217705b706d708fe90d45e2f15ea8b7 Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Mon, 25 May 2026 10:02:49 +0200 Subject: [PATCH 243/384] feat: allow payment only op for bridging --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../stellar-wallet-snap/src/api/xdr.test.ts | 14 +++++++------- merged-packages/stellar-wallet-snap/src/api/xdr.ts | 5 ++++- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 6f96fef2..9e408deb 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "jF7tBkmaNRfHE6oqO7dsGLbTxRry+GLlC0p44exGKuY=", + "shasum": "MpounG90lvEVTjh6YZBuZ2NHs0dUNlU4XO6odOBAygs=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/api/xdr.test.ts b/merged-packages/stellar-wallet-snap/src/api/xdr.test.ts index e75dc2fa..c7af88d5 100644 --- a/merged-packages/stellar-wallet-snap/src/api/xdr.test.ts +++ b/merged-packages/stellar-wallet-snap/src/api/xdr.test.ts @@ -43,6 +43,13 @@ function buildTransactionXdr(operations: xdr.Operation[]): string { describe('SwapTransactionXdrStruct', () => { it.each([ buildTransactionXdr([new Contract(contractId).call('swap')]), + buildTransactionXdr([ + Operation.payment({ + destination: feeDestination, + asset: Asset.native(), + amount: '1', + }), + ]), buildTransactionXdr([ Operation.pathPaymentStrictSend({ sendAsset: Asset.native(), @@ -81,13 +88,6 @@ describe('SwapTransactionXdrStruct', () => { it.each([ 'not-xdr', - buildTransactionXdr([ - Operation.payment({ - destination: feeDestination, - asset: Asset.native(), - amount: '1', - }), - ]), buildTransactionXdr([ Operation.pathPaymentStrictSend({ sendAsset: Asset.native(), diff --git a/merged-packages/stellar-wallet-snap/src/api/xdr.ts b/merged-packages/stellar-wallet-snap/src/api/xdr.ts index 949c91a4..8e138410 100644 --- a/merged-packages/stellar-wallet-snap/src/api/xdr.ts +++ b/merged-packages/stellar-wallet-snap/src/api/xdr.ts @@ -67,6 +67,8 @@ function isPathPaymentOperation(operationType: string | undefined): boolean { * - `invokeHostFunction`: Soroban swaps are assembled as a single contract * invocation; resource fee and authorization checks happen later in the * transaction flow. + * - `payment`: bridge routes send funds to the bridge contract, which handles + * the rest of the route. * - `pathPayment*`, `payment`: classic swaps use the path payment for the * asset exchange, followed by the fee-send payment appended to the route. * - `changeTrust`, `pathPayment*`, `payment`: same classic swap shape, with a @@ -87,7 +89,8 @@ export const SwapTransactionXdrStruct = refine( // Soroban route: the swap is represented by one contract invocation. if ( operationTypes.length === 1 && - firstOperation === 'invokeHostFunction' + (firstOperation === 'invokeHostFunction' || + firstOperation === 'payment') ) { return true; } From 3edb3433367e3cd34c6f16f1a47f9ae3b39e15b6 Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Mon, 25 May 2026 11:19:55 +0200 Subject: [PATCH 244/384] chore: addess copilot's comments --- merged-packages/stellar-wallet-snap/src/api/xdr.test.ts | 3 ++- merged-packages/stellar-wallet-snap/src/api/xdr.ts | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/api/xdr.test.ts b/merged-packages/stellar-wallet-snap/src/api/xdr.test.ts index c7af88d5..6a036c83 100644 --- a/merged-packages/stellar-wallet-snap/src/api/xdr.test.ts +++ b/merged-packages/stellar-wallet-snap/src/api/xdr.test.ts @@ -18,6 +18,7 @@ const contractId = 'CASUP2OPFVEHCWGP2XLBXOV7DQIQIT42AQISG4MXAZGNLVFFN63X7WRT'; const issuer = Keypair.random().publicKey(); const destination = Keypair.random().publicKey(); +const bridgeDestination = Keypair.random().publicKey(); const feeDestination = Keypair.random().publicKey(); const usdc = new Asset('USDC', issuer); @@ -45,7 +46,7 @@ describe('SwapTransactionXdrStruct', () => { buildTransactionXdr([new Contract(contractId).call('swap')]), buildTransactionXdr([ Operation.payment({ - destination: feeDestination, + destination: bridgeDestination, asset: Asset.native(), amount: '1', }), diff --git a/merged-packages/stellar-wallet-snap/src/api/xdr.ts b/merged-packages/stellar-wallet-snap/src/api/xdr.ts index 8e138410..1d0df95d 100644 --- a/merged-packages/stellar-wallet-snap/src/api/xdr.ts +++ b/merged-packages/stellar-wallet-snap/src/api/xdr.ts @@ -67,8 +67,10 @@ function isPathPaymentOperation(operationType: string | undefined): boolean { * - `invokeHostFunction`: Soroban swaps are assembled as a single contract * invocation; resource fee and authorization checks happen later in the * transaction flow. - * - `payment`: bridge routes send funds to the bridge contract, which handles - * the rest of the route. + * - `payment`: bridge deposit routes are represented on Stellar as a single + * payment to the bridge deposit account. Destination and memo expectations are + * owned by the CrossChain quote / approval layer; this struct only gates the + * operation shape before Stellar-level validation runs downstream. * - `pathPayment*`, `payment`: classic swaps use the path payment for the * asset exchange, followed by the fee-send payment appended to the route. * - `changeTrust`, `pathPayment*`, `payment`: same classic swap shape, with a @@ -86,7 +88,7 @@ export const SwapTransactionXdrStruct = refine( const operationTypes = getTransactionOperationTypes(value); const [firstOperation, secondOperation, thirdOperation] = operationTypes; - // Soroban route: the swap is represented by one contract invocation. + // Soroban swap route or bridge deposit route. if ( operationTypes.length === 1 && (firstOperation === 'invokeHostFunction' || From a39f0c4c0ed24cbec92b0151d12e25a7900bc14b Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Mon, 25 May 2026 16:42:12 +0200 Subject: [PATCH 245/384] fix: fix copilot ai comment --- .../stellar-wallet-snap/locales/en.json | 18 ++++++ .../stellar-wallet-snap/locales/es.json | 18 ++++++ .../stellar-wallet-snap/messages.json | 18 ++++++ .../stellar-wallet-snap/snap.manifest.json | 2 +- .../components/TransactionAlert.test.tsx | 42 +++++++++++++ .../components/TransactionAlert.tsx | 62 +++++++++++++++++-- 6 files changed, 154 insertions(+), 6 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/locales/en.json b/merged-packages/stellar-wallet-snap/locales/en.json index 9b0fe132..f3bda328 100644 --- a/merged-packages/stellar-wallet-snap/locales/en.json +++ b/merged-packages/stellar-wallet-snap/locales/en.json @@ -112,12 +112,30 @@ "confirmation.securityScanAPIErrorMessage": { "message": "Only continue if you trust every address involved." }, + "confirmation.securityScanErrorTitle": { + "message": "Security scan failed" + }, + "confirmation.securityScanErrorSubtitle": { + "message": "{reason}" + }, + "confirmation.securityScanIncompleteTitle": { + "message": "Security scan incomplete" + }, + "confirmation.securityScanIncompleteSubtitle": { + "message": "{reason}. Only continue if you trust every address involved." + }, "confirmation.simulationErrorTitle": { "message": "This transaction was reverted during simulation." }, "confirmation.simulationErrorSubtitle": { "message": "{reason}" }, + "confirmation.validationScanErrorTitle": { + "message": "Security validation failed" + }, + "confirmation.validationScanErrorSubtitle": { + "message": "{reason}" + }, "confirmation.validationErrorTitle": { "message": "This is a deceptive request" }, diff --git a/merged-packages/stellar-wallet-snap/locales/es.json b/merged-packages/stellar-wallet-snap/locales/es.json index 43af768e..22a5ef9c 100644 --- a/merged-packages/stellar-wallet-snap/locales/es.json +++ b/merged-packages/stellar-wallet-snap/locales/es.json @@ -100,12 +100,30 @@ "confirmation.securityScanAPIErrorMessage": { "message": "Only continue if you trust every address involved." }, + "confirmation.securityScanErrorTitle": { + "message": "Security scan failed" + }, + "confirmation.securityScanErrorSubtitle": { + "message": "{reason}" + }, + "confirmation.securityScanIncompleteTitle": { + "message": "Security scan incomplete" + }, + "confirmation.securityScanIncompleteSubtitle": { + "message": "{reason}. Only continue if you trust every address involved." + }, "confirmation.simulationErrorTitle": { "message": "This transaction was reverted during simulation." }, "confirmation.simulationErrorSubtitle": { "message": "{reason}" }, + "confirmation.validationScanErrorTitle": { + "message": "Security validation failed" + }, + "confirmation.validationScanErrorSubtitle": { + "message": "{reason}" + }, "confirmation.validationErrorTitle": { "message": "This is a deceptive request" }, diff --git a/merged-packages/stellar-wallet-snap/messages.json b/merged-packages/stellar-wallet-snap/messages.json index ded6528c..3d9b3b60 100644 --- a/merged-packages/stellar-wallet-snap/messages.json +++ b/merged-packages/stellar-wallet-snap/messages.json @@ -110,12 +110,30 @@ "confirmation.securityScanAPIErrorMessage": { "message": "Only continue if you trust every address involved." }, + "confirmation.securityScanErrorTitle": { + "message": "Security scan failed" + }, + "confirmation.securityScanErrorSubtitle": { + "message": "{reason}" + }, + "confirmation.securityScanIncompleteTitle": { + "message": "Security scan incomplete" + }, + "confirmation.securityScanIncompleteSubtitle": { + "message": "{reason}. Only continue if you trust every address involved." + }, "confirmation.simulationErrorTitle": { "message": "This transaction was reverted during simulation." }, "confirmation.simulationErrorSubtitle": { "message": "{reason}" }, + "confirmation.validationScanErrorTitle": { + "message": "Security validation failed" + }, + "confirmation.validationScanErrorSubtitle": { + "message": "{reason}" + }, "confirmation.validationErrorTitle": { "message": "This is a deceptive request" }, diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 3edd53d3..658b5e8b 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "phV177JvpYA8z0Pse9m/CzbIW5+ne82WUWHGo4yafb0=", + "shasum": "KMqaJ6vdB5gfcrDsEAS/YO58fKyGz9SxZgVNmj/hsIY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.test.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.test.tsx index bf0d70c7..893a4143 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.test.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.test.tsx @@ -74,6 +74,48 @@ describe('TransactionAlert', () => { }); }); + it('renders validation scan errors with validation failure copy', () => { + const component = TransactionAlert({ + preferences, + validation: null, + error: { + type: 'validation', + code: 'invalid_transaction', + message: 'invalid_transaction', + }, + scanFetchStatus: FetchStatus.Fetched, + showValidationAlert: true, + showSimulationError: false, + }); + + expect(getType(component)).toBe('Banner'); + expect(getProps(component)).toMatchObject({ + severity: 'warning', + title: 'Security validation failed', + }); + }); + + it('renders response scan errors with incomplete scan copy', () => { + const component = TransactionAlert({ + preferences, + validation: null, + error: { + type: 'response', + code: 'empty', + message: 'No scan results returned', + }, + scanFetchStatus: FetchStatus.Fetched, + showValidationAlert: true, + showSimulationError: false, + }); + + expect(getType(component)).toBe('Banner'); + expect(getProps(component)).toMatchObject({ + severity: 'warning', + title: 'Security scan incomplete', + }); + }); + it('does not render validation alerts when security alerts are disabled', () => { const component = TransactionAlert({ preferences, diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx index 6cdd30d7..4a733257 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx @@ -56,6 +56,41 @@ const ERROR_MESSAGE_IDS: Record = { unsupportedeip712message: 'transactionScan.errors.unsupportedEIP712Message', }; +const DEFAULT_ERROR_ALERT = { + severity: 'warning', + title: 'confirmation.securityScanErrorTitle', + subtitle: 'confirmation.securityScanErrorSubtitle', +} as const satisfies { + severity: BannerProps['severity']; + title: LocalizedMessage; + subtitle: LocalizedMessage; +}; + +const ERROR_TYPE_TO_ALERT: Record< + string, + { + severity: BannerProps['severity']; + title: LocalizedMessage; + subtitle: LocalizedMessage; + } +> = { + simulation: { + severity: 'warning', + title: 'confirmation.simulationErrorTitle', + subtitle: 'confirmation.simulationErrorSubtitle', + }, + validation: { + severity: 'warning', + title: 'confirmation.validationScanErrorTitle', + subtitle: 'confirmation.validationScanErrorSubtitle', + }, + response: { + severity: 'warning', + title: 'confirmation.securityScanIncompleteTitle', + subtitle: 'confirmation.securityScanIncompleteSubtitle', + }, +}; + export const TransactionAlert = ({ preferences, validation, @@ -124,13 +159,12 @@ export const TransactionAlert = ({ error && shouldShowError(error, showSimulationError, showValidationAlert) ) { + const alert = getErrorAlert(error); + return ( - + - {translate('confirmation.simulationErrorSubtitle', { + {translate(alert.subtitle, { reason: getErrorMessage(error, preferences.locale), })} @@ -165,6 +199,24 @@ function shouldShowError( return showSimulationError || showValidationAlert; } +/** + * Gets the alert copy for a scan error type. + * + * @param error - The scan error returned by the transaction scan service. + * @returns Localized title/subtitle identifiers and banner severity. + */ +function getErrorAlert(error: TransactionScanError): { + severity: BannerProps['severity']; + title: LocalizedMessage; + subtitle: LocalizedMessage; +} { + if (error.type) { + return ERROR_TYPE_TO_ALERT[error.type] ?? DEFAULT_ERROR_ALERT; + } + + return DEFAULT_ERROR_ALERT; +} + /** * Gets a user-facing scan error message. * From 5519c949a20049a443fe3e7e499aef09f17c124e Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 27 May 2026 17:25:15 +0800 Subject: [PATCH 246/384] chore: add expire time validation (#78) ## Explanation Adds transaction expiration (time-bounds maxTime) handling to prevent simulation/validation of expired Stellar envelopes, wiring the logic through the Transaction wrapper and TransactionSimulator, and covering it with tests. ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- .../handlers/keyring/signTransaction.test.ts | 46 ++++ .../src/handlers/keyring/signTransaction.ts | 4 + .../services/transaction/Transaction.test.ts | 88 +++++++ .../src/services/transaction/Transaction.ts | 16 ++ .../transaction/TransactionService.ts | 1 + .../transaction/TransactionSimulator.test.ts | 246 ++++++++++++------ .../transaction/TransactionSimulator.ts | 4 + .../src/services/transaction/exceptions.ts | 7 + .../src/services/transaction/utils.ts | 36 +++ 9 files changed, 363 insertions(+), 85 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts index d082e0f2..0baeb02c 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts @@ -296,6 +296,52 @@ describe('SignTransactionHandler', () => { expect(renderConfirmationDialog).not.toHaveBeenCalled(); }); + it('returns error -3 when the transaction has expired', async () => { + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); + const USDC_ISSUER = + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + const MOCK_USDC_ASSET = { code: 'USDC', issuer: USDC_ISSUER } as const; + const mockNow = 1700000000000; + jest.useFakeTimers(); + jest.setSystemTime(mockNow); + + try { + const tx = buildMockClassicTransaction( + [ + { + type: 'pathPaymentStrictSend', + params: { + source: mockAccount.address, + sendAsset: MOCK_USDC_ASSET, + sendAmount: '40', + destination: mockAccount.address, + destAsset: MOCK_USDC_ASSET, + destMin: '35', + }, + }, + ], + { + networkPassphrase: Networks.PUBLIC, + source: { accountId: mockAccount.address, sequence: '1' }, + timeout: 1, + }, + ); + + jest.advanceTimersByTime(2000); + const result = await handler.handle( + buildRequest(mockAccount.id, tx.getRaw().toXDR(), { + opts: { networkPassphrase: Networks.PUBLIC }, + }), + ); + expect(result).toMatchObject({ + error: { code: Sep43ErrorCode.InvalidRequest }, + }); + expect(renderConfirmationDialog).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + it.each([ ['opts.submit', { submit: true }], ['opts.submitUrl', { submitUrl: 'https://horizon.stellar.org' }], diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts index f0dd90c5..cb630123 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts @@ -18,6 +18,7 @@ import { OperationMapper } from '../../services/transaction'; import { assertAccountInvolvesTransaction, assertTransactionScope, + assertTransactionTimeBound, collectTransactionAssetCaipIds, } from '../../services/transaction/utils'; import type { Wallet } from '../../services/wallet'; @@ -91,6 +92,9 @@ export class SignTransactionHandler extends BaseSep43KeyringHandler< // We gate signing to envelopes that involve this wallet. assertAccountInvolvesTransaction(transaction, wallet.address); + // Ensure the transaction has not expired + assertTransactionTimeBound(transaction); + // Computing fee will inject the fee into the transaction const transactionWithFee = await this.#transactionService.computingFee(transaction); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts index 5e741f7e..39bcf569 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts @@ -176,4 +176,92 @@ describe('Transaction', () => { expect(wrapped.getMemo()).toStrictEqual(expected); }, ); + + describe('expiration time', () => { + const mockNow = 1700000000000; + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(mockNow); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('reads expiration time from inner transaction for a fee-bump envelope', () => { + const source = Keypair.random(); + const feeSource = Keypair.random(); + const dest = Keypair.random().publicKey(); + + const inner = new StellarTransactionBuilder( + new Account(source.publicKey(), '1'), + { fee: '100', networkPassphrase: Networks.TESTNET }, + ) + .addOperation( + Operation.payment({ + destination: dest, + asset: Asset.native(), + amount: '1', + }), + ) + .setTimeout(60) + .build(); + + const feeBump = StellarTransactionBuilder.buildFeeBumpTransaction( + feeSource, + String(Number(inner.fee) * 2), + inner, + Networks.TESTNET, + ); + + expect(new Transaction(feeBump).expirationTime).toStrictEqual( + mockNow / 1000 + 60, + ); + }); + + it('reads expiration time from the transaction itself for a classic transaction', () => { + const source = Keypair.random(); + const dest = Keypair.random().publicKey(); + + const inner = new StellarTransactionBuilder( + new Account(source.publicKey(), '1'), + { fee: '100', networkPassphrase: Networks.TESTNET }, + ) + .addOperation( + Operation.payment({ + destination: dest, + asset: Asset.native(), + amount: '1', + }), + ) + .setTimeout(60) + .build(); + + expect(new Transaction(inner).expirationTime).toStrictEqual( + mockNow / 1000 + 60, + ); + }); + + it('returns undefined if the transaction has no expiration time', () => { + const source = Keypair.random(); + const dest = Keypair.random().publicKey(); + + const inner = new StellarTransactionBuilder( + new Account(source.publicKey(), '1'), + { fee: '100', networkPassphrase: Networks.TESTNET }, + ) + .addOperation( + Operation.payment({ + destination: dest, + asset: Asset.native(), + amount: '1', + }), + ) + // Set timeout to 0 means no expiration time + .setTimeout(0) + .build(); + + expect(new Transaction(inner).expirationTime).toBeUndefined(); + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts index 3a92da32..e154cf53 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts @@ -5,6 +5,7 @@ import type { import { FeeBumpTransaction } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; +import { parseExpirationMaxTime } from './utils'; import type { KnownCaip2ChainId } from '../../api'; import { bufferToUint8Array } from '../../utils'; import { networkToCaip2ChainId } from '../network/utils'; @@ -98,6 +99,21 @@ export class Transaction { } } + /** + * The expiration time of the transaction. + * + * @see https://github.com/stellar/js-stellar-base/blob/master/src/transaction_builder.js#L320 + * + * @returns Unix timestamp (seconds) for `maxTime`, or `undefined` when there is no upper bound (`maxTime` of `0`). + */ + get expirationTime(): number | undefined { + const raw = this.getRaw(); + if (raw instanceof FeeBumpTransaction) { + return parseExpirationMaxTime(raw.innerTransaction.timeBounds?.maxTime); + } + return parseExpirationMaxTime(raw.timeBounds?.maxTime); + } + /** * Total fee in stroops charged to {@link Transaction.feeSourceAccount} for this envelope. * If it is a fee bump transaction, it will be the fee of the fee bump transaction, instead of the inner transaction. diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index 447fc2cc..ac588c6f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -534,6 +534,7 @@ export class TransactionService { * @param onChainAccount - The on-chain account to validate against. * @param options - Optional options for the transaction validation {@link TransactionSimulatorOptions}. * @throws {TransactionScopeNotMatchException} When {@link OnChainAccount.scope} does not match {@link Transaction.scope}. + * @throws {TransactionExpireException} When the transaction time bound has passed. * @throws {TransactionValidationException} When the transaction cannot be validated. */ validateTransaction( diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts index c3b9a963..93b26096 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts @@ -17,6 +17,7 @@ import { InvalidAmountForCreateAccountException, InvalidInvokeContractStructureException, RemoveTrustlineWithNonZeroBalanceException, + TransactionExpireException, TransactionScopeNotMatchException, TransactionValidationException, TrustlineNotAuthorizedException, @@ -42,7 +43,10 @@ import { buildMockInvokeHostFunctionTransaction, type BuildMockTransactionOptions, } from './__mocks__/transaction.fixtures'; -import { getTestWallet } from '../wallet/__mocks__/wallet.fixtures'; +import { + generateStellarAddress, + getTestWallet, +} from '../wallet/__mocks__/wallet.fixtures'; const SEP41_ASSET_MAINNET = 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as const; @@ -247,6 +251,8 @@ function onChainFromMockBalances( return new OnChainAccount(acc, scope, horizonSource(acc, scope)); } +const destinationAddress = generateStellarAddress(); + describe('TransactionSimulator', () => { const simulator = new TransactionSimulator(); @@ -258,14 +264,14 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { type: 'payment', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, asset: 'native', amount: '10', }, @@ -323,13 +329,13 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); + const tx = buildEnvelopeTransaction(wallet.address, '1', (tb) => tb .addOperation( StellarOperation.payment({ source: wallet.address, - destination: dest, + destination: destinationAddress, asset: Asset.native(), amount: '1', }), @@ -358,7 +364,7 @@ describe('TransactionSimulator', () => { expect(() => simulator.simulate(tx, onChainAccount, { - preloadedAccounts: [destOnChainAccount(dest)], + preloadedAccounts: [destOnChainAccount(destinationAddress)], }), ).toThrow(InvalidInvokeContractStructureException); }); @@ -370,7 +376,7 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { @@ -385,7 +391,7 @@ describe('TransactionSimulator', () => { type: 'payment', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, asset: 'native', amount: '10', }, @@ -397,10 +403,72 @@ describe('TransactionSimulator', () => { expect(() => simulator.simulate(tx, onChainAccount, { expectedOPTypes: [SupportedOperations.Payment], - preloadedAccounts: [destOnChainAccount(dest)], + preloadedAccounts: [destOnChainAccount(destinationAddress)], }), ).toThrow(TransactionValidationException); }); + + it('throws when the transaction has expired', () => { + const mockNow = 1700000000000; + jest.useFakeTimers(); + jest.setSystemTime(mockNow); + + try { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'createAccount', + params: { + source: wallet.address, + destination: destinationAddress, + startingBalance: '10', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1', { timeout: 1 }), + ); + + jest.advanceTimersByTime(2000); + + expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + TransactionExpireException, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('passes when the transaction does not have an expiration time', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'createAccount', + params: { + source: wallet.address, + destination: destinationAddress, + startingBalance: '10', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1', { timeout: 0 }), + ); + + expect(() => simulator.simulate(tx, onChainAccount)).not.toThrow( + TransactionExpireException, + ); + }); }); describe('payment', () => { @@ -411,14 +479,14 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { type: 'payment', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, asset: 'native', amount: '10', }, @@ -428,7 +496,7 @@ describe('TransactionSimulator', () => { ); const stack = simulator.simulate(tx, onChainAccount, { - preloadedAccounts: [destOnChainAccount(dest)], + preloadedAccounts: [destOnChainAccount(destinationAddress)], }); expect(stack).toHaveLength(2); }); @@ -467,14 +535,14 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { type: 'payment', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, asset: 'native', amount: '1', }, @@ -485,7 +553,7 @@ describe('TransactionSimulator', () => { expect(() => simulator.simulate(tx, onChainAccount, { - preloadedAccounts: [destOnChainAccount(dest)], + preloadedAccounts: [destOnChainAccount(destinationAddress)], }), ).toThrow(InsufficientBalanceException); }); @@ -497,14 +565,14 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { type: 'payment', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, asset: MOCK_USDC_ASSET, amount: '1', }, @@ -515,7 +583,7 @@ describe('TransactionSimulator', () => { expect(() => simulator.simulate(tx, onChainAccount, { - preloadedAccounts: [destOnChainAccount(dest)], + preloadedAccounts: [destOnChainAccount(destinationAddress)], }), ).toThrow(TrustlineNotFoundException); }); @@ -535,14 +603,14 @@ describe('TransactionSimulator', () => { }, ], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { type: 'payment', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, asset: MOCK_USDC_ASSET, amount: '1', }, @@ -553,7 +621,7 @@ describe('TransactionSimulator', () => { expect(() => simulator.simulate(tx, onChainAccount, { - preloadedAccounts: [destOnChainAccount(dest)], + preloadedAccounts: [destOnChainAccount(destinationAddress)], }), ).toThrow(TrustlineNotAuthorizedException); }); @@ -572,14 +640,14 @@ describe('TransactionSimulator', () => { }, ], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { type: 'payment', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, asset: MOCK_USDC_ASSET, amount: '1', }, @@ -590,7 +658,9 @@ describe('TransactionSimulator', () => { expect(() => simulator.simulate(tx, onChainAccount, { - preloadedAccounts: [destOnChainAccountUnauthorized(dest)], + preloadedAccounts: [ + destOnChainAccountUnauthorized(destinationAddress), + ], }), ).toThrow(TrustlineNotAuthorizedException); }); @@ -641,7 +711,7 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { @@ -650,7 +720,7 @@ describe('TransactionSimulator', () => { source: wallet.address, sendAsset: 'native', sendAmount: '10', - destination: dest, + destination: destinationAddress, destAsset: MOCK_USDC_ASSET, destMin: '5', }, @@ -662,12 +732,12 @@ describe('TransactionSimulator', () => { expect( simulator.simulate(tx, onChainAccount, { expectedOPTypes: [SupportedOperations.PathPayment], - preloadedAccounts: [destOnChainAccount(dest)], + preloadedAccounts: [destOnChainAccount(destinationAddress)], }), ).toHaveLength(2); }); - it('succeeds for strict send path to self on same credit asset when naive dest balance+destMin exceeds limit', () => { + it('succeeds for strict send path to self on same credit asset when naive destination balance plus destMin exceeds limit', () => { const wallet = getTestWallet(); const onChainAccount = onChainFromMockBalances(wallet.address, '1', { nativeBalance: 500, @@ -762,12 +832,16 @@ describe('TransactionSimulator', () => { }, ], }); - const dest = Keypair.random().publicKey(); - const loadedDestAccount = onChainFromMockBalances(dest, '1', { - nativeBalance: 50, - subentryCount: 0, - assets: [], - }); + + const loadedDestAccount = onChainFromMockBalances( + destinationAddress, + '1', + { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }, + ); const tx = buildMockClassicTransaction( [ { @@ -776,7 +850,7 @@ describe('TransactionSimulator', () => { source: wallet.address, sendAsset: MOCK_USDC_ASSET, sendMax: '20', - destination: dest, + destination: destinationAddress, destAsset: 'native', destAmount: '10', }, @@ -800,12 +874,16 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); - const loadedDestAccount = onChainFromMockBalances(dest, '1', { - nativeBalance: 50, - subentryCount: 0, - assets: [], - }); + + const loadedDestAccount = onChainFromMockBalances( + destinationAddress, + '1', + { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }, + ); const tx = buildMockClassicTransaction( [ { @@ -814,7 +892,7 @@ describe('TransactionSimulator', () => { source: wallet.address, sendAsset: MOCK_USDC_ASSET, sendMax: '20', - destination: dest, + destination: destinationAddress, destAsset: 'native', destAmount: '10', }, @@ -838,12 +916,16 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); - const loadedDestAccount = onChainFromMockBalances(dest, '1', { - nativeBalance: 50, - subentryCount: 0, - assets: [], - }); + + const loadedDestAccount = onChainFromMockBalances( + destinationAddress, + '1', + { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }, + ); const tx = buildMockClassicTransaction( [ { @@ -852,7 +934,7 @@ describe('TransactionSimulator', () => { source: wallet.address, sendAsset: 'native', sendAmount: '10', - destination: dest, + destination: destinationAddress, destAsset: MOCK_USDC_ASSET, destMin: '5', }, @@ -878,14 +960,14 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { type: 'createAccount', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, startingBalance: '2', }, }, @@ -903,14 +985,14 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { type: 'createAccount', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, startingBalance: '0.5', }, }, @@ -930,14 +1012,14 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { type: 'createAccount', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, startingBalance: '2', }, }, @@ -947,7 +1029,7 @@ describe('TransactionSimulator', () => { expect(() => simulator.simulate(tx, onChainAccount, { - preloadedAccounts: [destOnChainAccount(dest)], + preloadedAccounts: [destOnChainAccount(destinationAddress)], }), ).toThrow(TransactionValidationException); }); @@ -1165,13 +1247,12 @@ describe('TransactionSimulator', () => { }); it('passes when sender on-chain snapshot includes SEP-41 balance covering transfer amount', () => { - const dest = Keypair.random().publicKey(); const sorobanTx = buildSep41TransferTransaction({ source: SOROBAN_INVOKE_SOURCE, sequence: '1', contractId: SEP41_CONTRACT_MAINNET, from: SOROBAN_INVOKE_SOURCE, - to: dest, + to: destinationAddress, amountSmallestUnits: '1', }); const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { @@ -1188,13 +1269,12 @@ describe('TransactionSimulator', () => { }); it('throws InsufficientBalanceException when SEP-41 transfer amount exceeds sender snapshot balance', () => { - const dest = Keypair.random().publicKey(); const sorobanTx = buildSep41TransferTransaction({ source: SOROBAN_INVOKE_SOURCE, sequence: '1', contractId: SEP41_CONTRACT_MAINNET, from: SOROBAN_INVOKE_SOURCE, - to: dest, + to: destinationAddress, amountSmallestUnits: '10', }); const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { @@ -1214,13 +1294,12 @@ describe('TransactionSimulator', () => { }); it('throws when SEP-41 balance exists only on a different preloaded account, not the sender', () => { - const dest = Keypair.random().publicKey(); const sorobanTx = buildSep41TransferTransaction({ source: SOROBAN_INVOKE_SOURCE, sequence: '1', contractId: SEP41_CONTRACT_MAINNET, from: SOROBAN_INVOKE_SOURCE, - to: dest, + to: destinationAddress, amountSmallestUnits: '1', }); const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { @@ -1247,13 +1326,12 @@ describe('TransactionSimulator', () => { }); it('throws when SEP-41 transfer has no SEP-41 balance row for sender and contract on snapshot', () => { - const dest = Keypair.random().publicKey(); const sorobanTx = buildSep41TransferTransaction({ source: SOROBAN_INVOKE_SOURCE, sequence: '1', contractId: SEP41_CONTRACT_MAINNET, from: SOROBAN_INVOKE_SOURCE, - to: dest, + to: destinationAddress, amountSmallestUnits: '1', }); const loaded = onChainFromMockBalances(SOROBAN_INVOKE_SOURCE, '1', { @@ -1291,14 +1369,14 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { type: 'createAccount', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, startingBalance: '2', }, }, @@ -1306,7 +1384,7 @@ describe('TransactionSimulator', () => { type: 'payment', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, asset: 'native', amount: '5', }, @@ -1325,7 +1403,7 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { @@ -1340,7 +1418,7 @@ describe('TransactionSimulator', () => { type: 'payment', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, asset: 'native', amount: '10', }, @@ -1350,7 +1428,7 @@ describe('TransactionSimulator', () => { ); const stack = simulator.simulate(tx, onChainAccount, { - preloadedAccounts: [destOnChainAccount(dest)], + preloadedAccounts: [destOnChainAccount(destinationAddress)], }); expect(stack).toHaveLength(3); }); @@ -1362,14 +1440,14 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { type: 'payment', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, asset: MOCK_USDC_ASSET, amount: '1', }, @@ -1388,7 +1466,7 @@ describe('TransactionSimulator', () => { expect(() => simulator.simulate(tx, onChainAccount, { - preloadedAccounts: [destOnChainAccount(dest)], + preloadedAccounts: [destOnChainAccount(destinationAddress)], }), ).toThrow(TrustlineNotFoundException); }); @@ -1400,7 +1478,7 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { @@ -1415,7 +1493,7 @@ describe('TransactionSimulator', () => { type: 'payment', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, asset: 'native', amount: '10', }, @@ -1430,7 +1508,7 @@ describe('TransactionSimulator', () => { SupportedOperations.ChangeTrust, SupportedOperations.Payment, ], - preloadedAccounts: [destOnChainAccount(dest)], + preloadedAccounts: [destOnChainAccount(destinationAddress)], }), ).toHaveLength(3); }); @@ -1483,14 +1561,14 @@ describe('TransactionSimulator', () => { subentryCount: 0, assets: [], }); - const dest = Keypair.random().publicKey(); + const tx = buildMockClassicTransaction( [ { type: 'createAccount', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, startingBalance: '2', }, }, @@ -1498,7 +1576,7 @@ describe('TransactionSimulator', () => { type: 'payment', params: { source: wallet.address, - destination: dest, + destination: destinationAddress, asset: 'native', amount: '5', }, @@ -1532,7 +1610,6 @@ describe('TransactionSimulator', () => { const issuerA = Keypair.random().publicKey(); const issuerB = Keypair.random().publicKey(); const sourceKey = Keypair.random().publicKey(); - const dest = Keypair.random().publicKey(); const loaded = onChainFromMockBalances(sourceKey, '1', { nativeBalance: 1.5, @@ -1576,7 +1653,7 @@ describe('TransactionSimulator', () => { type: 'payment', params: { source: sourceKey, - destination: dest, + destination: destinationAddress, asset: 'native', amount: '0.4', }, @@ -1593,7 +1670,7 @@ describe('TransactionSimulator', () => { SupportedOperations.ChangeTrust, SupportedOperations.Payment, ], - preloadedAccounts: [destOnChainAccount(dest)], + preloadedAccounts: [destOnChainAccount(destinationAddress)], }), ).toThrow(InsufficientBalanceToCoverFeeException); }); @@ -1602,7 +1679,6 @@ describe('TransactionSimulator', () => { const issuerA = Keypair.random().publicKey(); const issuerB = Keypair.random().publicKey(); const sourceKey = Keypair.random().publicKey(); - const dest = Keypair.random().publicKey(); const loaded = onChainFromMockBalances(sourceKey, '1', { nativeBalance: 1.6, @@ -1646,7 +1722,7 @@ describe('TransactionSimulator', () => { type: 'payment', params: { source: sourceKey, - destination: dest, + destination: destinationAddress, asset: 'native', amount: '0.4', }, @@ -1663,7 +1739,7 @@ describe('TransactionSimulator', () => { SupportedOperations.ChangeTrust, SupportedOperations.Payment, ], - preloadedAccounts: [destOnChainAccount(dest)], + preloadedAccounts: [destOnChainAccount(destinationAddress)], }), ).toHaveLength(4); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts index b4106ca1..5c9fac80 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts @@ -24,6 +24,7 @@ import { import type { Transaction } from './Transaction'; import { assertInvokeHostFunctionSoleOperation, + assertTransactionTimeBound, assertTransactionScope, assertTransactionSourceAccount, } from './utils'; @@ -172,6 +173,9 @@ export class TransactionSimulator { ): asserts ops is SupportedOPType[] { const { expectedOPTypes = [] } = options ?? {}; + // Ensure the transaction is not expired + assertTransactionTimeBound(transaction); + // Ensure the transaction scope matches the account scope. assertTransactionScope(transaction, account.scope); // Envelope must involve this wallet as source or fee source (API XDR or in-app builds). diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts index 8a8ab9fc..858f0c74 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts @@ -40,6 +40,13 @@ export class TransactionScopeNotMatchException extends TransactionValidationExce } } +export class TransactionExpireException extends TransactionValidationException { + constructor(expirationTime: number) { + super(`Transaction expired (maxTime: ${expirationTime})`); + this.name = 'TransactionExpireException'; + } +} + export class UnsupportedOperationTypeException extends TransactionValidationException { constructor(operationType: string) { super(`Unsupported operation type: ${operationType}`); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts index 47820701..38bcad15 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts @@ -3,6 +3,7 @@ import { Asset } from '@stellar/stellar-sdk'; import { InvalidInvokeContractStructureException, + TransactionExpireException, TransactionScopeNotMatchException, TransactionValidationException, } from './exceptions'; @@ -136,6 +137,22 @@ export function assertAssetScopeMatch( } } +/** + * Asserts the transaction has not expired. + * + * @param transaction - Wrapped Stellar transaction. + * @throws {TransactionExpireException} When the transaction has expired. + */ +export function assertTransactionTimeBound(transaction: Transaction): void { + const { expirationTime } = transaction; + if (expirationTime === undefined) { + return; + } + if (expirationTime < Math.floor(Date.now() / 1000)) { + throw new TransactionExpireException(expirationTime); + } +} + /** * Maps an `OperationMapper` asset reference to its CAIP-19 id. * @@ -205,3 +222,22 @@ export function collectTransactionAssetCaipIds( } return [...ids]; } + +/** + * Parses Stellar `maxTime` into a unix expiration timestamp. + * + * @param maxTime - `timeBounds.maxTime` from the envelope (unix seconds as a string). + * @returns Parsed unix seconds, or `undefined` when there is no upper bound (`0`). + */ +export function parseExpirationMaxTime( + maxTime: string | undefined, +): number | undefined { + if (maxTime === undefined) { + return undefined; + } + const parsed = parseInt(maxTime, 10); + if (Number.isNaN(parsed) || parsed === 0) { + return undefined; + } + return parsed; +} From 063f713acbc3857210d2d43c787aafb05766bd9a Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Wed, 27 May 2026 15:23:51 +0200 Subject: [PATCH 247/384] feat: add Stellar account asset info RPC --- .../clientRequest/changeTrustOpt.test.ts | 8 ++ .../handlers/clientRequest/changeTrustOpt.ts | 4 + .../src/handlers/cronjob/api.ts | 13 ++ .../handlers/cronjob/trackTransaction.test.ts | 87 +++++++++++- .../src/handlers/cronjob/trackTransaction.ts | 128 +++++++++++++++++- .../trackTransactionHorizonTrustline.test.ts | 106 +++++++++++++++ .../trackTransactionHorizonTrustline.ts | 50 +++++++ .../src/handlers/keyring/api.ts | 27 ++++ .../src/handlers/keyring/exceptions.ts | 6 + .../src/handlers/keyring/keyring.test.ts | 126 ++++++++++++++++- .../src/handlers/keyring/keyring.ts | 118 +++++++++++++++- .../stellar-wallet-snap/src/permissions.ts | 2 + 12 files changed, 664 insertions(+), 11 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.ts diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts index 402d55c8..8f4c9186 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts @@ -263,6 +263,10 @@ describe('ChangeTrustOptHandler', () => { txId: '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0e1', scope, accountIds: [account.id], + trustlineVerification: { + assetId, + action: ChangeTrustOptAction.Add, + }, }); }); @@ -362,6 +366,10 @@ describe('ChangeTrustOptHandler', () => { txId: '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0e1', scope, accountIds: [account.id], + trustlineVerification: { + assetId, + action: ChangeTrustOptAction.Delete, + }, }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts index eb42866d..fdfd9a75 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts @@ -159,6 +159,10 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< txId: transactionId, scope, accountIds: [account.id], + trustlineVerification: { + assetId, + action, + }, }); return { diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts index 68f2ef62..43b5dd42 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts @@ -18,6 +18,7 @@ import type { Json, JsonRpcRequest } from '@metamask/utils'; import { JsonRpcRequestStruct, + KnownCaip19ClassicAssetStruct, KnownCaip2ChainIdStruct, UuidStruct, } from '../../api'; @@ -46,12 +47,24 @@ export const RefreshConfirmationPricesParamsStruct = type({ interfaceKey: ConfirmationInterfaceKeyStruct, }); +export const TrackTransactionTrustlineActionStruct = enums(['add', 'delete']); + +export const TrackTransactionTrustlineVerificationStruct = object({ + assetId: KnownCaip19ClassicAssetStruct, + action: TrackTransactionTrustlineActionStruct, +}); + export const TrackTransactionParamsStruct = type({ txId: nonempty(string()), scope: KnownCaip2ChainIdStruct, accountIds: nonempty(array(UuidStruct)), /** Reschedule counter; omitted on first schedule (treated as 0). */ attempt: optional(size(integer(), 0, 30)), + /** + * When set, {@link TrackTransactionHandler} syncs until a fresh Horizon load matches this + * trustline outcome before marking the keyring transaction Confirmed. + */ + trustlineVerification: optional(TrackTransactionTrustlineVerificationStruct), }); export const SyncAccountParamsStruct = object({ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts index b7de843e..cd7ed9fe 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts @@ -3,22 +3,35 @@ import { TransactionType, type Transaction as KeyringTransaction, } from '@metamask/keyring-api'; +import { Account as StellarAccount } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; import { BackgroundEventMethod } from './api'; import { TrackTransactionHandler } from './trackTransaction'; -import { KnownCaip2ChainId } from '../../api'; +import { KnownCaip2ChainId, type KnownCaip19ClassicAssetId } from '../../api'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; +import { USDC_CLASSIC } from '../../services/asset-metadata/__mocks__/assets.fixtures'; import { InMemoryCache } from '../../services/cache'; import { NetworkService } from '../../services/network'; import { TransactionPollException } from '../../services/network/exceptions'; -import { OnChainAccountService } from '../../services/on-chain-account'; +import { + OnChainAccount, + OnChainAccountService, +} from '../../services/on-chain-account'; import { TransactionService } from '../../services/transaction'; import { createMockTransactionService } from '../../services/transaction/__mocks__/transaction.fixtures'; import { logger, noOpLogger } from '../../utils/logger'; import { scheduleBackgroundEvent } from '../../utils/snap'; jest.mock('../../utils/logger'); +jest.mock('./trackTransactionHorizonTrustline', () => { + const actual = jest.requireActual('./trackTransactionHorizonTrustline'); + return { + ...actual, + delayMilliseconds: jest.fn().mockResolvedValue(undefined), + }; +}); jest.mock('../../utils/snap', () => { const actual = jest.requireActual('../../utils/snap'); return { @@ -34,6 +47,7 @@ describe('TrackTransactionHandler', () => { const txId = 'abc123'; const scope = KnownCaip2ChainId.Testnet; const accountId = '22222222-2222-4222-8222-222222222222'; + const classicAssetId = USDC_CLASSIC as KnownCaip19ClassicAssetId; beforeEach(() => { jest.mocked(scheduleBackgroundEvent).mockClear(); @@ -87,6 +101,11 @@ describe('TrackTransactionHandler', () => { .spyOn(OnChainAccountService.prototype, 'synchronize') .mockResolvedValue(undefined); + const resolveOnChainAccount = jest.spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccount', + ); + const updateKeyringTransactionStatus = jest .spyOn(TransactionService.prototype, 'updateKeyringTransactionStatus') .mockResolvedValue(undefined); @@ -122,10 +141,74 @@ describe('TrackTransactionHandler', () => { pollTransaction, findKeyringTransactionByTransactionId, synchronize, + resolveOnChainAccount, updateKeyringTransactionStatus, }; } + it('settles confirmed change-trust after Horizon trustline matches expectation', async () => { + const { + handler, + account, + pollTransaction, + synchronize, + resolveOnChainAccount, + updateKeyringTransactionStatus, + findKeyringTransactionByTransactionId, + } = setup(); + findKeyringTransactionByTransactionId.mockResolvedValue( + createPersistedKeyringTransaction(), + ); + pollTransaction.mockResolvedValue(txId); + + const stellarAccount = new StellarAccount(account.address, '1'); + const staleHorizonAccount = new OnChainAccount(stellarAccount, scope); + staleHorizonAccount.setAsset(classicAssetId, { + balance: new BigNumber(0), + symbol: 'USDC', + limit: new BigNumber('9223372036854775807'), + address: account.address, + authorized: true, + }); + const updatedHorizonAccount = new OnChainAccount(stellarAccount, scope); + + resolveOnChainAccount + .mockResolvedValueOnce(staleHorizonAccount) + .mockResolvedValue(updatedHorizonAccount); + + const callOrder: string[] = []; + synchronize.mockImplementation(async () => { + callOrder.push('sync'); + }); + updateKeyringTransactionStatus.mockImplementation(async () => { + callOrder.push('settle'); + }); + + await handler.handle({ + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIds: [accountId], + trustlineVerification: { + assetId: classicAssetId, + action: 'delete', + }, + }, + }); + + expect(synchronize).toHaveBeenCalledTimes(2); + expect(resolveOnChainAccount).toHaveBeenCalledTimes(2); + expect(callOrder).toStrictEqual(['sync', 'sync', 'settle']); + expect(updateKeyringTransactionStatus).toHaveBeenCalledWith({ + txId, + accountIds: [accountId], + status: TransactionStatus.Confirmed, + }); + }); + it('loads persisted keyring transaction from state before Soroban poll', async () => { const { handler, pollTransaction, findKeyringTransactionByTransactionId } = setup(); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts index 709f91c7..69dec05c 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts @@ -12,6 +12,12 @@ import { TrackTransactionJsonRpcRequestStruct, } from './api'; import { CronjobBaseHandler } from './base'; +import type { TrackTransactionTrustlineVerification } from './trackTransactionHorizonTrustline'; +import { + delayMilliseconds, + isHorizonTrustlineMatchingExpectation, + TrackTransactionTrustlineAction, +} from './trackTransactionHorizonTrustline'; import type { KnownCaip2ChainId } from '../../api'; import type { AccountService, @@ -25,10 +31,19 @@ import type { ILogger } from '../../utils/logger'; import { createPrefixedLogger } from '../../utils/logger'; import { Duration, scheduleBackgroundEvent } from '../../utils/snap'; +/** Horizon trustline polls after RPC success (Soroban can lead Horizon indexing). */ +const HORIZON_TRUSTLINE_VERIFY_MAX_ATTEMPTS = 6; + +/** Delay between Horizon verification sync attempts. */ +const HORIZON_TRUSTLINE_VERIFY_DELAY_MS = 2000; + /** * Polls Soroban RPC for transaction settlement first, then updates keyring status and runs * {@link OnChainAccountService.synchronize}. The persisted keyring transaction in snap state * (by hash) is the source of truth for which account to sync. + * + * Change-trust jobs may pass `trustlineVerification`; those sync until a fresh Horizon load + * matches the expected trustline before marking the keyring row Confirmed. */ export class TrackTransactionHandler extends CronjobBaseHandler { static async scheduleBackgroundEvent( @@ -135,18 +150,44 @@ export class TrackTransactionHandler extends CronjobBaseHandler 0) { + if ( + keyringStatus === TransactionStatus.Confirmed && + trustlineVerification + ) { + await this.#synchronizeUntilHorizonTrustlineMatches({ + accounts: accountsToSync, + scope, + verification: { + assetId: trustlineVerification.assetId, + action: + trustlineVerification.action === 'add' + ? TrackTransactionTrustlineAction.Add + : TrackTransactionTrustlineAction.Delete, + }, + }); + } else if (keyringStatus === TransactionStatus.Confirmed) { + await this.#synchronizeAccounts(accountsToSync, scope); + } + } + + if (keyringStatus) { + await this.#settleKeyringRow(txId, accountIds, keyringStatus); + } + + if ( + accountsToSync.length > 0 && + keyringStatus !== TransactionStatus.Confirmed + ) { await this.#synchronizeAccounts(accountsToSync, scope); - } else { + } + + if (accountsToSync.length === 0) { this.logger.warn( 'TrackTransaction: account not found when tracking the transaction, unable to sync', { @@ -191,6 +232,79 @@ export class TrackTransactionHandler extends CronjobBaseHandler { + const { accounts, scope, verification } = params; + const account = accounts[0]; + if (!account) { + return; + } + + for ( + let attempt = 0; + attempt < HORIZON_TRUSTLINE_VERIFY_MAX_ATTEMPTS; + attempt += 1 + ) { + await this.#synchronizeAccounts(accounts, scope); + + const horizonAccount = + await this.#onChainAccountService.resolveOnChainAccount( + account.address, + scope, + ); + + if ( + isHorizonTrustlineMatchingExpectation( + horizonAccount, + verification.assetId, + verification.action, + ) + ) { + this.logger.info( + 'TrackTransaction: Horizon trustline matches expectation', + { + attempt, + assetId: verification.assetId, + action: verification.action, + }, + ); + return; + } + + if (attempt < HORIZON_TRUSTLINE_VERIFY_MAX_ATTEMPTS - 1) { + this.logger.warn( + 'TrackTransaction: Horizon trustline not yet consistent; retrying', + { + attempt, + assetId: verification.assetId, + action: verification.action, + }, + ); + await delayMilliseconds(HORIZON_TRUSTLINE_VERIFY_DELAY_MS); + } + } + + this.logger.warn( + 'TrackTransaction: Horizon trustline verification exhausted attempts', + { + assetId: verification.assetId, + action: verification.action, + maxAttempts: HORIZON_TRUSTLINE_VERIFY_MAX_ATTEMPTS, + }, + ); + } + async #settleKeyringRow( txId: string, accountIds: readonly string[], diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.test.ts new file mode 100644 index 00000000..41b25f14 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.test.ts @@ -0,0 +1,106 @@ +import { Account as StellarAccount } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import { + isHorizonTrustlineMatchingExpectation, + TrackTransactionTrustlineAction, +} from './trackTransactionHorizonTrustline'; +import { KnownCaip2ChainId } from '../../api'; +import type { KnownCaip19ClassicAssetId } from '../../api'; +import { OnChainAccount } from '../../services/on-chain-account'; + +const CLASSIC_ASSET_ID = + 'stellar:testnet/asset:GTN-GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF' as KnownCaip19ClassicAssetId; + +function createHorizonAccountWithTrustline(limit: string): OnChainAccount { + const stellarAccount = new StellarAccount( + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + '1', + ); + const onChainAccount = new OnChainAccount( + stellarAccount, + KnownCaip2ChainId.Testnet, + ); + onChainAccount.setAsset(CLASSIC_ASSET_ID, { + balance: new BigNumber(0), + symbol: 'GTN', + limit: new BigNumber(limit), + address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + authorized: true, + }); + return onChainAccount; +} + +describe('isHorizonTrustlineMatchingExpectation', () => { + it('returns true for delete when the trustline is absent on Horizon', () => { + const account = new OnChainAccount( + new StellarAccount( + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + '1', + ), + KnownCaip2ChainId.Testnet, + ); + + expect( + isHorizonTrustlineMatchingExpectation( + account, + CLASSIC_ASSET_ID, + TrackTransactionTrustlineAction.Delete, + ), + ).toBe(true); + }); + + it('returns true for delete when the trustline limit is zero', () => { + const account = createHorizonAccountWithTrustline('0'); + + expect( + isHorizonTrustlineMatchingExpectation( + account, + CLASSIC_ASSET_ID, + TrackTransactionTrustlineAction.Delete, + ), + ).toBe(true); + }); + + it('returns false for delete when the trustline limit is greater than zero', () => { + const account = createHorizonAccountWithTrustline('9223372036854775807'); + + expect( + isHorizonTrustlineMatchingExpectation( + account, + CLASSIC_ASSET_ID, + TrackTransactionTrustlineAction.Delete, + ), + ).toBe(false); + }); + + it('returns true for add when the trustline limit is greater than zero', () => { + const account = createHorizonAccountWithTrustline('100'); + + expect( + isHorizonTrustlineMatchingExpectation( + account, + CLASSIC_ASSET_ID, + TrackTransactionTrustlineAction.Add, + ), + ).toBe(true); + }); + + it('returns false for add when the trustline is absent', () => { + const account = new OnChainAccount( + new StellarAccount( + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + '1', + ), + KnownCaip2ChainId.Testnet, + ); + + expect( + isHorizonTrustlineMatchingExpectation( + account, + CLASSIC_ASSET_ID, + TrackTransactionTrustlineAction.Add, + ), + ).toBe(false); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.ts new file mode 100644 index 00000000..109bba37 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.ts @@ -0,0 +1,50 @@ +import type { KnownCaip19ClassicAssetId } from '../../api'; +import type { OnChainAccount } from '../../services/on-chain-account'; + +/** + * Expected trustline outcome after a {@link ClientRequestMethod.ChangeTrustOpt} transaction. + */ +export enum TrackTransactionTrustlineAction { + Add = 'add', + Delete = 'delete', +} + +export type TrackTransactionTrustlineVerification = { + assetId: KnownCaip19ClassicAssetId; + action: TrackTransactionTrustlineAction; +}; + +/** + * Returns whether a fresh Horizon account load reflects the expected trustline change. + * + * @param onChainAccount - Account loaded from Horizon (not persisted snap snapshot). + * @param assetId - Classic CAIP-19 asset id for the trustline. + * @param action - Opt-in expects limit greater than 0; opt-out expects line absent or limit 0. + * @returns `true` when Horizon matches the expected post-tx trustline state. + */ +export function isHorizonTrustlineMatchingExpectation( + onChainAccount: OnChainAccount, + assetId: KnownCaip19ClassicAssetId, + action: TrackTransactionTrustlineAction, +): boolean { + if (action === TrackTransactionTrustlineAction.Delete) { + if (!onChainAccount.hasAsset(assetId)) { + return true; + } + const row = onChainAccount.getAsset(assetId); + return row?.limit?.isZero() ?? false; + } + + const row = onChainAccount.getAsset(assetId); + return row?.limit?.gt(0) ?? false; +} + +/** + * @param ms - Milliseconds to wait. + * @returns A promise that resolves after `ms`. + */ +export async function delayMilliseconds(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts index 7f58fe6f..f34c0f58 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts @@ -16,6 +16,7 @@ import { nullable, enums, refine, + boolean, } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { base64 } from '@metamask/utils'; @@ -342,6 +343,32 @@ export const GetAccountBalancesRequestStruct = object({ ), }); +/** Stellar-only keyring RPC (not in `@metamask/keyring-api` yet). */ +export const KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD = + 'keyring_getAccountAssetInfo' as const; + +export const GetAccountAssetInfoRequestStruct = GetAccountBalancesRequestStruct; + +/** + * Optional per-asset fields for chains that use trust lines (Stellar classic). + */ +export const AccountAssetInfoExtraStruct = object({ + limit: optional(string()), + authorized: optional(boolean()), + sponsored: optional(boolean()), +}); + +export const AccountAssetInfoEntryStruct = object({ + metadata: type({}), + extra: optional(AccountAssetInfoExtraStruct), +}); + +export type AccountAssetInfoExtra = Infer; + +export type GetAccountAssetInfoRequest = Infer< + typeof GetAccountAssetInfoRequestStruct +>; + /** * The options for the createAccount method. */ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts index bb26d105..aa47e33f 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts @@ -196,6 +196,12 @@ export class KeyringGetAccountBalancesException extends KeyringException { } } +export class KeyringGetAccountAssetInfoException extends KeyringException { + constructor(accountId: string) { + super(`Failed to get account asset info for account ${accountId}`); + } +} + export class KeyringResolveAccountAddressException extends KeyringException { constructor( scope: KnownCaip2ChainId, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 673053ba..ea718754 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -15,6 +15,7 @@ import type { Json } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { + KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD, MultichainMethod, SignAuthEntryResponseStruct, SignMessageResponseStruct, @@ -26,6 +27,7 @@ import { KeyringDeleteAccountException, KeyringDiscoverAccountsException, KeyringGetAccountBalancesException, + KeyringGetAccountAssetInfoException, KeyringGetAccountException, KeyringListAccountAssetsException, KeyringListAccountsException, @@ -44,7 +46,10 @@ import { generateStellarKeyringAccount, } from '../../services/account/__mocks__/account.fixtures'; import { AccountNotFoundException } from '../../services/account/exceptions'; -import { createMockAssetMetadataService } from '../../services/asset-metadata/__mocks__/assets.fixtures'; +import { + createMockAssetMetadataService, + USDC_CLASSIC, +} from '../../services/asset-metadata/__mocks__/assets.fixtures'; import { OnChainAccountService } from '../../services/on-chain-account'; import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; import type { OnChainAccount } from '../../services/on-chain-account/OnChainAccount'; @@ -674,6 +679,125 @@ describe('KeyringHandler', () => { }); }); + describe('getAccountAssetInfo', () => { + it('returns metadata and trustline extra for a classic asset with limit', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue({ + getAsset: () => ({ + balance: new BigNumber('0'), + symbol: 'USDC', + limit: new BigNumber('10000000'), + authorized: true, + sponsored: false, + decimals: 7, + }), + } as unknown as OnChainAccount); + + const result = await keyringHandler.getAccountAssetInfo(mockAccountId, [ + USDC_CLASSIC, + ]); + + expect(result[USDC_CLASSIC]?.metadata.symbol).toBe('USDC'); + expect(result[USDC_CLASSIC]?.extra).toStrictEqual({ + limit: '1', + authorized: true, + sponsored: false, + }); + }); + + it('returns extra with zero limit for classic tombstone rows', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue({ + getAsset: () => ({ + balance: new BigNumber('0'), + symbol: 'USDC', + limit: new BigNumber(0), + decimals: 7, + }), + } as unknown as OnChainAccount); + + const result = await keyringHandler.getAccountAssetInfo(mockAccountId, [ + USDC_CLASSIC, + ]); + + expect(result[USDC_CLASSIC]?.extra).toStrictEqual({ limit: '0' }); + }); + + it('omits extra when classic asset has no on-chain row', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue({ + getAsset: () => undefined, + } as unknown as OnChainAccount); + + const result = await keyringHandler.getAccountAssetInfo(mockAccountId, [ + USDC_CLASSIC, + ]); + + expect(result[USDC_CLASSIC]?.metadata).toBeDefined(); + expect(result[USDC_CLASSIC]?.extra).toBeUndefined(); + }); + + it('routes keyring_getAccountAssetInfo via handle', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue({ + getAsset: () => ({ + balance: new BigNumber('10'), + symbol: 'XLM', + }), + } as unknown as OnChainAccount); + + const result = await keyringHandler.handle('metamask', { + jsonrpc: '2.0', + id: 1, + method: KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD, + params: { accountId: mockAccountId, assets: [slipId] }, + }); + + expect(handleKeyringRequest).not.toHaveBeenCalled(); + expect(result).toHaveProperty(slipId); + }); + + it('throws when asset info resolution fails', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockRejectedValue(new Error('Horizon unavailable')); + + await expect( + keyringHandler.getAccountAssetInfo(mockAccountId, [USDC_CLASSIC]), + ).rejects.toThrow(KeyringGetAccountAssetInfoException); + }); + }); + describe('resolveAccountAddress', () => { it('resolves an account address from opts.address', async () => { const { resolveAccountSpy } = getAccountServiceSpies(); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index a0056816..88efb7c9 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -23,8 +23,9 @@ import { InvalidParamsError, type Json, type JsonRpcRequest, + FungibleAssetMetadataStruct, + type FungibleAssetMetadata, } from '@metamask/snaps-sdk'; -import { FungibleAssetMetadataStruct } from '@metamask/snaps-sdk'; import { ensureError, type CaipAssetTypeOrId } from '@metamask/utils'; import type { @@ -32,6 +33,7 @@ import type { GetAccountRequest, ResolveAccountAddressJsonRpcRequest, MultichainMethod, + AccountAssetInfoExtra, } from './api'; import { CreateAccountOptionsStruct, @@ -44,6 +46,8 @@ import { SetSelectedAccountsRequestStruct, ListAccountAssetsRequestStruct, GetAccountBalancesRequestStruct, + GetAccountAssetInfoRequestStruct, + KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD, } from './api'; import type { IKeyringRequestHandler } from './base'; import { @@ -52,6 +56,7 @@ import { KeyringDiscoverAccountsException, KeyringEmitAccountCreatedEventException, KeyringGetAccountBalancesException, + KeyringGetAccountAssetInfoException, KeyringGetAccountException, KeyringListAccountAssetsException, KeyringListAccountsException, @@ -74,6 +79,7 @@ import type { OnChainAccount, OnChainAccountService, } from '../../services/on-chain-account'; +import type { SpendableBalance } from '../../services/on-chain-account/api'; import type { TransactionService } from '../../services/transaction/TransactionService'; import type { ILogger } from '../../utils'; import { @@ -81,6 +87,7 @@ import { Duration, getSlip44AssetId, getSnapProvider, + isClassicAssetId, isSep41Id, isSlip44Id, toDisplayBalance, @@ -91,6 +98,11 @@ import { } from '../../utils'; import { SyncAccountsHandler } from '../cronjob/syncAccounts'; +export type AccountAssetInfoEntry = { + metadata: FungibleAssetMetadata; + extra?: AccountAssetInfoExtra; +}; + export class KeyringHandler implements Keyring { readonly #logger: ILogger; @@ -131,6 +143,14 @@ export class KeyringHandler implements Keyring { const result = (await withCatchAndThrowSnapError(async () => { validateOrigin(origin, request.method); + if (request.method === KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD) { + validateRequest(request.params, GetAccountAssetInfoRequestStruct); + const { accountId, assets } = request.params as { + accountId: string; + assets: KnownCaip19AssetIdOrSlip44Id[]; + }; + return await this.getAccountAssetInfo(accountId, assets); + } return handleKeyringRequest(this, request); }, this.#logger)) ?? null; @@ -507,6 +527,102 @@ export class KeyringHandler implements Keyring { } } + /** + * Returns fungible metadata and optional trust-line fields for the requested assets. + * Classic Stellar assets include `extra.limit` when an on-chain row exists; omit `extra` + * when the asset is not on the account (e.g. portfolio import pending trust line). + * + * @param accountId - Keyring account id. + * @param assets - CAIP-19 asset ids to resolve. + * @returns Per-asset metadata and optional extra fields. + */ + async getAccountAssetInfo( + accountId: string, + assets: KnownCaip19AssetIdOrSlip44Id[], + ): Promise> { + validateRequest({ accountId, assets }, GetAccountAssetInfoRequestStruct); + + const scope = AppConfig.selectedNetwork; + const result = {} as Record< + KnownCaip19AssetIdOrSlip44Id, + AccountAssetInfoEntry + >; + + try { + const { onChainAccount } = await this.#resolveAccountByAccountId( + accountId, + scope, + ); + + const assetsMetadata = + await this.#assetMetadataService.getAssetsMetadataByAssetIds(assets); + + for (const assetId of assets) { + const assetMetadata = assetsMetadata[assetId]; + if ( + assetMetadata === undefined || + assetMetadata === null || + !FungibleAssetMetadataStruct.is(assetMetadata) || + assetMetadata.units[0]?.decimals === undefined + ) { + continue; + } + + const onChainRow = + onChainAccount === null + ? undefined + : onChainAccount.getAsset(assetId); + + if (isSep41Id(assetId) && !onChainRow?.balance.gt(0)) { + continue; + } + + const { decimals } = assetMetadata.units[0]; + const extra = this.#buildAccountAssetInfoExtra( + assetId, + onChainRow, + decimals, + ); + + result[assetId] = { + metadata: assetMetadata, + ...(extra === undefined ? {} : { extra }), + }; + } + + return result; + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to get account asset info', + ensureError(error).message, + ); + throw new KeyringGetAccountAssetInfoException(accountId); + } + } + + #buildAccountAssetInfoExtra( + assetId: KnownCaip19AssetIdOrSlip44Id, + onChainRow: SpendableBalance | undefined, + decimals: number, + ): AccountAssetInfoExtra | undefined { + if (!isClassicAssetId(assetId) || onChainRow === undefined) { + return undefined; + } + if (onChainRow.limit === undefined) { + return undefined; + } + + return { + limit: toDisplayBalance(onChainRow.limit, decimals), + ...(onChainRow.authorized === undefined + ? {} + : { authorized: onChainRow.authorized }), + ...(onChainRow.sponsored === undefined + ? {} + : { sponsored: onChainRow.sponsored }), + }; + } + async resolveAccountAddress( scope: KnownCaip2ChainId, request: ResolveAccountAddressJsonRpcRequest, diff --git a/merged-packages/stellar-wallet-snap/src/permissions.ts b/merged-packages/stellar-wallet-snap/src/permissions.ts index 0438f7cd..ea9f28dd 100644 --- a/merged-packages/stellar-wallet-snap/src/permissions.ts +++ b/merged-packages/stellar-wallet-snap/src/permissions.ts @@ -2,6 +2,7 @@ import { KeyringRpcMethod } from '@metamask/keyring-api'; import { Environment } from './api'; import { AppConfig } from './config'; +import { KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD } from './handlers/keyring/api'; const isDev = AppConfig.environment !== Environment.Production; @@ -38,6 +39,7 @@ const metamaskPermissions = new Set([ KeyringRpcMethod.ListAccountAssets, KeyringRpcMethod.ResolveAccountAddress, KeyringRpcMethod.SetSelectedAccounts, + KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD, ]); const metamask = 'metamask'; From 7a6ea64543405a6e2d4390893383740fd349129a Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Wed, 27 May 2026 15:55:26 +0200 Subject: [PATCH 248/384] chore: fix lint and tests --- .../stellar-wallet-snap/src/context.ts | 1 + .../trackTransactionHorizonTrustline.ts | 8 +- .../src/handlers/keyring/keyring.test.ts | 75 ++++++++++++------- .../src/handlers/keyring/keyring.ts | 12 ++- .../OnChainAccountSynchronizeService.test.ts | 17 +++-- .../src/utils/requestResponse.test.ts | 2 + 6 files changed, 76 insertions(+), 39 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 7fe9b37a..a157c6bb 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -155,6 +155,7 @@ const keyringHandler = new KeyringHandler({ accountService, onChainAccountService, transactionService, + assetMetadataService, handlers: keyringMethodHandlers, }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.ts index 109bba37..e64a6e7e 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.ts @@ -27,15 +27,15 @@ export function isHorizonTrustlineMatchingExpectation( assetId: KnownCaip19ClassicAssetId, action: TrackTransactionTrustlineAction, ): boolean { + const row = onChainAccount.getRawAsset(assetId); + if (action === TrackTransactionTrustlineAction.Delete) { - if (!onChainAccount.hasAsset(assetId)) { + if (row === undefined) { return true; } - const row = onChainAccount.getAsset(assetId); - return row?.limit?.isZero() ?? false; + return row.limit?.isZero() ?? false; } - const row = onChainAccount.getAsset(assetId); return row?.limit?.gt(0) ?? false; } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index a61ab7a7..ffb92907 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -12,6 +12,7 @@ import { import { InvalidParamsError, type JsonRpcRequest } from '@metamask/snaps-sdk'; import { create } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; import { KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD, @@ -34,7 +35,11 @@ import { KeyringResolveAccountAddressException, } from './exceptions'; import { KeyringHandler } from './keyring'; -import { KnownCaip2ChainId } from '../../api'; +import { + type KnownCaip19AssetIdOrSlip44Id, + type KnownCaip19ClassicAssetId, + KnownCaip2ChainId, +} from '../../api'; import { KEYRING_ACCOUNT_TYPE } from '../../constants'; import { AccountService, @@ -47,8 +52,10 @@ import { import { AccountNotFoundException } from '../../services/account/exceptions'; import { createMockAssetMetadataService, + generateMockKeyringAssetMetadata, USDC_CLASSIC, } from '../../services/asset-metadata/__mocks__/assets.fixtures'; +import type { KeyringAssetMetadataByAssetId } from '../../services/asset-metadata/api'; import { OnChainAccountService } from '../../services/on-chain-account'; import { createMockAccountWithBalances, @@ -137,11 +144,25 @@ describe('KeyringHandler', () => { const { accountService, onChainAccountService } = mockOnChainAccountService(); const { transactionService } = createMockTransactionService(); + const { service: assetMetadataService, getAssetsMetadataByAssetIdsSpy } = + createMockAssetMetadataService(); + const mockKeyringAssetMetadata = generateMockKeyringAssetMetadata(); + getAssetsMetadataByAssetIdsSpy.mockImplementation( + async (assetIds: KnownCaip19AssetIdOrSlip44Id[]) => { + const metadataByAssetId = {} as KeyringAssetMetadataByAssetId; + for (const assetId of assetIds) { + metadataByAssetId[assetId] = + mockKeyringAssetMetadata[assetId] ?? null; + } + return metadataByAssetId; + }, + ); keyringHandler = new KeyringHandler({ logger, accountService, onChainAccountService, transactionService, + assetMetadataService, handlers: { [MultichainMethod.SignMessage]: mockSignMessageHandler, [MultichainMethod.SignTransaction]: mockSignTransactionHandler, @@ -694,21 +715,21 @@ describe('KeyringHandler', () => { it('returns metadata and trustline extra for a classic asset with limit', async () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); + onChainAccount.setAsset(USDC_CLASSIC as KnownCaip19ClassicAssetId, { + balance: new BigNumber('0'), + symbol: 'USDC', + limit: new BigNumber('10000000'), + authorized: true, + sponsored: false, + decimals: 7, + }); jest .spyOn( OnChainAccountService.prototype, 'resolveOnChainAccountByKeyringAccountId', ) - .mockResolvedValue({ - getAsset: () => ({ - balance: new BigNumber('0'), - symbol: 'USDC', - limit: new BigNumber('10000000'), - authorized: true, - sponsored: false, - decimals: 7, - }), - } as unknown as OnChainAccount); + .mockResolvedValue(onChainAccount); const result = await keyringHandler.getAccountAssetInfo(mockAccountId, [ USDC_CLASSIC, @@ -725,19 +746,19 @@ describe('KeyringHandler', () => { it('returns extra with zero limit for classic tombstone rows', async () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); + onChainAccount.setAsset(USDC_CLASSIC as KnownCaip19ClassicAssetId, { + balance: new BigNumber('0'), + symbol: 'USDC', + limit: new BigNumber(0), + decimals: 7, + }); jest .spyOn( OnChainAccountService.prototype, 'resolveOnChainAccountByKeyringAccountId', ) - .mockResolvedValue({ - getAsset: () => ({ - balance: new BigNumber('0'), - symbol: 'USDC', - limit: new BigNumber(0), - decimals: 7, - }), - } as unknown as OnChainAccount); + .mockResolvedValue(onChainAccount); const result = await keyringHandler.getAccountAssetInfo(mockAccountId, [ USDC_CLASSIC, @@ -749,14 +770,13 @@ describe('KeyringHandler', () => { it('omits extra when classic asset has no on-chain row', async () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); jest .spyOn( OnChainAccountService.prototype, 'resolveOnChainAccountByKeyringAccountId', ) - .mockResolvedValue({ - getAsset: () => undefined, - } as unknown as OnChainAccount); + .mockResolvedValue(onChainAccount); const result = await keyringHandler.getAccountAssetInfo(mockAccountId, [ USDC_CLASSIC, @@ -770,17 +790,16 @@ describe('KeyringHandler', () => { const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address, { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 1.000001, + }); jest .spyOn( OnChainAccountService.prototype, 'resolveOnChainAccountByKeyringAccountId', ) - .mockResolvedValue({ - getAsset: () => ({ - balance: new BigNumber('10'), - symbol: 'XLM', - }), - } as unknown as OnChainAccount); + .mockResolvedValue(onChainAccount); const result = await keyringHandler.handle('metamask', { jsonrpc: '2.0', diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 0630f515..b7528d27 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -73,6 +73,7 @@ import type { StellarKeyringAccount, } from '../../services/account'; import { AccountNotFoundException } from '../../services/account/exceptions'; +import type { AssetMetadataService } from '../../services/asset-metadata/AssetMetadataService'; import { getNativeAssetMetadata } from '../../services/asset-metadata/utils'; import type { OnChainAccount, @@ -111,6 +112,8 @@ export class KeyringHandler implements Keyring { readonly #transactionService: TransactionService; + readonly #assetMetadataService: AssetMetadataService; + readonly #handlers: Record; constructor({ @@ -118,18 +121,21 @@ export class KeyringHandler implements Keyring { accountService, onChainAccountService, transactionService, + assetMetadataService, handlers, }: { logger: ILogger; accountService: AccountService; onChainAccountService: OnChainAccountService; transactionService: TransactionService; + assetMetadataService: AssetMetadataService; handlers: Record; }) { this.#logger = createPrefixedLogger(logger, '[🔑 KeyringHandler]'); this.#accountService = accountService; this.#onChainAccountService = onChainAccountService; this.#transactionService = transactionService; + this.#assetMetadataService = assetMetadataService; this.#handlers = handlers; } @@ -551,9 +557,13 @@ export class KeyringHandler implements Keyring { } const { decimals } = assetMetadata.units[0]; + const onChainRowForExtra = + onChainAccount === null || !isClassicAssetId(assetId) + ? onChainRow + : onChainAccount.getRawAsset(assetId); const extra = this.#buildAccountAssetInfoExtra( assetId, - onChainRow, + onChainRowForExtra, decimals, ); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts index 0be621a4..3601241c 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts @@ -30,6 +30,9 @@ import type { StellarAssetMetadata } from '../asset-metadata/api'; import { AssetMetadataService } from '../asset-metadata/AssetMetadataService'; import { AccountNotActivatedException, NetworkService } from '../network'; +const isKeyringEmitCall = (call: unknown[], event: KeyringEvent): boolean => + (call[1] as KeyringEvent) === event; + jest.mock('../../utils/logger'); jest.mock('../../utils/snap'); jest.mock('@metamask/keyring-snap-sdk', () => ({ @@ -485,7 +488,7 @@ describe('OnChainAccountSynchronizeService', () => { ); const balanceEventCalls = emitSnapKeyringEventSpy.mock.calls.filter( - (call) => call[1] === KeyringEvent.AccountBalancesUpdated, + (call) => isKeyringEmitCall(call, KeyringEvent.AccountBalancesUpdated), ); expect(balanceEventCalls).toHaveLength(4); expect(balanceEventCalls[3]?.[2]).toStrictEqual( @@ -624,7 +627,9 @@ describe('OnChainAccountSynchronizeService', () => { ); // sync 4 const balanceEventCalls = emitSnapKeyringEventSpy.mock.calls - .filter((call) => call[1] === KeyringEvent.AccountBalancesUpdated) + .filter((call) => + isKeyringEmitCall(call, KeyringEvent.AccountBalancesUpdated), + ) .map((call) => call[2]); expect(balanceEventCalls).toHaveLength(4); const sync1Payload = balanceEventCalls[0] as { @@ -812,8 +817,8 @@ describe('OnChainAccountSynchronizeService', () => { expect(saveManySpy).toHaveBeenCalledTimes(4); expect(emitSnapKeyringEventSpy).toHaveBeenCalledTimes(6); - const balanceCalls = emitSnapKeyringEventSpy.mock.calls.filter( - (call) => call[1] === KeyringEvent.AccountBalancesUpdated, + const balanceCalls = emitSnapKeyringEventSpy.mock.calls.filter((call) => + isKeyringEmitCall(call, KeyringEvent.AccountBalancesUpdated), ); expect(balanceCalls).toHaveLength(4); @@ -856,8 +861,8 @@ describe('OnChainAccountSynchronizeService', () => { [EURC_CLASSIC]: { unit: 'EURC', amount: '10' }, }); - const assetListCalls = emitSnapKeyringEventSpy.mock.calls.filter( - (call) => call[1] === KeyringEvent.AccountAssetListUpdated, + const assetListCalls = emitSnapKeyringEventSpy.mock.calls.filter((call) => + isKeyringEmitCall(call, KeyringEvent.AccountAssetListUpdated), ); expect(assetListCalls).toHaveLength(2); diff --git a/merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts b/merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts index c4748cba..c94c79e9 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts @@ -11,6 +11,7 @@ import { validateResponse, validateOrigin, } from './requestResponse'; +import { KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD } from '../handlers/keyring/api'; const TestStruct = object({ url: string(), @@ -75,6 +76,7 @@ describe('validateOrigin', () => { KeyringRpcMethod.ListAccountAssets, KeyringRpcMethod.ResolveAccountAddress, KeyringRpcMethod.SetSelectedAccounts, + KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD, ])('allows method %s for metamask', (method) => { const origin = 'metamask'; From 49a1420b58a8d04f04ffb8950ec67dc7b44ded6f Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Thu, 28 May 2026 15:50:26 +0200 Subject: [PATCH 249/384] fix: fix comments --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../scanRefresher.test.ts | 7 +- .../scanRefresher.ts | 15 +--- .../SecurityAlertsApiClient.test.ts | 3 +- .../TransactionScanService.test.ts | 10 +-- .../TransactionScanService.ts | 19 ++-- .../src/services/transaction-scan/api.ts | 12 ++- .../components/TransactionAlert.test.tsx | 88 ++++++++++++------- .../components/TransactionAlert.tsx | 60 ++++++------- .../src/ui/confirmation/controller.tsx | 3 +- .../src/ui/confirmation/utils.test.ts | 5 +- .../src/ui/confirmation/utils.ts | 20 ++++- .../ConfirmSignChangeTrustOptIn.tsx | 5 +- .../ConfirmSignChangeTrustOptOut.tsx | 5 +- .../ConfirmSignTransaction.tsx | 5 +- 15 files changed, 148 insertions(+), 111 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 658b5e8b..9d4f82f9 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "KMqaJ6vdB5gfcrDsEAS/YO58fKyGz9SxZgVNmj/hsIY=", + "shasum": "EWAoMlF7BRi4zxdbv322P++kmRXy3hAyNJDOyNvZ7kE=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/scanRefresher.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/scanRefresher.test.ts index 03ee94e1..586e1fca 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/scanRefresher.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/scanRefresher.test.ts @@ -3,7 +3,10 @@ import { ConfirmationContextRefresherKey } from './api'; import { ConfirmationScanRefresher } from './scanRefresher'; import { KnownCaip2ChainId } from '../../../api'; import type { TransactionScanService } from '../../../services/transaction-scan'; -import { TransactionScanOption } from '../../../services/transaction-scan'; +import { + TransactionScanOption, + TransactionScanValidationType, +} from '../../../services/transaction-scan'; import { FetchStatus } from '../../../ui/confirmation/api'; import { logger } from '../../../utils/logger'; @@ -19,7 +22,7 @@ describe('ConfirmationScanRefresher', () => { status: 'SUCCESS' as const, estimatedChanges: { assets: [] }, validation: { - type: 'Benign' as const, + type: TransactionScanValidationType.Benign, reason: null, description: null, }, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/scanRefresher.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/scanRefresher.ts index b7f1685e..d65252fe 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/scanRefresher.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/scanRefresher.ts @@ -78,21 +78,14 @@ export class ConfirmationScanRefresher implements IConfirmationContextRefresher ctx: ConfirmationDataContext, ): Promise { const scanCtx = ctx as SecurityScanContext; - const scanRequest = scanCtx.securityScanRequest; - if (!scanRequest) { + if (!this.shouldFetch(ctx)) { return this.recoveryResult(ctx); } + const scanRequest = scanCtx.securityScanRequest as NonNullable< + SecurityScanContext['securityScanRequest'] + >; const options = this.#getScanOptions(scanCtx.preferences); - if (options.length === 0) { - return { - result: { - scan: null, - scanFetchStatus: FetchStatus.Fetched, - }, - reschedule: false, - }; - } try { const scan = await this.#transactionScanService.scanTransaction({ diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction-scan/SecurityAlertsApiClient.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction-scan/SecurityAlertsApiClient.test.ts index 3a6cd321..9efd0f17 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction-scan/SecurityAlertsApiClient.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction-scan/SecurityAlertsApiClient.test.ts @@ -3,6 +3,7 @@ import { SecurityAlertsApiClient, TransactionScanException, TransactionScanOption, + TransactionScanValidationType, } from '.'; import { KnownCaip2ChainId } from '../../api'; import { logger } from '../../utils/logger'; @@ -37,7 +38,7 @@ describe('SecurityAlertsApiClient', () => { const { client, fetchMock } = setup({ validation: { status: 'Success', - result_type: 'Benign', + result_type: TransactionScanValidationType.Benign, }, simulation: null, }); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction-scan/TransactionScanService.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction-scan/TransactionScanService.test.ts index 4e1b39d7..0a9130b7 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction-scan/TransactionScanService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction-scan/TransactionScanService.test.ts @@ -1,4 +1,4 @@ -import { TransactionScanOption } from './api'; +import { TransactionScanOption, TransactionScanValidationType } from './api'; import type { SecurityAlertsApiClient } from './SecurityAlertsApiClient'; import { TransactionScanService } from './TransactionScanService'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -41,7 +41,7 @@ describe('TransactionScanService', () => { securityAlertsApiClient.scanTransaction.mockResolvedValue({ validation: { status: 'Success', - result_type: 'Warning', + result_type: TransactionScanValidationType.Warning, reason: 'known_attacker', description: 'Known attacker involved', }, @@ -82,7 +82,7 @@ describe('TransactionScanService', () => { ], }, validation: { - type: 'Warning', + type: TransactionScanValidationType.Warning, reason: 'known_attacker', description: 'Known attacker involved', }, @@ -168,7 +168,7 @@ describe('TransactionScanService', () => { simulation: null, validation: { status: 'Success', - result_type: 'Benign', + result_type: TransactionScanValidationType.Benign, }, }); @@ -186,7 +186,7 @@ describe('TransactionScanService', () => { assets: [], }, validation: { - type: 'Benign', + type: TransactionScanValidationType.Benign, reason: null, description: null, }, diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction-scan/TransactionScanService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction-scan/TransactionScanService.ts index 8a9bbad2..634d6209 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction-scan/TransactionScanService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction-scan/TransactionScanService.ts @@ -1,11 +1,12 @@ -import { - TransactionScanOption, - type StellarAssetDiff, - type StellarTransactionScanResponse, - type TransactionScanAssetChange, - type TransactionScanError, - type TransactionScanResult, - type TransactionScanValidation, +import { TransactionScanOption } from './api'; +import type { + StellarAssetDiff, + StellarTransactionScanResponse, + TransactionScanAssetChange, + TransactionScanError, + TransactionScanResult, + TransactionScanValidation, + TransactionScanValidationType, } from './api'; import type { SecurityAlertsApiClient } from './SecurityAlertsApiClient'; import type { KnownCaip2ChainId } from '../../api'; @@ -139,7 +140,7 @@ export class TransactionScanService { >, ): TransactionScanValidation { return { - type: validation.result_type, + type: validation.result_type as TransactionScanValidationType, reason: validation.reason ?? null, description: validation.description ?? null, }; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction-scan/api.ts b/merged-packages/stellar-wallet-snap/src/services/transaction-scan/api.ts index 1750850d..75065f90 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction-scan/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction-scan/api.ts @@ -21,6 +21,12 @@ export enum TransactionScanOption { Validation = 'validation', } +export enum TransactionScanValidationType { + Benign = 'Benign', + Warning = 'Warning', + Malicious = 'Malicious', +} + export type StellarSecurityAlertsChain = 'pubnet' | 'testnet' | 'futurenet'; export type SecurityAlertsMetadata = @@ -92,7 +98,7 @@ const StellarSimulationErrorStruct = type({ const StellarValidationSuccessStruct = type({ status: literal('Success'), - result_type: enums(['Benign', 'Warning', 'Malicious']), + result_type: enums(Object.values(TransactionScanValidationType)), classification: optional(string()), description: optional(string()), reason: optional(string()), @@ -139,7 +145,7 @@ export type TransactionScanEstimatedChanges = { }; export type TransactionScanValidation = { - type: 'Benign' | 'Warning' | 'Malicious' | null; + type: TransactionScanValidationType | null; reason: string | null; description: string | null; }; @@ -160,7 +166,7 @@ const TransactionScanAssetChangeStruct = type({ }); const TransactionScanValidationStruct = type({ - type: nullable(enums(['Benign', 'Warning', 'Malicious'])), + type: nullable(enums(Object.values(TransactionScanValidationType))), reason: nullable(string()), description: nullable(string()), }); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.test.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.test.tsx index 893a4143..d35d1c42 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.test.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.test.tsx @@ -3,6 +3,7 @@ import type { GetPreferencesResult, } from '@metamask/snaps-sdk'; +import { TransactionScanValidationType } from '../../../services/transaction-scan'; import { FetchStatus } from '../api'; import { TransactionAlert } from './TransactionAlert'; @@ -20,14 +21,14 @@ const preferences: GetPreferencesResult = { showTestnets: true, }; -function getType(component: ComponentOrElement): string | undefined { +function getType(component: ComponentOrElement | null): string | undefined { return typeof component === 'object' && component !== null ? component.type : undefined; } function getProps( - component: ComponentOrElement, + component: ComponentOrElement | null, ): Record | undefined { const candidate = component as { props?: Record }; return typeof component === 'object' && component !== null @@ -42,8 +43,6 @@ describe('TransactionAlert', () => { validation: null, error: null, scanFetchStatus: FetchStatus.Fetching, - showValidationAlert: true, - showSimulationError: true, }); expect(getType(component)).toBe('Banner'); @@ -55,7 +54,10 @@ describe('TransactionAlert', () => { it('renders simulation errors when only simulation alerts are enabled', () => { const component = TransactionAlert({ - preferences, + preferences: { + ...preferences, + useSecurityAlerts: false, + }, validation: null, error: { type: 'simulation', @@ -63,8 +65,6 @@ describe('TransactionAlert', () => { message: 'insufficient_balance', }, scanFetchStatus: FetchStatus.Fetched, - showValidationAlert: false, - showSimulationError: true, }); expect(getType(component)).toBe('Banner'); @@ -76,7 +76,10 @@ describe('TransactionAlert', () => { it('renders validation scan errors with validation failure copy', () => { const component = TransactionAlert({ - preferences, + preferences: { + ...preferences, + simulateOnChainActions: false, + }, validation: null, error: { type: 'validation', @@ -84,8 +87,6 @@ describe('TransactionAlert', () => { message: 'invalid_transaction', }, scanFetchStatus: FetchStatus.Fetched, - showValidationAlert: true, - showSimulationError: false, }); expect(getType(component)).toBe('Banner'); @@ -97,7 +98,10 @@ describe('TransactionAlert', () => { it('renders response scan errors with incomplete scan copy', () => { const component = TransactionAlert({ - preferences, + preferences: { + ...preferences, + simulateOnChainActions: false, + }, validation: null, error: { type: 'response', @@ -105,8 +109,6 @@ describe('TransactionAlert', () => { message: 'No scan results returned', }, scanFetchStatus: FetchStatus.Fetched, - showValidationAlert: true, - showSimulationError: false, }); expect(getType(component)).toBe('Banner'); @@ -118,33 +120,35 @@ describe('TransactionAlert', () => { it('does not render validation alerts when security alerts are disabled', () => { const component = TransactionAlert({ - preferences, + preferences: { + ...preferences, + useSecurityAlerts: false, + }, validation: { - type: 'Malicious', + type: TransactionScanValidationType.Malicious, reason: 'known_attacker', description: null, }, error: null, scanFetchStatus: FetchStatus.Fetched, - showValidationAlert: false, - showSimulationError: true, }); - expect(getType(component)).toBe('Box'); + expect(component).toBeNull(); }); it('renders malicious validation alerts as danger banners', () => { const component = TransactionAlert({ - preferences, + preferences: { + ...preferences, + simulateOnChainActions: false, + }, validation: { - type: 'Malicious', + type: TransactionScanValidationType.Malicious, reason: 'known_attacker', description: null, }, error: null, scanFetchStatus: FetchStatus.Fetched, - showValidationAlert: true, - showSimulationError: false, }); expect(getType(component)).toBe('Banner'); @@ -156,16 +160,17 @@ describe('TransactionAlert', () => { it('renders warning validation alerts with softer warning copy', () => { const component = TransactionAlert({ - preferences, + preferences: { + ...preferences, + simulateOnChainActions: false, + }, validation: { - type: 'Warning', + type: TransactionScanValidationType.Warning, reason: 'suspicious_request', description: null, }, error: null, scanFetchStatus: FetchStatus.Fetched, - showValidationAlert: true, - showSimulationError: false, }); expect(getType(component)).toBe('Banner'); @@ -181,8 +186,6 @@ describe('TransactionAlert', () => { validation: null, error: null, scanFetchStatus: FetchStatus.Error, - showValidationAlert: false, - showSimulationError: true, }); expect(getType(component)).toBe('Banner'); @@ -196,16 +199,37 @@ describe('TransactionAlert', () => { const component = TransactionAlert({ preferences, validation: { - type: 'Benign', + type: TransactionScanValidationType.Benign, reason: null, description: null, }, error: null, scanFetchStatus: FetchStatus.Fetched, - showValidationAlert: true, - showSimulationError: true, }); - expect(getType(component)).toBe('Box'); + expect(component).toBeNull(); + }); + + it('renders scan errors before validation severity findings', () => { + const component = TransactionAlert({ + preferences, + validation: { + type: TransactionScanValidationType.Malicious, + reason: 'known_attacker', + description: null, + }, + error: { + type: 'simulation', + code: 'invalid_transaction', + message: 'invalid_transaction', + }, + scanFetchStatus: FetchStatus.Fetched, + }); + + expect(getType(component)).toBe('Banner'); + expect(getProps(component)).toMatchObject({ + severity: 'warning', + title: 'This transaction was reverted during simulation.', + }); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx index 4a733257..92cc01b8 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx @@ -1,7 +1,6 @@ import type { ComponentOrElement } from '@metamask/snaps-sdk'; import { Banner, - Box, Icon, Link, Text as SnapText, @@ -12,6 +11,7 @@ import type { TransactionScanError, TransactionScanValidation, } from '../../../services/transaction-scan'; +import { TransactionScanValidationType } from '../../../services/transaction-scan'; import type { Locale, LocalizedMessage } from '../../../utils'; import { i18n } from '../../../utils'; import type { ConfirmationBaseProps } from '../api'; @@ -22,8 +22,6 @@ type TransactionAlertProps = { validation: TransactionScanValidation | null; error: TransactionScanError | null; scanFetchStatus: FetchStatus; - showValidationAlert: boolean; - showSimulationError: boolean; }; const VALIDATION_TYPE_TO_ALERT: Partial< @@ -36,12 +34,12 @@ const VALIDATION_TYPE_TO_ALERT: Partial< } > > = { - Malicious: { + [TransactionScanValidationType.Malicious]: { severity: 'danger', title: 'confirmation.validationErrorTitle', subtitle: 'confirmation.validationErrorSubtitle', }, - Warning: { + [TransactionScanValidationType.Warning]: { severity: 'warning', title: 'confirmation.validationWarningTitle', subtitle: 'confirmation.validationWarningSubtitle', @@ -96,9 +94,7 @@ export const TransactionAlert = ({ validation, error, scanFetchStatus, - showValidationAlert, - showSimulationError, -}: TransactionAlertProps): ComponentOrElement => { +}: TransactionAlertProps): ComponentOrElement | null => { const translate = i18n(preferences.locale as Locale); if (scanFetchStatus === FetchStatus.Fetching) { @@ -127,7 +123,22 @@ export const TransactionAlert = ({ ); } - if (validation?.type && showValidationAlert) { + // Match the extension confirmation pattern: show scan failures before severity findings. + if (error && shouldShowError(error, preferences)) { + const alert = getErrorAlert(error); + + return ( + + + {translate(alert.subtitle, { + reason: getErrorMessage(error, preferences.locale), + })} + + + ); + } + + if (validation?.type && preferences.useSecurityAlerts) { const alert = VALIDATION_TYPE_TO_ALERT[validation.type]; if (alert) { @@ -153,50 +164,33 @@ export const TransactionAlert = ({ ); } - } - - if ( - error && - shouldShowError(error, showSimulationError, showValidationAlert) - ) { - const alert = getErrorAlert(error); - return ( - - - {translate(alert.subtitle, { - reason: getErrorMessage(error, preferences.locale), - })} - - - ); + // Benign validation results intentionally render no banner. } - return {null}; + return null; }; /** * Determines whether a scan error should be visible for the enabled alert type. * * @param error - The scan error to evaluate. - * @param showSimulationError - Whether simulation errors are visible. - * @param showValidationAlert - Whether validation errors are visible. + * @param preferences - User preferences controlling scan behavior. * @returns True when the error should be rendered. */ function shouldShowError( error: TransactionScanError, - showSimulationError: boolean, - showValidationAlert: boolean, + preferences: ConfirmationBaseProps['preferences'], ): boolean { if (error.type === 'simulation') { - return showSimulationError; + return preferences.simulateOnChainActions; } if (error.type === 'validation') { - return showValidationAlert; + return preferences.useSecurityAlerts; } - return showSimulationError || showValidationAlert; + return preferences.simulateOnChainActions || preferences.useSecurityAlerts; } /** diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 1674f04b..2463bea6 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -10,6 +10,7 @@ import { formatFeeData, formatOrigin, getPreferencesWithFallback, + hasEnabledTransactionScan, } from './utils'; import type { KnownCaip2ChainId } from '../../api'; import type { SecurityScanRequest } from '../../services/transaction-scan'; @@ -162,7 +163,7 @@ export class ConfirmationUXController { const enableSecurityScan = renderOptions.scanTxn && - (preferences.useSecurityAlerts || preferences.simulateOnChainActions) && + hasEnabledTransactionScan(preferences) && params.securityScanRequest !== undefined; const defaultContext = { diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts index a394d6f6..df443eaf 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts @@ -2,6 +2,7 @@ import type { GetPreferencesResult } from '@metamask/snaps-sdk'; import { FetchStatus } from './api'; import { isConfirmDisabledByScan } from './utils'; +import { TransactionScanValidationType } from '../../services/transaction-scan'; const preferences: GetPreferencesResult = { locale: 'en', @@ -37,7 +38,7 @@ describe('confirmation utils', () => { status: 'SUCCESS', estimatedChanges: { assets: [] }, validation: { - type: 'Malicious', + type: TransactionScanValidationType.Malicious, reason: 'known_attacker', description: null, }, @@ -78,7 +79,7 @@ describe('confirmation utils', () => { status: 'SUCCESS', estimatedChanges: { assets: [] }, validation: { - type: 'Malicious', + type: TransactionScanValidationType.Malicious, reason: 'known_attacker', description: null, }, diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts index 27f2889e..14d6a4a9 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts @@ -8,7 +8,10 @@ import { KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; import { getNativeAssetMetadata } from '../../services/asset-metadata/utils'; import { parseOperationAssetReference } from '../../services/transaction/utils'; -import type { TransactionScanResult } from '../../services/transaction-scan'; +import { + TransactionScanValidationType, + type TransactionScanResult, +} from '../../services/transaction-scan'; import type { Locale } from '../../utils'; import { FALLBACK_LANGUAGE, @@ -170,10 +173,23 @@ export function isConfirmDisabledByScan(params: { const { preferences, scan, scanFetchStatus } = params; return ( scanFetchStatus === FetchStatus.Fetching || - (preferences.useSecurityAlerts && scan?.validation?.type === 'Malicious') + (preferences.useSecurityAlerts && + scan?.validation?.type === TransactionScanValidationType.Malicious) ); } +/** + * Determines whether transaction scan UI should be shown for the current preferences. + * + * @param preferences - User preferences controlling scan behavior. + * @returns True when either security validation alerts or simulation alerts are enabled. + */ +export function hasEnabledTransactionScan( + preferences: GetPreferencesResult, +): boolean { + return preferences.useSecurityAlerts || preferences.simulateOnChainActions; +} + /** * Display-friendly resolution of a Stellar operation `asset` reference. * Used by the confirmation UI to render assets and to look up prices. diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx index 17b9c319..a3fc571e 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx @@ -30,6 +30,7 @@ import { Asset, AssetIcon, FeeRow, TransactionAlert } from '../../components'; import { getAccountName, getClassicAssetExplorerUrl, + hasEnabledTransactionScan, isConfirmDisabledByScan, getNetworkName, } from '../../utils'; @@ -66,14 +67,12 @@ export const ConfirmSignChangeTrustOptIn = ({ return ( - {preferences.useSecurityAlerts || preferences.simulateOnChainActions ? ( + {hasEnabledTransactionScan(preferences) ? ( ) : null} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx index ca958bf2..9c8f9f1c 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx @@ -30,6 +30,7 @@ import { Asset, AssetIcon, FeeRow, TransactionAlert } from '../../components'; import { getAccountName, getClassicAssetExplorerUrl, + hasEnabledTransactionScan, isConfirmDisabledByScan, getNetworkName, } from '../../utils'; @@ -66,14 +67,12 @@ export const ConfirmSignChangeTrustOptOut = ({ return ( - {preferences.useSecurityAlerts || preferences.simulateOnChainActions ? ( + {hasEnabledTransactionScan(preferences) ? ( ) : null} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx index 5fefd279..b75ba77e 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -31,6 +31,7 @@ import { TransactionAlert } from '../../components/TransactionAlert'; import { getAccountName, getNetworkName, + hasEnabledTransactionScan, isConfirmDisabledByScan, resolveAssetDisplay, } from '../../utils'; @@ -185,14 +186,12 @@ export const ConfirmSignTransaction = ({ return ( - {preferences.useSecurityAlerts || preferences.simulateOnChainActions ? ( + {hasEnabledTransactionScan(preferences) ? ( ) : null} From 5a9556d2519f5ff9f2fdeb1f6454761def3f2c4a Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Thu, 28 May 2026 16:13:01 +0200 Subject: [PATCH 250/384] chore: use rpc instead of keyring for extra asset info --- .../stellar-wallet-snap/src/context.ts | 16 +- .../src/handlers/clientRequest/api.test.ts | 55 ++++ .../src/handlers/clientRequest/api.ts | 48 +++ .../clientRequest/getAccountAssetInfo.test.ts | 290 ++++++++++++++++++ .../clientRequest/getAccountAssetInfo.ts | 63 ++++ .../src/handlers/clientRequest/index.ts | 1 + .../src/handlers/keyring/api.ts | 27 -- .../src/handlers/keyring/exceptions.ts | 6 - .../src/handlers/keyring/keyring.test.ts | 146 +-------- .../src/handlers/keyring/keyring.ts | 128 -------- .../stellar-wallet-snap/src/permissions.ts | 2 - .../AccountAssetInfoService.ts | 180 +++++++++++ .../src/services/account-asset-info/api.ts | 18 ++ .../services/account-asset-info/exceptions.ts | 6 + .../src/services/account-asset-info/index.ts | 14 + .../src/utils/requestResponse.test.ts | 2 - 16 files changed, 691 insertions(+), 311 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/account-asset-info/AccountAssetInfoService.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/account-asset-info/api.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/account-asset-info/exceptions.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/account-asset-info/index.ts diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index a157c6bb..5db56df8 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -11,6 +11,7 @@ import { ClientRequestMethod, } from './handlers/clientRequest'; import { ComputeFeeHandler } from './handlers/clientRequest/computeFee'; +import { GetAccountAssetInfoHandler } from './handlers/clientRequest/getAccountAssetInfo'; import { OnAddressInputHandler } from './handlers/clientRequest/onAddressInput'; import { OnAmountInputHandler } from './handlers/clientRequest/onAmountInput'; import { SignAndSendTransactionHandler } from './handlers/clientRequest/signAndSendTransaction'; @@ -30,6 +31,7 @@ import { SignTransactionHandler, } from './handlers/keyring'; import { AccountService, AccountsRepository } from './services/account'; +import { AccountAssetInfoService } from './services/account-asset-info'; import { AssetMetadataRepository, AssetMetadataService, @@ -98,6 +100,13 @@ const onChainAccountService = new OnChainAccountService({ assetMetadataService, }); +const accountAssetInfoService = new AccountAssetInfoService({ + logger, + accountService, + onChainAccountService, + assetMetadataService, +}); + const transactionService = new TransactionService({ logger, transactionRepository, @@ -155,7 +164,6 @@ const keyringHandler = new KeyringHandler({ accountService, onChainAccountService, transactionService, - assetMetadataService, handlers: keyringMethodHandlers, }); @@ -245,11 +253,17 @@ const computeFeeHandler = new ComputeFeeHandler({ transactionService, }); +const getAccountAssetInfoHandler = new GetAccountAssetInfoHandler({ + logger, + accountAssetInfoService, +}); + const clientRequestMethodHandlers: Record< ClientRequestMethod, IClientRequestHandler > = { [ClientRequestMethod.ChangeTrustOpt]: changeTrustOptHandler, + [ClientRequestMethod.GetAccountAssetInfo]: getAccountAssetInfoHandler, [ClientRequestMethod.OnAddressInput]: onAddressInputHandler, [ClientRequestMethod.OnAmountInput]: onAmountInputHandler, [ClientRequestMethod.SignAndSendTransaction]: signAndSendTransactionHandler, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts index 1d18ba9b..231616cc 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts @@ -12,6 +12,8 @@ import { ClientRequestMethod, ClientRequestMethodStruct, JsonRpcRequestWithAccountStruct, + GetAccountAssetInfoJsonRpcRequestStruct, + GetAccountAssetInfoJsonRpcResponseStruct, OnAddressInputJsonRpcRequestStruct, OnAddressInputJsonRpcResponseStruct, OnAmountInputJsonRpcRequestStruct, @@ -618,3 +620,56 @@ describe('OnAmountInputJsonRpcResponseStruct', () => { }, ); }); + +describe('GetAccountAssetInfoJsonRpcRequestStruct', () => { + it('accepts a valid getAccountAssetInfo JSON-RPC request', () => { + expect(() => + assert( + { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.GetAccountAssetInfo, + params: { + accountId, + scope, + assets: [classicAssetId], + }, + }, + GetAccountAssetInfoJsonRpcRequestStruct, + ), + ).not.toThrow(); + }); + + it('rejects getAccountAssetInfo when scope is missing', () => { + expect(() => + assert( + { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.GetAccountAssetInfo, + params: { + accountId, + assets: [classicAssetId], + }, + }, + GetAccountAssetInfoJsonRpcRequestStruct, + ), + ).toThrow(StructError); + }); +}); + +describe('GetAccountAssetInfoJsonRpcResponseStruct', () => { + it('accepts a valid getAccountAssetInfo JSON-RPC response', () => { + expect(() => + assert( + { + [classicAssetId]: { + metadata: { name: 'USD Coin', symbol: 'USDC', units: [] }, + extra: { limit: '1' }, + }, + }, + GetAccountAssetInfoJsonRpcResponseStruct, + ), + ).not.toThrow(); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts index eccaeced..16631f53 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts @@ -15,6 +15,7 @@ import { nonempty, integer, min, + record, } from '@metamask/superstruct'; import type { JsonRpcRequest } from '@metamask/utils'; import { parseCaipAssetType } from '@metamask/utils'; @@ -33,6 +34,7 @@ import { ValidStellarAmountStruct, SwapTransactionXdrStruct, } from '../../api'; +import { AccountAssetInfoEntryStruct } from '../../services/account-asset-info'; import { isSep41Id } from '../../utils'; /** @@ -47,6 +49,7 @@ export enum ClientRequestMethod { ComputeFee = 'computeFee', /** -------------------------------- Stellar Specific -------------------------------- */ ChangeTrustOpt = 'changeTrustOpt', + GetAccountAssetInfo = 'getAccountAssetInfo', } export enum MultiChainSendErrorCodes { @@ -136,6 +139,37 @@ export const ChangeTrustOptJsonRpcResponseStruct = object({ transactionId: optional(StellarTransactionHashStruct), }); +const GetAccountAssetInfoParamsStruct = object({ + accountId: UuidStruct, + scope: KnownCaip2ChainIdStruct, + assets: array( + union([ + KnownCaip19Sep41AssetStruct, + KnownCaip19ClassicAssetStruct, + KnownCaip19Slip44IdStruct, + ]), + ), +}); + +/** + * Validation struct for the getAccountAssetInfo JSON-RPC request. + */ +export const GetAccountAssetInfoJsonRpcRequestStruct = assign( + JsonRpcRequestStruct, + object({ + method: literal(ClientRequestMethod.GetAccountAssetInfo), + params: GetAccountAssetInfoParamsStruct, + }), +); + +/** + * Validation struct for the getAccountAssetInfo JSON-RPC response. + */ +export const GetAccountAssetInfoJsonRpcResponseStruct = record( + string(), + AccountAssetInfoEntryStruct, +); + /** * Validation struct for the sendTransaction JSON-RPC request. */ @@ -283,6 +317,20 @@ export type ChangeTrustOptJsonRpcResponse = Infer< typeof ChangeTrustOptJsonRpcResponseStruct >; +/** + * Type for the getAccountAssetInfo JSON-RPC request. + */ +export type GetAccountAssetInfoJsonRpcRequest = Infer< + typeof GetAccountAssetInfoJsonRpcRequestStruct +>; + +/** + * Type for the getAccountAssetInfo JSON-RPC response. + */ +export type GetAccountAssetInfoJsonRpcResponse = Infer< + typeof GetAccountAssetInfoJsonRpcResponseStruct +>; + /** * Type for the onAddressInput JSON-RPC request. */ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts new file mode 100644 index 00000000..7d967b39 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts @@ -0,0 +1,290 @@ +import { BigNumber } from 'bignumber.js'; + +import type { GetAccountAssetInfoJsonRpcResponse } from './api'; +import { ClientRequestMethod } from './api'; +import { GetAccountAssetInfoHandler } from './getAccountAssetInfo'; +import { + type KnownCaip19AssetIdOrSlip44Id, + type KnownCaip19ClassicAssetId, + KnownCaip2ChainId, +} from '../../api'; +import { AccountService } from '../../services/account'; +import { AccountAssetInfoService } from '../../services/account-asset-info'; +import { GetAccountAssetInfoException } from '../../services/account-asset-info/exceptions'; +import { + createMockAssetMetadataService, + generateMockKeyringAssetMetadata, + USDC_CLASSIC, +} from '../../services/asset-metadata/__mocks__/assets.fixtures'; +import type { KeyringAssetMetadataByAssetId } from '../../services/asset-metadata/api'; +import { OnChainAccountService } from '../../services/on-chain-account'; +import { + createMockAccountWithBalances, + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + horizonSource, + mockOnChainAccountService, + type MockAccountWithBalancesData, +} from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; +import { OnChainAccount } from '../../services/on-chain-account/OnChainAccount'; +import { getSlip44AssetId } from '../../utils'; +import { logger } from '../../utils/logger'; + +jest.mock('../../utils/logger'); + +describe('GetAccountAssetInfoHandler', () => { + const mockAccountId = '11111111-1111-4111-8111-111111111111'; + const scope = KnownCaip2ChainId.Mainnet; + let handler: GetAccountAssetInfoHandler; + + const createTestOnChainAccount = ( + address: string, + data: MockAccountWithBalancesData = DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ): OnChainAccount => { + const stellarAccount = createMockAccountWithBalances(address, '1', data); + return new OnChainAccount( + stellarAccount, + KnownCaip2ChainId.Mainnet, + horizonSource(stellarAccount, KnownCaip2ChainId.Mainnet), + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + + const { accountService, onChainAccountService } = + mockOnChainAccountService(); + const { service: assetMetadataService, getAssetsMetadataByAssetIdsSpy } = + createMockAssetMetadataService(); + const mockKeyringAssetMetadata = generateMockKeyringAssetMetadata(); + getAssetsMetadataByAssetIdsSpy.mockImplementation( + async (assetIds: KnownCaip19AssetIdOrSlip44Id[]) => { + const metadataByAssetId = {} as KeyringAssetMetadataByAssetId; + for (const assetId of assetIds) { + metadataByAssetId[assetId] = + mockKeyringAssetMetadata[assetId] ?? null; + } + return metadataByAssetId; + }, + ); + + const accountAssetInfoService = new AccountAssetInfoService({ + logger, + accountService, + onChainAccountService, + assetMetadataService, + }); + + handler = new GetAccountAssetInfoHandler({ + logger, + accountAssetInfoService, + }); + }); + + it('returns metadata and trustline extra for a classic asset with limit', async () => { + jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ + account: { + id: mockAccountId, + address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, + } as Awaited>); + const onChainAccount = createTestOnChainAccount( + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ); + onChainAccount.setAsset(USDC_CLASSIC as KnownCaip19ClassicAssetId, { + balance: new BigNumber('0'), + symbol: 'USDC', + limit: new BigNumber('10000000'), + authorized: true, + sponsored: false, + decimals: 7, + }); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue(onChainAccount); + + const result = (await handler.handle({ + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.GetAccountAssetInfo, + params: { + accountId: mockAccountId, + scope, + assets: [USDC_CLASSIC], + }, + })) as GetAccountAssetInfoJsonRpcResponse; + + expect(result[USDC_CLASSIC]).toMatchObject({ + metadata: { symbol: 'USDC' }, + extra: { + limit: '1', + authorized: true, + sponsored: false, + }, + }); + }); + + it('returns extra with zero limit for classic tombstone rows', async () => { + jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ + account: { + id: mockAccountId, + address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, + } as Awaited>); + const onChainAccount = createTestOnChainAccount( + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ); + onChainAccount.setAsset(USDC_CLASSIC as KnownCaip19ClassicAssetId, { + balance: new BigNumber('0'), + symbol: 'USDC', + limit: new BigNumber(0), + decimals: 7, + }); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue(onChainAccount); + + const result = (await handler.handle({ + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.GetAccountAssetInfo, + params: { + accountId: mockAccountId, + scope, + assets: [USDC_CLASSIC], + }, + })) as GetAccountAssetInfoJsonRpcResponse; + + expect(result[USDC_CLASSIC]?.extra).toStrictEqual({ limit: '0' }); + }); + + it('omits extra when classic asset has no on-chain row', async () => { + jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ + account: { + id: mockAccountId, + address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, + } as Awaited>); + const onChainAccount = createTestOnChainAccount( + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue(onChainAccount); + + const result = (await handler.handle({ + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.GetAccountAssetInfo, + params: { + accountId: mockAccountId, + scope, + assets: [USDC_CLASSIC], + }, + })) as GetAccountAssetInfoJsonRpcResponse; + + expect(result[USDC_CLASSIC]?.metadata).toBeDefined(); + expect(result[USDC_CLASSIC]?.extra).toBeUndefined(); + }); + + it('tolerates unactivated accounts with null on-chain state', async () => { + jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ + account: { + id: mockAccountId, + address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, + } as Awaited>); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue(null); + + const result = (await handler.handle({ + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.GetAccountAssetInfo, + params: { + accountId: mockAccountId, + scope, + assets: [USDC_CLASSIC], + }, + })) as GetAccountAssetInfoJsonRpcResponse; + + expect(result[USDC_CLASSIC]?.metadata).toBeDefined(); + expect(result[USDC_CLASSIC]?.extra).toBeUndefined(); + }); + + it('returns native slip44 metadata when on-chain account exists', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ + account: { + id: mockAccountId, + address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, + } as Awaited>); + const onChainAccount = createTestOnChainAccount( + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 1.000001, + }, + ); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue(onChainAccount); + + const result = (await handler.handle({ + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.GetAccountAssetInfo, + params: { + accountId: mockAccountId, + scope, + assets: [slipId], + }, + })) as GetAccountAssetInfoJsonRpcResponse; + + expect(result).toHaveProperty(slipId); + }); + + it('throws when asset info resolution fails', async () => { + jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ + account: { + id: mockAccountId, + address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, + } as Awaited>); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockRejectedValue(new Error('Horizon unavailable')); + + await expect( + handler.handle({ + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.GetAccountAssetInfo, + params: { + accountId: mockAccountId, + scope, + assets: [USDC_CLASSIC], + }, + }), + ).rejects.toThrow(GetAccountAssetInfoException); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts new file mode 100644 index 00000000..35a7f5ee --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts @@ -0,0 +1,63 @@ +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import type { + GetAccountAssetInfoJsonRpcRequest, + GetAccountAssetInfoJsonRpcResponse, +} from './api'; +import { + GetAccountAssetInfoJsonRpcRequestStruct, + GetAccountAssetInfoJsonRpcResponseStruct, +} from './api'; +import type { IClientRequestHandler } from './base'; +import type { AccountAssetInfoService } from '../../services/account-asset-info'; +import { createPrefixedLogger, type ILogger } from '../../utils/logger'; +import { BaseHandler } from '../base'; + +export class GetAccountAssetInfoHandler + extends BaseHandler< + GetAccountAssetInfoJsonRpcRequest, + GetAccountAssetInfoJsonRpcResponse + > + implements IClientRequestHandler +{ + readonly #accountAssetInfoService: AccountAssetInfoService; + + constructor({ + logger, + accountAssetInfoService, + }: { + logger: ILogger; + accountAssetInfoService: AccountAssetInfoService; + }) { + super({ + logger: createPrefixedLogger(logger, '[📦 GetAccountAssetInfoHandler]'), + requestStruct: GetAccountAssetInfoJsonRpcRequestStruct, + responseStruct: GetAccountAssetInfoJsonRpcResponseStruct, + }); + this.#accountAssetInfoService = accountAssetInfoService; + } + + /** + * Returns fungible metadata and optional trust-line fields for the requested assets. + * Tolerates unactivated accounts (no on-chain row) for portfolio-import UX. + * + * @param request - JSON-RPC request with accountId, scope, and assets. + * @returns Per-asset metadata and optional trust-line extra fields. + */ + protected async handleRequest( + request: GetAccountAssetInfoJsonRpcRequest, + ): Promise { + const { accountId, scope, assets } = request.params; + return this.#accountAssetInfoService.getAccountAssetInfo({ + accountId, + scope, + assets, + }); + } + + async handle( + request: GetAccountAssetInfoJsonRpcRequest | JsonRpcRequest | Json, + ): Promise { + return super.handle(request); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/index.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/index.ts index 2f949a4c..f33817c7 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/index.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/index.ts @@ -1,4 +1,5 @@ export * from './changeTrustOpt'; +export * from './getAccountAssetInfo'; export * from './clientRequest'; export * from './api'; export type { IClientRequestHandler } from './base'; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts index f34c0f58..7f58fe6f 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/api.ts @@ -16,7 +16,6 @@ import { nullable, enums, refine, - boolean, } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { base64 } from '@metamask/utils'; @@ -343,32 +342,6 @@ export const GetAccountBalancesRequestStruct = object({ ), }); -/** Stellar-only keyring RPC (not in `@metamask/keyring-api` yet). */ -export const KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD = - 'keyring_getAccountAssetInfo' as const; - -export const GetAccountAssetInfoRequestStruct = GetAccountBalancesRequestStruct; - -/** - * Optional per-asset fields for chains that use trust lines (Stellar classic). - */ -export const AccountAssetInfoExtraStruct = object({ - limit: optional(string()), - authorized: optional(boolean()), - sponsored: optional(boolean()), -}); - -export const AccountAssetInfoEntryStruct = object({ - metadata: type({}), - extra: optional(AccountAssetInfoExtraStruct), -}); - -export type AccountAssetInfoExtra = Infer; - -export type GetAccountAssetInfoRequest = Infer< - typeof GetAccountAssetInfoRequestStruct ->; - /** * The options for the createAccount method. */ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts index aa47e33f..bb26d105 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts @@ -196,12 +196,6 @@ export class KeyringGetAccountBalancesException extends KeyringException { } } -export class KeyringGetAccountAssetInfoException extends KeyringException { - constructor(accountId: string) { - super(`Failed to get account asset info for account ${accountId}`); - } -} - export class KeyringResolveAccountAddressException extends KeyringException { constructor( scope: KnownCaip2ChainId, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index ffb92907..b621256e 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -12,10 +12,8 @@ import { import { InvalidParamsError, type JsonRpcRequest } from '@metamask/snaps-sdk'; import { create } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; -import { BigNumber } from 'bignumber.js'; import { - KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD, MultichainMethod, SignAuthEntryResponseStruct, SignMessageResponseStruct, @@ -27,7 +25,6 @@ import { KeyringDeleteAccountException, KeyringDiscoverAccountsException, KeyringGetAccountBalancesException, - KeyringGetAccountAssetInfoException, KeyringGetAccountException, KeyringListAccountAssetsException, KeyringListAccountsException, @@ -35,11 +32,7 @@ import { KeyringResolveAccountAddressException, } from './exceptions'; import { KeyringHandler } from './keyring'; -import { - type KnownCaip19AssetIdOrSlip44Id, - type KnownCaip19ClassicAssetId, - KnownCaip2ChainId, -} from '../../api'; +import { KnownCaip2ChainId } from '../../api'; import { KEYRING_ACCOUNT_TYPE } from '../../constants'; import { AccountService, @@ -50,12 +43,6 @@ import { generateStellarKeyringAccount, } from '../../services/account/__mocks__/account.fixtures'; import { AccountNotFoundException } from '../../services/account/exceptions'; -import { - createMockAssetMetadataService, - generateMockKeyringAssetMetadata, - USDC_CLASSIC, -} from '../../services/asset-metadata/__mocks__/assets.fixtures'; -import type { KeyringAssetMetadataByAssetId } from '../../services/asset-metadata/api'; import { OnChainAccountService } from '../../services/on-chain-account'; import { createMockAccountWithBalances, @@ -144,25 +131,11 @@ describe('KeyringHandler', () => { const { accountService, onChainAccountService } = mockOnChainAccountService(); const { transactionService } = createMockTransactionService(); - const { service: assetMetadataService, getAssetsMetadataByAssetIdsSpy } = - createMockAssetMetadataService(); - const mockKeyringAssetMetadata = generateMockKeyringAssetMetadata(); - getAssetsMetadataByAssetIdsSpy.mockImplementation( - async (assetIds: KnownCaip19AssetIdOrSlip44Id[]) => { - const metadataByAssetId = {} as KeyringAssetMetadataByAssetId; - for (const assetId of assetIds) { - metadataByAssetId[assetId] = - mockKeyringAssetMetadata[assetId] ?? null; - } - return metadataByAssetId; - }, - ); keyringHandler = new KeyringHandler({ logger, accountService, onChainAccountService, transactionService, - assetMetadataService, handlers: { [MultichainMethod.SignMessage]: mockSignMessageHandler, [MultichainMethod.SignTransaction]: mockSignTransactionHandler, @@ -711,123 +684,6 @@ describe('KeyringHandler', () => { }); }); - describe('getAccountAssetInfo', () => { - it('returns metadata and trustline extra for a classic asset with limit', async () => { - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - const onChainAccount = createTestOnChainAccount(mockAccount.address); - onChainAccount.setAsset(USDC_CLASSIC as KnownCaip19ClassicAssetId, { - balance: new BigNumber('0'), - symbol: 'USDC', - limit: new BigNumber('10000000'), - authorized: true, - sponsored: false, - decimals: 7, - }); - jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockResolvedValue(onChainAccount); - - const result = await keyringHandler.getAccountAssetInfo(mockAccountId, [ - USDC_CLASSIC, - ]); - - expect(result[USDC_CLASSIC]?.metadata.symbol).toBe('USDC'); - expect(result[USDC_CLASSIC]?.extra).toStrictEqual({ - limit: '1', - authorized: true, - sponsored: false, - }); - }); - - it('returns extra with zero limit for classic tombstone rows', async () => { - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - const onChainAccount = createTestOnChainAccount(mockAccount.address); - onChainAccount.setAsset(USDC_CLASSIC as KnownCaip19ClassicAssetId, { - balance: new BigNumber('0'), - symbol: 'USDC', - limit: new BigNumber(0), - decimals: 7, - }); - jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockResolvedValue(onChainAccount); - - const result = await keyringHandler.getAccountAssetInfo(mockAccountId, [ - USDC_CLASSIC, - ]); - - expect(result[USDC_CLASSIC]?.extra).toStrictEqual({ limit: '0' }); - }); - - it('omits extra when classic asset has no on-chain row', async () => { - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - const onChainAccount = createTestOnChainAccount(mockAccount.address); - jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockResolvedValue(onChainAccount); - - const result = await keyringHandler.getAccountAssetInfo(mockAccountId, [ - USDC_CLASSIC, - ]); - - expect(result[USDC_CLASSIC]?.metadata).toBeDefined(); - expect(result[USDC_CLASSIC]?.extra).toBeUndefined(); - }); - - it('routes keyring_getAccountAssetInfo via handle', async () => { - const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - const onChainAccount = createTestOnChainAccount(mockAccount.address, { - ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, - nativeBalance: 1.000001, - }); - jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockResolvedValue(onChainAccount); - - const result = await keyringHandler.handle('metamask', { - jsonrpc: '2.0', - id: 1, - method: KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD, - params: { accountId: mockAccountId, assets: [slipId] }, - }); - - expect(handleKeyringRequest).not.toHaveBeenCalled(); - expect(result).toHaveProperty(slipId); - }); - - it('throws when asset info resolution fails', async () => { - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockRejectedValue(new Error('Horizon unavailable')); - - await expect( - keyringHandler.getAccountAssetInfo(mockAccountId, [USDC_CLASSIC]), - ).rejects.toThrow(KeyringGetAccountAssetInfoException); - }); - }); - describe('resolveAccountAddress', () => { it('resolves an account address from opts.address', async () => { const { resolveAccountSpy } = getAccountServiceSpies(); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index b7528d27..423aa4e0 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -23,8 +23,6 @@ import { InvalidParamsError, type Json, type JsonRpcRequest, - FungibleAssetMetadataStruct, - type FungibleAssetMetadata, } from '@metamask/snaps-sdk'; import { ensureError, type CaipAssetTypeOrId } from '@metamask/utils'; @@ -33,7 +31,6 @@ import type { GetAccountRequest, ResolveAccountAddressJsonRpcRequest, MultichainMethod, - AccountAssetInfoExtra, } from './api'; import { CreateAccountOptionsStruct, @@ -46,8 +43,6 @@ import { SetSelectedAccountsRequestStruct, ListAccountAssetsRequestStruct, GetAccountBalancesRequestStruct, - GetAccountAssetInfoRequestStruct, - KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD, } from './api'; import type { IKeyringRequestHandler } from './base'; import { @@ -56,7 +51,6 @@ import { KeyringDiscoverAccountsException, KeyringEmitAccountCreatedEventException, KeyringGetAccountBalancesException, - KeyringGetAccountAssetInfoException, KeyringGetAccountException, KeyringListAccountAssetsException, KeyringListAccountsException, @@ -73,13 +67,11 @@ import type { StellarKeyringAccount, } from '../../services/account'; import { AccountNotFoundException } from '../../services/account/exceptions'; -import type { AssetMetadataService } from '../../services/asset-metadata/AssetMetadataService'; import { getNativeAssetMetadata } from '../../services/asset-metadata/utils'; import type { OnChainAccount, OnChainAccountService, } from '../../services/on-chain-account'; -import type { SpendableBalance } from '../../services/on-chain-account/api'; import type { TransactionService } from '../../services/transaction/TransactionService'; import type { ILogger } from '../../utils'; import { @@ -87,8 +79,6 @@ import { Duration, getSlip44AssetId, getSnapProvider, - isClassicAssetId, - isSep41Id, isSlip44Id, toDisplayBalance, rethrowIfInstanceElseThrow, @@ -98,11 +88,6 @@ import { } from '../../utils'; import { SyncAccountsHandler } from '../cronjob/syncAccounts'; -export type AccountAssetInfoEntry = { - metadata: FungibleAssetMetadata; - extra?: AccountAssetInfoExtra; -}; - export class KeyringHandler implements Keyring { readonly #logger: ILogger; @@ -112,8 +97,6 @@ export class KeyringHandler implements Keyring { readonly #transactionService: TransactionService; - readonly #assetMetadataService: AssetMetadataService; - readonly #handlers: Record; constructor({ @@ -121,21 +104,18 @@ export class KeyringHandler implements Keyring { accountService, onChainAccountService, transactionService, - assetMetadataService, handlers, }: { logger: ILogger; accountService: AccountService; onChainAccountService: OnChainAccountService; transactionService: TransactionService; - assetMetadataService: AssetMetadataService; handlers: Record; }) { this.#logger = createPrefixedLogger(logger, '[🔑 KeyringHandler]'); this.#accountService = accountService; this.#onChainAccountService = onChainAccountService; this.#transactionService = transactionService; - this.#assetMetadataService = assetMetadataService; this.#handlers = handlers; } @@ -143,14 +123,6 @@ export class KeyringHandler implements Keyring { const result = (await withCatchAndThrowSnapError(async () => { validateOrigin(origin, request.method); - if (request.method === KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD) { - validateRequest(request.params, GetAccountAssetInfoRequestStruct); - const { accountId, assets } = request.params as { - accountId: string; - assets: KnownCaip19AssetIdOrSlip44Id[]; - }; - return await this.getAccountAssetInfo(accountId, assets); - } return handleKeyringRequest(this, request); }, this.#logger)) ?? null; @@ -506,106 +478,6 @@ export class KeyringHandler implements Keyring { } } - /** - * Returns fungible metadata and optional trust-line fields for the requested assets. - * Classic Stellar assets include `extra.limit` when an on-chain row exists; omit `extra` - * when the asset is not on the account (e.g. portfolio import pending trust line). - * - * @param accountId - Keyring account id. - * @param assets - CAIP-19 asset ids to resolve. - * @returns Per-asset metadata and optional extra fields. - */ - async getAccountAssetInfo( - accountId: string, - assets: KnownCaip19AssetIdOrSlip44Id[], - ): Promise> { - validateRequest({ accountId, assets }, GetAccountAssetInfoRequestStruct); - - const scope = AppConfig.selectedNetwork; - const result = {} as Record< - KnownCaip19AssetIdOrSlip44Id, - AccountAssetInfoEntry - >; - - try { - const { onChainAccount } = await this.#resolveAccountByAccountId( - accountId, - scope, - ); - - const assetsMetadata = - await this.#assetMetadataService.getAssetsMetadataByAssetIds(assets); - - for (const assetId of assets) { - const assetMetadata = assetsMetadata[assetId]; - if ( - assetMetadata === undefined || - assetMetadata === null || - !FungibleAssetMetadataStruct.is(assetMetadata) || - assetMetadata.units[0]?.decimals === undefined - ) { - continue; - } - - const onChainRow = - onChainAccount === null - ? undefined - : onChainAccount.getAsset(assetId); - - if (isSep41Id(assetId) && !onChainRow?.balance.gt(0)) { - continue; - } - - const { decimals } = assetMetadata.units[0]; - const onChainRowForExtra = - onChainAccount === null || !isClassicAssetId(assetId) - ? onChainRow - : onChainAccount.getRawAsset(assetId); - const extra = this.#buildAccountAssetInfoExtra( - assetId, - onChainRowForExtra, - decimals, - ); - - result[assetId] = { - metadata: assetMetadata, - ...(extra === undefined ? {} : { extra }), - }; - } - - return result; - } catch (error: unknown) { - this.#logger.logErrorWithDetails( - 'Failed to get account asset info', - ensureError(error).message, - ); - throw new KeyringGetAccountAssetInfoException(accountId); - } - } - - #buildAccountAssetInfoExtra( - assetId: KnownCaip19AssetIdOrSlip44Id, - onChainRow: SpendableBalance | undefined, - decimals: number, - ): AccountAssetInfoExtra | undefined { - if (!isClassicAssetId(assetId) || onChainRow === undefined) { - return undefined; - } - if (onChainRow.limit === undefined) { - return undefined; - } - - return { - limit: toDisplayBalance(onChainRow.limit, decimals), - ...(onChainRow.authorized === undefined - ? {} - : { authorized: onChainRow.authorized }), - ...(onChainRow.sponsored === undefined - ? {} - : { sponsored: onChainRow.sponsored }), - }; - } - async resolveAccountAddress( scope: KnownCaip2ChainId, request: ResolveAccountAddressJsonRpcRequest, diff --git a/merged-packages/stellar-wallet-snap/src/permissions.ts b/merged-packages/stellar-wallet-snap/src/permissions.ts index ea9f28dd..0438f7cd 100644 --- a/merged-packages/stellar-wallet-snap/src/permissions.ts +++ b/merged-packages/stellar-wallet-snap/src/permissions.ts @@ -2,7 +2,6 @@ import { KeyringRpcMethod } from '@metamask/keyring-api'; import { Environment } from './api'; import { AppConfig } from './config'; -import { KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD } from './handlers/keyring/api'; const isDev = AppConfig.environment !== Environment.Production; @@ -39,7 +38,6 @@ const metamaskPermissions = new Set([ KeyringRpcMethod.ListAccountAssets, KeyringRpcMethod.ResolveAccountAddress, KeyringRpcMethod.SetSelectedAccounts, - KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD, ]); const metamask = 'metamask'; diff --git a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/AccountAssetInfoService.ts b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/AccountAssetInfoService.ts new file mode 100644 index 00000000..82a9f1da --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/AccountAssetInfoService.ts @@ -0,0 +1,180 @@ +import { + FungibleAssetMetadataStruct, + type FungibleAssetMetadata, +} from '@metamask/snaps-sdk'; +import { ensureError } from '@metamask/utils'; + +import type { AccountAssetInfoExtra } from './api'; +import { GetAccountAssetInfoException } from './exceptions'; +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip2ChainId, +} from '../../api'; +import type { ILogger } from '../../utils'; +import { + createPrefixedLogger, + isClassicAssetId, + isSep41Id, + toDisplayBalance, +} from '../../utils'; +import type { AccountService } from '../account'; +import type { AssetMetadataService } from '../asset-metadata/AssetMetadataService'; +import type { + OnChainAccount, + OnChainAccountService, +} from '../on-chain-account'; +import type { SpendableBalance } from '../on-chain-account/api'; + +export type AccountAssetInfoEntry = { + metadata: FungibleAssetMetadata; + extra?: AccountAssetInfoExtra; +}; + +export type GetAccountAssetInfoParams = { + accountId: string; + scope: KnownCaip2ChainId; + assets: KnownCaip19AssetIdOrSlip44Id[]; +}; + +export class AccountAssetInfoService { + readonly #logger: ILogger; + + readonly #accountService: AccountService; + + readonly #onChainAccountService: OnChainAccountService; + + readonly #assetMetadataService: AssetMetadataService; + + constructor({ + logger, + accountService, + onChainAccountService, + assetMetadataService, + }: { + logger: ILogger; + accountService: AccountService; + onChainAccountService: OnChainAccountService; + assetMetadataService: AssetMetadataService; + }) { + this.#logger = createPrefixedLogger(logger, '[📦 AccountAssetInfoService]'); + this.#accountService = accountService; + this.#onChainAccountService = onChainAccountService; + this.#assetMetadataService = assetMetadataService; + } + + /** + * Returns fungible metadata and optional trust-line fields for the requested assets. + * Classic Stellar assets include `extra.limit` when an on-chain row exists; omit `extra` + * when the asset is not on the account (e.g. portfolio import pending trust line). + * + * @param params - Account id, scope, and CAIP-19 asset ids to resolve. + * @returns Per-asset metadata and optional extra fields. + */ + async getAccountAssetInfo( + params: GetAccountAssetInfoParams, + ): Promise> { + const { accountId, scope, assets } = params; + const result = {} as Record< + KnownCaip19AssetIdOrSlip44Id, + AccountAssetInfoEntry + >; + + try { + const onChainAccount = await this.#resolveOnChainAccount( + accountId, + scope, + ); + + const assetsMetadata = + await this.#assetMetadataService.getAssetsMetadataByAssetIds(assets); + + for (const assetId of assets) { + const assetMetadata = assetsMetadata[assetId]; + if ( + assetMetadata === undefined || + assetMetadata === null || + !FungibleAssetMetadataStruct.is(assetMetadata) || + assetMetadata.units[0]?.decimals === undefined + ) { + continue; + } + + const onChainRow = + onChainAccount === null + ? undefined + : onChainAccount.getAsset(assetId); + + if (isSep41Id(assetId) && !onChainRow?.balance.gt(0)) { + continue; + } + + const { decimals } = assetMetadata.units[0]; + const onChainRowForExtra = + onChainAccount === null || !isClassicAssetId(assetId) + ? onChainRow + : onChainAccount.getRawAsset(assetId); + const extra = buildAccountAssetInfoExtra( + assetId, + onChainRowForExtra, + decimals, + ); + + result[assetId] = { + metadata: assetMetadata, + ...(extra === undefined ? {} : { extra }), + }; + } + + return result; + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to get account asset info', + ensureError(error).message, + ); + throw new GetAccountAssetInfoException(accountId); + } + } + + async #resolveOnChainAccount( + accountId: string, + scope: KnownCaip2ChainId, + ): Promise { + await this.#accountService.resolveAccount({ accountId }); + + return this.#onChainAccountService.resolveOnChainAccountByKeyringAccountId( + accountId, + scope, + ); + } +} + +/** + * Builds optional trust-line extra fields for classic Stellar assets. + * + * @param assetId - CAIP-19 asset id. + * @param onChainRow - On-chain balance row, if any. + * @param decimals - Asset display decimals. + * @returns Trust-line extra fields, or undefined when not applicable. + */ +export function buildAccountAssetInfoExtra( + assetId: KnownCaip19AssetIdOrSlip44Id, + onChainRow: SpendableBalance | undefined, + decimals: number, +): AccountAssetInfoExtra | undefined { + if (!isClassicAssetId(assetId) || onChainRow === undefined) { + return undefined; + } + if (onChainRow.limit === undefined) { + return undefined; + } + + return { + limit: toDisplayBalance(onChainRow.limit, decimals), + ...(onChainRow.authorized === undefined + ? {} + : { authorized: onChainRow.authorized }), + ...(onChainRow.sponsored === undefined + ? {} + : { sponsored: onChainRow.sponsored }), + }; +} diff --git a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/api.ts b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/api.ts new file mode 100644 index 00000000..3a629860 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/api.ts @@ -0,0 +1,18 @@ +import type { Infer } from '@metamask/superstruct'; +import { boolean, object, optional, string, type } from '@metamask/superstruct'; + +/** + * Optional per-asset fields for chains that use trust lines (Stellar classic). + */ +export const AccountAssetInfoExtraStruct = object({ + limit: optional(string()), + authorized: optional(boolean()), + sponsored: optional(boolean()), +}); + +export type AccountAssetInfoExtra = Infer; + +export const AccountAssetInfoEntryStruct = object({ + metadata: type({}), + extra: optional(AccountAssetInfoExtraStruct), +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/exceptions.ts new file mode 100644 index 00000000..f2968eca --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/exceptions.ts @@ -0,0 +1,6 @@ +export class GetAccountAssetInfoException extends Error { + constructor(accountId: string) { + super(`Failed to get account asset info for account ${accountId}`); + this.name = 'GetAccountAssetInfoException'; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/index.ts b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/index.ts new file mode 100644 index 00000000..584a3a26 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/index.ts @@ -0,0 +1,14 @@ +export { + AccountAssetInfoService, + buildAccountAssetInfoExtra, +} from './AccountAssetInfoService'; +export type { + AccountAssetInfoEntry, + GetAccountAssetInfoParams, +} from './AccountAssetInfoService'; +export { + AccountAssetInfoExtraStruct, + AccountAssetInfoEntryStruct, +} from './api'; +export type { AccountAssetInfoExtra } from './api'; +export { GetAccountAssetInfoException } from './exceptions'; diff --git a/merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts b/merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts index c94c79e9..c4748cba 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts @@ -11,7 +11,6 @@ import { validateResponse, validateOrigin, } from './requestResponse'; -import { KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD } from '../handlers/keyring/api'; const TestStruct = object({ url: string(), @@ -76,7 +75,6 @@ describe('validateOrigin', () => { KeyringRpcMethod.ListAccountAssets, KeyringRpcMethod.ResolveAccountAddress, KeyringRpcMethod.SetSelectedAccounts, - KEYRING_GET_ACCOUNT_ASSET_INFO_METHOD, ])('allows method %s for metamask', (method) => { const origin = 'metamask'; From 4ade948cc880db07a68d729f75797ab522fb4715 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 28 May 2026 22:27:55 +0800 Subject: [PATCH 251/384] feat: add confirmSend RPC (#76) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Explanation Adds a new confirmSend client-request/RPC flow for Stellar “Unified Non‑EVM Send”, including a dedicated confirmation UI and handler wiring so a client can validate, confirm, sign, submit, and track a send transaction. ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- .../stellar-wallet-snap/src/context.ts | 10 + .../src/handlers/clientRequest/api.test.ts | 293 ++++++++++-- .../src/handlers/clientRequest/api.ts | 145 +++++- .../clientRequest/confirmSend.test.ts | 452 ++++++++++++++++++ .../src/handlers/clientRequest/confirmSend.ts | 298 ++++++++++++ .../clientRequest/onAmountInput.test.ts | 50 +- .../handlers/clientRequest/onAmountInput.ts | 8 +- .../src/handlers/user-input/userInput.ts | 2 + .../stellar-wallet-snap/src/index.ts | 6 +- .../src/ui/confirmation/api.ts | 1 + .../src/ui/confirmation/controller.tsx | 22 +- .../src/ui/confirmation/utils.ts | 18 +- .../ConfirmSendTransaction.tsx | 182 +++++++ .../views/ConfirmSendTransaction/events.tsx | 50 ++ 14 files changed, 1473 insertions(+), 64 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/events.tsx diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 7fe9b37a..119f8f5e 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -11,6 +11,7 @@ import { ClientRequestMethod, } from './handlers/clientRequest'; import { ComputeFeeHandler } from './handlers/clientRequest/computeFee'; +import { ConfirmSendHandler } from './handlers/clientRequest/confirmSend'; import { OnAddressInputHandler } from './handlers/clientRequest/onAddressInput'; import { OnAmountInputHandler } from './handlers/clientRequest/onAmountInput'; import { SignAndSendTransactionHandler } from './handlers/clientRequest/signAndSendTransaction'; @@ -238,6 +239,14 @@ const signAndSendTransactionHandler = new SignAndSendTransactionHandler({ transactionService, }); +const confirmSendHandler = new ConfirmSendHandler({ + logger, + accountResolver, + transactionService, + assetMetadataService, + confirmationUIController, +}); + const computeFeeHandler = new ComputeFeeHandler({ logger, accountResolver, @@ -251,6 +260,7 @@ const clientRequestMethodHandlers: Record< [ClientRequestMethod.ChangeTrustOpt]: changeTrustOptHandler, [ClientRequestMethod.OnAddressInput]: onAddressInputHandler, [ClientRequestMethod.OnAmountInput]: onAmountInputHandler, + [ClientRequestMethod.ConfirmSend]: confirmSendHandler, [ClientRequestMethod.SignAndSendTransaction]: signAndSendTransactionHandler, [ClientRequestMethod.ComputeFee]: computeFeeHandler, }; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts index 1d18ba9b..8d26625a 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts @@ -1,4 +1,4 @@ -import { assert, StructError } from '@metamask/superstruct'; +import { assert, create, StructError } from '@metamask/superstruct'; import { Account, Contract, @@ -17,6 +17,8 @@ import { OnAmountInputJsonRpcRequestStruct, OnAmountInputJsonRpcResponseStruct, ComputeFeeJsonRpcRequestStruct, + ConfirmSendJsonRpcRequestStruct, + ConfirmSendJsonRpcResponseStruct, SignAndSendTransactionJsonRpcRequestStruct, SignAndSendTransactionJsonRpcResponseStruct, } from './api'; @@ -49,6 +51,10 @@ const sep41AssetId = const slip44AssetId = 'stellar:pubnet/slip44:148'; const stellarAddress = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; +const destinationAddress = + 'GDTF7ERUQVTX23ZD6NY5XRYC5IQAKWFVTQ6IXSMEZWGVNDDGPYCVHRZP'; +const transactionHash = + '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0e1'; describe('JsonRpcRequestWithAccountStruct', () => { it.each([ @@ -477,42 +483,69 @@ describe('OnAddressInputJsonRpcResponseStruct', () => { }); describe('OnAmountInputJsonRpcRequestStruct', () => { + const baseWireRequest = { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAmountInput, + params: { + accountId, + assetId: classicAssetId, + value: '10', + }, + }; + it.each([ { - jsonrpc: '2.0' as const, - id: 1, - method: ClientRequestMethod.OnAmountInput, - params: { - accountId, - assetId: classicAssetId, - value: '10', + request: baseWireRequest, + expectedScope: 'stellar:pubnet', + }, + { + request: { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + assetId: slip44AssetId, + value: '1.0000001', + to: stellarAddress, + }, }, + expectedScope: 'stellar:pubnet', }, { - jsonrpc: '2.0' as const, - id: 1, - method: ClientRequestMethod.OnAmountInput, - params: { - accountId, - assetId: slip44AssetId, - value: '1.0000001', - to: stellarAddress, + request: { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + assetId: sep41AssetId, + value: '1.12345678', + }, }, + expectedScope: 'stellar:pubnet', }, { - jsonrpc: '2.0' as const, - id: 1, - method: ClientRequestMethod.OnAmountInput, - params: { - accountId, - assetId: sep41AssetId, - value: '1.12345678', + request: { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + assetId: 'stellar:testnet/slip44:148', + value: '10', + }, }, + expectedScope: 'stellar:testnet', }, - ])('accepts a valid onAmountInput JSON-RPC request', (request) => { - expect(() => - assert(request, OnAmountInputJsonRpcRequestStruct), - ).not.toThrow(); + ])( + 'accepts a valid onAmountInput JSON-RPC request', + ({ request, expectedScope }) => { + const result = create(request, OnAmountInputJsonRpcRequestStruct); + + expect(result.params.scope).toBe(expectedScope); + }, + ); + + it('derives scope from assetId via coercion', () => { + const result = create(baseWireRequest, OnAmountInputJsonRpcRequestStruct); + + expect(result.params.scope).toBe('stellar:pubnet'); }); it.each([ @@ -618,3 +651,209 @@ describe('OnAmountInputJsonRpcResponseStruct', () => { }, ); }); + +describe('ConfirmSendJsonRpcRequestStruct', () => { + const baseWireRequest = { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.ConfirmSend, + params: { + fromAccountId: accountId, + toAddress: destinationAddress, + assetId: classicAssetId, + amount: '1', + }, + }; + + it.each([ + { + request: baseWireRequest, + expectedScope: 'stellar:pubnet', + }, + { + request: { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + assetId: sep41AssetId, + amount: '1.12345678', + }, + }, + expectedScope: 'stellar:pubnet', + }, + { + request: { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + assetId: slip44AssetId, + amount: '1.0000001', + }, + }, + expectedScope: 'stellar:pubnet', + }, + { + request: { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + assetId: 'stellar:testnet/slip44:148', + amount: '10', + }, + }, + expectedScope: 'stellar:testnet', + }, + ])( + 'accepts a valid confirmSend JSON-RPC request', + ({ request, expectedScope }) => { + const result = create(request, ConfirmSendJsonRpcRequestStruct); + + expect(result.params.accountId).toBe(accountId); + expect(result.params.scope).toBe(expectedScope); + }, + ); + + it('coerces fromAccountId to accountId and derives scope from assetId', () => { + const result = create(baseWireRequest, ConfirmSendJsonRpcRequestStruct); + + expect(result.params.accountId).toBe(accountId); + expect(result.params.fromAccountId).toBe(accountId); + expect(result.params.scope).toBe('stellar:pubnet'); + }); + + it('derives testnet scope from a testnet asset id', () => { + const result = create( + { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + assetId: 'stellar:testnet/slip44:148', + }, + }, + ConfirmSendJsonRpcRequestStruct, + ); + + expect(result.params.scope).toBe('stellar:testnet'); + }); + + it.each([ + { + ...baseWireRequest, + method: ClientRequestMethod.OnAmountInput, + }, + { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + fromAccountId: 'not-a-uuid', + }, + }, + { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + toAddress: 'not-a-stellar-address', + }, + }, + { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + assetId: 'stellar:pubnet/asset:INVALID', + }, + }, + { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + amount: '', + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.ConfirmSend, + params: { + toAddress: destinationAddress, + assetId: classicAssetId, + amount: '1', + }, + }, + ])('rejects an invalid confirmSend JSON-RPC request', (request) => { + expect(() => assert(request, ConfirmSendJsonRpcRequestStruct)).toThrow( + StructError, + ); + }); + + it.each([ + { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + assetId: sep41AssetId, + amount: '-1', + }, + }, + { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + assetId: classicAssetId, + amount: '1.00000001', + }, + }, + { + ...baseWireRequest, + params: { + ...baseWireRequest.params, + assetId: slip44AssetId, + amount: '0', + }, + }, + ])( + 'rejects a confirmSend JSON-RPC request when amount rules fail refinement', + (request) => { + expect(() => assert(request, ConfirmSendJsonRpcRequestStruct)).toThrow( + StructError, + ); + }, + ); +}); + +describe('ConfirmSendJsonRpcResponseStruct', () => { + it.each([ + { valid: true, errors: [], transactionId: transactionHash }, + { + valid: false, + errors: [{ code: 'Invalid' }], + }, + { + valid: false, + errors: [{ code: 'InsufficientBalance' }], + }, + { + valid: false, + errors: [{ code: 'InsufficientBalanceToCoverFee' }], + }, + ])('accepts a valid confirmSend JSON-RPC response', (response) => { + expect(() => + assert(response, ConfirmSendJsonRpcResponseStruct), + ).not.toThrow(); + }); + + it.each([ + { valid: 'yes', errors: [], transactionId: transactionHash }, + { valid: true, errors: [], transactionId: 'dGVzdA==' }, + { valid: true, transactionId: transactionHash }, + { + valid: true, + transactionId: + '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0', + }, + { valid: false, errors: [{ code: 1 }] }, + ])('rejects an invalid confirmSend JSON-RPC response', (response) => { + expect(() => assert(response, ConfirmSendJsonRpcResponseStruct)).toThrow( + StructError, + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts index eccaeced..c264c394 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts @@ -15,6 +15,7 @@ import { nonempty, integer, min, + coerce, } from '@metamask/superstruct'; import type { JsonRpcRequest } from '@metamask/utils'; import { parseCaipAssetType } from '@metamask/utils'; @@ -42,6 +43,7 @@ export enum ClientRequestMethod { /** -------------------------------- Wallet Standard -------------------------------- */ OnAddressInput = 'onAddressInput', OnAmountInput = 'onAmountInput', + ConfirmSend = 'confirmSend', // Standard multichain workflow for bridge SignAndSendTransaction = 'signAndSendTransaction', ComputeFee = 'computeFee', @@ -187,26 +189,54 @@ export const OnAddressInputJsonRpcResponseStruct = object({ ), }); -/** - * Validation struct for the onAmountInput JSON-RPC request. - */ -export const OnAmountInputJsonRpcRequestStruct = refine( +const OnAmountInputParamsWireStruct = object({ + accountId: UuidStruct, + assetId: union([ + KnownCaip19ClassicAssetStruct, + KnownCaip19Sep41AssetStruct, + KnownCaip19Slip44IdStruct, + ]), + value: nonempty(string()), + to: optional(StellarAddressStruct), +}); + +const OnAmountInputParamsStruct = assign( + OnAmountInputParamsWireStruct, + object({ + scope: KnownCaip2ChainIdStruct, + }), +); + +const OnAmountInputJsonRpcRequestCoercedStruct = coerce( assign( JsonRpcRequestStruct, object({ method: literal(ClientRequestMethod.OnAmountInput), - params: object({ - accountId: UuidStruct, - assetId: union([ - KnownCaip19ClassicAssetStruct, - KnownCaip19Sep41AssetStruct, - KnownCaip19Slip44IdStruct, - ]), - value: nonempty(string()), - to: optional(StellarAddressStruct), - }), + params: OnAmountInputParamsStruct, + }), + ), + assign( + JsonRpcRequestStruct, + object({ + method: literal(ClientRequestMethod.OnAmountInput), + params: OnAmountInputParamsWireStruct, }), ), + (request) => ({ + ...request, + params: { + ...request.params, + scope: parseCaipAssetType(request.params.assetId).chainId, + }, + }), +); + +/** + * Validation struct for the onAmountInput JSON-RPC request. + * Derives `scope` from `assetId` (clients do not send scope). + */ +export const OnAmountInputJsonRpcRequestStruct = refine( + OnAmountInputJsonRpcRequestCoercedStruct, 'on-amount-input-request', ({ params }) => { if ( @@ -231,6 +261,79 @@ export const OnAmountInputJsonRpcResponseStruct = object({ ), }); +const ConfirmSendParamsStruct = object({ + fromAccountId: UuidStruct, + toAddress: StellarAddressStruct, + assetId: union([ + KnownCaip19ClassicAssetStruct, + KnownCaip19Sep41AssetStruct, + KnownCaip19Slip44IdStruct, + ]), + amount: nonempty(string()), +}); + +/** + * Validation struct for the confirmSend JSON-RPC request. + * Coerces `fromAccountId` to `accountId` and derives `scope` from `assetId` (clients do not send scope). + */ +export const ConfirmSendJsonRpcRequestCoercedStruct = coerce( + assign( + JsonRpcRequestStruct, + object({ + method: literal(ClientRequestMethod.ConfirmSend), + params: assign( + ConfirmSendParamsStruct, + object({ + accountId: UuidStruct, + scope: KnownCaip2ChainIdStruct, + }), + ), + }), + ), + assign( + JsonRpcRequestStruct, + object({ + method: literal(ClientRequestMethod.ConfirmSend), + params: ConfirmSendParamsStruct, + }), + ), + (request) => ({ + ...request, + params: { + ...request.params, + accountId: request.params.fromAccountId, + scope: parseCaipAssetType(request.params.assetId).chainId, + }, + }), +); + +export const ConfirmSendJsonRpcRequestStruct = refine( + ConfirmSendJsonRpcRequestCoercedStruct, + 'confirm-send-request', + ({ params }) => { + if ( + (isSep41Id(params.assetId) && ValidAmountStruct.is(params.amount)) || + (!isSep41Id(params.assetId) && ValidStellarAmountStruct.is(params.amount)) + ) { + return true; + } + return 'Invalid amount'; + }, +); + +/** + * Validation struct for the confirmSend JSON-RPC response. + */ +export const ConfirmSendJsonRpcResponseStruct = object({ + valid: boolean(), + errors: array( + object({ + code: string(), + }), + ), + transactionId: optional(StellarTransactionHashStruct), +}); + /** * Validation struct for the computeFee JSON-RPC request. */ @@ -311,6 +414,20 @@ export type OnAmountInputJsonRpcResponse = Infer< typeof OnAmountInputJsonRpcResponseStruct >; +/** + * Type for the confirmSend JSON-RPC request. + */ +export type ConfirmSendJsonRpcRequest = Infer< + typeof ConfirmSendJsonRpcRequestStruct +>; + +/** + * Type for the confirmSend JSON-RPC response. + */ +export type ConfirmSendJsonRpcResponse = Infer< + typeof ConfirmSendJsonRpcResponseStruct +>; + /** * Type for the sendTransaction JSON-RPC request. */ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts new file mode 100644 index 00000000..384e7532 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts @@ -0,0 +1,452 @@ +import { + InvalidParamsError, + UserRejectedRequestError, +} from '@metamask/snaps-sdk'; +import { Networks } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import { + ClientRequestMethod, + MultiChainSendErrorCodes, + type ConfirmSendJsonRpcRequest, +} from './api'; +import { ConfirmSendHandler } from './confirmSend'; +import { + KnownCaip2ChainId, + type KnownCaip19ClassicAssetId, + type KnownCaip19Sep41AssetId, +} from '../../api'; +import { AccountService } from '../../services/account'; +import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; +import type { StellarAssetMetadata } from '../../services/asset-metadata'; +import { AssetMetadataService } from '../../services/asset-metadata'; +import { + createMockAssetMetadataService, + generateMockStellarAssetMetadata, + USDC_CLASSIC, + USDC_SEP41, +} from '../../services/asset-metadata/__mocks__/assets.fixtures'; +import { AccountNotActivatedException } from '../../services/network'; +import { + OnChainAccount, + OnChainAccountService, +} from '../../services/on-chain-account'; +import { + createMockAccountWithBalances, + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + horizonSource, + mockOnChainAccountService, +} from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; +import { TransactionService } from '../../services/transaction'; +import { + buildMockClassicTransaction, + createMockTransactionService, +} from '../../services/transaction/__mocks__/transaction.fixtures'; +import { + InsufficientBalanceException, + InsufficientBalanceToCoverFeeException, + TransactionValidationException, +} from '../../services/transaction/exceptions'; +import { KeyringTransactionType } from '../../services/transaction/KeyringTransactionBuilder'; +import { WalletService } from '../../services/wallet'; +import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; +import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import { ConfirmationUXController } from '../../ui/confirmation/controller'; +import { logger } from '../../utils/logger'; +import { AccountResolver } from '../accountResolver'; +import { TrackTransactionHandler } from '../cronjob/trackTransaction'; + +jest.mock('../../utils/logger'); +jest.mock('../../utils/snap'); +jest.mock('../../ui/confirmation/views/AccountActivationPrompt/render', () => ({ + render: jest.fn().mockResolvedValue(undefined), +})); + +const destinationAddress = + 'GDTF7ERUQVTX23ZD6NY5XRYC5IQAKWFVTQ6IXSMEZWGVNDDGPYCVHRZP'; + +describe('ConfirmSendHandler', () => { + const accountId = '11111111-1111-4111-8111-111111111111'; + const assetId = USDC_CLASSIC as KnownCaip19ClassicAssetId; + const scope = KnownCaip2ChainId.Mainnet; + const transactionId = + '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0e1'; + + function setup() { + const wallet = getTestWallet(); + const account = generateStellarKeyringAccount( + accountId, + wallet.address, + 'entropy-source-1', + 0, + ); + const mockRawAccount = createMockAccountWithBalances(wallet.address, '1', { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 10, + assets: [], + }); + const onChainAccount = new OnChainAccount( + mockRawAccount, + scope, + horizonSource(mockRawAccount, scope), + ); + + const { accountService, onChainAccountService, walletService } = + mockOnChainAccountService(); + jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ + account, + }); + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue(onChainAccount); + jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + + const transaction = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: destinationAddress, + asset: { + code: 'USDC', + issuer: + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, + amount: '1', + }, + }, + ], + { + networkPassphrase: Networks.PUBLIC, + source: { + accountId: wallet.address, + sequence: onChainAccount.sequenceNumber, + }, + }, + ); + + const { transactionService, transactionRepositorySaveManySpy } = + createMockTransactionService(); + const createValidatedSendTransaction = jest + .spyOn(TransactionService.prototype, 'createValidatedSendTransaction') + .mockResolvedValue(transaction); + const sendTransaction = jest + .spyOn(TransactionService.prototype, 'sendTransaction') + .mockResolvedValue(transactionId); + const savePendingKeyringTransaction = jest.spyOn( + TransactionService.prototype, + 'savePendingKeyringTransaction', + ); + const signTransactionSpy = jest.spyOn(wallet, 'signTransaction'); + const scheduleBackgroundEvent = jest + .spyOn(TrackTransactionHandler, 'scheduleBackgroundEvent') + .mockResolvedValue(undefined); + + const { service: assetMetadataService } = createMockAssetMetadataService(); + const assetMetadata = generateMockStellarAssetMetadata()[ + assetId + ] as StellarAssetMetadata; + jest + .spyOn(AssetMetadataService.prototype, 'resolve') + .mockResolvedValue(assetMetadata); + + const accountResolver = new AccountResolver({ + accountService, + onChainAccountService, + walletService, + }); + + const renderConfirmationDialog = jest + .spyOn(ConfirmationUXController.prototype, 'renderConfirmationDialog') + .mockResolvedValue(true); + const confirmationUIController = new ConfirmationUXController({ logger }); + + const handler = new ConfirmSendHandler({ + logger, + accountResolver, + assetMetadataService, + transactionService, + confirmationUIController, + }); + + return { + handler, + account, + onChainAccount, + wallet, + assetMetadata, + transaction, + createValidatedSendTransaction, + resolveOnChainAccountSpy, + renderConfirmationDialog, + sendTransaction, + savePendingKeyringTransaction, + signTransactionSpy, + scheduleBackgroundEvent, + transactionRepositorySaveManySpy, + }; + } + + function baseRequest( + overrides: Partial< + Pick< + ConfirmSendJsonRpcRequest['params'], + 'fromAccountId' | 'toAddress' | 'assetId' | 'amount' + > + > = {}, + ) { + return { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.ConfirmSend, + params: { + fromAccountId: accountId, + assetId, + toAddress: destinationAddress, + amount: '1', + ...overrides, + }, + }; + } + + it('returns invalid when value has more decimal places than the asset supports', async () => { + const sep41AssetId = USDC_SEP41 as KnownCaip19Sep41AssetId; + const { handler, createValidatedSendTransaction } = setup(); + const assetMetadata = generateMockStellarAssetMetadata()[ + sep41AssetId + ] as StellarAssetMetadata; + jest + .spyOn(AssetMetadataService.prototype, 'resolve') + .mockResolvedValue(assetMetadata); + + expect( + await handler.handle( + baseRequest({ assetId: sep41AssetId, amount: '1.12345678' }), + ), + ).toStrictEqual({ + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }); + expect(createValidatedSendTransaction).not.toHaveBeenCalled(); + }); + + it('returns valid when send validation succeeds', async () => { + const { + handler, + account, + onChainAccount, + wallet, + assetMetadata, + transaction, + createValidatedSendTransaction, + renderConfirmationDialog, + signTransactionSpy, + sendTransaction, + savePendingKeyringTransaction, + scheduleBackgroundEvent, + } = setup(); + + const result = await handler.handle(baseRequest()); + + expect(result).toStrictEqual({ + valid: true, + errors: [], + transactionId, + }); + expect(createValidatedSendTransaction).toHaveBeenCalledWith({ + onChainAccount, + scope, + assetId, + amount: new BigNumber('10000000'), + destination: destinationAddress, + }); + expect(renderConfirmationDialog).toHaveBeenCalledWith({ + scope, + interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, + fee: transaction.totalFee.toString(), + renderContext: { + account, + assetMetadata, + toAddress: destinationAddress, + amount: '1', + }, + renderOptions: { + loadPrice: true, + }, + tokenPrices: { + [assetId]: null, + }, + }); + expect(signTransactionSpy).toHaveBeenCalledWith(transaction); + expect(sendTransaction).toHaveBeenCalledWith({ + wallet, + onChainAccount, + scope, + transaction, + pollTransaction: false, + }); + expect(savePendingKeyringTransaction).toHaveBeenCalledWith({ + type: KeyringTransactionType.Send, + request: { + txId: transactionId, + account, + scope, + toAddress: destinationAddress, + amount: '1', + asset: { + type: assetId, + symbol: 'USDC', + }, + }, + }); + expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ + txId: transactionId, + scope, + accountIds: [account.id], + }); + }); + + it('throws UserRejectedRequestError when confirmation is rejected', async () => { + const { + handler, + renderConfirmationDialog, + signTransactionSpy, + sendTransaction, + savePendingKeyringTransaction, + scheduleBackgroundEvent, + } = setup(); + renderConfirmationDialog.mockResolvedValue(false); + + await expect(handler.handle(baseRequest())).rejects.toThrow( + UserRejectedRequestError, + ); + + expect(signTransactionSpy).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + expect(savePendingKeyringTransaction).not.toHaveBeenCalled(); + expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); + }); + + it('returns insufficient balance when createValidatedSendTransaction throws InsufficientBalanceException', async () => { + const { handler, createValidatedSendTransaction } = setup(); + createValidatedSendTransaction.mockRejectedValueOnce( + new InsufficientBalanceException('0', '1'), + ); + + expect(await handler.handle(baseRequest())).toStrictEqual({ + valid: false, + errors: [{ code: MultiChainSendErrorCodes.InsufficientBalance }], + }); + }); + + it('returns insufficient balance to cover fee when createValidatedSendTransaction throws InsufficientBalanceToCoverFeeException', async () => { + const { handler, createValidatedSendTransaction } = setup(); + createValidatedSendTransaction.mockRejectedValueOnce( + new InsufficientBalanceToCoverFeeException('0', '1'), + ); + + expect(await handler.handle(baseRequest())).toStrictEqual({ + valid: false, + errors: [ + { code: MultiChainSendErrorCodes.InsufficientBalanceToCoverFee }, + ], + }); + }); + + it('returns invalid when createValidatedSendTransaction throws TransactionValidationException', async () => { + const { handler, createValidatedSendTransaction } = setup(); + createValidatedSendTransaction.mockRejectedValueOnce( + new TransactionValidationException('x'), + ); + + expect(await handler.handle(baseRequest())).toStrictEqual({ + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }); + }); + + it('returns invalid when createValidatedSendTransaction throws AccountNotActivatedException', async () => { + const { handler, createValidatedSendTransaction, wallet } = setup(); + createValidatedSendTransaction.mockRejectedValueOnce( + new AccountNotActivatedException(wallet.address, scope), + ); + + expect(await handler.handle(baseRequest())).toStrictEqual({ + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }); + }); + + it('returns invalid when on-chain account is not activated', async () => { + const { handler, resolveOnChainAccountSpy, wallet } = setup(); + resolveOnChainAccountSpy.mockRejectedValueOnce( + new AccountNotActivatedException(wallet.address, scope), + ); + + expect(await handler.handle(baseRequest())).toStrictEqual({ + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }); + }); + + it('rethrows unexpected errors from createValidatedSendTransaction', async () => { + const { handler, createValidatedSendTransaction } = setup(); + createValidatedSendTransaction.mockRejectedValueOnce( + new Error('unexpected'), + ); + + await expect(handler.handle(baseRequest())).rejects.toThrow('unexpected'); + }); + + it('continues successfully when saving pending transaction fails', async () => { + const { + handler, + transactionRepositorySaveManySpy, + sendTransaction, + scheduleBackgroundEvent, + } = setup(); + transactionRepositorySaveManySpy.mockRejectedValueOnce( + new Error('failed save'), + ); + + const result = await handler.handle(baseRequest()); + + expect(result).toStrictEqual({ + valid: true, + errors: [], + transactionId, + }); + expect(sendTransaction).toHaveBeenCalledTimes(1); + expect(scheduleBackgroundEvent).toHaveBeenCalled(); + }); + + it('throws InvalidParamsError when amount fails struct validation', async () => { + const { handler, createValidatedSendTransaction } = setup(); + + await expect( + handler.handle(baseRequest({ amount: '1.00000001' })), + ).rejects.toThrow(InvalidParamsError); + + expect(createValidatedSendTransaction).not.toHaveBeenCalled(); + }); + + it('throws InvalidParamsError when the request fails struct validation', async () => { + const { handler } = setup(); + const badRequest = { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.OnAmountInput, + params: { + accountId, + assetId, + amount: '', + }, + }; + + await expect(handler.handle(badRequest)).rejects.toThrow( + InvalidParamsError, + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts new file mode 100644 index 00000000..6fd1c29d --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts @@ -0,0 +1,298 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; +import { ensureError } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { + ConfirmSendJsonRpcRequest, + ConfirmSendJsonRpcResponse, +} from './api'; +import { + ConfirmSendJsonRpcRequestStruct, + ConfirmSendJsonRpcResponseStruct, + MultiChainSendErrorCodes, +} from './api'; +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip2ChainId, +} from '../../api'; +import type { StellarKeyringAccount } from '../../services/account'; +import type { + AssetMetadataService, + StellarAssetMetadata, +} from '../../services/asset-metadata'; +import { + InsufficientBalanceException, + InsufficientBalanceToCoverFeeException, + TransactionValidationException, + KeyringTransactionType, +} from '../../services/transaction'; +import type { TransactionService } from '../../services/transaction'; +import type { ContextWithPrices } from '../../ui/confirmation/api'; +import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; +import { hasDecimals, toSmallestUnit } from '../../utils'; +import { createPrefixedLogger } from '../../utils/logger'; +import type { ILogger } from '../../utils/logger'; +import type { + AccountResolver, + ResolvedActivatedAccount, +} from '../accountResolver'; +import { BaseClientRequestHandler } from './base'; +import { AccountNotActivatedException } from '../../services/network'; +import type { ConfirmationUXController } from '../../ui/confirmation/controller'; +import { TrackTransactionHandler } from '../cronjob/trackTransaction'; + +/** + * Confirms and submits a send transaction for Unified Non-EVM Send. + * + * Unlike {@link OnAmountInputHandler}, this handler resolves the on-chain account from + * live network data (default {@link AccountResolver} options) so balance, sequence, and + * fees are current at submission time. + */ +export class ConfirmSendHandler extends BaseClientRequestHandler< + ConfirmSendJsonRpcRequest, + ConfirmSendJsonRpcResponse +> { + readonly #transactionService: TransactionService; + + readonly #assetMetadataService: AssetMetadataService; + + readonly #confirmationUIController: ConfirmationUXController; + + readonly #logger: ILogger; + + constructor({ + logger, + accountResolver, + transactionService, + assetMetadataService, + confirmationUIController, + }: { + logger: ILogger; + accountResolver: AccountResolver; + transactionService: TransactionService; + assetMetadataService: AssetMetadataService; + confirmationUIController: ConfirmationUXController; + }) { + const prefixedLogger = createPrefixedLogger( + logger, + '[👍 ConfirmSendHandler]', + ); + super({ + accountResolver, + logger: prefixedLogger, + requestStruct: ConfirmSendJsonRpcRequestStruct, + responseStruct: ConfirmSendJsonRpcResponseStruct, + }); + this.#transactionService = transactionService; + this.#assetMetadataService = assetMetadataService; + this.#confirmationUIController = confirmationUIController; + this.#logger = prefixedLogger; + } + + /** + * Builds a validated send transaction, shows confirmation, then signs and submits. + * + * @param resolved - Keyring account, live on-chain snapshot, and wallet. + * @param request - JSON-RPC request with send params (`scope` is derived from `assetId`). + * @returns `{ valid: true, errors: [], transactionId }` on success, or `{ valid: false, errors }` for validation failures. + * @throws {UserRejectedRequestError} If the user rejects the confirmation prompt. + */ + protected async execute( + resolved: ResolvedActivatedAccount, + request: ConfirmSendJsonRpcRequest, + ): Promise { + try { + const { + wallet, + onChainAccount, + account: stellarKeyringAccount, + } = resolved; + const { amount, toAddress, assetId, scope } = request.params; + const assetMetadata = await this.#assetMetadataService.resolve(assetId); + const { decimals, symbol } = assetMetadata.units[0]; + + const amountInSmallestUnit = toSmallestUnit( + new BigNumber(amount), + decimals, + ); + + if (hasDecimals(amountInSmallestUnit)) { + return { + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }; + } + + const transaction = + await this.#transactionService.createValidatedSendTransaction({ + onChainAccount, + scope, + assetId, + amount: amountInSmallestUnit, + destination: toAddress, + }); + + if ( + !(await this.#confirmSend({ + request, + account: stellarKeyringAccount, + assetMetadata, + scope, + fee: transaction.totalFee, + })) + ) { + throw ensureError(new UserRejectedRequestError()); + } + + wallet.signTransaction(transaction); + + const transactionId = await this.#transactionService.sendTransaction({ + wallet, + onChainAccount, + scope, + transaction, + pollTransaction: false, + }); + + await this.#savePendingTransaction({ + txId: transactionId, + account: stellarKeyringAccount, + scope, + toAddress, + amount, + asset: { + type: assetId, + symbol, + }, + }); + + await TrackTransactionHandler.scheduleBackgroundEvent({ + txId: transactionId, + scope, + // TODO: we should depend on the transaction instead of passing an account id here + accountIds: [stellarKeyringAccount.id], + }); + + return { + valid: true, + errors: [], + transactionId, + }; + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to confirm send transaction', + error, + ); + if (error instanceof InsufficientBalanceException) { + return { + valid: false, + errors: [{ code: MultiChainSendErrorCodes.InsufficientBalance }], + }; + } + if (error instanceof InsufficientBalanceToCoverFeeException) { + return { + valid: false, + errors: [ + { code: MultiChainSendErrorCodes.InsufficientBalanceToCoverFee }, + ], + }; + } + if ( + error instanceof TransactionValidationException || + error instanceof AccountNotActivatedException + ) { + return { + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }; + } + throw error; + } + } + + async #confirmSend(params: { + request: ConfirmSendJsonRpcRequest; + account: StellarKeyringAccount; + assetMetadata: StellarAssetMetadata; + scope: KnownCaip2ChainId; + fee: BigNumber; + }): Promise { + const { request, account, assetMetadata, fee, scope } = params; + const { toAddress, amount, assetId } = request.params; + + return ( + (await this.#confirmationUIController.renderConfirmationDialog({ + scope, + renderContext: { + account, + assetMetadata, + toAddress, + amount, + }, + fee: fee.toString(), + interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, + renderOptions: { + loadPrice: true, + }, + tokenPrices: { + [assetId]: null, + } as ContextWithPrices['tokenPrices'], + })) === true + ); + } + + async #savePendingTransaction({ + txId, + account, + scope, + toAddress, + amount, + asset, + }: { + txId: string; + account: StellarKeyringAccount; + scope: KnownCaip2ChainId; + toAddress: string; + amount: string; + asset: { + type: KnownCaip19AssetIdOrSlip44Id; + symbol: string; + }; + }): Promise { + try { + await this.#transactionService.savePendingKeyringTransaction({ + type: KeyringTransactionType.Send, + request: { + txId, + account, + scope, + toAddress, + amount, + asset, + }, + }); + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to save pending transaction', + error, + ); + // we should not throw error here, as we want to continue the flow even if the pending transaction is not saved + } + } + + /** + * Override the base handler to return invalid when the account is not activated. + * Instead of showing the account not activated alert, it returns an invalid response. + * + * @param _error - The error to handle. + * @returns The invalid response when the account is not activated. + */ + protected override async handleAccountNotActivatedError( + _error: AccountNotActivatedException, + ): Promise { + return { + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts index 6d068381..8bcdb1cc 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts @@ -56,6 +56,10 @@ const destinationAddress = 'GDTF7ERUQVTX23ZD6NY5XRYC5IQAKWFVTQ6IXSMEZWGVNDDGPYCVHRZP'; describe('OnAmountInputHandler', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + const accountId = '11111111-1111-4111-8111-111111111111'; const assetId = USDC_CLASSIC as KnownCaip19ClassicAssetId; const scope = KnownCaip2ChainId.Mainnet; @@ -131,10 +135,15 @@ describe('OnAmountInputHandler', () => { } function baseRequest( - overrides: Partial = {}, - ): OnAmountInputJsonRpcRequest { + overrides: Partial< + Pick< + OnAmountInputJsonRpcRequest['params'], + 'accountId' | 'assetId' | 'value' | 'to' + > + > = {}, + ) { return { - jsonrpc: '2.0', + jsonrpc: '2.0' as const, id: 1, method: ClientRequestMethod.OnAmountInput, params: { @@ -168,11 +177,21 @@ describe('OnAmountInputHandler', () => { }); it('returns valid when send validation succeeds', async () => { - const { handler, onChainAccount, createValidatedSendTransaction } = setup(); + const { + handler, + account, + onChainAccount, + createValidatedSendTransaction, + resolveOnChainAccountByKeyringAccountIdSpy, + } = setup(); const result = await handler.handle(baseRequest()); expect(result).toStrictEqual({ valid: true, errors: [] }); + expect(resolveOnChainAccountByKeyringAccountIdSpy).toHaveBeenCalledWith( + account.id, + scope, + ); expect(createValidatedSendTransaction).toHaveBeenCalledWith({ onChainAccount, scope, @@ -183,6 +202,29 @@ describe('OnAmountInputHandler', () => { }); }); + it('resolves on-chain account using scope derived from assetId', async () => { + const testnetAssetId = `${KnownCaip2ChainId.Testnet}/slip44:148`; + const { + handler, + account, + createValidatedSendTransaction, + resolveOnChainAccountByKeyringAccountIdSpy, + } = setup(); + + await handler.handle(baseRequest({ assetId: testnetAssetId })); + + expect(resolveOnChainAccountByKeyringAccountIdSpy).toHaveBeenCalledWith( + account.id, + KnownCaip2ChainId.Testnet, + ); + expect(createValidatedSendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + scope: KnownCaip2ChainId.Testnet, + assetId: testnetAssetId, + }), + ); + }); + it('passes explicit destination when params.to is set', async () => { const { handler, onChainAccount, createValidatedSendTransaction } = setup(); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts index cbbf2453..6eb604e9 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts @@ -1,4 +1,3 @@ -import { parseCaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import type { @@ -11,7 +10,6 @@ import { OnAmountInputJsonRpcResponseStruct, } from './api'; import { BaseClientRequestHandler } from './base'; -import type { KnownCaip2ChainId } from '../../api'; import type { AssetMetadataService } from '../../services/asset-metadata'; import { AccountNotActivatedException } from '../../services/network/exceptions'; import type { TransactionService } from '../../services/transaction'; @@ -73,7 +71,7 @@ export class OnAmountInputHandler extends BaseClientRequestHandler< * repeated amount checks stay responsive. * * @param resolved - Keyring account, persisted on-chain snapshot, and wallet. - * @param request - JSON-RPC request with `assetId`, `value` (positive amount string), and optional `to`. + * @param request - JSON-RPC request with `assetId`, `value` (positive amount string), and optional `to` (`scope` is derived from `assetId`). * @returns Validation result with `valid` and optional error codes. */ protected async execute( @@ -82,9 +80,7 @@ export class OnAmountInputHandler extends BaseClientRequestHandler< ): Promise { try { const { onChainAccount } = resolved; - const { assetId, value, to } = request.params; - - const scope = parseCaipAssetType(assetId).chainId as KnownCaip2ChainId; + const { assetId, value, to, scope } = request.params; const { units } = await this.#assetMetadataService.resolve(assetId); const { decimals } = units[0]; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts index 53ed9c68..8ba752b3 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts @@ -2,6 +2,7 @@ import type { InterfaceContext, UserInputEvent } from '@metamask/snaps-sdk'; import type { UserInputUiEventHandler } from './api'; import { createEventHandlers as createAccountActivationPromptEvents } from '../../ui/confirmation/views/AccountActivationPrompt/events'; +import { createEventHandlers as createConfirmSendTransactionEvents } from '../../ui/confirmation/views/ConfirmSendTransaction/events'; import { createEventHandlers as createSignAuthEntryEvents } from '../../ui/confirmation/views/ConfirmSignAuthEntry/events'; import { createEventHandlers as createSignChangeTrustOptInEvents } from '../../ui/confirmation/views/ConfirmSignChangeTrustOptIn/events'; import { createEventHandlers as createSignChangeTrustOptOutEvents } from '../../ui/confirmation/views/ConfirmSignChangeTrustOptOut/events'; @@ -51,6 +52,7 @@ export class UserInputHandler { ...createSignChangeTrustOptInEvents(), ...createSignChangeTrustOptOutEvents(), ...createAccountActivationPromptEvents(), + ...createConfirmSendTransactionEvents(), }; /** diff --git a/merged-packages/stellar-wallet-snap/src/index.ts b/merged-packages/stellar-wallet-snap/src/index.ts index 2f4cf41d..afd28254 100644 --- a/merged-packages/stellar-wallet-snap/src/index.ts +++ b/merged-packages/stellar-wallet-snap/src/index.ts @@ -89,9 +89,9 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { case 'stellar_signAuthEntry': return signAuthEntryHandler.handle(request.params as Json); case 'stellar_changeTrustOpt': - return clientRequestHandler.handle( - request.params as unknown as JsonRpcRequest, - ); + case 'stellar_confirmSend': + case 'stellar_onAmountInput': + case 'stellar_onAddressInput': case 'stellar_signAndSendTransaction': return clientRequestHandler.handle( request.params as unknown as JsonRpcRequest, diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts index 23dc1a34..08a5b8ad 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts @@ -56,6 +56,7 @@ export enum ConfirmationInterfaceKey { SignMessage = 'SignMessage', SignTransaction = 'SignTransaction', SignAuthEntry = 'SignAuthEntry', + ConfirmSendTransaction = 'ConfirmSendTransaction', } export const ConfirmationInterfaceKeyStruct = enums( diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index d6bd1d91..51232c46 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -22,6 +22,8 @@ import { updateInterfaceIfExists, } from '../../utils'; import { STELLAR_IMAGE } from '../images/icon'; +import type { ConfirmSendTransactionProps } from './views/ConfirmSendTransaction/ConfirmSendTransaction'; +import { ConfirmSendTransaction } from './views/ConfirmSendTransaction/ConfirmSendTransaction'; import { ConfirmSignAuthEntry, type ConfirmSignAuthEntryProps, @@ -60,6 +62,11 @@ type RenderConfirmationDialogCommon = { tokenPrices?: ContextWithPrices['tokenPrices']; }; +type ConfirmationDialogWithFee = + | ConfirmationInterfaceKey.SignTransaction + | ConfirmationInterfaceKey.ChangeTrustlineOptIn + | ConfirmationInterfaceKey.ChangeTrustlineOptOut + | ConfirmationInterfaceKey.ConfirmSendTransaction; /** * Discriminated union: confirmations that have a fee (example: sign transaction) * MUST provide one; fee-less confirmations (sign message, etc.) MUST NOT. @@ -68,18 +75,13 @@ type RenderConfirmationDialogCommon = { */ type RenderConfirmationDialogParams = | (RenderConfirmationDialogCommon & { - interfaceKey: - | ConfirmationInterfaceKey.SignTransaction - | ConfirmationInterfaceKey.ChangeTrustlineOptIn - | ConfirmationInterfaceKey.ChangeTrustlineOptOut; + interfaceKey: ConfirmationDialogWithFee; fee: string; }) | (RenderConfirmationDialogCommon & { interfaceKey: Exclude< ConfirmationInterfaceKey, - | ConfirmationInterfaceKey.SignTransaction - | ConfirmationInterfaceKey.ChangeTrustlineOptIn - | ConfirmationInterfaceKey.ChangeTrustlineOptOut + ConfirmationDialogWithFee >; fee?: never; }); @@ -277,6 +279,12 @@ export class ConfirmationUXController { {...(context as unknown as ConfirmSignAuthEntryProps)} /> ); + case ConfirmationInterfaceKey.ConfirmSendTransaction: + return ( + + ); default: { const exhaustive: never = interfaceKey; throw new Error(`Unsupported interface key: ${String(exhaustive)}`); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts index 3209a35f..55386495 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts @@ -12,8 +12,8 @@ import type { Locale } from '../../utils'; import { FALLBACK_LANGUAGE, getPreferences, - normalizeAmount, parseClassicAssetCodeIssuer, + toDisplayBalance, } from '../../utils'; import { xlmIcon } from '../images'; @@ -92,6 +92,18 @@ export async function getPreferencesWithFallback(): Promise { + const t = i18n(locale); + const { address } = account; + const { assetId, symbol } = assetMetadata; + const parsedAsset = parseCaipAssetType(assetId); + let assetLink: string | undefined; + if (!isSlip44Id(assetId)) { + assetLink = + parsedAsset.assetNamespace === 'sep41' + ? getSepAssetExplorerUrl(parsedAsset.assetReference) + : getClassicAssetExplorerUrl(parsedAsset.assetReference); + } + const assetIconUrl = isSlip44Id(assetId) ? xlmIcon : assetMetadata.iconUrl; + const assetPrice = tokenPrices?.[assetId] ?? null; + + return ( + + + + {null} + {t(`confirmation.transaction.title`)} + {null} + + + {/* TODO: add security alert / transaction simulation result */} + +
+ {origin ? ( + + + + {t('confirmation.origin')} + + + + + + {origin} + + ) : null} + {/* From */} + + + {t('confirmation.account')} + + +
+ + + {/* To */} + + + {t('confirmation.to')} + + +
+ + + + + {t('confirmation.estimatedChanges.send')} + + + + {/* Network */} + + + {t('confirmation.network')} + + + + {getNetworkName(scope)} + + + {null} + {/* Fee Breakdown */} + +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/events.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/events.tsx new file mode 100644 index 00000000..9506d178 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/events.tsx @@ -0,0 +1,50 @@ +import type { + UserInputUiEventHandler, + UserInputUiEventHandlerContext, +} from '../../../../handlers/user-input/api'; +import { resolveInterface } from '../../../../utils'; + +/** + * Handles the click event for the cancel button. + * + * @param options - The user input handler context from `confirmSend`. + * @returns A promise that resolves when the interface has been updated. + */ +async function onCancelButtonClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id } = options; + await resolveInterface(id, false); +} + +/** + * Handles the click event for the confirm button. + * + * @param options - The user input handler context from `confirmSend`. + * @returns A promise that resolves when the interface has been updated. + */ +async function onConfirmButtonClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id } = options; + await resolveInterface(id, true); +} + +export enum ConfirmSendTransactionFormNames { + Cancel = 'confirm-send-transaction-cancel', + Confirm = 'confirm-send-transaction-confirm', +} + +/** + * Create event handlers bound to a SnapClient instance. + * + * @returns Object containing event handlers. + */ +export function createEventHandlers(): Record { + return { + [ConfirmSendTransactionFormNames.Cancel]: async (options) => + onCancelButtonClick(options), + [ConfirmSendTransactionFormNames.Confirm]: async (options) => + onConfirmButtonClick(options), + }; +} From a130134f085bb702a150d09f9bfd8eae5060ae16 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Fri, 29 May 2026 11:50:31 +0200 Subject: [PATCH 252/384] fix: fix comments --- merged-packages/stellar-wallet-snap/snap.manifest.json | 2 +- .../cronjob/refreshConfirmationContext/scanRefresher.ts | 4 ---- .../src/ui/confirmation/components/TransactionAlert.tsx | 4 ++-- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 9d4f82f9..b43891b2 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "EWAoMlF7BRi4zxdbv322P++kmRXy3hAyNJDOyNvZ7kE=", + "shasum": "Pgeb7G9usrwUVpMdMU0LaszaxXGICctagd9YBNAexY0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/scanRefresher.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/scanRefresher.ts index d65252fe..7854ef8d 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/scanRefresher.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/scanRefresher.ts @@ -78,10 +78,6 @@ export class ConfirmationScanRefresher implements IConfirmationContextRefresher ctx: ConfirmationDataContext, ): Promise { const scanCtx = ctx as SecurityScanContext; - if (!this.shouldFetch(ctx)) { - return this.recoveryResult(ctx); - } - const scanRequest = scanCtx.securityScanRequest as NonNullable< SecurityScanContext['securityScanRequest'] >; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx index 92cc01b8..609ed437 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionAlert.tsx @@ -141,6 +141,8 @@ export const TransactionAlert = ({ if (validation?.type && preferences.useSecurityAlerts) { const alert = VALIDATION_TYPE_TO_ALERT[validation.type]; + // Only warning and malicious validation results map to banners. Benign + // or unsupported validation types intentionally fall through to `null`. if (alert) { const description = validation.description?.trim(); const subtitle = @@ -164,8 +166,6 @@ export const TransactionAlert = ({
); } - - // Benign validation results intentionally render no banner. } return null; From 4145a731e4a7c0c5a34dd95ca56142f93231d695 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 29 May 2026 21:05:28 +0800 Subject: [PATCH 253/384] feat: add qr code on account activation dialog (#82) ## Explanation - Add `qrcode-generator` dependency and a new `generateAddressQrCode` helper that returns an SVG string. - Redesign `AccountActivationPrompt` to show the QR code, address, and two description lines; remove the per-method icons/copy and their assets/translation keys. - Extend `showDialog` with an optional dialog `type` parameter and use `'alert'` for the account activation prompt; add a large batch of unrelated locale strings to `en.json`/`es.json`. image FIgma: [link](https://www.figma.com/design/WQaDTNshHVJ1auKOfTcya8/STELLAR---Two-Steps-Trustline-Enablement?node-id=0-1&p=f&t=uhSggmbNFehAXR28-0) ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- .../stellar-wallet-snap/locales/en.json | 19 +- .../stellar-wallet-snap/locales/es.json | 298 +++++++++++++++++- .../stellar-wallet-snap/messages.json | 19 +- .../stellar-wallet-snap/package.json | 1 + .../src/handlers/user-input/userInput.ts | 2 - .../src/ui/confirmation/qrcode.ts | 25 ++ .../AccountActivationPrompt.tsx | 71 +---- .../views/AccountActivationPrompt/events.tsx | 34 -- .../views/AccountActivationPrompt/render.tsx | 2 +- .../src/ui/images/account-active-method-1.svg | 3 - .../src/ui/images/account-active-method-2.svg | 3 - .../src/ui/images/index.ts | 9 +- .../stellar-wallet-snap/src/utils/snap.ts | 9 +- 13 files changed, 359 insertions(+), 136 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/qrcode.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/events.tsx delete mode 100644 merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-1.svg delete mode 100644 merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-2.svg diff --git a/merged-packages/stellar-wallet-snap/locales/en.json b/merged-packages/stellar-wallet-snap/locales/en.json index f3bda328..febcfb36 100644 --- a/merged-packages/stellar-wallet-snap/locales/en.json +++ b/merged-packages/stellar-wallet-snap/locales/en.json @@ -443,25 +443,16 @@ "message": "Activate Stellar Wallet" }, "confirmation.accountActivation.description": { - "message": "Your Stellar wallet needs XLM." + "message": "On Stellar, your wallet must hold a minimum of 1 XLM before you can hold other assets." + }, + "confirmation.accountActivation.callToAction": { + "message": "Add XLM to your wallet to get started." }, "confirmation.accountActivation.address": { - "message": "Your wallet address." + "message": "Stellar address." }, "confirmation.accountActivation.copyAddress": { "message": "Copy Address" - }, - "confirmation.accountActivation.method1.title": { - "message": "Ask someone to send XLM" - }, - "confirmation.accountActivation.method1.description": { - "message": "Share your address · come back when received" - }, - "confirmation.accountActivation.method2.title": { - "message": "Fund from exchange" - }, - "confirmation.accountActivation.method2.description": { - "message": "Coinbase, Binance, etc → send to address below" } } } diff --git a/merged-packages/stellar-wallet-snap/locales/es.json b/merged-packages/stellar-wallet-snap/locales/es.json index 22a5ef9c..febcfb36 100644 --- a/merged-packages/stellar-wallet-snap/locales/es.json +++ b/merged-packages/stellar-wallet-snap/locales/es.json @@ -43,6 +43,9 @@ "confirmation.cancelButton": { "message": "Cancel" }, + "confirmation.closeButton": { + "message": "Close" + }, "confirmation.signMessage.title": { "message": "Sign message" }, @@ -56,10 +59,13 @@ "message": "You are authorizing a smart contract to act on your behalf. Only approve if you trust this site." }, "confirmation.signAuthEntry.contract": { - "message": "Contract" + "message": "Contract ID" }, "confirmation.signAuthEntry.function": { - "message": "Function" + "message": "Function Name" + }, + "confirmation.signAuthEntry.parameters": { + "message": "Parameters" }, "confirmation.signAuthEntry.expiresAt": { "message": "Expires at ledger" @@ -76,6 +82,12 @@ "confirmation.account": { "message": "Account" }, + "confirmation.memo": { + "message": "Memo" + }, + "confirmation.asset": { + "message": "Asset" + }, "confirmation.signTransaction.title": { "message": "Sign transaction" }, @@ -142,6 +154,273 @@ "confirmation.validationErrorSecurityAdviced": { "message": "Security advice by" }, + "confirmation.transaction.accountmerge": { + "message": "Merge account" + }, + "confirmation.transaction.allowtrust": { + "message": "Allow trust" + }, + "confirmation.transaction.beginsponsoringfuturereserves": { + "message": "Begin sponsoring future reserves" + }, + "confirmation.transaction.bumpsequence": { + "message": "Bump sequence" + }, + "confirmation.transaction.changetrust": { + "message": "Change trust line" + }, + "confirmation.transaction.claimclaimablebalance": { + "message": "Claim claimable balance" + }, + "confirmation.transaction.clawback": { + "message": "Clawback" + }, + "confirmation.transaction.clawbackclaimablebalance": { + "message": "Clawback claimable balance" + }, + "confirmation.transaction.createaccount": { + "message": "Create account" + }, + "confirmation.transaction.createclaimablebalance": { + "message": "Create claimable balance" + }, + "confirmation.transaction.createpassiveselloffer": { + "message": "Create passive sell offer" + }, + "confirmation.transaction.endsponsoringfuturereserves": { + "message": "End sponsoring future reserves" + }, + "confirmation.transaction.extendfootprintttl": { + "message": "Extend footprint TTL" + }, + "confirmation.transaction.inflation": { + "message": "Inflation" + }, + "confirmation.transaction.invokehostfunction": { + "message": "Invoke host function" + }, + "confirmation.transaction.liquiditypooldeposit": { + "message": "Liquidity pool deposit" + }, + "confirmation.transaction.liquiditypoolwithdraw": { + "message": "Liquidity pool withdraw" + }, + "confirmation.transaction.managedata": { + "message": "Manage data" + }, + "confirmation.transaction.managebuyoffer": { + "message": "Manage buy offer" + }, + "confirmation.transaction.manageselloffer": { + "message": "Manage sell offer" + }, + "confirmation.transaction.pathpaymentstrictreceive": { + "message": "Path payment (strict receive)" + }, + "confirmation.transaction.pathpaymentstrictsend": { + "message": "Path payment (strict send)" + }, + "confirmation.transaction.payment": { + "message": "Payment" + }, + "confirmation.transaction.restorefootprint": { + "message": "Restore footprint" + }, + "confirmation.transaction.revokesponsorship": { + "message": "Revoke sponsorship" + }, + "confirmation.transaction.setoptions": { + "message": "Set options" + }, + "confirmation.transaction.settrustlineflags": { + "message": "Set trust line flags" + }, + "confirmation.transaction.param.account": { + "message": "Account" + }, + "confirmation.transaction.param.amount": { + "message": "Amount" + }, + "confirmation.transaction.param.asset": { + "message": "Asset" + }, + "confirmation.transaction.param.assetCode": { + "message": "Asset code" + }, + "confirmation.transaction.param.authorize": { + "message": "Authorize" + }, + "confirmation.transaction.param.balanceId": { + "message": "Balance ID" + }, + "confirmation.transaction.param.buyAmount": { + "message": "Buy amount" + }, + "confirmation.transaction.param.bumpTo": { + "message": "Bump to" + }, + "confirmation.transaction.param.buying": { + "message": "Buying" + }, + "confirmation.transaction.param.claimants": { + "message": "Claimants" + }, + "confirmation.transaction.param.clearFlags": { + "message": "Clear flags" + }, + "confirmation.transaction.param.contractId": { + "message": "Contract ID" + }, + "confirmation.transaction.param.destAmount": { + "message": "Destination amount" + }, + "confirmation.transaction.param.destAsset": { + "message": "Destination asset" + }, + "confirmation.transaction.param.destMin": { + "message": "Destination minimum" + }, + "confirmation.transaction.param.destination": { + "message": "Destination" + }, + "confirmation.transaction.param.extendTo": { + "message": "Extend to" + }, + "confirmation.transaction.param.flags": { + "message": "Flags" + }, + "confirmation.transaction.param.functionName": { + "message": "Function" + }, + "confirmation.transaction.param.arguments": { + "message": "Arguments" + }, + "confirmation.transaction.param.from": { + "message": "From" + }, + "confirmation.transaction.param.highThreshold": { + "message": "High threshold" + }, + "confirmation.transaction.param.homeDomain": { + "message": "Home domain" + }, + "confirmation.transaction.param.hostFunctionXdrBase64": { + "message": "Host function (XDR, base64)" + }, + "confirmation.transaction.param.inflationDest": { + "message": "Inflation destination" + }, + "confirmation.transaction.param.limit": { + "message": "Limit" + }, + "confirmation.transaction.param.line": { + "message": "Trust line" + }, + "confirmation.transaction.param.liquidityPoolId": { + "message": "Liquidity pool ID" + }, + "confirmation.transaction.param.lowThreshold": { + "message": "Low threshold" + }, + "confirmation.transaction.param.masterWeight": { + "message": "Master weight" + }, + "confirmation.transaction.param.maxAmountA": { + "message": "Max amount A" + }, + "confirmation.transaction.param.maxAmountB": { + "message": "Max amount B" + }, + "confirmation.transaction.param.maxPrice": { + "message": "Max price" + }, + "confirmation.transaction.param.medThreshold": { + "message": "Medium threshold" + }, + "confirmation.transaction.param.minAmountA": { + "message": "Min amount A" + }, + "confirmation.transaction.param.minAmountB": { + "message": "Min amount B" + }, + "confirmation.transaction.param.minPrice": { + "message": "Min price" + }, + "confirmation.transaction.param.name": { + "message": "Name" + }, + "confirmation.transaction.param.note": { + "message": "Note" + }, + "confirmation.transaction.param.offerId": { + "message": "Offer ID" + }, + "confirmation.transaction.param.path": { + "message": "Path" + }, + "confirmation.transaction.param.price": { + "message": "Price" + }, + "confirmation.transaction.param.seller": { + "message": "Seller" + }, + "confirmation.transaction.param.sendAmount": { + "message": "Send amount" + }, + "confirmation.transaction.param.sendAsset": { + "message": "Send asset" + }, + "confirmation.transaction.param.sendMax": { + "message": "Send max" + }, + "confirmation.transaction.param.selling": { + "message": "Selling" + }, + "confirmation.transaction.param.setFlags": { + "message": "Set flags" + }, + "confirmation.transaction.param.signer": { + "message": "Signer" + }, + "confirmation.transaction.param.signerEd25519": { + "message": "Signer (public key)" + }, + "confirmation.transaction.param.signerSha256Hash": { + "message": "Signer (SHA-256)" + }, + "confirmation.transaction.param.signerPreAuthTx": { + "message": "Signer (pre-auth tx)" + }, + "confirmation.transaction.param.signerSignedPayload": { + "message": "Signer (signed payload)" + }, + "confirmation.transaction.param.signerWeight": { + "message": "Signer weight" + }, + "confirmation.transaction.param.sponsoredId": { + "message": "Sponsored account" + }, + "confirmation.transaction.param.startingBalance": { + "message": "Starting balance" + }, + "confirmation.transaction.param.source": { + "message": "Source" + }, + "confirmation.transaction.param.trustor": { + "message": "Trustor" + }, + "confirmation.transaction.param.valueBase64": { + "message": "Value (base64)" + }, + "confirmation.signChangeTrustOptIn.title": { + "message": "Add {asset} trustline" + }, + "confirmation.signChangeTrustOptIn.updateTitle": { + "message": "Update {asset} trustline limit" + }, + "confirmation.signChangeTrustOptOut.title": { + "message": "Remove {asset} trustline" + }, "transactionScan.errors.unknownError": { "message": "An unknown error occurred" }, @@ -159,6 +438,21 @@ }, "transactionScan.errors.unsupportedEIP712Message": { "message": "Unsupported method" + }, + "confirmation.accountActivation.title": { + "message": "Activate Stellar Wallet" + }, + "confirmation.accountActivation.description": { + "message": "On Stellar, your wallet must hold a minimum of 1 XLM before you can hold other assets." + }, + "confirmation.accountActivation.callToAction": { + "message": "Add XLM to your wallet to get started." + }, + "confirmation.accountActivation.address": { + "message": "Stellar address." + }, + "confirmation.accountActivation.copyAddress": { + "message": "Copy Address" } } } diff --git a/merged-packages/stellar-wallet-snap/messages.json b/merged-packages/stellar-wallet-snap/messages.json index 3d9b3b60..fcdf1176 100644 --- a/merged-packages/stellar-wallet-snap/messages.json +++ b/merged-packages/stellar-wallet-snap/messages.json @@ -441,24 +441,15 @@ "message": "Activate Stellar Wallet" }, "confirmation.accountActivation.description": { - "message": "Your Stellar wallet needs XLM." + "message": "On Stellar, your wallet must hold a minimum of 1 XLM before you can hold other assets." + }, + "confirmation.accountActivation.callToAction": { + "message": "Add XLM to your wallet to get started." }, "confirmation.accountActivation.address": { - "message": "Your wallet address." + "message": "Stellar address." }, "confirmation.accountActivation.copyAddress": { "message": "Copy Address" - }, - "confirmation.accountActivation.method1.title": { - "message": "Ask someone to send XLM" - }, - "confirmation.accountActivation.method1.description": { - "message": "Share your address · come back when received" - }, - "confirmation.accountActivation.method2.title": { - "message": "Fund from exchange" - }, - "confirmation.accountActivation.method2.description": { - "message": "Coinbase, Binance, etc → send to address below" } } diff --git a/merged-packages/stellar-wallet-snap/package.json b/merged-packages/stellar-wallet-snap/package.json index 411c1e7f..34dc94f1 100644 --- a/merged-packages/stellar-wallet-snap/package.json +++ b/merged-packages/stellar-wallet-snap/package.json @@ -62,6 +62,7 @@ "jest-transform-stub": "2.0.0", "lodash": "^4.18.1", "prettier": "^3.5.3", + "qrcode-generator": "^2.0.4", "ts-jest": "^29.4.0" }, "publishConfig": { diff --git a/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts index 8ba752b3..50a525b7 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts @@ -1,7 +1,6 @@ import type { InterfaceContext, UserInputEvent } from '@metamask/snaps-sdk'; import type { UserInputUiEventHandler } from './api'; -import { createEventHandlers as createAccountActivationPromptEvents } from '../../ui/confirmation/views/AccountActivationPrompt/events'; import { createEventHandlers as createConfirmSendTransactionEvents } from '../../ui/confirmation/views/ConfirmSendTransaction/events'; import { createEventHandlers as createSignAuthEntryEvents } from '../../ui/confirmation/views/ConfirmSignAuthEntry/events'; import { createEventHandlers as createSignChangeTrustOptInEvents } from '../../ui/confirmation/views/ConfirmSignChangeTrustOptIn/events'; @@ -51,7 +50,6 @@ export class UserInputHandler { ...createSignAuthEntryEvents(), ...createSignChangeTrustOptInEvents(), ...createSignChangeTrustOptOutEvents(), - ...createAccountActivationPromptEvents(), ...createConfirmSendTransactionEvents(), }; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/qrcode.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/qrcode.ts new file mode 100644 index 00000000..9a20549c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/qrcode.ts @@ -0,0 +1,25 @@ +import qrCode from 'qrcode-generator'; + +// Constants for QR code generation +const QR_CODE_TYPE_NUMBER = 4; +const QR_CODE_CELL_SIZE = 5; +const QR_CODE_MARGIN = 16; +const QR_CODE_ERROR_CORRECTION_LEVEL = 'M'; + +/** + * Generates a QR code for a Stellar address. + * + * @param address - The Stellar address to generate a QR code for. + * @returns The SVG string of the QR code. + */ +export function generateAddressQrCode(address: string): string | null { + try { + const qr = qrCode(QR_CODE_TYPE_NUMBER, QR_CODE_ERROR_CORRECTION_LEVEL); + qr.addData(address); + qr.make(); + return qr.createSvgTag(QR_CODE_CELL_SIZE, QR_CODE_MARGIN); + } catch { + // Silent failure, return null + return null; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/AccountActivationPrompt.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/AccountActivationPrompt.tsx index 679dbaa3..a9ff026f 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/AccountActivationPrompt.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/AccountActivationPrompt.tsx @@ -1,25 +1,17 @@ import type { ComponentOrElement } from '@metamask/snaps-sdk'; import { Box, - Button, Container, Copyable, - Footer, Heading, Image, Section, Text as SnapText, } from '@metamask/snaps-sdk/jsx'; -import { AccountActivationPromptFormNames } from './events'; import type { Locale } from '../../../../utils'; import { i18n } from '../../../../utils'; -import { - xlmIcon, - accountActiveMethod1Icon, - accountActiveMethod2Icon, -} from '../../../images'; -import { AssetIcon } from '../../components/AssetIcon'; +import { generateAddressQrCode } from '../../qrcode'; export type AccountActivationPromptProps = { accountAddress: string; @@ -32,6 +24,8 @@ export const AccountActivationPrompt = ({ }: AccountActivationPromptProps): ComponentOrElement => { const translate = i18n(locale); + const qrCode = generateAddressQrCode(accountAddress); + return ( @@ -41,61 +35,30 @@ export const AccountActivationPrompt = ({ {translate('confirmation.accountActivation.title')} {null} - - - - - - {translate('confirmation.accountActivation.description')} - - {null} {null}
- - + + {qrCode ? : null} + {translate('confirmation.accountActivation.address')} + {null} - -
-
- - - - - {translate('confirmation.accountActivation.method1.title')} - - - {translate( - 'confirmation.accountActivation.method1.description', - )} - - - -
-
- - - - - {translate('confirmation.accountActivation.method2.title')} - - - {translate( - 'confirmation.accountActivation.method2.description', - )} - - + {null} + {null} + + {translate('confirmation.accountActivation.description')} + + {null} + {null} + + {translate('confirmation.accountActivation.callToAction')} +
-
- -
); }; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/events.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/events.tsx deleted file mode 100644 index ac10a844..00000000 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/events.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { - UserInputUiEventHandler, - UserInputUiEventHandlerContext, -} from '../../../../handlers/user-input/api'; -import { resolveInterface } from '../../../../utils'; - -/** - * Handles the click event for the close button. - * - * @param options - The user input handler context from `onUserInput`. - * @returns A promise that resolves when the interface has been updated. - */ -async function onCloseButtonClick( - options: UserInputUiEventHandlerContext, -): Promise { - const { id } = options; - await resolveInterface(id, true); -} - -export enum AccountActivationPromptFormNames { - Close = 'account-activation-prompt-close', -} - -/** - * Create event handlers bound to a SnapClient instance. - * - * @returns Object containing event handlers. - */ -export function createEventHandlers(): Record { - return { - [AccountActivationPromptFormNames.Close]: async (options) => - onCloseButtonClick(options), - }; -} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/render.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/render.tsx index 10fa4a07..0a3bddb6 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/render.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/AccountActivationPrompt/render.tsx @@ -18,7 +18,7 @@ export async function render(accountAddress: string): Promise { {}, ); - const dialogPromise = showDialog(id); + const dialogPromise = showDialog(id, 'alert'); return dialogPromise; } diff --git a/merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-1.svg b/merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-1.svg deleted file mode 100644 index 53abff3e..00000000 --- a/merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-1.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-2.svg b/merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-2.svg deleted file mode 100644 index 5047a760..00000000 --- a/merged-packages/stellar-wallet-snap/src/ui/images/account-active-method-2.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/merged-packages/stellar-wallet-snap/src/ui/images/index.ts b/merged-packages/stellar-wallet-snap/src/ui/images/index.ts index d5510f7c..12b618a0 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/images/index.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/images/index.ts @@ -1,11 +1,4 @@ -import accountActiveMethod1Icon from './account-active-method-1.svg'; -import accountActiveMethod2Icon from './account-active-method-2.svg'; import questionMarkIcon from './question-mark.svg'; import xlmIcon from './slip44:148.svg'; -export { - xlmIcon, - questionMarkIcon, - accountActiveMethod1Icon, - accountActiveMethod2Icon, -}; +export { xlmIcon, questionMarkIcon }; diff --git a/merged-packages/stellar-wallet-snap/src/utils/snap.ts b/merged-packages/stellar-wallet-snap/src/utils/snap.ts index e71820d6..9c658325 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/snap.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/snap.ts @@ -356,12 +356,19 @@ export async function updateInterfaceWithContext< * Shows a dialog using the provided ID. * * @param id - The ID for the dialog. + * @param type - The type of dialog to show. Defaults to 'custom'. * @returns A promise that resolves to a string. */ -export async function showDialog(id: string): Promise { +export async function showDialog( + id: string, + type?: 'alert' | 'prompt' | 'confirmation', +): Promise { return getSnapProvider().request({ method: 'snap_dialog', params: { + // If type is not provided, it will default to 'custom'. + // @see https://docs.metamask.io/snaps/features/custom-ui/dialogs/#display-a-custom-dialog + ...(type ? { type } : {}), id, }, }); From f11663a1105540c9daa20a71efd5e922177e5b90 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 29 May 2026 21:06:00 +0800 Subject: [PATCH 254/384] feat: add transaction analysis (#83) ## Explanation This PR adds MetaMask analytics helpers for transaction/security events and wires transaction lifecycle tracking into selected Stellar Snap transaction flows, while centralizing the MetaMask origin string. ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- .../stellar-wallet-snap/src/constants.ts | 5 + .../clientRequest/changeTrustOpt.test.ts | 60 +++++ .../handlers/clientRequest/changeTrustOpt.ts | 25 +++ .../clientRequest/confirmSend.test.ts | 61 +++++ .../src/handlers/clientRequest/confirmSend.ts | 27 ++- .../signAndSendTransaction.test.ts | 21 ++ .../clientRequest/signAndSendTransaction.ts | 9 +- .../src/handlers/cronjob/trackTransaction.ts | 15 +- .../src/handlers/keyring/keyring.test.ts | 20 +- .../stellar-wallet-snap/src/permissions.ts | 3 +- .../src/ui/confirmation/controller.tsx | 5 +- .../src/utils/__mocks__/snap.ts | 11 + .../src/utils/requestResponse.test.ts | 3 +- .../stellar-wallet-snap/src/utils/snap.ts | 208 ++++++++++++++++++ 14 files changed, 456 insertions(+), 17 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/constants.ts b/merged-packages/stellar-wallet-snap/src/constants.ts index 89f68f15..99797b38 100644 --- a/merged-packages/stellar-wallet-snap/src/constants.ts +++ b/merged-packages/stellar-wallet-snap/src/constants.ts @@ -70,3 +70,8 @@ export const MAX_INT64 = '9223372036854775807'; * The type for the keyring account. */ export const KEYRING_ACCOUNT_TYPE = XlmAccountType.Account; + +/** + * The origin for the MetaMask wallet. + */ +export const METAMASK_ORIGIN = 'metamask'; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts index ce6782e6..6f6b9840 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts @@ -9,6 +9,7 @@ import { } from './api'; import { ChangeTrustOptHandler } from './changeTrustOpt'; import { KnownCaip2ChainId, type KnownCaip19ClassicAssetId } from '../../api'; +import { METAMASK_ORIGIN } from '../../constants'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; import type { StellarAssetMetadata } from '../../services/asset-metadata'; @@ -38,6 +39,7 @@ import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; import { ConfirmationUXController } from '../../ui/confirmation/controller'; import { logger } from '../../utils/logger'; +import * as snapUtils from '../../utils/snap'; import { AccountResolver } from '../accountResolver'; import { TrackTransactionHandler } from '../cronjob/trackTransaction'; @@ -175,6 +177,19 @@ describe('ChangeTrustOptHandler', () => { confirmationUIController, }); + const trackTransactionAddedSpy = jest.spyOn( + snapUtils, + 'trackTransactionAdded', + ); + const trackTransactionRejectedSpy = jest.spyOn( + snapUtils, + 'trackTransactionRejected', + ); + const trackTransactionApprovedSpy = jest.spyOn( + snapUtils, + 'trackTransactionApproved', + ); + return { handler, account, @@ -194,6 +209,9 @@ describe('ChangeTrustOptHandler', () => { resolve, renderConfirmationDialog, signTransactionSpy, + trackTransactionAddedSpy, + trackTransactionRejectedSpy, + trackTransactionApprovedSpy, }; } @@ -421,4 +439,46 @@ describe('ChangeTrustOptHandler', () => { expect(sendTransaction).toHaveBeenCalledTimes(1); expect(TrackTransactionHandler.scheduleBackgroundEvent).toHaveBeenCalled(); }); + + describe('tracks transaction events', () => { + it('tracks transaction added', async () => { + const { handler, account, trackTransactionAddedSpy } = setup(); + await handler.handle(addRequest); + expect(trackTransactionAddedSpy).toHaveBeenCalledWith({ + accountType: account.type, + chainIdCaip: scope, + origin: METAMASK_ORIGIN, + }); + }); + + it('tracks transaction rejected', async () => { + const { + handler, + account, + trackTransactionRejectedSpy, + renderConfirmationDialog, + } = setup(); + renderConfirmationDialog.mockResolvedValue(false); + + await expect(handler.handle(addRequest)).rejects.toThrow( + UserRejectedRequestError, + ); + + expect(trackTransactionRejectedSpy).toHaveBeenCalledWith({ + accountType: account.type, + chainIdCaip: scope, + origin: METAMASK_ORIGIN, + }); + }); + + it('tracks transaction approved', async () => { + const { handler, account, trackTransactionApprovedSpy } = setup(); + await handler.handle(addRequest); + expect(trackTransactionApprovedSpy).toHaveBeenCalledWith({ + accountType: account.type, + chainIdCaip: scope, + origin: METAMASK_ORIGIN, + }); + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts index 16c696d1..0b18636a 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts @@ -19,6 +19,7 @@ import type { KnownCaip19AssetIdOrSlip44Id, KnownCaip2ChainId, } from '../../api'; +import { METAMASK_ORIGIN } from '../../constants'; import type { StellarKeyringAccount } from '../../services/account'; import type { AssetMetadataService, @@ -37,6 +38,11 @@ import type { import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; import type { ConfirmationUXController } from '../../ui/confirmation/controller'; import { createPrefixedLogger, type ILogger } from '../../utils/logger'; +import { + trackTransactionAdded, + trackTransactionApproved, + trackTransactionRejected, +} from '../../utils/snap'; import { TrackTransactionHandler } from '../cronjob/trackTransaction'; export class ChangeTrustOptHandler extends BaseClientRequestHandler< @@ -125,6 +131,12 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< limit: limitForTx, }); + await trackTransactionAdded({ + origin: METAMASK_ORIGIN, + accountType: account.type, + chainIdCaip: scope, + }); + const confirmed = await this.#confirmChangeTrustOpt({ request, account, @@ -135,9 +147,20 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< }); if (!confirmed) { + await trackTransactionRejected({ + origin: METAMASK_ORIGIN, + accountType: account.type, + chainIdCaip: scope, + }); throw ensureError(new UserRejectedRequestError()); } + await trackTransactionApproved({ + origin: METAMASK_ORIGIN, + accountType: account.type, + chainIdCaip: scope, + }); + wallet.signTransaction(transaction); const transactionId = await this.#transactionService.sendTransaction({ @@ -262,8 +285,10 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< transaction, confirmationInterfaceKey, } = params; + return ( (await this.#confirmationUIController.renderConfirmationDialog({ + origin: METAMASK_ORIGIN, scope, renderContext: { account, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts index 384e7532..cf4a9eed 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts @@ -16,6 +16,7 @@ import { type KnownCaip19ClassicAssetId, type KnownCaip19Sep41AssetId, } from '../../api'; +import { METAMASK_ORIGIN } from '../../constants'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; import type { StellarAssetMetadata } from '../../services/asset-metadata'; @@ -53,6 +54,7 @@ import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; import { ConfirmationUXController } from '../../ui/confirmation/controller'; import { logger } from '../../utils/logger'; +import * as snapUtils from '../../utils/snap'; import { AccountResolver } from '../accountResolver'; import { TrackTransactionHandler } from '../cronjob/trackTransaction'; @@ -171,6 +173,19 @@ describe('ConfirmSendHandler', () => { confirmationUIController, }); + const trackTransactionAddedSpy = jest.spyOn( + snapUtils, + 'trackTransactionAdded', + ); + const trackTransactionRejectedSpy = jest.spyOn( + snapUtils, + 'trackTransactionRejected', + ); + const trackTransactionApprovedSpy = jest.spyOn( + snapUtils, + 'trackTransactionApproved', + ); + return { handler, account, @@ -186,6 +201,9 @@ describe('ConfirmSendHandler', () => { signTransactionSpy, scheduleBackgroundEvent, transactionRepositorySaveManySpy, + trackTransactionAddedSpy, + trackTransactionRejectedSpy, + trackTransactionApprovedSpy, }; } @@ -266,6 +284,7 @@ describe('ConfirmSendHandler', () => { scope, interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, fee: transaction.totalFee.toString(), + origin: METAMASK_ORIGIN, renderContext: { account, assetMetadata, @@ -449,4 +468,46 @@ describe('ConfirmSendHandler', () => { InvalidParamsError, ); }); + + describe('tracks transaction events', () => { + it('tracks transaction added', async () => { + const { handler, account, trackTransactionAddedSpy } = setup(); + await handler.handle(baseRequest()); + expect(trackTransactionAddedSpy).toHaveBeenCalledWith({ + accountType: account.type, + chainIdCaip: scope, + origin: METAMASK_ORIGIN, + }); + }); + + it('tracks transaction rejected', async () => { + const { + handler, + account, + trackTransactionRejectedSpy, + renderConfirmationDialog, + } = setup(); + renderConfirmationDialog.mockResolvedValue(false); + + await expect(handler.handle(baseRequest())).rejects.toThrow( + UserRejectedRequestError, + ); + + expect(trackTransactionRejectedSpy).toHaveBeenCalledWith({ + accountType: account.type, + chainIdCaip: scope, + origin: METAMASK_ORIGIN, + }); + }); + + it('tracks transaction approved', async () => { + const { handler, account, trackTransactionApprovedSpy } = setup(); + await handler.handle(baseRequest()); + expect(trackTransactionApprovedSpy).toHaveBeenCalledWith({ + accountType: account.type, + chainIdCaip: scope, + origin: METAMASK_ORIGIN, + }); + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts index 6fd1c29d..8d9db190 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts @@ -15,6 +15,7 @@ import type { KnownCaip19AssetIdOrSlip44Id, KnownCaip2ChainId, } from '../../api'; +import { METAMASK_ORIGIN } from '../../constants'; import type { StellarKeyringAccount } from '../../services/account'; import type { AssetMetadataService, @@ -29,7 +30,13 @@ import { import type { TransactionService } from '../../services/transaction'; import type { ContextWithPrices } from '../../ui/confirmation/api'; import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; -import { hasDecimals, toSmallestUnit } from '../../utils'; +import { + hasDecimals, + toSmallestUnit, + trackTransactionAdded, + trackTransactionApproved, + trackTransactionRejected, +} from '../../utils'; import { createPrefixedLogger } from '../../utils/logger'; import type { ILogger } from '../../utils/logger'; import type { @@ -132,6 +139,12 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< destination: toAddress, }); + await trackTransactionAdded({ + origin: METAMASK_ORIGIN, + accountType: stellarKeyringAccount.type, + chainIdCaip: scope, + }); + if ( !(await this.#confirmSend({ request, @@ -141,9 +154,20 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< fee: transaction.totalFee, })) ) { + await trackTransactionRejected({ + origin: METAMASK_ORIGIN, + accountType: stellarKeyringAccount.type, + chainIdCaip: scope, + }); throw ensureError(new UserRejectedRequestError()); } + await trackTransactionApproved({ + origin: METAMASK_ORIGIN, + accountType: stellarKeyringAccount.type, + chainIdCaip: scope, + }); + wallet.signTransaction(transaction); const transactionId = await this.#transactionService.sendTransaction({ @@ -223,6 +247,7 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< return ( (await this.#confirmationUIController.renderConfirmationDialog({ scope, + origin: METAMASK_ORIGIN, renderContext: { account, assetMetadata, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.test.ts index f45fd9cb..8062bfa6 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.test.ts @@ -7,6 +7,7 @@ import { } from './api'; import { SignAndSendTransactionHandler } from './signAndSendTransaction'; import { KnownCaip19Slip44IdMap, KnownCaip2ChainId } from '../../api'; +import { METAMASK_ORIGIN } from '../../constants'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; import { @@ -30,6 +31,7 @@ import { WalletService } from '../../services/wallet'; import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; import { toCaip19ClassicAssetId, toDisplayBalance } from '../../utils'; import { logger } from '../../utils/logger'; +import * as snapUtils from '../../utils/snap'; import { AccountResolver } from '../accountResolver'; import { TrackTransactionHandler } from '../cronjob/trackTransaction'; @@ -115,6 +117,11 @@ describe('SignAndSendTransactionHandler', () => { transactionService, }); + const trackTransactionSubmittedSpy = jest.spyOn( + snapUtils, + 'trackTransactionSubmitted', + ); + const request: SignAndSendTransactionJsonRpcRequest = { jsonrpc: '2.0', id: 1, @@ -145,6 +152,7 @@ describe('SignAndSendTransactionHandler', () => { savePendingKeyringTransaction, scheduleBackgroundEvent, signTransactionSpy, + trackTransactionSubmittedSpy, }; } @@ -353,4 +361,17 @@ describe('SignAndSendTransactionHandler', () => { }, }); }); + + describe('tracks transaction events', () => { + it('tracks transaction submitted', async () => { + const { handler, account, request, trackTransactionSubmittedSpy } = + setup(); + await handler.handle(request); + expect(trackTransactionSubmittedSpy).toHaveBeenCalledWith({ + accountType: account.type, + chainIdCaip: scope, + origin: METAMASK_ORIGIN, + }); + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts index 48db5c8b..8e720321 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts @@ -20,7 +20,7 @@ import { type ResolvedActivatedAccount, } from '../accountResolver'; import { BaseClientRequestHandler } from './base'; -import { NATIVE_ASSET_SYMBOL } from '../../constants'; +import { METAMASK_ORIGIN, NATIVE_ASSET_SYMBOL } from '../../constants'; import type { StellarKeyringAccount } from '../../services/account'; import { KeyringTransactionType, @@ -32,6 +32,7 @@ import { parseOperationAssetReference } from '../../services/transaction/utils'; import { toDisplayBalance } from '../../utils/currency'; import { createPrefixedLogger } from '../../utils/logger'; import type { ILogger } from '../../utils/logger'; +import { trackTransactionSubmitted } from '../../utils/snap'; import { TrackTransactionHandler } from '../cronjob/trackTransaction'; type PendingSwapDetails = { @@ -119,6 +120,12 @@ export class SignAndSendTransactionHandler extends BaseClientRequestHandler< pollTransaction: false, }); + await trackTransactionSubmitted({ + origin: METAMASK_ORIGIN, + accountType: account.type, + chainIdCaip: scope, + }); + await this.#savePendingTransaction({ transactionId: transactionHash, account, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts index 709f91c7..3fbccb19 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts @@ -13,6 +13,7 @@ import { } from './api'; import { CronjobBaseHandler } from './base'; import type { KnownCaip2ChainId } from '../../api'; +import { KEYRING_ACCOUNT_TYPE, METAMASK_ORIGIN } from '../../constants'; import type { AccountService, StellarKeyringAccount, @@ -23,7 +24,11 @@ import type { OnChainAccountService } from '../../services/on-chain-account'; import type { TransactionService } from '../../services/transaction'; import type { ILogger } from '../../utils/logger'; import { createPrefixedLogger } from '../../utils/logger'; -import { Duration, scheduleBackgroundEvent } from '../../utils/snap'; +import { + Duration, + scheduleBackgroundEvent, + trackTransactionFinalized, +} from '../../utils/snap'; /** * Polls Soroban RPC for transaction settlement first, then updates keyring status and runs @@ -112,6 +117,14 @@ export class TrackTransactionHandler extends CronjobBaseHandler { const handleKeyringRequestSpy = jest.mocked(handleKeyringRequest); handleKeyringRequestSpy.mockResolvedValue([]); - const result = await keyringHandler.handle('metamask', request); + const result = await keyringHandler.handle(METAMASK_ORIGIN, request); expect(handleKeyringRequestSpy).toHaveBeenCalledWith( keyringHandler, @@ -174,7 +174,7 @@ describe('KeyringHandler', () => { const handleKeyringRequestSpy = jest.mocked(handleKeyringRequest); handleKeyringRequestSpy.mockResolvedValue(null); - const result = await keyringHandler.handle('metamask', request); + const result = await keyringHandler.handle(METAMASK_ORIGIN, request); expect(handleKeyringRequestSpy).toHaveBeenCalledWith( keyringHandler, @@ -861,7 +861,7 @@ describe('KeyringHandler', () => { const signMessagePayload = { id: keyringRequestId, - origin: 'metamask', + origin: METAMASK_ORIGIN, request: { method: MultichainMethod.SignMessage, params: { @@ -901,7 +901,7 @@ describe('KeyringHandler', () => { const signTransactionPayload = { id: keyringRequestId, - origin: 'metamask', + origin: METAMASK_ORIGIN, request: { method: MultichainMethod.SignTransaction, params: { xdr }, @@ -940,7 +940,7 @@ describe('KeyringHandler', () => { const signAuthEntryPayload = { id: keyringRequestId, - origin: 'metamask', + origin: METAMASK_ORIGIN, request: { method: MultichainMethod.SignAuthEntry, params: { authEntry }, @@ -967,7 +967,7 @@ describe('KeyringHandler', () => { await expect( keyringHandler.submitRequest({ id: keyringRequestId, - origin: 'metamask', + origin: METAMASK_ORIGIN, request: { method: 'invalid:method' as MultichainMethod, params: { message: 'Hello, world!' }, @@ -995,7 +995,7 @@ describe('KeyringHandler', () => { const signMessagePayload = { id: keyringRequestId, - origin: 'metamask', + origin: METAMASK_ORIGIN, request: { method: MultichainMethod.SignMessage, params: { @@ -1031,7 +1031,7 @@ describe('KeyringHandler', () => { const signTransactionPayload = { id: keyringRequestId, - origin: 'metamask', + origin: METAMASK_ORIGIN, request: { method: MultichainMethod.SignTransaction, params: { xdr }, @@ -1065,7 +1065,7 @@ describe('KeyringHandler', () => { const signAuthEntryPayload = { id: keyringRequestId, - origin: 'metamask', + origin: METAMASK_ORIGIN, request: { method: MultichainMethod.SignAuthEntry, params: { authEntry }, diff --git a/merged-packages/stellar-wallet-snap/src/permissions.ts b/merged-packages/stellar-wallet-snap/src/permissions.ts index 0438f7cd..658c3989 100644 --- a/merged-packages/stellar-wallet-snap/src/permissions.ts +++ b/merged-packages/stellar-wallet-snap/src/permissions.ts @@ -2,6 +2,7 @@ import { KeyringRpcMethod } from '@metamask/keyring-api'; import { Environment } from './api'; import { AppConfig } from './config'; +import { METAMASK_ORIGIN } from './constants'; const isDev = AppConfig.environment !== Environment.Production; @@ -40,7 +41,7 @@ const metamaskPermissions = new Set([ KeyringRpcMethod.SetSelectedAccounts, ]); -const metamask = 'metamask'; +const metamask = METAMASK_ORIGIN; export const originPermissions = new Map>([]); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index d31417bf..607dcf94 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -13,6 +13,7 @@ import { hasEnabledTransactionScan, } from './utils'; import type { KnownCaip2ChainId } from '../../api'; +import { METAMASK_ORIGIN } from '../../constants'; import type { SecurityScanRequest } from '../../services/transaction-scan'; import type { ILogger, Locale } from '../../utils'; import { @@ -112,7 +113,7 @@ export class ConfirmationUXController { * @param params.renderContext - The context for the render. * @param params.interfaceKey - The key of the interface to render. * @param params.fee - Fee in stroops, REQUIRED for SignTransaction, forbidden otherwise. - * @param params.origin - [Optional] The origin of the confirmation. Defaults to 'metamask'. + * @param params.origin - [Optional] The origin of the confirmation. Defaults to METAMASK_ORIGIN. * @param params.renderOptions - [Optional] The options for the render. Defaults to {@link #defaultRenderOptions}. * @param params.tokenPrices - [Optional] The token prices for the render {@link ContextWithPrices['tokenPrices']}. * @returns A promise that resolves to the dialog result. @@ -125,7 +126,7 @@ export class ConfirmationUXController { interfaceKey, scope, renderContext, - origin = 'metamask', + origin = METAMASK_ORIGIN, fee, } = params; const renderOptions = { diff --git a/merged-packages/stellar-wallet-snap/src/utils/__mocks__/snap.ts b/merged-packages/stellar-wallet-snap/src/utils/__mocks__/snap.ts index 416baf31..793a8f06 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/__mocks__/snap.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/__mocks__/snap.ts @@ -14,6 +14,8 @@ export const listEntropySources = jest.fn(); export const getDefaultEntropySource = jest.fn(); +export const trackEvent = jest.fn(); + export const { getState, setState, @@ -24,4 +26,13 @@ export const { resolveInterface, scheduleBackgroundEvent, Duration, + TransactionEventType, + SecurityEventType, + trackTransactionAdded, + trackTransactionRejected, + trackTransactionApproved, + trackTransactionSubmitted, + trackTransactionFinalized, + trackSecurityAlertDetected, + trackSecurityScanCompleted, } = actual; diff --git a/merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts b/merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts index c4748cba..4ba0a3fe 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/requestResponse.test.ts @@ -11,6 +11,7 @@ import { validateResponse, validateOrigin, } from './requestResponse'; +import { METAMASK_ORIGIN } from '../constants'; const TestStruct = object({ url: string(), @@ -76,7 +77,7 @@ describe('validateOrigin', () => { KeyringRpcMethod.ResolveAccountAddress, KeyringRpcMethod.SetSelectedAccounts, ])('allows method %s for metamask', (method) => { - const origin = 'metamask'; + const origin = METAMASK_ORIGIN; expect(() => validateOrigin(origin, method)).not.toThrow(); }); diff --git a/merged-packages/stellar-wallet-snap/src/utils/snap.ts b/merged-packages/stellar-wallet-snap/src/utils/snap.ts index 9c658325..723fdc45 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/snap.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/snap.ts @@ -26,6 +26,25 @@ export enum Duration { OneHour = 'PT1H', } +/** + * Enum for transaction tracking event types. + */ +export enum TransactionEventType { + TransactionAdded = 'Transaction Added', + TransactionRejected = 'Transaction Rejected', + TransactionApproved = 'Transaction Approved', + TransactionSubmitted = 'Transaction Submitted', + TransactionFinalized = 'Transaction Finalized', +} + +/** + * Enum for security alert tracking event types. + */ +export enum SecurityEventType { + SecurityAlertDetected = 'Security Alert Detected', + SecurityScanCompleted = 'Security Scan Completed', +} + /** * Returns the Snap provider. * @@ -404,3 +423,192 @@ export async function getPreferences(): Promise { method: 'snap_getPreferences', }); } + +/** + * Track an event in MetaMask analytics. + * + * @param event - The event name to track. + * @param properties - Additional properties to include with the event. + */ +export async function trackEvent( + event: string, + properties: Record, +): Promise { + try { + await snap.request({ + method: 'snap_trackEvent', + params: { + event: { + event, + properties, + }, + }, + }); + } catch { + // Silently fail if tracking fails - we don't want to interrupt the user flow + } +} + +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * Track a "Transaction Added" event when a transaction confirmation is shown. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + */ +export async function trackTransactionAdded(properties: { + origin: string; + accountType: string; + chainIdCaip: string; +}): Promise { + await trackEvent(TransactionEventType.TransactionAdded, { + message: 'Snap transaction added', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + }); +} + +/** + * Track a "Transaction Rejected" event when user rejects a transaction. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + */ +export async function trackTransactionRejected(properties: { + origin: string; + accountType: string; + chainIdCaip: string; +}): Promise { + await trackEvent(TransactionEventType.TransactionRejected, { + message: 'Snap transaction rejected', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + }); +} + +/** + * Track a "Transaction Submitted" event when a transaction is successfully broadcast. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + */ +export async function trackTransactionSubmitted(properties: { + origin: string; + accountType: string; + chainIdCaip: string; +}): Promise { + await trackEvent(TransactionEventType.TransactionSubmitted, { + message: 'Snap transaction submitted', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + }); +} + +/** + * Track a "Transaction Approved" event when a transaction is approved. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + */ +export async function trackTransactionApproved(properties: { + origin: string; + accountType: string; + chainIdCaip: string; +}): Promise { + await trackEvent(TransactionEventType.TransactionApproved, { + message: 'Snap transaction approved', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + }); +} + +/** + * Track a "Transaction Finalized" event when a transaction reaches final state. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + */ +export async function trackTransactionFinalized(properties: { + origin: string; + accountType: string; + chainIdCaip: string; +}): Promise { + await trackEvent(TransactionEventType.TransactionFinalized, { + message: 'Snap transaction finalized', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + }); +} + +/** + * Track a "Security Alert Detected" event when a malicious or warning transaction is detected. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + * @param properties.securityAlertResponse - The type of security alert (Warning, Malicious). + * @param properties.securityAlertReason - The reason for the security alert. + * @param properties.securityAlertDescription - Human-readable description of the alert. + */ +export async function trackSecurityAlertDetected(properties: { + origin: string; + accountType: string; + chainIdCaip: string; + securityAlertResponse: string; + securityAlertReason: string | null; + securityAlertDescription: string; +}): Promise { + await trackEvent(SecurityEventType.SecurityAlertDetected, { + message: 'Snap security alert detected', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + security_alert_response: properties.securityAlertResponse, + security_alert_reason: properties.securityAlertReason, + security_alert_description: properties.securityAlertDescription, + }); +} + +/** + * Track a "Security Scan Completed" event when a transaction security scan finishes. + * + * @param properties - Event properties. + * @param properties.origin - The origin of the request. + * @param properties.accountType - The type of account. + * @param properties.chainIdCaip - The CAIP-2 chain ID. + * @param properties.scanStatus - The status of the scan (SUCCESS, ERROR). + * @param properties.hasSecurityAlerts - Whether security alerts were detected. + */ +export async function trackSecurityScanCompleted(properties: { + origin: string; + accountType: string; + chainIdCaip: string; + scanStatus: string; + hasSecurityAlerts: boolean; +}): Promise { + await trackEvent(SecurityEventType.SecurityScanCompleted, { + message: 'Snap security scan completed', + origin: properties.origin, + account_type: properties.accountType, + chain_id_caip: properties.chainIdCaip, + scan_status: properties.scanStatus, + has_security_alerts: properties.hasSecurityAlerts, + }); +} +/* eslint-enable @typescript-eslint/naming-convention */ From eeabcff342195b1de63bb3278997834c2fcd9ca3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:08:37 +0800 Subject: [PATCH 255/384] =?UTF-8?q?fix:=20enforces=20SEP-29=20=E2=80=9Cmem?= =?UTF-8?q?o=20required=E2=80=9D=20=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Explanation This PR enforces SEP-29 “memo required” behavior by detecting config.memo_required on destination accounts and blocking payment/path-payment simulations when the transaction envelope has no usable memo. sep-29: https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0029.md Testing 1. (Test sep29 is blocking a transaction) - Send fund to GB6NFMXBTT66UPEOTILSWP5SIN2YLIAQEKVGAJXO3OIV3T3HJ3MKKGJE, the account has config to be required memo (https://stellar.expert/explorer/public/account/GB6NFMXBTT66UPEOTILSWP5SIN2YLIAQEKVGAJXO3OIV3T3HJ3MKKGJE) ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- .../stellar-wallet-snap/src/constants.ts | 16 ++ .../on-chain-account/OnChainAccount.test.ts | 75 +++++++ .../on-chain-account/OnChainAccount.ts | 28 ++- .../OnChainAccountSerializable.ts | 2 + .../transaction/TransactionSimulator.test.ts | 206 ++++++++++++++++++ .../transaction/TransactionSimulator.ts | 19 +- .../src/services/transaction/exceptions.ts | 7 + .../services/transaction/simulation/api.ts | 17 +- .../transaction/simulation/simulators.ts | 51 +++-- .../src/services/transaction/utils.ts | 23 ++ 10 files changed, 411 insertions(+), 33 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/constants.ts b/merged-packages/stellar-wallet-snap/src/constants.ts index 99797b38..d625a821 100644 --- a/merged-packages/stellar-wallet-snap/src/constants.ts +++ b/merged-packages/stellar-wallet-snap/src/constants.ts @@ -75,3 +75,19 @@ export const KEYRING_ACCOUNT_TYPE = XlmAccountType.Account; * The origin for the MetaMask wallet. */ export const METAMASK_ORIGIN = 'metamask'; + +/** + * The key for the memo required attribute. + * It is used to check if the account requires a memo based on the SEP-0029 standard. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0029.md + */ +export const MEMO_REQUIRED_KEY = 'config.memo_required'; + +/** + * ACCOUNT_REQUIRES_MEMO is the base64 encoding of "1". + * SEP 29 uses this value to define transaction memo requirements for incoming payments. + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0029.md + */ +export const ACCOUNT_REQUIRES_MEMO = 'MQ=='; diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts index 743e0090..c0f1fba7 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts @@ -1,3 +1,4 @@ +import type { Horizon } from '@stellar/stellar-sdk'; import { Account, Keypair } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; @@ -20,6 +21,7 @@ import { OnChainAccountSerializableFullStruct } from './OnChainAccountSerializab import { calculateSpendableBalance, minimumBalanceStroops } from './utils'; import type { KnownCaip19Sep41AssetId } from '../../api'; import { KnownCaip2ChainId } from '../../api'; +import { ACCOUNT_REQUIRES_MEMO, MEMO_REQUIRED_KEY } from '../../constants'; import { getSlip44AssetId, toCaip19ClassicAssetId, @@ -38,6 +40,22 @@ function optionalBigNumberString( return value === undefined ? undefined : value.toString(); } +/** + * Attaches Horizon `data_attr` to a mock account for {@link OnChainAccount.fromHorizon} tests. + * + * @param mockAccount + * @param dataAttr + */ +function mockHorizonAccountResponse( + mockAccount: Account, + dataAttr: Record, +): Horizon.AccountResponse { + return Object.assign(mockAccount, { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Horizon API field + data_attr: dataAttr, + }) as unknown as Horizon.AccountResponse; +} + describe('OnChainAccount', () => { const testWalletSigner = getTestWallet(); const testMockAccount = createMockAccountWithBalances( @@ -443,6 +461,7 @@ describe('OnChainAccount', () => { subentryCount: onChainAccount.subentryCount, numSponsoring: onChainAccount.numSponsoring, numSponsored: onChainAccount.numSponsored, + dataEntries: {}, }); const nativeId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); const usdcId = toCaip19ClassicAssetId( @@ -502,6 +521,61 @@ describe('OnChainAccount', () => { }); }); + describe('requiresMemo and dataEntries', () => { + it('is true when Horizon data_attr has the SEP-29 memo_required flag', () => { + const accountId = Keypair.random().publicKey(); + const mockAccount = createMockAccountWithBalances( + accountId, + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const onChain = OnChainAccount.fromHorizon( + mockHorizonAccountResponse(mockAccount, { + [MEMO_REQUIRED_KEY]: ACCOUNT_REQUIRES_MEMO, + }), + KnownCaip2ChainId.Mainnet, + ); + + expect(onChain.requiresMemo).toBe(true); + }); + + it('is false when data_attr omits memo_required', () => { + const accountId = Keypair.random().publicKey(); + const mockAccount = createMockAccountWithBalances( + accountId, + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const onChain = OnChainAccount.fromHorizon( + mockHorizonAccountResponse(mockAccount, {}), + KnownCaip2ChainId.Mainnet, + ); + + expect(onChain.requiresMemo).toBe(false); + }); + + it('round-trips dataEntries and requiresMemo through toSerializable', () => { + const accountId = Keypair.random().publicKey(); + const mockAccount = createMockAccountWithBalances( + accountId, + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const ref = OnChainAccount.fromHorizon( + mockHorizonAccountResponse(mockAccount, { + [MEMO_REQUIRED_KEY]: ACCOUNT_REQUIRES_MEMO, + }), + KnownCaip2ChainId.Mainnet, + ); + const restored = OnChainAccount.fromSerializable(ref.toSerializable()); + + expect(restored.requiresMemo).toBe(true); + expect(restored.toSerializableFull().meta.dataEntries).toStrictEqual({ + [MEMO_REQUIRED_KEY]: ACCOUNT_REQUIRES_MEMO, + }); + }); + }); + describe('fromSerializable', () => { it('round-trips with toSerializable for Horizon-bound wallet', () => { const { onChainAccount: ref } = createTestWallet(); @@ -520,6 +594,7 @@ describe('OnChainAccount', () => { 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', ); expect(restored.getAsset(usdcId)).toStrictEqual(ref.getAsset(usdcId)); + expect(restored.requiresMemo).toBe(ref.requiresMemo); expect( restored.getAsset(getSlip44AssetId(KnownCaip2ChainId.Mainnet)), ).toStrictEqual( diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts index 4b234214..d2b229d9 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts @@ -27,7 +27,11 @@ import type { KnownCaip19Sep41AssetId, KnownCaip2ChainId, } from '../../api'; -import { NATIVE_ASSET_SYMBOL } from '../../constants'; +import { + ACCOUNT_REQUIRES_MEMO, + MEMO_REQUIRED_KEY, + NATIVE_ASSET_SYMBOL, +} from '../../constants'; import { getSlip44AssetId, isClassicAssetId, @@ -54,6 +58,8 @@ export class OnChainAccount { #numSponsored: number | undefined; + #dataEntries: Record | undefined; + #rawNativeBalance: BigNumber | undefined; readonly #balances: Map = @@ -125,6 +131,10 @@ export class OnChainAccount { throw new OnChainAccountMetadataNotAvailableException(this.accountId); } + get requiresMemo(): boolean { + return this.#dataEntries?.[MEMO_REQUIRED_KEY] === ACCOUNT_REQUIRES_MEMO; + } + /** * Whether the asset is visible for keyring / client flows (active trustline, positive SEP-41, or native). * @@ -295,6 +305,7 @@ export class OnChainAccount { const subentryCount = this.#subentryCount; const numSponsoring = this.#numSponsoring; const numSponsored = this.#numSponsored; + const dataEntries = this.#dataEntries ?? {}; if ( subentryCount === undefined || @@ -364,6 +375,7 @@ export class OnChainAccount { subentryCount, numSponsoring, numSponsored, + dataEntries, }, balances, rawNativeBalance: this.#rawNativeBalance.toFixed(0), @@ -393,7 +405,7 @@ export class OnChainAccount { /** * Builds from a Horizon `loadAccount` response. - * With a native balance line → full binding; otherwise → minimal binding (sequence-only style). + * With a native balance line → full binding (includes `data_attr` as `meta.dataEntries`); * * @param response - Horizon `loadAccount` payload. * @param scope - CAIP-2 network. @@ -410,7 +422,8 @@ export class OnChainAccount { const subentryCount = response.subentry_count ?? 0; const numSponsoring = response.num_sponsoring ?? 0; const numSponsored = response.num_sponsored ?? 0; - const meta = { subentryCount, numSponsoring, numSponsored }; + const dataEntries = response.data_attr ?? {}; + const meta = { subentryCount, numSponsoring, numSponsored, dataEntries }; const balances: SerializableSpendableBalance[] = []; const horizonBalances = response.balances ?? []; @@ -453,11 +466,9 @@ export class OnChainAccount { } if (rawNativeBalance === undefined) { - return new OnChainAccount(stellarAccount, scope, { - accountId: response.accountId(), - sequenceNumber: response.sequenceNumber(), - scope, - }); + // this should never happen, + // as any account that exists on the ledger has a native (XLM) balance line + throw new OnChainAccountException('Native balance is not available'); } const data: OnChainAccountSerializableFull = { @@ -493,6 +504,7 @@ export class OnChainAccount { this.#subentryCount = meta.subentryCount; this.#numSponsoring = meta.numSponsoring; this.#numSponsored = meta.numSponsored; + this.#dataEntries = meta.dataEntries ?? {}; this.#rawNativeBalance = new BigNumber(data.rawNativeBalance); const nativeId = getSlip44AssetId(scope); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts index 35f47acb..8e58f0ef 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSerializable.ts @@ -7,6 +7,7 @@ import { number, object, optional, + record, string, union, } from '@metamask/superstruct'; @@ -91,6 +92,7 @@ export const OnChainAccountSerializableFullStruct = assign( subentryCount: number(), numSponsoring: number(), numSponsored: number(), + dataEntries: optional(record(string(), string())), }), balances: array(SerializableSpendableBalanceStruct), rawNativeBalance: string(), diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts index 93b26096..490afe6c 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts @@ -3,6 +3,7 @@ import { Account, Asset, Keypair, + Memo, nativeToScVal, Networks, Operation as StellarOperation, @@ -17,6 +18,7 @@ import { InvalidAmountForCreateAccountException, InvalidInvokeContractStructureException, RemoveTrustlineWithNonZeroBalanceException, + RequiresMemoException, TransactionExpireException, TransactionScopeNotMatchException, TransactionValidationException, @@ -31,6 +33,7 @@ import { TransactionSimulator, } from './TransactionSimulator'; import { KnownCaip2ChainId } from '../../api'; +import { ACCOUNT_REQUIRES_MEMO, MEMO_REQUIRED_KEY } from '../../constants'; import { caip2ChainIdToNetwork } from '../network/utils'; import { createMockAccountWithBalances, @@ -210,6 +213,27 @@ function destOnChainAccount(destPublicKey: string): OnChainAccount { }); } +/** + * Destination account with SEP-29 `config.memo_required` set. + * + * @param destPublicKey - Payment destination Stellar account id (G…). + * @returns Preload account that {@link OnChainAccount.requiresMemo} treats as memo-required. + */ +function destOnChainAccountRequiresMemo(destPublicKey: string): OnChainAccount { + const account = destOnChainAccount(destPublicKey); + const serializable = account.toSerializableFull(); + return OnChainAccount.fromSerializable({ + ...serializable, + meta: { + ...serializable.meta, + dataEntries: { + ...serializable.meta.dataEntries, + [MEMO_REQUIRED_KEY]: ACCOUNT_REQUIRES_MEMO, + }, + }, + }); +} + /** * Destination with a USDC trustline that exists but is not authorized (`is_authorized` false). * @@ -501,6 +525,112 @@ describe('TransactionSimulator', () => { expect(stack).toHaveLength(2); }); + it('throws when destination requires memo and payment envelope has no memo', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: destinationAddress, + asset: 'native', + amount: '10', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [ + destOnChainAccountRequiresMemo(destinationAddress), + ], + }), + ).toThrow(RequiresMemoException); + }); + + it('succeeds when destination requires memo and payment envelope has a text memo', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + + const tx = buildEnvelopeTransaction( + wallet.address, + '1', + (tb) => + tb + .addOperation( + StellarOperation.payment({ + source: wallet.address, + destination: destinationAddress, + asset: Asset.native(), + amount: '10', + }), + ) + .addMemo(Memo.text('deposit-ref')), + { feeStroops: '100', scope: KnownCaip2ChainId.Mainnet }, + ); + + expect( + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [ + destOnChainAccountRequiresMemo(destinationAddress), + ], + }), + ).toHaveLength(2); + }); + + it.each([ + { label: 'empty text', memo: Memo.text('') }, + { label: 'whitespace-only text', memo: Memo.text(' ') }, + ])( + 'throws when destination requires memo and payment has $label', + ({ memo }: { label: string; memo: Memo }) => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + + const tx = buildEnvelopeTransaction( + wallet.address, + '1', + (tb) => + tb + .addOperation( + StellarOperation.payment({ + source: wallet.address, + destination: destinationAddress, + asset: Asset.native(), + amount: '10', + }), + ) + .addMemo(memo), + { feeStroops: '100', scope: KnownCaip2ChainId.Mainnet }, + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [ + destOnChainAccountRequiresMemo(destinationAddress), + ], + }), + ).toThrow(RequiresMemoException); + }, + ); + it('throws when destination account is not in the simulation set', () => { const walletKey = Keypair.random().publicKey(); const external = Keypair.random().publicKey(); @@ -704,6 +834,82 @@ describe('TransactionSimulator', () => { }); describe('pathPayment', () => { + it('throws when destination requires memo and path payment envelope has no memo', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + + const tx = buildMockClassicTransaction( + [ + { + type: 'pathPaymentStrictSend', + params: { + source: wallet.address, + sendAsset: 'native', + sendAmount: '10', + destination: destinationAddress, + destAsset: MOCK_USDC_ASSET, + destMin: '5', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + expectedOPTypes: [SupportedOperations.PathPayment], + preloadedAccounts: [ + destOnChainAccountRequiresMemo(destinationAddress), + ], + }), + ).toThrow(RequiresMemoException); + }); + + it('succeeds when destination requires memo and path payment envelope has a text memo', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + + const tx = buildEnvelopeTransaction( + wallet.address, + '1', + (tb) => + tb + .addOperation( + StellarOperation.pathPaymentStrictSend({ + source: wallet.address, + sendAsset: Asset.native(), + sendAmount: '10', + destination: destinationAddress, + destAsset: new Asset( + MOCK_USDC_ASSET.code, + MOCK_USDC_ASSET.issuer, + ), + destMin: '5', + path: [], + }), + ) + .addMemo(Memo.text('swap-ref')), + { feeStroops: '100', scope: KnownCaip2ChainId.Mainnet }, + ); + + expect( + simulator.simulate(tx, onChainAccount, { + expectedOPTypes: [SupportedOperations.PathPayment], + preloadedAccounts: [ + destOnChainAccountRequiresMemo(destinationAddress), + ], + }), + ).toHaveLength(2); + }); + it('succeeds for strict send when source pays native and destination receives a credit asset', () => { const wallet = getTestWallet(); const onChainAccount = onChainFromMockBalances(wallet.address, '1', { diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts index 5c9fac80..01c97d11 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts @@ -87,6 +87,8 @@ export class TransactionSimulator { * then runs ordered simulation for supported ops; classic ops update balances / trustlines. * Soroban `invokeHostFunction` is only allowed as a single-op tx and is a no-op for state. * All involved accounts must be known from the wallet snapshot (or {@link TransactionSimulatorOptions.preloadedAccounts}). + * Payment and path-payment destinations must appear in that set (or preloads) so balances and SEP-29 + * `requiresMemo` can be read; otherwise simulation fails with “account not loaded” before memo checks run. * For a sole SEP-41 `transfer` invoke, the sender's contract token balance must appear on that account's {@link OnChainAccount} snapshot (e.g. {@link OnChainAccount.setAsset}). * * @param transaction - Wrapped Stellar transaction. @@ -156,6 +158,7 @@ export class TransactionSimulator { txSource, scope, operations, + transaction, }); this.#applyOP({ op, state, txSource, scope, opIndex }); stack.push(state); @@ -248,6 +251,7 @@ export class TransactionSimulator { subentryCount: account.subentryCount, numSponsoring: account.numSponsoring, numSponsored: account.numSponsored, + requiresMemo: account.requiresMemo, trustlines, sep41Balances, }; @@ -317,8 +321,10 @@ export class TransactionSimulator { txSource: string; scope: KnownCaip2ChainId; operations: readonly Operation[]; + transaction: Transaction; }): void { - const { op, opIndex, state, txSource, scope, operations } = params; + const { op, opIndex, state, txSource, scope, operations, transaction } = + params; const operationType = this.#getSupportedOperationType(op); this.#operationSimulator[operationType].validate( @@ -327,6 +333,7 @@ export class TransactionSimulator { txSource, scope, opIndex, + transaction, }, op, operations, @@ -364,8 +371,13 @@ export class TransactionSimulator { #cloneAccountState(accountState: AccountState): AccountState { const trustlines = new Map(); const sep41Balances = new Map(); - const { nativeRawBalance, subentryCount, numSponsoring, numSponsored } = - accountState; + const { + nativeRawBalance, + subentryCount, + numSponsoring, + numSponsored, + requiresMemo, + } = accountState; for (const [assetId, trustline] of accountState.trustlines) { trustlines.set(assetId, { balance: new BigNumber(trustline.balance.toString()), @@ -382,6 +394,7 @@ export class TransactionSimulator { subentryCount, numSponsoring, numSponsored, + requiresMemo, trustlines, sep41Balances, }; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts index 858f0c74..07a2ea7b 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts @@ -66,6 +66,13 @@ export class InvalidAssetForSep41TransferException extends TransactionValidation } } +/** Thrown when the account requires a memo. */ +export class RequiresMemoException extends TransactionValidationException { + constructor(accountId: string) { + super(`Account ${accountId} requires a memo`); + } +} + /** * Thrown when `Operation.createAccount` starting balance is below 1 XLM (no sponsorship modeled). */ diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/api.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/api.ts index 1883c466..345a320e 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/api.ts @@ -5,6 +5,7 @@ import type { KnownCaip19Sep41AssetId, KnownCaip2ChainId, } from '../../../api'; +import type { Transaction } from '../Transaction'; /** * Trustline row for simulation. `sponsored` mirrors Horizon: non-empty `balance.sponsor` means reserve is sponsored. @@ -30,6 +31,7 @@ export type AccountState = { subentryCount: number; numSponsoring: number; numSponsored: number; + requiresMemo: boolean; trustlines: Map; /** * SEP-41 contract token balances from the wallet snapshot (smallest units), keyed by CAIP-19 SEP-41 id. @@ -47,24 +49,27 @@ export type SimulationState = { accounts: Map; }; -/** - * Context for validating one classic operation against the current simulation snapshot. - */ -export type Context = { +/** Per-operation context for balance / trustline apply steps. */ +export type ApplyContext = { state: SimulationState; txSource: string; scope: KnownCaip2ChainId; opIndex: number; }; +/** Extends {@link ApplyContext} with the envelope (e.g. memo checks on payment validation). */ +export type ValidateContext = ApplyContext & { + transaction: Transaction; +}; + /** * Validates and applies a single supported classic operation against {@link SimulationState}. */ export type OperationSimulator = { validate( - ctx: Context, + ctx: ValidateContext, op: Operation, allOperations?: readonly Operation[], ): void; - apply(ctx: Context, op: Operation): void; + apply(ctx: ApplyContext, op: Operation): void; }; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts index 7284210c..bcda010f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts @@ -2,7 +2,12 @@ import type { Operation } from '@stellar/stellar-sdk'; import { Asset } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; -import type { OperationSimulator, Context, AccountState } from './api'; +import type { + OperationSimulator, + ApplyContext, + ValidateContext, + AccountState, +} from './api'; import { getAccount, effectiveSource, @@ -32,6 +37,7 @@ import { TrustlineNotFoundException, UpdateTrustlineException, } from '../exceptions'; +import { assertMemoWhenDestinationRequires } from '../utils'; type ClassicAssetId = KnownCaip19ClassicAssetId | KnownCaip19Slip44Id; @@ -209,7 +215,7 @@ function applyCredit(params: { } export class PaymentOPSimulator implements OperationSimulator { - validate(ctx: Context, op: Operation.Payment): void { + validate(ctx: ValidateContext, op: Operation.Payment): void { const payment = op; const { opIndex } = ctx; const { assetId, payAmt, source, dest, sourceId, destId } = @@ -221,6 +227,12 @@ export class PaymentOPSimulator implements OperationSimulator { ); } + assertMemoWhenDestinationRequires( + ctx.transaction, + destId, + dest.requiresMemo, + ); + validateDebit({ account: source, accountId: sourceId, @@ -250,7 +262,7 @@ export class PaymentOPSimulator implements OperationSimulator { }); } - apply(ctx: Context, op: Operation.Payment): void { + apply(ctx: ApplyContext, op: Operation.Payment): void { const { assetId, payAmt, source, dest } = this.#getContextData(ctx, op); applyDebit({ account: source, assetId, amount: payAmt }); @@ -258,7 +270,7 @@ export class PaymentOPSimulator implements OperationSimulator { } #getContextData( - ctx: Context, + ctx: ApplyContext, op: Operation.Payment, ): { sourceId: string; @@ -291,7 +303,7 @@ export class PaymentOPSimulator implements OperationSimulator { } export class PathPaymentOPSimulator implements OperationSimulator { - validate(ctx: Context, op: PathPaymentOP): void { + validate(ctx: ValidateContext, op: PathPaymentOP): void { const { source, sourceId, sendAssetId, sendAmount } = this.#sourceData( ctx, op, @@ -301,6 +313,12 @@ export class PathPaymentOPSimulator implements OperationSimulator { op, ); + assertMemoWhenDestinationRequires( + ctx.transaction, + destId, + dest.requiresMemo, + ); + validateDebit({ account: source, accountId: sourceId, @@ -330,7 +348,7 @@ export class PathPaymentOPSimulator implements OperationSimulator { }); } - apply(ctx: Context, op: PathPaymentOP): void { + apply(ctx: ApplyContext, op: PathPaymentOP): void { const { source, sendAssetId, sendAmount } = this.#sourceData(ctx, op); const { dest, destAssetId, destAmount } = this.#destinationData(ctx, op); @@ -347,7 +365,7 @@ export class PathPaymentOPSimulator implements OperationSimulator { } #sourceData( - ctx: Context, + ctx: ApplyContext, op: PathPaymentOP, ): { source: AccountState; @@ -371,7 +389,7 @@ export class PathPaymentOPSimulator implements OperationSimulator { } #destinationData( - ctx: Context, + ctx: ApplyContext, op: PathPaymentOP, ): { dest: AccountState; @@ -396,7 +414,7 @@ export class PathPaymentOPSimulator implements OperationSimulator { } export class CreateAccountOPSimulator implements OperationSimulator { - validate(ctx: Context, op: Operation.CreateAccount): void { + validate(ctx: ValidateContext, op: Operation.CreateAccount): void { const { state, opIndex } = ctx; if (typeof op.destination !== 'string' || op.destination.length === 0) { throw new TransactionValidationException( @@ -430,7 +448,7 @@ export class CreateAccountOPSimulator implements OperationSimulator { } } - apply(ctx: Context, op: Operation.CreateAccount): void { + apply(ctx: ApplyContext, op: Operation.CreateAccount): void { const { state } = ctx; const { source, destId, startingBalance } = this.#getContextData(ctx, op); @@ -441,13 +459,14 @@ export class CreateAccountOPSimulator implements OperationSimulator { subentryCount: 0, numSponsoring: 0, numSponsored: 0, + requiresMemo: false, trustlines: new Map(), sep41Balances: new Map(), }); } #getContextData( - ctx: Context, + ctx: ApplyContext, op: Operation.CreateAccount, ): { source: AccountState; destId: string; startingBalance: BigNumber } { const { txSource, state } = ctx; @@ -460,7 +479,7 @@ export class CreateAccountOPSimulator implements OperationSimulator { } export class ChangeTrustOPSimulator implements OperationSimulator { - validate(ctx: Context, op: Operation.ChangeTrust): void { + validate(ctx: ValidateContext, op: Operation.ChangeTrust): void { const { opIndex } = ctx; if ( op.limit === undefined || @@ -515,7 +534,7 @@ export class ChangeTrustOPSimulator implements OperationSimulator { } } - apply(ctx: Context, op: Operation.ChangeTrust): void { + apply(ctx: ApplyContext, op: Operation.ChangeTrust): void { const { source, assetId, trustlineLimit } = this.#getContextData(ctx, op); const sourceTrustline = source.trustlines.get(assetId); @@ -555,7 +574,7 @@ export class ChangeTrustOPSimulator implements OperationSimulator { } #getContextData( - ctx: Context, + ctx: ApplyContext, op: Operation.ChangeTrust, ): { source: AccountState; @@ -590,7 +609,7 @@ export class ChangeTrustOPSimulator implements OperationSimulator { } export class InvokeHostFunctionOPSimulator implements OperationSimulator { - validate(ctx: Context, op: Operation.InvokeHostFunction): void { + validate(ctx: ValidateContext, op: Operation.InvokeHostFunction): void { const { txSource, state, scope } = ctx; const sourceId = effectiveSource(op, txSource); // Contract transaction should always be sourced from the user wallet account @@ -629,7 +648,7 @@ export class InvokeHostFunctionOPSimulator implements OperationSimulator { } } - apply(_ctx: Context, _op: Operation.InvokeHostFunction): void { + apply(_ctx: ApplyContext, _op: Operation.InvokeHostFunction): void { // InvokeHostFunction is a single operation transaction, // hence we don't need to apply any balance or trustline effects for Soroban invoke during simulation. } diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts index 38bcad15..acf2252b 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts @@ -3,6 +3,7 @@ import { Asset } from '@stellar/stellar-sdk'; import { InvalidInvokeContractStructureException, + RequiresMemoException, TransactionExpireException, TransactionScopeNotMatchException, TransactionValidationException, @@ -153,6 +154,28 @@ export function assertTransactionTimeBound(transaction: Transaction): void { } } +/** + * Throws when `destRequiresMemo` is true and the envelope has no memo (SEP-29). + * + * @param transaction - Wrapped Stellar transaction. + * @param destAccountId - Payment or path-payment destination. + * @param destRequiresMemo - From {@link OnChainAccount.requiresMemo} or simulation state. + * @throws {RequiresMemoException} When a memo is required but missing or blank. + */ +export function assertMemoWhenDestinationRequires( + transaction: Transaction, + destAccountId: string, + destRequiresMemo: boolean, +): void { + const memo = transaction.getMemo(); + // Whitespace-only memos count as missing under SEP-29. + // Hence, we dont consider them. + if (!destRequiresMemo || (memo !== null && /\S/u.test(memo))) { + return; + } + throw new RequiresMemoException(destAccountId); +} + /** * Maps an `OperationMapper` asset reference to its CAIP-19 id. * From 3e00ee95c2b9fd70dfb2758cd6be166bb37dbe50 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Mon, 1 Jun 2026 15:06:03 +0200 Subject: [PATCH 256/384] chore: address comment --- .../clientRequest/getAccountAssetInfo.test.ts | 148 ++++++++---------- .../clientRequest/getAccountAssetInfo.ts | 70 +++++++-- .../AccountAssetInfoService.ts | 17 +- 3 files changed, 138 insertions(+), 97 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts index 7d967b39..861460ca 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts @@ -9,8 +9,8 @@ import { KnownCaip2ChainId, } from '../../api'; import { AccountService } from '../../services/account'; +import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; import { AccountAssetInfoService } from '../../services/account-asset-info'; -import { GetAccountAssetInfoException } from '../../services/account-asset-info/exceptions'; import { createMockAssetMetadataService, generateMockKeyringAssetMetadata, @@ -26,15 +26,20 @@ import { type MockAccountWithBalancesData, } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; import { OnChainAccount } from '../../services/on-chain-account/OnChainAccount'; +import { WalletService } from '../../services/wallet'; +import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; import { getSlip44AssetId } from '../../utils'; import { logger } from '../../utils/logger'; +import { AccountResolver } from '../accountResolver'; jest.mock('../../utils/logger'); +jest.mock('../../ui/confirmation/views/AccountActivationPrompt/render', () => ({ + render: jest.fn().mockResolvedValue(undefined), +})); describe('GetAccountAssetInfoHandler', () => { const mockAccountId = '11111111-1111-4111-8111-111111111111'; const scope = KnownCaip2ChainId.Mainnet; - let handler: GetAccountAssetInfoHandler; const createTestOnChainAccount = ( address: string, @@ -48,11 +53,28 @@ describe('GetAccountAssetInfoHandler', () => { ); }; - beforeEach(() => { - jest.clearAllMocks(); + function setup() { + const wallet = getTestWallet(); + const account = generateStellarKeyringAccount( + mockAccountId, + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + 'entropy-source-1', + 0, + ); - const { accountService, onChainAccountService } = + const { accountService, onChainAccountService, walletService } = mockOnChainAccountService(); + jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ + account, + }); + jest + .spyOn(WalletService.prototype, 'resolveWallet') + .mockResolvedValue(wallet); + const resolveOnChainAccountByKeyringAccountIdSpy = jest.spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ); + const { service: assetMetadataService, getAssetsMetadataByAssetIdsSpy } = createMockAssetMetadataService(); const mockKeyringAssetMetadata = generateMockKeyringAssetMetadata(); @@ -74,19 +96,30 @@ describe('GetAccountAssetInfoHandler', () => { assetMetadataService, }); - handler = new GetAccountAssetInfoHandler({ + const accountResolver = new AccountResolver({ + accountService, + onChainAccountService, + walletService, + }); + + const handler = new GetAccountAssetInfoHandler({ logger, + accountResolver, accountAssetInfoService, }); + + return { + handler, + resolveOnChainAccountByKeyringAccountIdSpy, + }; + } + + afterEach(() => { + jest.restoreAllMocks(); }); it('returns metadata and trustline extra for a classic asset with limit', async () => { - jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ - account: { - id: mockAccountId, - address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', - }, - } as Awaited>); + const { handler, resolveOnChainAccountByKeyringAccountIdSpy } = setup(); const onChainAccount = createTestOnChainAccount( 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', ); @@ -98,12 +131,9 @@ describe('GetAccountAssetInfoHandler', () => { sponsored: false, decimals: 7, }); - jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockResolvedValue(onChainAccount); + resolveOnChainAccountByKeyringAccountIdSpy.mockResolvedValue( + onChainAccount, + ); const result = (await handler.handle({ jsonrpc: '2.0', @@ -127,12 +157,7 @@ describe('GetAccountAssetInfoHandler', () => { }); it('returns extra with zero limit for classic tombstone rows', async () => { - jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ - account: { - id: mockAccountId, - address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', - }, - } as Awaited>); + const { handler, resolveOnChainAccountByKeyringAccountIdSpy } = setup(); const onChainAccount = createTestOnChainAccount( 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', ); @@ -142,12 +167,9 @@ describe('GetAccountAssetInfoHandler', () => { limit: new BigNumber(0), decimals: 7, }); - jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockResolvedValue(onChainAccount); + resolveOnChainAccountByKeyringAccountIdSpy.mockResolvedValue( + onChainAccount, + ); const result = (await handler.handle({ jsonrpc: '2.0', @@ -164,21 +186,13 @@ describe('GetAccountAssetInfoHandler', () => { }); it('omits extra when classic asset has no on-chain row', async () => { - jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ - account: { - id: mockAccountId, - address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', - }, - } as Awaited>); + const { handler, resolveOnChainAccountByKeyringAccountIdSpy } = setup(); const onChainAccount = createTestOnChainAccount( 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', ); - jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockResolvedValue(onChainAccount); + resolveOnChainAccountByKeyringAccountIdSpy.mockResolvedValue( + onChainAccount, + ); const result = (await handler.handle({ jsonrpc: '2.0', @@ -196,18 +210,8 @@ describe('GetAccountAssetInfoHandler', () => { }); it('tolerates unactivated accounts with null on-chain state', async () => { - jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ - account: { - id: mockAccountId, - address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', - }, - } as Awaited>); - jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockResolvedValue(null); + const { handler, resolveOnChainAccountByKeyringAccountIdSpy } = setup(); + resolveOnChainAccountByKeyringAccountIdSpy.mockResolvedValue(null); const result = (await handler.handle({ jsonrpc: '2.0', @@ -226,12 +230,7 @@ describe('GetAccountAssetInfoHandler', () => { it('returns native slip44 metadata when on-chain account exists', async () => { const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); - jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ - account: { - id: mockAccountId, - address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', - }, - } as Awaited>); + const { handler, resolveOnChainAccountByKeyringAccountIdSpy } = setup(); const onChainAccount = createTestOnChainAccount( 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', { @@ -239,12 +238,9 @@ describe('GetAccountAssetInfoHandler', () => { nativeBalance: 1.000001, }, ); - jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockResolvedValue(onChainAccount); + resolveOnChainAccountByKeyringAccountIdSpy.mockResolvedValue( + onChainAccount, + ); const result = (await handler.handle({ jsonrpc: '2.0', @@ -260,19 +256,11 @@ describe('GetAccountAssetInfoHandler', () => { expect(result).toHaveProperty(slipId); }); - it('throws when asset info resolution fails', async () => { - jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ - account: { - id: mockAccountId, - address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', - }, - } as Awaited>); - jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockRejectedValue(new Error('Horizon unavailable')); + it('throws when on-chain account resolution fails', async () => { + const { handler, resolveOnChainAccountByKeyringAccountIdSpy } = setup(); + resolveOnChainAccountByKeyringAccountIdSpy.mockRejectedValue( + new Error('Horizon unavailable'), + ); await expect( handler.handle({ @@ -285,6 +273,6 @@ describe('GetAccountAssetInfoHandler', () => { assets: [USDC_CLASSIC], }, }), - ).rejects.toThrow(GetAccountAssetInfoException); + ).rejects.toThrow('Horizon unavailable'); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts index 35a7f5ee..51e26a71 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts @@ -8,50 +8,96 @@ import { GetAccountAssetInfoJsonRpcRequestStruct, GetAccountAssetInfoJsonRpcResponseStruct, } from './api'; -import type { IClientRequestHandler } from './base'; +import { BaseClientRequestHandler } from './base'; import type { AccountAssetInfoService } from '../../services/account-asset-info'; +import type { AccountNotActivatedException } from '../../services/network/exceptions'; import { createPrefixedLogger, type ILogger } from '../../utils/logger'; -import { BaseHandler } from '../base'; - -export class GetAccountAssetInfoHandler - extends BaseHandler< - GetAccountAssetInfoJsonRpcRequest, - GetAccountAssetInfoJsonRpcResponse - > - implements IClientRequestHandler -{ +import type { + AccountResolver, + ResolvedActivatedAccount, +} from '../accountResolver'; +import { RESOLVE_ACCOUNT_FULL_FROM_KEYRING_STATE } from '../accountResolver'; + +export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< + GetAccountAssetInfoJsonRpcRequest, + GetAccountAssetInfoJsonRpcResponse +> { readonly #accountAssetInfoService: AccountAssetInfoService; + #pendingRequest?: GetAccountAssetInfoJsonRpcRequest; + constructor({ logger, + accountResolver, accountAssetInfoService, }: { logger: ILogger; + accountResolver: AccountResolver; accountAssetInfoService: AccountAssetInfoService; }) { super({ logger: createPrefixedLogger(logger, '[📦 GetAccountAssetInfoHandler]'), + accountResolver, requestStruct: GetAccountAssetInfoJsonRpcRequestStruct, responseStruct: GetAccountAssetInfoJsonRpcResponseStruct, + resolveAccountOptions: RESOLVE_ACCOUNT_FULL_FROM_KEYRING_STATE, }); this.#accountAssetInfoService = accountAssetInfoService; } + protected override async handleRequest( + request: GetAccountAssetInfoJsonRpcRequest, + ): Promise { + this.#pendingRequest = request; + try { + return await super.handleRequest(request); + } finally { + this.#pendingRequest = undefined; + } + } + /** * Returns fungible metadata and optional trust-line fields for the requested assets. - * Tolerates unactivated accounts (no on-chain row) for portfolio-import UX. * + * @param resolved - Keyring account and persisted on-chain snapshot. * @param request - JSON-RPC request with accountId, scope, and assets. * @returns Per-asset metadata and optional trust-line extra fields. */ - protected async handleRequest( + protected async execute( + resolved: ResolvedActivatedAccount, request: GetAccountAssetInfoJsonRpcRequest, ): Promise { + const { scope, assets } = request.params; + return this.#accountAssetInfoService.getAccountAssetInfo({ + accountId: resolved.account.id, + scope, + assets, + onChainAccount: resolved.onChainAccount, + }); + } + + /** + * Returns fungible metadata without trust-line extras when the account is not activated. + * Tolerates unactivated accounts for portfolio-import UX instead of showing the activation prompt. + * + * @param _error - The account not activated error. + * @returns Per-asset metadata without on-chain trust-line fields. + */ + protected override async handleAccountNotActivatedError( + _error: AccountNotActivatedException, + ): Promise { + const request = this.#pendingRequest; + if (request === undefined) { + throw new Error( + 'Missing request context for unactivated account handling', + ); + } const { accountId, scope, assets } = request.params; return this.#accountAssetInfoService.getAccountAssetInfo({ accountId, scope, assets, + onChainAccount: null, }); } diff --git a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/AccountAssetInfoService.ts b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/AccountAssetInfoService.ts index 82a9f1da..7d410518 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/AccountAssetInfoService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/AccountAssetInfoService.ts @@ -34,6 +34,8 @@ export type GetAccountAssetInfoParams = { accountId: string; scope: KnownCaip2ChainId; assets: KnownCaip19AssetIdOrSlip44Id[]; + /** When set, skips on-chain resolution and uses this snapshot (including `null`). */ + onChainAccount?: OnChainAccount | null; }; export class AccountAssetInfoService { @@ -73,17 +75,22 @@ export class AccountAssetInfoService { async getAccountAssetInfo( params: GetAccountAssetInfoParams, ): Promise> { - const { accountId, scope, assets } = params; + const { + accountId, + scope, + assets, + onChainAccount: providedOnChainAccount, + } = params; const result = {} as Record< KnownCaip19AssetIdOrSlip44Id, AccountAssetInfoEntry >; try { - const onChainAccount = await this.#resolveOnChainAccount( - accountId, - scope, - ); + const onChainAccount = + providedOnChainAccount === undefined + ? await this.#resolveOnChainAccount(accountId, scope) + : providedOnChainAccount; const assetsMetadata = await this.#assetMetadataService.getAssetsMetadataByAssetIds(assets); From 0d30583d9ad7be7c22efb6022c1ae25dc452d84a Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Mon, 1 Jun 2026 15:09:18 +0200 Subject: [PATCH 257/384] chore: remove AccountAssetInfoService --- .../stellar-wallet-snap/src/context.ts | 11 +- .../clientRequest/getAccountAssetInfo.test.ts | 10 +- .../clientRequest/getAccountAssetInfo.ts | 147 ++++++++++++-- .../AccountAssetInfoService.ts | 187 ------------------ .../src/services/account-asset-info/api.ts | 6 + .../src/services/account-asset-info/index.ts | 10 +- 6 files changed, 137 insertions(+), 234 deletions(-) delete mode 100644 merged-packages/stellar-wallet-snap/src/services/account-asset-info/AccountAssetInfoService.ts diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 6d2b97bc..cec6979f 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -33,7 +33,6 @@ import { SignTransactionHandler, } from './handlers/keyring'; import { AccountService, AccountsRepository } from './services/account'; -import { AccountAssetInfoService } from './services/account-asset-info'; import { AssetMetadataRepository, AssetMetadataService, @@ -106,13 +105,6 @@ const onChainAccountService = new OnChainAccountService({ assetMetadataService, }); -const accountAssetInfoService = new AccountAssetInfoService({ - logger, - accountService, - onChainAccountService, - assetMetadataService, -}); - const transactionService = new TransactionService({ logger, transactionRepository, @@ -281,7 +273,8 @@ const computeFeeHandler = new ComputeFeeHandler({ const getAccountAssetInfoHandler = new GetAccountAssetInfoHandler({ logger, - accountAssetInfoService, + accountResolver, + assetMetadataService, }); const clientRequestMethodHandlers: Record< diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts index 861460ca..874dbeab 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts @@ -10,7 +10,6 @@ import { } from '../../api'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; -import { AccountAssetInfoService } from '../../services/account-asset-info'; import { createMockAssetMetadataService, generateMockKeyringAssetMetadata, @@ -89,13 +88,6 @@ describe('GetAccountAssetInfoHandler', () => { }, ); - const accountAssetInfoService = new AccountAssetInfoService({ - logger, - accountService, - onChainAccountService, - assetMetadataService, - }); - const accountResolver = new AccountResolver({ accountService, onChainAccountService, @@ -105,7 +97,7 @@ describe('GetAccountAssetInfoHandler', () => { const handler = new GetAccountAssetInfoHandler({ logger, accountResolver, - accountAssetInfoService, + assetMetadataService, }); return { diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts index 51e26a71..208a4cca 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts @@ -1,4 +1,6 @@ +import { FungibleAssetMetadataStruct } from '@metamask/snaps-sdk'; import type { Json, JsonRpcRequest } from '@metamask/utils'; +import { ensureError } from '@metamask/utils'; import type { GetAccountAssetInfoJsonRpcRequest, @@ -9,9 +11,21 @@ import { GetAccountAssetInfoJsonRpcResponseStruct, } from './api'; import { BaseClientRequestHandler } from './base'; -import type { AccountAssetInfoService } from '../../services/account-asset-info'; +import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; +import type { AccountAssetInfoEntry } from '../../services/account-asset-info'; +import type { AccountAssetInfoExtra } from '../../services/account-asset-info/api'; +import { GetAccountAssetInfoException } from '../../services/account-asset-info/exceptions'; +import type { AssetMetadataService } from '../../services/asset-metadata/AssetMetadataService'; import type { AccountNotActivatedException } from '../../services/network/exceptions'; -import { createPrefixedLogger, type ILogger } from '../../utils/logger'; +import type { OnChainAccount } from '../../services/on-chain-account'; +import type { SpendableBalance } from '../../services/on-chain-account/api'; +import { + createPrefixedLogger, + isClassicAssetId, + isSep41Id, + toDisplayBalance, + type ILogger, +} from '../../utils'; import type { AccountResolver, ResolvedActivatedAccount, @@ -22,27 +36,34 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< GetAccountAssetInfoJsonRpcRequest, GetAccountAssetInfoJsonRpcResponse > { - readonly #accountAssetInfoService: AccountAssetInfoService; + readonly #assetMetadataService: AssetMetadataService; + + readonly #logger: ILogger; #pendingRequest?: GetAccountAssetInfoJsonRpcRequest; constructor({ logger, accountResolver, - accountAssetInfoService, + assetMetadataService, }: { logger: ILogger; accountResolver: AccountResolver; - accountAssetInfoService: AccountAssetInfoService; + assetMetadataService: AssetMetadataService; }) { + const prefixedLogger = createPrefixedLogger( + logger, + '[📦 GetAccountAssetInfoHandler]', + ); super({ - logger: createPrefixedLogger(logger, '[📦 GetAccountAssetInfoHandler]'), + logger: prefixedLogger, accountResolver, requestStruct: GetAccountAssetInfoJsonRpcRequestStruct, responseStruct: GetAccountAssetInfoJsonRpcResponseStruct, resolveAccountOptions: RESOLVE_ACCOUNT_FULL_FROM_KEYRING_STATE, }); - this.#accountAssetInfoService = accountAssetInfoService; + this.#assetMetadataService = assetMetadataService; + this.#logger = prefixedLogger; } protected override async handleRequest( @@ -67,13 +88,12 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< resolved: ResolvedActivatedAccount, request: GetAccountAssetInfoJsonRpcRequest, ): Promise { - const { scope, assets } = request.params; - return this.#accountAssetInfoService.getAccountAssetInfo({ - accountId: resolved.account.id, - scope, + const { assets } = request.params; + return this.#buildAccountAssetInfoResponse( + resolved.account.id, assets, - onChainAccount: resolved.onChainAccount, - }); + resolved.onChainAccount, + ); } /** @@ -92,13 +112,8 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< 'Missing request context for unactivated account handling', ); } - const { accountId, scope, assets } = request.params; - return this.#accountAssetInfoService.getAccountAssetInfo({ - accountId, - scope, - assets, - onChainAccount: null, - }); + const { accountId, assets } = request.params; + return this.#buildAccountAssetInfoResponse(accountId, assets, null); } async handle( @@ -106,4 +121,96 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< ): Promise { return super.handle(request); } + + async #buildAccountAssetInfoResponse( + accountId: string, + assets: KnownCaip19AssetIdOrSlip44Id[], + onChainAccount: OnChainAccount | null, + ): Promise> { + const result = {} as Record< + KnownCaip19AssetIdOrSlip44Id, + AccountAssetInfoEntry + >; + + try { + const assetsMetadata = + await this.#assetMetadataService.getAssetsMetadataByAssetIds(assets); + + for (const assetId of assets) { + const assetMetadata = assetsMetadata[assetId]; + if ( + assetMetadata === undefined || + assetMetadata === null || + !FungibleAssetMetadataStruct.is(assetMetadata) || + assetMetadata.units[0]?.decimals === undefined + ) { + continue; + } + + const onChainRow = + onChainAccount === null + ? undefined + : onChainAccount.getAsset(assetId); + + if (isSep41Id(assetId) && !onChainRow?.balance.gt(0)) { + continue; + } + + const { decimals } = assetMetadata.units[0]; + const onChainRowForExtra = + onChainAccount === null || !isClassicAssetId(assetId) + ? onChainRow + : onChainAccount.getRawAsset(assetId); + const extra = buildAccountAssetInfoExtra( + assetId, + onChainRowForExtra, + decimals, + ); + + result[assetId] = { + metadata: assetMetadata, + ...(extra === undefined ? {} : { extra }), + }; + } + + return result; + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to get account asset info', + ensureError(error).message, + ); + throw new GetAccountAssetInfoException(accountId); + } + } +} + +/** + * Builds optional trust-line extra fields for classic Stellar assets. + * + * @param assetId - CAIP-19 asset id. + * @param onChainRow - On-chain balance row, if any. + * @param decimals - Asset display decimals. + * @returns Trust-line extra fields, or undefined when not applicable. + */ +function buildAccountAssetInfoExtra( + assetId: KnownCaip19AssetIdOrSlip44Id, + onChainRow: SpendableBalance | undefined, + decimals: number, +): AccountAssetInfoExtra | undefined { + if (!isClassicAssetId(assetId) || onChainRow === undefined) { + return undefined; + } + if (onChainRow.limit === undefined) { + return undefined; + } + + return { + limit: toDisplayBalance(onChainRow.limit, decimals), + ...(onChainRow.authorized === undefined + ? {} + : { authorized: onChainRow.authorized }), + ...(onChainRow.sponsored === undefined + ? {} + : { sponsored: onChainRow.sponsored }), + }; } diff --git a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/AccountAssetInfoService.ts b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/AccountAssetInfoService.ts deleted file mode 100644 index 7d410518..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/AccountAssetInfoService.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { - FungibleAssetMetadataStruct, - type FungibleAssetMetadata, -} from '@metamask/snaps-sdk'; -import { ensureError } from '@metamask/utils'; - -import type { AccountAssetInfoExtra } from './api'; -import { GetAccountAssetInfoException } from './exceptions'; -import type { - KnownCaip19AssetIdOrSlip44Id, - KnownCaip2ChainId, -} from '../../api'; -import type { ILogger } from '../../utils'; -import { - createPrefixedLogger, - isClassicAssetId, - isSep41Id, - toDisplayBalance, -} from '../../utils'; -import type { AccountService } from '../account'; -import type { AssetMetadataService } from '../asset-metadata/AssetMetadataService'; -import type { - OnChainAccount, - OnChainAccountService, -} from '../on-chain-account'; -import type { SpendableBalance } from '../on-chain-account/api'; - -export type AccountAssetInfoEntry = { - metadata: FungibleAssetMetadata; - extra?: AccountAssetInfoExtra; -}; - -export type GetAccountAssetInfoParams = { - accountId: string; - scope: KnownCaip2ChainId; - assets: KnownCaip19AssetIdOrSlip44Id[]; - /** When set, skips on-chain resolution and uses this snapshot (including `null`). */ - onChainAccount?: OnChainAccount | null; -}; - -export class AccountAssetInfoService { - readonly #logger: ILogger; - - readonly #accountService: AccountService; - - readonly #onChainAccountService: OnChainAccountService; - - readonly #assetMetadataService: AssetMetadataService; - - constructor({ - logger, - accountService, - onChainAccountService, - assetMetadataService, - }: { - logger: ILogger; - accountService: AccountService; - onChainAccountService: OnChainAccountService; - assetMetadataService: AssetMetadataService; - }) { - this.#logger = createPrefixedLogger(logger, '[📦 AccountAssetInfoService]'); - this.#accountService = accountService; - this.#onChainAccountService = onChainAccountService; - this.#assetMetadataService = assetMetadataService; - } - - /** - * Returns fungible metadata and optional trust-line fields for the requested assets. - * Classic Stellar assets include `extra.limit` when an on-chain row exists; omit `extra` - * when the asset is not on the account (e.g. portfolio import pending trust line). - * - * @param params - Account id, scope, and CAIP-19 asset ids to resolve. - * @returns Per-asset metadata and optional extra fields. - */ - async getAccountAssetInfo( - params: GetAccountAssetInfoParams, - ): Promise> { - const { - accountId, - scope, - assets, - onChainAccount: providedOnChainAccount, - } = params; - const result = {} as Record< - KnownCaip19AssetIdOrSlip44Id, - AccountAssetInfoEntry - >; - - try { - const onChainAccount = - providedOnChainAccount === undefined - ? await this.#resolveOnChainAccount(accountId, scope) - : providedOnChainAccount; - - const assetsMetadata = - await this.#assetMetadataService.getAssetsMetadataByAssetIds(assets); - - for (const assetId of assets) { - const assetMetadata = assetsMetadata[assetId]; - if ( - assetMetadata === undefined || - assetMetadata === null || - !FungibleAssetMetadataStruct.is(assetMetadata) || - assetMetadata.units[0]?.decimals === undefined - ) { - continue; - } - - const onChainRow = - onChainAccount === null - ? undefined - : onChainAccount.getAsset(assetId); - - if (isSep41Id(assetId) && !onChainRow?.balance.gt(0)) { - continue; - } - - const { decimals } = assetMetadata.units[0]; - const onChainRowForExtra = - onChainAccount === null || !isClassicAssetId(assetId) - ? onChainRow - : onChainAccount.getRawAsset(assetId); - const extra = buildAccountAssetInfoExtra( - assetId, - onChainRowForExtra, - decimals, - ); - - result[assetId] = { - metadata: assetMetadata, - ...(extra === undefined ? {} : { extra }), - }; - } - - return result; - } catch (error: unknown) { - this.#logger.logErrorWithDetails( - 'Failed to get account asset info', - ensureError(error).message, - ); - throw new GetAccountAssetInfoException(accountId); - } - } - - async #resolveOnChainAccount( - accountId: string, - scope: KnownCaip2ChainId, - ): Promise { - await this.#accountService.resolveAccount({ accountId }); - - return this.#onChainAccountService.resolveOnChainAccountByKeyringAccountId( - accountId, - scope, - ); - } -} - -/** - * Builds optional trust-line extra fields for classic Stellar assets. - * - * @param assetId - CAIP-19 asset id. - * @param onChainRow - On-chain balance row, if any. - * @param decimals - Asset display decimals. - * @returns Trust-line extra fields, or undefined when not applicable. - */ -export function buildAccountAssetInfoExtra( - assetId: KnownCaip19AssetIdOrSlip44Id, - onChainRow: SpendableBalance | undefined, - decimals: number, -): AccountAssetInfoExtra | undefined { - if (!isClassicAssetId(assetId) || onChainRow === undefined) { - return undefined; - } - if (onChainRow.limit === undefined) { - return undefined; - } - - return { - limit: toDisplayBalance(onChainRow.limit, decimals), - ...(onChainRow.authorized === undefined - ? {} - : { authorized: onChainRow.authorized }), - ...(onChainRow.sponsored === undefined - ? {} - : { sponsored: onChainRow.sponsored }), - }; -} diff --git a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/api.ts b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/api.ts index 3a629860..1b210fb0 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/api.ts @@ -1,3 +1,4 @@ +import type { FungibleAssetMetadata } from '@metamask/snaps-sdk'; import type { Infer } from '@metamask/superstruct'; import { boolean, object, optional, string, type } from '@metamask/superstruct'; @@ -12,6 +13,11 @@ export const AccountAssetInfoExtraStruct = object({ export type AccountAssetInfoExtra = Infer; +export type AccountAssetInfoEntry = { + metadata: FungibleAssetMetadata; + extra?: AccountAssetInfoExtra; +}; + export const AccountAssetInfoEntryStruct = object({ metadata: type({}), extra: optional(AccountAssetInfoExtraStruct), diff --git a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/index.ts b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/index.ts index 584a3a26..058c6243 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/index.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/index.ts @@ -1,14 +1,6 @@ -export { - AccountAssetInfoService, - buildAccountAssetInfoExtra, -} from './AccountAssetInfoService'; -export type { - AccountAssetInfoEntry, - GetAccountAssetInfoParams, -} from './AccountAssetInfoService'; export { AccountAssetInfoExtraStruct, AccountAssetInfoEntryStruct, } from './api'; -export type { AccountAssetInfoExtra } from './api'; +export type { AccountAssetInfoEntry, AccountAssetInfoExtra } from './api'; export { GetAccountAssetInfoException } from './exceptions'; From e9b1edff1487864f4188815d0004dbb90d43bdb9 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Mon, 1 Jun 2026 15:22:56 +0200 Subject: [PATCH 258/384] chore: revert track transaction change --- .../clientRequest/changeTrustOpt.test.ts | 8 -- .../handlers/clientRequest/changeTrustOpt.ts | 4 - .../src/handlers/cronjob/api.ts | 13 -- .../handlers/cronjob/trackTransaction.test.ts | 87 +----------- .../src/handlers/cronjob/trackTransaction.ts | 128 +----------------- .../trackTransactionHorizonTrustline.test.ts | 106 --------------- .../trackTransactionHorizonTrustline.ts | 50 ------- 7 files changed, 9 insertions(+), 387 deletions(-) delete mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.test.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.ts diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts index 906cd7c4..6f6b9840 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts @@ -289,10 +289,6 @@ describe('ChangeTrustOptHandler', () => { txId: '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0e1', scope, accountIds: [account.id], - trustlineVerification: { - assetId, - action: ChangeTrustOptAction.Add, - }, }); }); @@ -400,10 +396,6 @@ describe('ChangeTrustOptHandler', () => { txId: '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0e1', scope, accountIds: [account.id], - trustlineVerification: { - assetId, - action: ChangeTrustOptAction.Delete, - }, }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts index 3261b5b3..0b18636a 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts @@ -183,10 +183,6 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< txId: transactionId, scope, accountIds: [account.id], - trustlineVerification: { - assetId, - action, - }, }); return { diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts index e26f4997..0d30bd51 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts @@ -18,7 +18,6 @@ import type { Json, JsonRpcRequest } from '@metamask/utils'; import { JsonRpcRequestStruct, - KnownCaip19ClassicAssetStruct, KnownCaip2ChainIdStruct, UuidStruct, } from '../../api'; @@ -50,13 +49,6 @@ export const RefreshConfirmationContextParamsStruct = type({ refresherKeys: nonempty(array(ConfirmationContextRefresherKeyStruct)), }); -export const TrackTransactionTrustlineActionStruct = enums(['add', 'delete']); - -export const TrackTransactionTrustlineVerificationStruct = object({ - assetId: KnownCaip19ClassicAssetStruct, - action: TrackTransactionTrustlineActionStruct, -}); - export const RefreshConfirmationContextJsonRpcRequestStruct = assign( JsonRpcRequestStruct, object({ @@ -71,11 +63,6 @@ export const TrackTransactionParamsStruct = type({ accountIds: nonempty(array(UuidStruct)), /** Reschedule counter; omitted on first schedule (treated as 0). */ attempt: optional(size(integer(), 0, 30)), - /** - * When set, {@link TrackTransactionHandler} syncs until a fresh Horizon load matches this - * trustline outcome before marking the keyring transaction Confirmed. - */ - trustlineVerification: optional(TrackTransactionTrustlineVerificationStruct), }); export const SyncAccountParamsStruct = object({ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts index cd7ed9fe..b7de843e 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts @@ -3,35 +3,22 @@ import { TransactionType, type Transaction as KeyringTransaction, } from '@metamask/keyring-api'; -import { Account as StellarAccount } from '@stellar/stellar-sdk'; -import { BigNumber } from 'bignumber.js'; import { BackgroundEventMethod } from './api'; import { TrackTransactionHandler } from './trackTransaction'; -import { KnownCaip2ChainId, type KnownCaip19ClassicAssetId } from '../../api'; +import { KnownCaip2ChainId } from '../../api'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; -import { USDC_CLASSIC } from '../../services/asset-metadata/__mocks__/assets.fixtures'; import { InMemoryCache } from '../../services/cache'; import { NetworkService } from '../../services/network'; import { TransactionPollException } from '../../services/network/exceptions'; -import { - OnChainAccount, - OnChainAccountService, -} from '../../services/on-chain-account'; +import { OnChainAccountService } from '../../services/on-chain-account'; import { TransactionService } from '../../services/transaction'; import { createMockTransactionService } from '../../services/transaction/__mocks__/transaction.fixtures'; import { logger, noOpLogger } from '../../utils/logger'; import { scheduleBackgroundEvent } from '../../utils/snap'; jest.mock('../../utils/logger'); -jest.mock('./trackTransactionHorizonTrustline', () => { - const actual = jest.requireActual('./trackTransactionHorizonTrustline'); - return { - ...actual, - delayMilliseconds: jest.fn().mockResolvedValue(undefined), - }; -}); jest.mock('../../utils/snap', () => { const actual = jest.requireActual('../../utils/snap'); return { @@ -47,7 +34,6 @@ describe('TrackTransactionHandler', () => { const txId = 'abc123'; const scope = KnownCaip2ChainId.Testnet; const accountId = '22222222-2222-4222-8222-222222222222'; - const classicAssetId = USDC_CLASSIC as KnownCaip19ClassicAssetId; beforeEach(() => { jest.mocked(scheduleBackgroundEvent).mockClear(); @@ -101,11 +87,6 @@ describe('TrackTransactionHandler', () => { .spyOn(OnChainAccountService.prototype, 'synchronize') .mockResolvedValue(undefined); - const resolveOnChainAccount = jest.spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccount', - ); - const updateKeyringTransactionStatus = jest .spyOn(TransactionService.prototype, 'updateKeyringTransactionStatus') .mockResolvedValue(undefined); @@ -141,74 +122,10 @@ describe('TrackTransactionHandler', () => { pollTransaction, findKeyringTransactionByTransactionId, synchronize, - resolveOnChainAccount, updateKeyringTransactionStatus, }; } - it('settles confirmed change-trust after Horizon trustline matches expectation', async () => { - const { - handler, - account, - pollTransaction, - synchronize, - resolveOnChainAccount, - updateKeyringTransactionStatus, - findKeyringTransactionByTransactionId, - } = setup(); - findKeyringTransactionByTransactionId.mockResolvedValue( - createPersistedKeyringTransaction(), - ); - pollTransaction.mockResolvedValue(txId); - - const stellarAccount = new StellarAccount(account.address, '1'); - const staleHorizonAccount = new OnChainAccount(stellarAccount, scope); - staleHorizonAccount.setAsset(classicAssetId, { - balance: new BigNumber(0), - symbol: 'USDC', - limit: new BigNumber('9223372036854775807'), - address: account.address, - authorized: true, - }); - const updatedHorizonAccount = new OnChainAccount(stellarAccount, scope); - - resolveOnChainAccount - .mockResolvedValueOnce(staleHorizonAccount) - .mockResolvedValue(updatedHorizonAccount); - - const callOrder: string[] = []; - synchronize.mockImplementation(async () => { - callOrder.push('sync'); - }); - updateKeyringTransactionStatus.mockImplementation(async () => { - callOrder.push('settle'); - }); - - await handler.handle({ - jsonrpc: '2.0', - id: 1, - method: BackgroundEventMethod.TrackTransaction, - params: { - txId, - scope, - accountIds: [accountId], - trustlineVerification: { - assetId: classicAssetId, - action: 'delete', - }, - }, - }); - - expect(synchronize).toHaveBeenCalledTimes(2); - expect(resolveOnChainAccount).toHaveBeenCalledTimes(2); - expect(callOrder).toStrictEqual(['sync', 'sync', 'settle']); - expect(updateKeyringTransactionStatus).toHaveBeenCalledWith({ - txId, - accountIds: [accountId], - status: TransactionStatus.Confirmed, - }); - }); - it('loads persisted keyring transaction from state before Soroban poll', async () => { const { handler, pollTransaction, findKeyringTransactionByTransactionId } = setup(); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts index 0b0e1da1..3fbccb19 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts @@ -12,12 +12,6 @@ import { TrackTransactionJsonRpcRequestStruct, } from './api'; import { CronjobBaseHandler } from './base'; -import type { TrackTransactionTrustlineVerification } from './trackTransactionHorizonTrustline'; -import { - delayMilliseconds, - isHorizonTrustlineMatchingExpectation, - TrackTransactionTrustlineAction, -} from './trackTransactionHorizonTrustline'; import type { KnownCaip2ChainId } from '../../api'; import { KEYRING_ACCOUNT_TYPE, METAMASK_ORIGIN } from '../../constants'; import type { @@ -36,19 +30,10 @@ import { trackTransactionFinalized, } from '../../utils/snap'; -/** Horizon trustline polls after RPC success (Soroban can lead Horizon indexing). */ -const HORIZON_TRUSTLINE_VERIFY_MAX_ATTEMPTS = 6; - -/** Delay between Horizon verification sync attempts. */ -const HORIZON_TRUSTLINE_VERIFY_DELAY_MS = 2000; - /** * Polls Soroban RPC for transaction settlement first, then updates keyring status and runs * {@link OnChainAccountService.synchronize}. The persisted keyring transaction in snap state * (by hash) is the source of truth for which account to sync. - * - * Change-trust jobs may pass `trustlineVerification`; those sync until a fresh Horizon load - * matches the expected trustline before marking the keyring row Confirmed. */ export class TrackTransactionHandler extends CronjobBaseHandler { static async scheduleBackgroundEvent( @@ -163,44 +148,18 @@ export class TrackTransactionHandler extends CronjobBaseHandler 0) { - if ( - keyringStatus === TransactionStatus.Confirmed && - trustlineVerification - ) { - await this.#synchronizeUntilHorizonTrustlineMatches({ - accounts: accountsToSync, - scope, - verification: { - assetId: trustlineVerification.assetId, - action: - trustlineVerification.action === 'add' - ? TrackTransactionTrustlineAction.Add - : TrackTransactionTrustlineAction.Delete, - }, - }); - } else if (keyringStatus === TransactionStatus.Confirmed) { - await this.#synchronizeAccounts(accountsToSync, scope); - } - } - if (keyringStatus) { await this.#settleKeyringRow(txId, accountIds, keyringStatus); } - if ( - accountsToSync.length > 0 && - keyringStatus !== TransactionStatus.Confirmed - ) { + // TODO: Consider skipping synchronize when the keyring row stayed + // pending (no terminal poll result) to avoid redundant on-chain refreshes. + const accountsToSync = await this.#resolveAccountsForSynchronize({ + persistedKeyringTransaction, + }); + if (accountsToSync.length > 0) { await this.#synchronizeAccounts(accountsToSync, scope); - } - - if (accountsToSync.length === 0) { + } else { this.logger.warn( 'TrackTransaction: account not found when tracking the transaction, unable to sync', { @@ -245,79 +204,6 @@ export class TrackTransactionHandler extends CronjobBaseHandler { - const { accounts, scope, verification } = params; - const account = accounts[0]; - if (!account) { - return; - } - - for ( - let attempt = 0; - attempt < HORIZON_TRUSTLINE_VERIFY_MAX_ATTEMPTS; - attempt += 1 - ) { - await this.#synchronizeAccounts(accounts, scope); - - const horizonAccount = - await this.#onChainAccountService.resolveOnChainAccount( - account.address, - scope, - ); - - if ( - isHorizonTrustlineMatchingExpectation( - horizonAccount, - verification.assetId, - verification.action, - ) - ) { - this.logger.info( - 'TrackTransaction: Horizon trustline matches expectation', - { - attempt, - assetId: verification.assetId, - action: verification.action, - }, - ); - return; - } - - if (attempt < HORIZON_TRUSTLINE_VERIFY_MAX_ATTEMPTS - 1) { - this.logger.warn( - 'TrackTransaction: Horizon trustline not yet consistent; retrying', - { - attempt, - assetId: verification.assetId, - action: verification.action, - }, - ); - await delayMilliseconds(HORIZON_TRUSTLINE_VERIFY_DELAY_MS); - } - } - - this.logger.warn( - 'TrackTransaction: Horizon trustline verification exhausted attempts', - { - assetId: verification.assetId, - action: verification.action, - maxAttempts: HORIZON_TRUSTLINE_VERIFY_MAX_ATTEMPTS, - }, - ); - } - async #settleKeyringRow( txId: string, accountIds: readonly string[], diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.test.ts deleted file mode 100644 index 41b25f14..00000000 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Account as StellarAccount } from '@stellar/stellar-sdk'; -import { BigNumber } from 'bignumber.js'; - -import { - isHorizonTrustlineMatchingExpectation, - TrackTransactionTrustlineAction, -} from './trackTransactionHorizonTrustline'; -import { KnownCaip2ChainId } from '../../api'; -import type { KnownCaip19ClassicAssetId } from '../../api'; -import { OnChainAccount } from '../../services/on-chain-account'; - -const CLASSIC_ASSET_ID = - 'stellar:testnet/asset:GTN-GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF' as KnownCaip19ClassicAssetId; - -function createHorizonAccountWithTrustline(limit: string): OnChainAccount { - const stellarAccount = new StellarAccount( - 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', - '1', - ); - const onChainAccount = new OnChainAccount( - stellarAccount, - KnownCaip2ChainId.Testnet, - ); - onChainAccount.setAsset(CLASSIC_ASSET_ID, { - balance: new BigNumber(0), - symbol: 'GTN', - limit: new BigNumber(limit), - address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', - authorized: true, - }); - return onChainAccount; -} - -describe('isHorizonTrustlineMatchingExpectation', () => { - it('returns true for delete when the trustline is absent on Horizon', () => { - const account = new OnChainAccount( - new StellarAccount( - 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', - '1', - ), - KnownCaip2ChainId.Testnet, - ); - - expect( - isHorizonTrustlineMatchingExpectation( - account, - CLASSIC_ASSET_ID, - TrackTransactionTrustlineAction.Delete, - ), - ).toBe(true); - }); - - it('returns true for delete when the trustline limit is zero', () => { - const account = createHorizonAccountWithTrustline('0'); - - expect( - isHorizonTrustlineMatchingExpectation( - account, - CLASSIC_ASSET_ID, - TrackTransactionTrustlineAction.Delete, - ), - ).toBe(true); - }); - - it('returns false for delete when the trustline limit is greater than zero', () => { - const account = createHorizonAccountWithTrustline('9223372036854775807'); - - expect( - isHorizonTrustlineMatchingExpectation( - account, - CLASSIC_ASSET_ID, - TrackTransactionTrustlineAction.Delete, - ), - ).toBe(false); - }); - - it('returns true for add when the trustline limit is greater than zero', () => { - const account = createHorizonAccountWithTrustline('100'); - - expect( - isHorizonTrustlineMatchingExpectation( - account, - CLASSIC_ASSET_ID, - TrackTransactionTrustlineAction.Add, - ), - ).toBe(true); - }); - - it('returns false for add when the trustline is absent', () => { - const account = new OnChainAccount( - new StellarAccount( - 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', - '1', - ), - KnownCaip2ChainId.Testnet, - ); - - expect( - isHorizonTrustlineMatchingExpectation( - account, - CLASSIC_ASSET_ID, - TrackTransactionTrustlineAction.Add, - ), - ).toBe(false); - }); -}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.ts deleted file mode 100644 index e64a6e7e..00000000 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransactionHorizonTrustline.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { KnownCaip19ClassicAssetId } from '../../api'; -import type { OnChainAccount } from '../../services/on-chain-account'; - -/** - * Expected trustline outcome after a {@link ClientRequestMethod.ChangeTrustOpt} transaction. - */ -export enum TrackTransactionTrustlineAction { - Add = 'add', - Delete = 'delete', -} - -export type TrackTransactionTrustlineVerification = { - assetId: KnownCaip19ClassicAssetId; - action: TrackTransactionTrustlineAction; -}; - -/** - * Returns whether a fresh Horizon account load reflects the expected trustline change. - * - * @param onChainAccount - Account loaded from Horizon (not persisted snap snapshot). - * @param assetId - Classic CAIP-19 asset id for the trustline. - * @param action - Opt-in expects limit greater than 0; opt-out expects line absent or limit 0. - * @returns `true` when Horizon matches the expected post-tx trustline state. - */ -export function isHorizonTrustlineMatchingExpectation( - onChainAccount: OnChainAccount, - assetId: KnownCaip19ClassicAssetId, - action: TrackTransactionTrustlineAction, -): boolean { - const row = onChainAccount.getRawAsset(assetId); - - if (action === TrackTransactionTrustlineAction.Delete) { - if (row === undefined) { - return true; - } - return row.limit?.isZero() ?? false; - } - - return row?.limit?.gt(0) ?? false; -} - -/** - * @param ms - Milliseconds to wait. - * @returns A promise that resolves after `ms`. - */ -export async function delayMilliseconds(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} From c73a99b26317c13398712d718514ad65fee007fe Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Mon, 1 Jun 2026 16:18:55 +0200 Subject: [PATCH 259/384] feat: enable security scan for send confirmations --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../clientRequest/confirmSend.test.ts | 8 ++++++ .../src/handlers/clientRequest/confirmSend.ts | 14 ++++++++-- .../ConfirmSendTransaction.tsx | 26 ++++++++++++++++--- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index b43891b2..b67fb105 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "Pgeb7G9usrwUVpMdMU0LaszaxXGICctagd9YBNAexY0=", + "shasum": "/+8k8twVbD7js7193EMZ649KJ7LKbUlmVYpZvVEBmog=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts index cf4a9eed..b0af23a9 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts @@ -266,6 +266,9 @@ describe('ConfirmSendHandler', () => { scheduleBackgroundEvent, } = setup(); + // Capture XDR before signing mutates the transaction. + const unsignedScanXdr = transaction.getRaw().toXDR(); + const result = await handler.handle(baseRequest()); expect(result).toStrictEqual({ @@ -293,6 +296,11 @@ describe('ConfirmSendHandler', () => { }, renderOptions: { loadPrice: true, + scanTxn: true, + }, + securityScanRequest: { + accountAddress: account.address, + transaction: unsignedScanXdr, }, tokenPrices: { [assetId]: null, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts index 8d9db190..3c44553a 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts @@ -27,7 +27,10 @@ import { TransactionValidationException, KeyringTransactionType, } from '../../services/transaction'; -import type { TransactionService } from '../../services/transaction'; +import type { + Transaction, + TransactionService, +} from '../../services/transaction'; import type { ContextWithPrices } from '../../ui/confirmation/api'; import { ConfirmationInterfaceKey } from '../../ui/confirmation/api'; import { @@ -152,6 +155,7 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< assetMetadata, scope, fee: transaction.totalFee, + transaction, })) ) { await trackTransactionRejected({ @@ -240,8 +244,9 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< assetMetadata: StellarAssetMetadata; scope: KnownCaip2ChainId; fee: BigNumber; + transaction: Transaction; }): Promise { - const { request, account, assetMetadata, fee, scope } = params; + const { request, account, assetMetadata, fee, scope, transaction } = params; const { toAddress, amount, assetId } = request.params; return ( @@ -258,6 +263,11 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, renderOptions: { loadPrice: true, + scanTxn: true, + }, + securityScanRequest: { + accountAddress: account.address, + transaction: transaction.getRaw().toXDR(), }, tokenPrices: { [assetId]: null, diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx index 78d27e36..33cf5bdf 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx @@ -27,13 +27,15 @@ import type { FeeData, } from '../../api'; import { FetchStatus } from '../../api'; -import { Asset, FeeRow } from '../../components'; +import { Asset, FeeRow, TransactionAlert } from '../../components'; import { getAccountExplorerUrl, getAccountName, getClassicAssetExplorerUrl, getNetworkName, getSepAssetExplorerUrl, + hasEnabledTransactionScan, + isConfirmDisabledByScan, } from '../../utils'; export type ConfirmSendTransactionProps = ConfirmationBaseProps & @@ -59,10 +61,17 @@ export const ConfirmSendTransaction = ({ origin, preferences, tokenPricesFetchStatus = FetchStatus.Initial, + scan, + scanFetchStatus = FetchStatus.Initial, }: ConfirmSendTransactionProps): ComponentOrElement => { const t = i18n(locale); const { address } = account; const { assetId, symbol } = assetMetadata; + const shouldDisableConfirmButton = isConfirmDisabledByScan({ + preferences, + scan, + scanFetchStatus, + }); const parsedAsset = parseCaipAssetType(assetId); let assetLink: string | undefined; if (!isSlip44Id(assetId)) { @@ -77,14 +86,20 @@ export const ConfirmSendTransaction = ({ return ( + {hasEnabledTransactionScan(preferences) ? ( + + ) : null} {null} {t(`confirmation.transaction.title`)} {null} - {/* TODO: add security alert / transaction simulation result */} -
{origin ? ( @@ -173,7 +188,10 @@ export const ConfirmSendTransaction = ({ - From 03326dd4672d44a728c97d20d1232b494a2437eb Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Mon, 1 Jun 2026 19:03:47 +0200 Subject: [PATCH 260/384] feat: add transaction validation refresh to confirmation flows --- .../stellar-wallet-snap/locales/es.json | 6 + .../stellar-wallet-snap/snap.manifest.json | 2 +- .../stellar-wallet-snap/src/context.ts | 15 +- .../clientRequest/changeTrustOpt.test.ts | 16 ++ .../handlers/clientRequest/changeTrustOpt.ts | 11 +- .../clientRequest/confirmSend.test.ts | 8 + .../src/handlers/clientRequest/confirmSend.ts | 6 + .../cronjob/refreshConfirmationContext/api.ts | 1 + .../refreshConfirmationContext/index.ts | 1 + .../transactionRefresher.test.ts | 255 ++++++++++++++++++ .../transactionRefresher.ts | 190 +++++++++++++ .../src/ui/confirmation/api.ts | 28 ++ .../components/TransactionValidationAlert.tsx | 36 +++ .../src/ui/confirmation/components/index.ts | 1 + .../src/ui/confirmation/controller.tsx | 44 +++ .../src/ui/confirmation/utils.ts | 13 + .../ConfirmSendTransaction.tsx | 24 +- .../ConfirmSignChangeTrustOptIn.tsx | 25 +- .../ConfirmSignChangeTrustOptOut.tsx | 25 +- 19 files changed, 684 insertions(+), 23 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionValidationAlert.tsx diff --git a/merged-packages/stellar-wallet-snap/locales/es.json b/merged-packages/stellar-wallet-snap/locales/es.json index febcfb36..d8fabbbc 100644 --- a/merged-packages/stellar-wallet-snap/locales/es.json +++ b/merged-packages/stellar-wallet-snap/locales/es.json @@ -130,6 +130,12 @@ "confirmation.simulationErrorSubtitle": { "message": "{reason}" }, + "confirmation.transactionInvalidTitle": { + "message": "Transaction is no longer valid" + }, + "confirmation.transactionInvalidSubtitle": { + "message": "It may have expired or your account balance changed. Close this request and try again." + }, "confirmation.validationScanErrorTitle": { "message": "Security validation failed" }, diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index b67fb105..4c8262b9 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "/+8k8twVbD7js7193EMZ649KJ7LKbUlmVYpZvVEBmog=", + "shasum": "wkTOqfbQT4cVfywVvPWaHQckM/bx9gyYqVk9BazieX4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index 173cee16..5e71abad 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -20,6 +20,7 @@ import { BackgroundEventMethod } from './handlers/cronjob/api'; import { ConfirmationPriceRefresher, ConfirmationScanRefresher, + ConfirmationTransactionRefresher, RefreshConfirmationContextHandler, } from './handlers/cronjob/refreshConfirmationContext'; import { SyncAccountsHandler } from './handlers/cronjob/syncAccounts'; @@ -187,11 +188,23 @@ const confirmationScanRefresher = new ConfirmationScanRefresher({ transactionScanService, }); +const confirmationTransactionRefresher = new ConfirmationTransactionRefresher({ + logger, + transactionService, + transactionBuilder, + assetMetadataService, + accountResolver, +}); + const refreshConfirmationContextHandler = new RefreshConfirmationContextHandler( { logger, confirmationUIController, - refreshers: [confirmationPriceRefresher, confirmationScanRefresher], + refreshers: [ + confirmationPriceRefresher, + confirmationScanRefresher, + confirmationTransactionRefresher, + ], }, ); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts index 6f6b9840..7f293459 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts @@ -256,11 +256,19 @@ describe('ChangeTrustOptHandler', () => { renderOptions: { loadPrice: true, scanTxn: true, + validateTxn: true, }, securityScanRequest: { accountAddress: account.address, transaction: expect.any(String), }, + transactionValidationRequest: { + accountId: account.id, + transaction: expect.any(String), + request: expect.objectContaining({ + method: ClientRequestMethod.ChangeTrustOpt, + }), + }, }), ); const signedTransaction = signTransactionSpy.mock.calls[0]?.[0]; @@ -369,11 +377,19 @@ describe('ChangeTrustOptHandler', () => { renderOptions: { loadPrice: true, scanTxn: true, + validateTxn: true, }, securityScanRequest: { accountAddress: account.address, transaction: expect.any(String), }, + transactionValidationRequest: { + accountId: account.id, + transaction: expect.any(String), + request: expect.objectContaining({ + method: ClientRequestMethod.ChangeTrustOpt, + }), + }, }), ); expect(sendTransaction).toHaveBeenCalled(); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts index 0b18636a..3b2053f4 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts @@ -276,15 +276,14 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< | ConfirmationInterfaceKey.ChangeTrustlineOptOut; }): Promise { const { - request: { - params: { scope }, - }, + request, account, assetMetadata, fee, transaction, confirmationInterfaceKey, } = params; + const { scope } = request.params; return ( (await this.#confirmationUIController.renderConfirmationDialog({ @@ -299,11 +298,17 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< renderOptions: { loadPrice: true, scanTxn: true, + validateTxn: true, }, securityScanRequest: { accountAddress: account.address, transaction: transaction.getRaw().toXDR(), }, + transactionValidationRequest: { + accountId: account.id, + transaction: transaction.getRaw().toXDR(), + request, + }, })) === true ); } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts index b0af23a9..1ce48d9c 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts @@ -297,11 +297,19 @@ describe('ConfirmSendHandler', () => { renderOptions: { loadPrice: true, scanTxn: true, + validateTxn: true, }, securityScanRequest: { accountAddress: account.address, transaction: unsignedScanXdr, }, + transactionValidationRequest: { + accountId: account.id, + transaction: unsignedScanXdr, + request: expect.objectContaining({ + method: ClientRequestMethod.ConfirmSend, + }), + }, tokenPrices: { [assetId]: null, }, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts index 3c44553a..76280f70 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts @@ -264,11 +264,17 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< renderOptions: { loadPrice: true, scanTxn: true, + validateTxn: true, }, securityScanRequest: { accountAddress: account.address, transaction: transaction.getRaw().toXDR(), }, + transactionValidationRequest: { + accountId: account.id, + transaction: transaction.getRaw().toXDR(), + request, + }, tokenPrices: { [assetId]: null, } as ContextWithPrices['tokenPrices'], diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/api.ts index 861efa8e..28f1fe52 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/api.ts @@ -7,6 +7,7 @@ import type { ContextWithPrices } from '../../../ui/confirmation/api'; export enum ConfirmationContextRefresherKey { Prices = 'prices', Scan = 'scan', + Transaction = 'transaction', } export const ConfirmationContextRefresherKeyStruct = enums( diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/index.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/index.ts index 40c259cb..8016bbd3 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/index.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/index.ts @@ -1,6 +1,7 @@ export { RefreshConfirmationContextHandler } from './handler'; export { ConfirmationPriceRefresher } from './priceRefresher'; export { ConfirmationScanRefresher } from './scanRefresher'; +export { ConfirmationTransactionRefresher } from './transactionRefresher'; export { ConfirmationContextRefresherKey, ConfirmationContextRefresherKeyStruct, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.test.ts new file mode 100644 index 00000000..73de690e --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.test.ts @@ -0,0 +1,255 @@ +import { Networks } from '@stellar/stellar-sdk'; + +import { createConfirmationDataContext } from './__fixtures__/context.fixtures'; +import { ConfirmationContextRefresherKey } from './api'; +import { ConfirmationTransactionRefresher } from './transactionRefresher'; +import { KnownCaip2ChainId } from '../../../api'; +import type { AssetMetadataService } from '../../../services/asset-metadata'; +import type { + Transaction, + TransactionBuilder, + TransactionService, +} from '../../../services/transaction'; +import { buildMockClassicTransaction } from '../../../services/transaction/__mocks__/transaction.fixtures'; +import { FetchStatus } from '../../../ui/confirmation/api'; +import { getSlip44AssetId } from '../../../utils'; +import { logger } from '../../../utils/logger'; +import type { AccountResolver } from '../../accountResolver'; +import { + ChangeTrustOptAction, + ClientRequestMethod, +} from '../../clientRequest/api'; + +describe('ConfirmationTransactionRefresher', () => { + const scope = KnownCaip2ChainId.Testnet; + const accountId = '11111111-1111-4111-8111-111111111111'; + const toAddress = 'GDPMFLKUGASUTWBN2XGYYKD27QGHCYH4BUFUTER4L23INYQ4JHDWFOIE'; + + const transaction = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { destination: toAddress, asset: 'native', amount: '1' }, + }, + ], + { networkPassphrase: Networks.TESTNET }, + ); + const transactionXdr = transaction.getRaw().toXDR(); + + const sendRequest = { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.ConfirmSend, + params: { + scope, + accountId, + fromAccountId: accountId, + toAddress, + assetId: getSlip44AssetId(scope), + amount: '1', + }, + }; + + const classicAssetId = `stellar:testnet/asset:USDC-${toAddress}`; + + const changeTrustAddRequest = { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.ChangeTrustOpt, + params: { + scope, + accountId, + assetId: classicAssetId, + action: ChangeTrustOptAction.Add, + limit: '1000', + }, + }; + + const changeTrustDeleteRequest = { + jsonrpc: '2.0' as const, + id: 1, + method: ClientRequestMethod.ChangeTrustOpt, + params: { + scope, + accountId, + assetId: classicAssetId, + action: ChangeTrustOptAction.Delete, + }, + }; + + function setup() { + const accountResolver = { + resolveAccount: jest + .fn() + .mockResolvedValue({ onChainAccount: { accountId, scope } }), + }; + const transactionBuilder = { + deserialize: jest.fn().mockReturnValue(transaction), + }; + const transactionService = { + createValidatedSendTransaction: jest.fn().mockResolvedValue(transaction), + createValidatedChangeTrustTransaction: jest + .fn() + .mockResolvedValue(transaction), + }; + const assetMetadataService = { + resolve: jest.fn().mockResolvedValue({ units: [{ decimals: 7 }] }), + }; + + const refresher = new ConfirmationTransactionRefresher({ + logger, + accountResolver: accountResolver as unknown as AccountResolver, + transactionBuilder: transactionBuilder as unknown as TransactionBuilder, + transactionService: transactionService as unknown as TransactionService, + assetMetadataService: + assetMetadataService as unknown as AssetMetadataService, + }); + + return { + refresher, + accountResolver, + transactionBuilder, + transactionService, + assetMetadataService, + }; + } + + function createTransactionContext( + overrides: Parameters[0] = {}, + ) { + return createConfirmationDataContext({ + transaction: transactionXdr, + transactionsFetchStatus: FetchStatus.Fetched, + accountId, + scope, + request: sendRequest, + ...overrides, + }); + } + + it('uses the transaction refresher key', () => { + const { refresher } = setup(); + expect(refresher.key).toBe(ConfirmationContextRefresherKey.Transaction); + }); + + it('re-validates the send transaction and returns no patch on success', async () => { + const { refresher, transactionService } = setup(); + + const result = await refresher.refresh(createTransactionContext()); + + expect( + transactionService.createValidatedSendTransaction, + ).toHaveBeenCalledWith({ + onChainAccount: { accountId, scope }, + scope, + assetId: sendRequest.params.assetId, + destination: toAddress, + amount: expect.anything(), + }); + expect(result).toBeNull(); + }); + + it('marks the transaction invalid when re-validation throws', async () => { + const { refresher, transactionService } = setup(); + transactionService.createValidatedSendTransaction.mockRejectedValueOnce( + new Error('insufficient balance'), + ); + + const result = await refresher.refresh(createTransactionContext()); + + expect(result).toStrictEqual({ + result: { transactionsFetchStatus: FetchStatus.Error }, + reschedule: false, + }); + }); + + it('re-validates a change-trust opt-in transaction', async () => { + const { refresher, transactionService } = setup(); + + const result = await refresher.refresh( + createTransactionContext({ request: changeTrustAddRequest }), + ); + + expect( + transactionService.createValidatedChangeTrustTransaction, + ).toHaveBeenCalledWith({ + onChainAccount: { accountId, scope }, + scope, + assetId: classicAssetId, + limit: '1000', + }); + expect( + transactionService.createValidatedSendTransaction, + ).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('re-validates a change-trust opt-out transaction with a zero limit', async () => { + const { refresher, transactionService } = setup(); + + await refresher.refresh( + createTransactionContext({ request: changeTrustDeleteRequest }), + ); + + expect( + transactionService.createValidatedChangeTrustTransaction, + ).toHaveBeenCalledWith({ + onChainAccount: { accountId, scope }, + scope, + assetId: classicAssetId, + limit: '0', + }); + }); + + it('marks the transaction invalid when the original envelope has expired', async () => { + const { refresher, transactionBuilder, transactionService } = setup(); + // The stored XDR being signed is expired, even though the rebuilt draft would be valid. + // `expirationTime` is a Unix timestamp in seconds. + transactionBuilder.deserialize.mockReturnValueOnce({ + expirationTime: Math.floor(Date.now() / 1000) - 1000, + } as unknown as Transaction); + + const result = await refresher.refresh(createTransactionContext()); + + expect( + transactionService.createValidatedSendTransaction, + ).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + result: { transactionsFetchStatus: FetchStatus.Error }, + reschedule: false, + }); + }); + + it('does not re-fetch once the transaction is already marked invalid', () => { + const { refresher } = setup(); + + expect( + refresher.shouldFetch( + createTransactionContext({ + transactionsFetchStatus: FetchStatus.Error, + }), + ), + ).toBe(false); + }); + + it('does not re-fetch when the context is missing transaction fields', () => { + const { refresher } = setup(); + + expect(refresher.shouldFetch(createConfirmationDataContext())).toBe(false); + }); + + it('clears a stuck loading state via recovery', () => { + const { refresher } = setup(); + + expect( + refresher.recoveryResult( + createTransactionContext({ + transactionsFetchStatus: FetchStatus.Fetching, + }), + ), + ).toStrictEqual({ + result: { transactionsFetchStatus: FetchStatus.Fetched }, + reschedule: false, + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts new file mode 100644 index 00000000..1fdcefa9 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts @@ -0,0 +1,190 @@ +import type { Json } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { + ConfirmationContextRefresherKey, + type ConfirmationContextRefreshResult, + type ConfirmationDataContext, + type IConfirmationContextRefresher, +} from './api'; +import type { AssetMetadataService } from '../../../services/asset-metadata'; +import type { + TransactionBuilder, + TransactionService, +} from '../../../services/transaction'; +import { assertTransactionTimeBound } from '../../../services/transaction/utils'; +import type { ContextWithTransactionScan } from '../../../ui/confirmation/api'; +import { + ContextWithTransactionScanStruct, + FetchStatus, +} from '../../../ui/confirmation/api'; +import { toSmallestUnit } from '../../../utils/currency'; +import type { ILogger } from '../../../utils/logger'; +import { createPrefixedLogger } from '../../../utils/logger'; +import type { AccountResolver } from '../../accountResolver'; +import { ResolveAccountSource } from '../../accountResolver'; +import { + ChangeTrustOptAction, + ClientRequestMethod, +} from '../../clientRequest/api'; + +type TransactionScanContext = ConfirmationDataContext & + ContextWithTransactionScan; + +/** + * Re-validates the pending transaction while the sign confirmation dialog is open. + * Transaction slice of the confirmation context refresh pipeline; periodically + * checks time bounds, fees, and balance against the latest on-chain account state. + */ +export class ConfirmationTransactionRefresher implements IConfirmationContextRefresher { + readonly key = ConfirmationContextRefresherKey.Transaction; + + readonly #transactionService: TransactionService; + + readonly #transactionBuilder: TransactionBuilder; + + readonly #assetMetadataService: AssetMetadataService; + + readonly #accountResolver: AccountResolver; + + readonly #logger: ILogger; + + constructor({ + logger, + transactionService, + transactionBuilder, + assetMetadataService, + accountResolver, + }: { + logger: ILogger; + transactionService: TransactionService; + transactionBuilder: TransactionBuilder; + assetMetadataService: AssetMetadataService; + accountResolver: AccountResolver; + }) { + this.#transactionService = transactionService; + this.#transactionBuilder = transactionBuilder; + this.#assetMetadataService = assetMetadataService; + this.#accountResolver = accountResolver; + this.#logger = createPrefixedLogger( + logger, + '[🔄 ConfirmationTransactionRefresher]', + ); + } + + shouldFetch(ctx: ConfirmationDataContext): boolean { + if (!this.isValidContext(ctx)) { + return false; + } + const scanCtx = ctx as TransactionScanContext; + // A prior cycle already marked the transaction invalid; nothing to re-fetch. + return scanCtx.transactionsFetchStatus !== FetchStatus.Error; + } + + recoveryResult( + ctx: ConfirmationDataContext, + ): ConfirmationContextRefreshResult { + const scanCtx = ctx as TransactionScanContext; + if (scanCtx.transactionsFetchStatus !== FetchStatus.Fetching) { + return null; + } + + return { + result: { transactionsFetchStatus: FetchStatus.Fetched }, + reschedule: false, + }; + } + + async refresh( + ctx: ConfirmationDataContext, + ): Promise { + const scanCtx = ctx as TransactionScanContext; + try { + const { + request, + accountId, + scope, + transaction: transactionXdr, + } = scanCtx; + + // Load the sender from the network so validation uses current sequence and balances. + const { onChainAccount } = await this.#accountResolver.resolveAccount({ + accountId, + scope, + options: { + onChainAccount: { + load: true, + source: ResolveAccountSource.OnChain, + }, + wallet: false, + }, + }); + + // Deserialize the envelope awaiting signature and assert its own time bound. + // The draft rebuilt below gets a fresh timeout, so validating that draft would + // miss expiry of the transaction the user is actually looking at. + const transaction = this.#transactionBuilder.deserialize({ + xdr: transactionXdr, + scope, + }); + assertTransactionTimeBound(transaction); + + // TODO(follow-up): this validates a rebuilt draft as a proxy for the stored + // envelope. It can miss divergence (payment vs createAccount on a deactivated + // destination, stale Soroban footprint). Seq drift is covered by the submit-time + // txBadSeq retry. For full fidelity, validate the stored envelope itself. + switch (request.method) { + case ClientRequestMethod.ConfirmSend: { + const assetMetadata = await this.#assetMetadataService.resolve( + request.params.assetId, + ); + const { decimals } = assetMetadata.units[0]; + const amount = toSmallestUnit( + new BigNumber(request.params.amount), + decimals, + ); + // Throws on insufficient balance, inactive destination, or fee estimate failure. + await this.#transactionService.createValidatedSendTransaction({ + onChainAccount, + scope, + assetId: request.params.assetId, + destination: request.params.toAddress, + amount, + }); + break; + } + case ClientRequestMethod.ChangeTrustOpt: + // Throws when change-trust limits or account state are no longer valid. + await this.#transactionService.createValidatedChangeTrustTransaction({ + onChainAccount, + scope, + assetId: request.params.assetId, + limit: + request.params.action === ChangeTrustOptAction.Delete + ? '0' + : request.params.limit, + }); + break; + default: + throw new Error('Unsupported request method for transaction refresh'); + } + + // Still valid: nothing to write. The status stays Fetched and we don't drive a + // reschedule ourselves (other refreshers keep the cron alive while the dialog is open). + return null; + } catch (error) { + this.#logger.error( + 'Error re-validating confirmation transaction:', + error, + ); + return { + result: { transactionsFetchStatus: FetchStatus.Error }, + reschedule: false, + }; + } + } + + isValidContext(ctx: Record): boolean { + return ContextWithTransactionScanStruct.is(ctx); + } +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts index 16eda15e..ede7149b 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts @@ -20,7 +20,14 @@ import { KnownCaip19ClassicAssetStruct, KnownCaip19Sep41AssetStruct, KnownCaip19Slip44IdStruct, + KnownCaip2ChainIdStruct, + UuidStruct, + XdrStruct, } from '../../api'; +import { + ChangeTrustOptJsonRpcRequestStruct, + ConfirmSendJsonRpcRequestStruct, +} from '../../handlers/clientRequest/api'; import { SecurityScanRequestStruct, TransactionScanResultStruct, @@ -76,6 +83,26 @@ export type ContextWithSecurityScan = Infer< typeof ContextWithSecurityScanStruct >; +/** + * Context required to re-validate the pending transaction (time bounds, fees, + * balance) against the latest on-chain state while the confirmation dialog is open. + */ +export const ContextWithTransactionScanStruct = type({ + transaction: nonempty(XdrStruct), + transactionsFetchStatus: enums(Object.values(FetchStatus)), + accountId: UuidStruct, + scope: KnownCaip2ChainIdStruct, + // Only send and change-trust transactions are re-validated. + request: union([ + ConfirmSendJsonRpcRequestStruct, + ChangeTrustOptJsonRpcRequestStruct, + ]), +}); + +export type ContextWithTransactionScan = Infer< + typeof ContextWithTransactionScanStruct +>; + export enum ConfirmationInterfaceKey { ChangeTrustlineOptIn = 'ChangeTrustlineOptIn', ChangeTrustlineOptOut = 'ChangeTrustlineOptOut', @@ -97,6 +124,7 @@ export type ConfirmationBaseProps = Partial & { scan?: TransactionScanResult | null; scanFetchStatus?: FetchStatus; securityScanRequest?: SecurityScanRequest; + transactionsFetchStatus?: FetchStatus; preferences: GetPreferencesResult; locale: string; scope: KnownCaip2ChainId; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionValidationAlert.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionValidationAlert.tsx new file mode 100644 index 00000000..6779e123 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/TransactionValidationAlert.tsx @@ -0,0 +1,36 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { Banner, Text as SnapText } from '@metamask/snaps-sdk/jsx'; + +import type { Locale } from '../../../utils'; +import { i18n } from '../../../utils'; +import type { ConfirmationBaseProps } from '../api'; +import { FetchStatus } from '../api'; + +type TransactionValidationAlertProps = { + preferences: ConfirmationBaseProps['preferences']; + transactionsFetchStatus: FetchStatus; +}; + +// Danger banner shown when background re-validation finds the pending transaction +// is no longer valid (expired, sequence changed, or insufficient balance). +export const TransactionValidationAlert = ({ + preferences, + transactionsFetchStatus, +}: TransactionValidationAlertProps): ComponentOrElement | null => { + if (transactionsFetchStatus !== FetchStatus.Error) { + return null; + } + + const translate = i18n(preferences.locale as Locale); + + return ( + + + {translate('confirmation.transactionInvalidSubtitle')} + + + ); +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts index dbbd2093..2f03a431 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts @@ -2,3 +2,4 @@ export * from './Fee'; export * from './AssetIcon'; export * from './Asset'; export * from './TransactionAlert'; +export * from './TransactionValidationAlert'; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 607dcf94..096d445f 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -14,6 +14,10 @@ import { } from './utils'; import type { KnownCaip2ChainId } from '../../api'; import { METAMASK_ORIGIN } from '../../constants'; +import type { + ChangeTrustOptJsonRpcRequest, + ConfirmSendJsonRpcRequest, +} from '../../handlers/clientRequest/api'; import type { SecurityScanRequest } from '../../services/transaction-scan'; import type { ILogger, Locale } from '../../utils'; import { @@ -54,6 +58,17 @@ type ConfirmationViewProps = Record; type ConfirmationRenderOptions = { loadPrice?: boolean; scanTxn?: boolean; + validateTxn?: boolean; +}; + +/** + * Context needed to re-validate the pending transaction against latest on-chain + * state while the confirmation dialog is open. + */ +type TransactionValidationRequest = { + accountId: string; + transaction: string; + request: ConfirmSendJsonRpcRequest | ChangeTrustOptJsonRpcRequest; }; /** Common params accepted by every {@link ConfirmationUXController.renderConfirmationDialog} call. */ @@ -63,6 +78,7 @@ type RenderConfirmationDialogCommon = { origin?: string; renderOptions?: ConfirmationRenderOptions; securityScanRequest?: Omit; + transactionValidationRequest?: TransactionValidationRequest; tokenPrices?: ContextWithPrices['tokenPrices']; }; @@ -96,6 +112,7 @@ export class ConfirmationUXController { readonly #defaultRenderOptions: ConfirmationRenderOptions = { loadPrice: false, scanTxn: false, + validateTxn: false, }; constructor({ logger }: { logger: ILogger }) { @@ -140,6 +157,15 @@ export class ConfirmationUXController { ); } + if ( + renderOptions.validateTxn && + params.transactionValidationRequest === undefined + ) { + throw new Error( + 'Cannot re-validate a transaction confirmation without a transaction validation request.', + ); + } + const preferences = await getPreferencesWithFallback(); const defaultTokenPrices = fee @@ -169,6 +195,10 @@ export class ConfirmationUXController { hasEnabledTransactionScan(preferences) && params.securityScanRequest !== undefined; + const enableTransactionValidation = + renderOptions.validateTxn && + params.transactionValidationRequest !== undefined; + const defaultContext = { // if pricing is disabled, mark as fetched immediately tokenPricesFetchStatus: enablePricing @@ -194,6 +224,17 @@ export class ConfirmationUXController { }, } : {}), + // Optimistic: tx was validated at build time, so keep confirm enabled; the + // refresher flips to Error if it later drifts (submission rejects invalid txs too). + // TODO(follow-up): re-validate synchronously right before signing. + ...(enableTransactionValidation + ? { + transactionsFetchStatus: FetchStatus.Fetched, + accountId: params.transactionValidationRequest?.accountId, + transaction: params.transactionValidationRequest?.transaction, + request: params.transactionValidationRequest?.request, + } + : {}), tokenPrices, }; @@ -230,6 +271,9 @@ export class ConfirmationUXController { if (enableSecurityScan) { refresherKeys.push(ConfirmationContextRefresherKey.Scan); } + if (enableTransactionValidation) { + refresherKeys.push(ConfirmationContextRefresherKey.Transaction); + } if (refresherKeys.length > 0) { await RefreshConfirmationContextHandler.scheduleBackgroundEvent( diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts index 428518d1..520a8abb 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts @@ -202,6 +202,19 @@ export function hasEnabledTransactionScan( return preferences.useSecurityAlerts || preferences.simulateOnChainActions; } +/** + * Determines whether the confirm action must be blocked because background + * re-validation found the pending transaction is no longer valid. + * + * @param transactionsFetchStatus - Latest transaction validation fetch status. + * @returns True when the confirm action should be disabled. + */ +export function isConfirmDisabledByTransactionValidation( + transactionsFetchStatus: FetchStatus | undefined, +): boolean { + return transactionsFetchStatus === FetchStatus.Error; +} + /** * Display-friendly resolution of a Stellar operation `asset` reference. * Used by the confirmation UI to render assets and to look up prices. diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx index 33cf5bdf..f0faafdc 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx @@ -27,7 +27,12 @@ import type { FeeData, } from '../../api'; import { FetchStatus } from '../../api'; -import { Asset, FeeRow, TransactionAlert } from '../../components'; +import { + Asset, + FeeRow, + TransactionAlert, + TransactionValidationAlert, +} from '../../components'; import { getAccountExplorerUrl, getAccountName, @@ -36,6 +41,7 @@ import { getSepAssetExplorerUrl, hasEnabledTransactionScan, isConfirmDisabledByScan, + isConfirmDisabledByTransactionValidation, } from '../../utils'; export type ConfirmSendTransactionProps = ConfirmationBaseProps & @@ -63,15 +69,17 @@ export const ConfirmSendTransaction = ({ tokenPricesFetchStatus = FetchStatus.Initial, scan, scanFetchStatus = FetchStatus.Initial, + transactionsFetchStatus = FetchStatus.Initial, }: ConfirmSendTransactionProps): ComponentOrElement => { const t = i18n(locale); const { address } = account; const { assetId, symbol } = assetMetadata; - const shouldDisableConfirmButton = isConfirmDisabledByScan({ - preferences, - scan, - scanFetchStatus, - }); + const shouldDisableConfirmButton = + isConfirmDisabledByScan({ + preferences, + scan, + scanFetchStatus, + }) || isConfirmDisabledByTransactionValidation(transactionsFetchStatus); const parsedAsset = parseCaipAssetType(assetId); let assetLink: string | undefined; if (!isSlip44Id(assetId)) { @@ -94,6 +102,10 @@ export const ConfirmSendTransaction = ({ preferences={preferences} /> ) : null} + {null} {t(`confirmation.transaction.title`)} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx index a3fc571e..b577260f 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx @@ -26,12 +26,19 @@ import type { FeeData, } from '../../api'; import { FetchStatus } from '../../api'; -import { Asset, AssetIcon, FeeRow, TransactionAlert } from '../../components'; +import { + Asset, + AssetIcon, + FeeRow, + TransactionAlert, + TransactionValidationAlert, +} from '../../components'; import { getAccountName, getClassicAssetExplorerUrl, hasEnabledTransactionScan, isConfirmDisabledByScan, + isConfirmDisabledByTransactionValidation, getNetworkName, } from '../../utils'; @@ -55,14 +62,16 @@ export const ConfirmSignChangeTrustOptIn = ({ tokenPricesFetchStatus = FetchStatus.Initial, scan, scanFetchStatus = FetchStatus.Initial, + transactionsFetchStatus = FetchStatus.Initial, }: ConfirmSignChangeTrustOptInProps): ComponentOrElement => { const t = i18n(locale); const { address } = account; - const shouldDisableConfirmButton = isConfirmDisabledByScan({ - preferences, - scan, - scanFetchStatus, - }); + const shouldDisableConfirmButton = + isConfirmDisabledByScan({ + preferences, + scan, + scanFetchStatus, + }) || isConfirmDisabledByTransactionValidation(transactionsFetchStatus); return ( @@ -75,6 +84,10 @@ export const ConfirmSignChangeTrustOptIn = ({ preferences={preferences} /> ) : null} + {null} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx index 9c8f9f1c..c710912e 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx @@ -26,12 +26,19 @@ import type { FeeData, } from '../../api'; import { FetchStatus } from '../../api'; -import { Asset, AssetIcon, FeeRow, TransactionAlert } from '../../components'; +import { + Asset, + AssetIcon, + FeeRow, + TransactionAlert, + TransactionValidationAlert, +} from '../../components'; import { getAccountName, getClassicAssetExplorerUrl, hasEnabledTransactionScan, isConfirmDisabledByScan, + isConfirmDisabledByTransactionValidation, getNetworkName, } from '../../utils'; @@ -55,14 +62,16 @@ export const ConfirmSignChangeTrustOptOut = ({ tokenPricesFetchStatus = FetchStatus.Initial, scan, scanFetchStatus = FetchStatus.Initial, + transactionsFetchStatus = FetchStatus.Initial, }: ConfirmSignChangeTrustOptOutProps): ComponentOrElement => { const t = i18n(locale); const { address } = account; - const shouldDisableConfirmButton = isConfirmDisabledByScan({ - preferences, - scan, - scanFetchStatus, - }); + const shouldDisableConfirmButton = + isConfirmDisabledByScan({ + preferences, + scan, + scanFetchStatus, + }) || isConfirmDisabledByTransactionValidation(transactionsFetchStatus); return ( @@ -75,6 +84,10 @@ export const ConfirmSignChangeTrustOptOut = ({ preferences={preferences} /> ) : null} + {null} From 0bc2e3b8b567f266a20eaa256865bf24cbbacea2 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Mon, 1 Jun 2026 19:10:16 +0200 Subject: [PATCH 261/384] fix: fix lint --- merged-packages/stellar-wallet-snap/locales/en.json | 6 ++++++ merged-packages/stellar-wallet-snap/messages.json | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/merged-packages/stellar-wallet-snap/locales/en.json b/merged-packages/stellar-wallet-snap/locales/en.json index febcfb36..d8fabbbc 100644 --- a/merged-packages/stellar-wallet-snap/locales/en.json +++ b/merged-packages/stellar-wallet-snap/locales/en.json @@ -130,6 +130,12 @@ "confirmation.simulationErrorSubtitle": { "message": "{reason}" }, + "confirmation.transactionInvalidTitle": { + "message": "Transaction is no longer valid" + }, + "confirmation.transactionInvalidSubtitle": { + "message": "It may have expired or your account balance changed. Close this request and try again." + }, "confirmation.validationScanErrorTitle": { "message": "Security validation failed" }, diff --git a/merged-packages/stellar-wallet-snap/messages.json b/merged-packages/stellar-wallet-snap/messages.json index fcdf1176..75a7a1c9 100644 --- a/merged-packages/stellar-wallet-snap/messages.json +++ b/merged-packages/stellar-wallet-snap/messages.json @@ -128,6 +128,12 @@ "confirmation.simulationErrorSubtitle": { "message": "{reason}" }, + "confirmation.transactionInvalidTitle": { + "message": "Transaction is no longer valid" + }, + "confirmation.transactionInvalidSubtitle": { + "message": "It may have expired or your account balance changed. Close this request and try again." + }, "confirmation.validationScanErrorTitle": { "message": "Security validation failed" }, From 1c52b482571ce5029daffb7f240919faa38f079e Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Mon, 1 Jun 2026 19:43:50 +0200 Subject: [PATCH 262/384] fix: fix copilot comments --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../transactionRefresher.ts | 22 +++++++------- .../src/ui/confirmation/api.ts | 6 ++-- .../src/ui/confirmation/controller.tsx | 8 ++--- .../src/ui/confirmation/utils.test.ts | 29 ++++++++++++++++++- 5 files changed, 47 insertions(+), 20 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 4c8262b9..75cfc764 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "wkTOqfbQT4cVfywVvPWaHQckM/bx9gyYqVk9BazieX4=", + "shasum": "l99ee2yJhFsWGk7cS60XHjrCb45owx/NjwUj+JchGCU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts index 1fdcefa9..d21644c9 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts @@ -13,9 +13,9 @@ import type { TransactionService, } from '../../../services/transaction'; import { assertTransactionTimeBound } from '../../../services/transaction/utils'; -import type { ContextWithTransactionScan } from '../../../ui/confirmation/api'; +import type { ContextWithTransactionValidation } from '../../../ui/confirmation/api'; import { - ContextWithTransactionScanStruct, + ContextWithTransactionValidationStruct, FetchStatus, } from '../../../ui/confirmation/api'; import { toSmallestUnit } from '../../../utils/currency'; @@ -28,8 +28,8 @@ import { ClientRequestMethod, } from '../../clientRequest/api'; -type TransactionScanContext = ConfirmationDataContext & - ContextWithTransactionScan; +type TransactionValidationContext = ConfirmationDataContext & + ContextWithTransactionValidation; /** * Re-validates the pending transaction while the sign confirmation dialog is open. @@ -76,16 +76,16 @@ export class ConfirmationTransactionRefresher implements IConfirmationContextRef if (!this.isValidContext(ctx)) { return false; } - const scanCtx = ctx as TransactionScanContext; + const validationCtx = ctx as TransactionValidationContext; // A prior cycle already marked the transaction invalid; nothing to re-fetch. - return scanCtx.transactionsFetchStatus !== FetchStatus.Error; + return validationCtx.transactionsFetchStatus !== FetchStatus.Error; } recoveryResult( ctx: ConfirmationDataContext, ): ConfirmationContextRefreshResult { - const scanCtx = ctx as TransactionScanContext; - if (scanCtx.transactionsFetchStatus !== FetchStatus.Fetching) { + const validationCtx = ctx as TransactionValidationContext; + if (validationCtx.transactionsFetchStatus !== FetchStatus.Fetching) { return null; } @@ -98,14 +98,14 @@ export class ConfirmationTransactionRefresher implements IConfirmationContextRef async refresh( ctx: ConfirmationDataContext, ): Promise { - const scanCtx = ctx as TransactionScanContext; + const validationCtx = ctx as TransactionValidationContext; try { const { request, accountId, scope, transaction: transactionXdr, - } = scanCtx; + } = validationCtx; // Load the sender from the network so validation uses current sequence and balances. const { onChainAccount } = await this.#accountResolver.resolveAccount({ @@ -185,6 +185,6 @@ export class ConfirmationTransactionRefresher implements IConfirmationContextRef } isValidContext(ctx: Record): boolean { - return ContextWithTransactionScanStruct.is(ctx); + return ContextWithTransactionValidationStruct.is(ctx); } } diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts index ede7149b..1915b67d 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts @@ -87,7 +87,7 @@ export type ContextWithSecurityScan = Infer< * Context required to re-validate the pending transaction (time bounds, fees, * balance) against the latest on-chain state while the confirmation dialog is open. */ -export const ContextWithTransactionScanStruct = type({ +export const ContextWithTransactionValidationStruct = type({ transaction: nonempty(XdrStruct), transactionsFetchStatus: enums(Object.values(FetchStatus)), accountId: UuidStruct, @@ -99,8 +99,8 @@ export const ContextWithTransactionScanStruct = type({ ]), }); -export type ContextWithTransactionScan = Infer< - typeof ContextWithTransactionScanStruct +export type ContextWithTransactionValidation = Infer< + typeof ContextWithTransactionValidationStruct >; export enum ConfirmationInterfaceKey { diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 096d445f..8c71aeca 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -227,12 +227,12 @@ export class ConfirmationUXController { // Optimistic: tx was validated at build time, so keep confirm enabled; the // refresher flips to Error if it later drifts (submission rejects invalid txs too). // TODO(follow-up): re-validate synchronously right before signing. - ...(enableTransactionValidation + ...(renderOptions.validateTxn && params.transactionValidationRequest ? { transactionsFetchStatus: FetchStatus.Fetched, - accountId: params.transactionValidationRequest?.accountId, - transaction: params.transactionValidationRequest?.transaction, - request: params.transactionValidationRequest?.request, + accountId: params.transactionValidationRequest.accountId, + transaction: params.transactionValidationRequest.transaction, + request: params.transactionValidationRequest.request, } : {}), tokenPrices, diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts index df443eaf..df7d12f0 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts @@ -1,7 +1,10 @@ import type { GetPreferencesResult } from '@metamask/snaps-sdk'; import { FetchStatus } from './api'; -import { isConfirmDisabledByScan } from './utils'; +import { + isConfirmDisabledByScan, + isConfirmDisabledByTransactionValidation, +} from './utils'; import { TransactionScanValidationType } from '../../services/transaction-scan'; const preferences: GetPreferencesResult = { @@ -90,4 +93,28 @@ describe('confirmation utils', () => { ).toBe(false); }); }); + + describe('isConfirmDisabledByTransactionValidation', () => { + it('disables confirm when re-validation reports an error', () => { + expect(isConfirmDisabledByTransactionValidation(FetchStatus.Error)).toBe( + true, + ); + }); + + it('does not disable confirm while re-validation is fetching', () => { + expect( + isConfirmDisabledByTransactionValidation(FetchStatus.Fetching), + ).toBe(false); + }); + + it('does not disable confirm when re-validation has fetched', () => { + expect( + isConfirmDisabledByTransactionValidation(FetchStatus.Fetched), + ).toBe(false); + }); + + it('does not disable confirm when the status is undefined', () => { + expect(isConfirmDisabledByTransactionValidation(undefined)).toBe(false); + }); + }); }); From 5b3e1b44acb3dbcab06d348d1270de5cc6cffc94 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Tue, 2 Jun 2026 15:46:04 +0200 Subject: [PATCH 263/384] fix: fix comments --- merged-packages/stellar-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/clientRequest/changeTrustOpt.ts | 5 +++-- .../src/handlers/clientRequest/confirmSend.ts | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 75cfc764..764e67cb 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "l99ee2yJhFsWGk7cS60XHjrCb45owx/NjwUj+JchGCU=", + "shasum": "rnecWdWBbtseCn4pl7lcpJOnkP9Ks5+o1Q/1Cc1yFWc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts index 3b2053f4..91cf68c1 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts @@ -284,6 +284,7 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< confirmationInterfaceKey, } = params; const { scope } = request.params; + const xdr = transaction.getRaw().toXDR(); return ( (await this.#confirmationUIController.renderConfirmationDialog({ @@ -302,11 +303,11 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< }, securityScanRequest: { accountAddress: account.address, - transaction: transaction.getRaw().toXDR(), + transaction: xdr, }, transactionValidationRequest: { accountId: account.id, - transaction: transaction.getRaw().toXDR(), + transaction: xdr, request, }, })) === true diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts index 76280f70..555a849b 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts @@ -248,6 +248,7 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< }): Promise { const { request, account, assetMetadata, fee, scope, transaction } = params; const { toAddress, amount, assetId } = request.params; + const xdr = transaction.getRaw().toXDR(); return ( (await this.#confirmationUIController.renderConfirmationDialog({ @@ -268,11 +269,11 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< }, securityScanRequest: { accountAddress: account.address, - transaction: transaction.getRaw().toXDR(), + transaction: xdr, }, transactionValidationRequest: { accountId: account.id, - transaction: transaction.getRaw().toXDR(), + transaction: xdr, request, }, tokenPrices: { From c4628825e9885c586a1b491bbaae964ceb339395 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 2 Jun 2026 16:20:41 +0200 Subject: [PATCH 264/384] feat: replace soroban polling by horizon tx tracking --- .../stellar-wallet-snap/snap.config.ts | 2 + .../stellar-wallet-snap/src/config.ts | 6 + .../handlers/cronjob/trackTransaction.test.ts | 186 ++++++++++++++---- .../src/handlers/cronjob/trackTransaction.ts | 167 +++++++++++----- .../services/network/NetworkService.test.ts | 54 +++++ .../src/services/network/NetworkService.ts | 45 ++++- .../src/services/network/api.ts | 11 ++ .../src/services/network/exceptions.ts | 5 +- .../stellar-wallet-snap/src/utils/snap.ts | 1 + 9 files changed, 391 insertions(+), 86 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.config.ts b/merged-packages/stellar-wallet-snap/snap.config.ts index 0bb3b981..6b594dfd 100644 --- a/merged-packages/stellar-wallet-snap/snap.config.ts +++ b/merged-packages/stellar-wallet-snap/snap.config.ts @@ -21,6 +21,8 @@ const config: SnapConfig = { TRANSACTION_TIMEOUT: process.env.TRANSACTION_TIMEOUT ?? '', TRANSACTION_POLLING_ATTEMPTS: process.env.TRANSACTION_POLLING_ATTEMPTS ?? '', + TRACK_TRANSACTION_MAX_RESCHEDULES: + process.env.TRACK_TRANSACTION_MAX_RESCHEDULES ?? '', TOKEN_API_BASE_URL: process.env.TOKEN_API_BASE_URL ?? '', TOKEN_API_CHUNK_SIZE: process.env.TOKEN_API_CHUNK_SIZE ?? '', STATIC_API_BASE_URL: process.env.STATIC_API_BASE_URL ?? '', diff --git a/merged-packages/stellar-wallet-snap/src/config.ts b/merged-packages/stellar-wallet-snap/src/config.ts index 37de7cde..eba8399d 100644 --- a/merged-packages/stellar-wallet-snap/src/config.ts +++ b/merged-packages/stellar-wallet-snap/src/config.ts @@ -79,6 +79,12 @@ const ConfigStruct = object({ transaction: object({ timeout: parseIntegerStruct(100, 180), pollingAttempts: parseIntegerStruct(0, 10), + /** + * Maximum background reschedules for the track-transaction cron job while Horizon has not + * indexed the transaction (404). Each reschedule is a separate cron run via + * `scheduleBackgroundEvent`, not an in-process retry loop. + */ + trackTransactionMaxReschedules: parseIntegerStruct(0, 10), /** * The base fee multiplier for the Stellar network. */ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts index b7de843e..c7c55335 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts @@ -7,16 +7,16 @@ import { import { BackgroundEventMethod } from './api'; import { TrackTransactionHandler } from './trackTransaction'; import { KnownCaip2ChainId } from '../../api'; +import { AppConfig } from '../../config'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; import { InMemoryCache } from '../../services/cache'; import { NetworkService } from '../../services/network'; -import { TransactionPollException } from '../../services/network/exceptions'; import { OnChainAccountService } from '../../services/on-chain-account'; import { TransactionService } from '../../services/transaction'; import { createMockTransactionService } from '../../services/transaction/__mocks__/transaction.fixtures'; import { logger, noOpLogger } from '../../utils/logger'; -import { scheduleBackgroundEvent } from '../../utils/snap'; +import { Duration, scheduleBackgroundEvent } from '../../utils/snap'; jest.mock('../../utils/logger'); jest.mock('../../utils/snap', () => { @@ -73,9 +73,9 @@ describe('TrackTransactionHandler', () => { .spyOn(AccountService.prototype, 'findById') .mockResolvedValue(account); - const pollTransaction = jest.spyOn( + const checkHorizonTransactionForTrack = jest.spyOn( NetworkService.prototype, - 'pollTransaction', + 'checkHorizonTransactionForTrack', ); const findKeyringTransactionByTransactionId = jest.spyOn( @@ -119,24 +119,27 @@ describe('TrackTransactionHandler', () => { account, findByIds, findById, - pollTransaction, + checkHorizonTransactionForTrack, findKeyringTransactionByTransactionId, synchronize, updateKeyringTransactionStatus, }; } - it('loads persisted keyring transaction from state before Soroban poll', async () => { - const { handler, pollTransaction, findKeyringTransactionByTransactionId } = - setup(); + it('loads persisted keyring transaction from state before Horizon track check', async () => { + const { + handler, + checkHorizonTransactionForTrack, + findKeyringTransactionByTransactionId, + } = setup(); const callOrder: string[] = []; findKeyringTransactionByTransactionId.mockImplementation(async () => { callOrder.push('findPersisted'); return undefined; }); - pollTransaction.mockImplementation(async () => { - callOrder.push('poll'); - return txId; + checkHorizonTransactionForTrack.mockImplementation(async () => { + callOrder.push('horizon'); + return 'confirmed'; }); await handler.handle({ @@ -150,14 +153,14 @@ describe('TrackTransactionHandler', () => { }, }); - expect(callOrder).toStrictEqual(['findPersisted', 'poll']); + expect(callOrder).toStrictEqual(['findPersisted', 'horizon']); }); - it('settles keyring row as confirmed when RPC poll succeeds', async () => { + it('syncs before settling confirmed when Horizon track check confirms', async () => { const { handler, account, - pollTransaction, + checkHorizonTransactionForTrack, synchronize, updateKeyringTransactionStatus, findKeyringTransactionByTransactionId, @@ -165,7 +168,15 @@ describe('TrackTransactionHandler', () => { findKeyringTransactionByTransactionId.mockResolvedValue( createPersistedKeyringTransaction(), ); - pollTransaction.mockResolvedValue(txId); + checkHorizonTransactionForTrack.mockResolvedValue('confirmed'); + + const callOrder: string[] = []; + synchronize.mockImplementation(async () => { + callOrder.push('sync'); + }); + updateKeyringTransactionStatus.mockImplementation(async () => { + callOrder.push('settle'); + }); await handler.handle({ jsonrpc: '2.0', @@ -178,7 +189,8 @@ describe('TrackTransactionHandler', () => { }, }); - expect(pollTransaction).toHaveBeenCalledWith(txId, scope); + expect(checkHorizonTransactionForTrack).toHaveBeenCalledWith(txId, scope); + expect(callOrder).toStrictEqual(['sync', 'settle']); expect(updateKeyringTransactionStatus).toHaveBeenCalledWith({ txId, accountIds: [accountId], @@ -189,10 +201,10 @@ describe('TrackTransactionHandler', () => { expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); }); - it('settles keyring row as failed for non-unknown poll status', async () => { + it('reschedules when Horizon track check returns pending on first attempt', async () => { const { handler, - pollTransaction, + checkHorizonTransactionForTrack, synchronize, updateKeyringTransactionStatus, findKeyringTransactionByTransactionId, @@ -200,9 +212,47 @@ describe('TrackTransactionHandler', () => { findKeyringTransactionByTransactionId.mockResolvedValue( createPersistedKeyringTransaction(), ); - pollTransaction.mockRejectedValue( - new TransactionPollException(txId, 'failed', scope), + checkHorizonTransactionForTrack.mockResolvedValue('pending'); + + await handler.handle({ + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIds: [accountId], + }, + }); + + expect(updateKeyringTransactionStatus).not.toHaveBeenCalled(); + expect(synchronize).not.toHaveBeenCalled(); + expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIds: [accountId], + attempt: 1, + }, + duration: Duration.TwoSeconds, + }); + }); + + it('settles confirmed after reschedule then confirmed across cron runs', async () => { + const { + handler, + checkHorizonTransactionForTrack, + synchronize, + updateKeyringTransactionStatus, + findKeyringTransactionByTransactionId, + } = setup(); + findKeyringTransactionByTransactionId.mockResolvedValue( + createPersistedKeyringTransaction(), ); + checkHorizonTransactionForTrack + .mockResolvedValueOnce('pending') + .mockResolvedValueOnce('confirmed'); await handler.handle({ jsonrpc: '2.0', @@ -215,19 +265,35 @@ describe('TrackTransactionHandler', () => { }, }); + expect(checkHorizonTransactionForTrack).toHaveBeenCalledTimes(1); + expect(updateKeyringTransactionStatus).not.toHaveBeenCalled(); + expect(scheduleBackgroundEvent).toHaveBeenCalledTimes(1); + + await handler.handle({ + jsonrpc: '2.0', + id: 2, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIds: [accountId], + attempt: 1, + }, + }); + + expect(checkHorizonTransactionForTrack).toHaveBeenCalledTimes(2); + expect(synchronize).toHaveBeenCalledTimes(1); expect(updateKeyringTransactionStatus).toHaveBeenCalledWith({ txId, accountIds: [accountId], - status: TransactionStatus.Failed, + status: TransactionStatus.Confirmed, }); - expect(synchronize).toHaveBeenCalledTimes(1); - expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); }); - it('leaves pending when poll status is unknown and still synchronizes', async () => { + it('leaves pending when Horizon keeps returning pending after max reschedules', async () => { const { handler, - pollTransaction, + checkHorizonTransactionForTrack, synchronize, updateKeyringTransactionStatus, findKeyringTransactionByTransactionId, @@ -235,9 +301,48 @@ describe('TrackTransactionHandler', () => { findKeyringTransactionByTransactionId.mockResolvedValue( createPersistedKeyringTransaction(), ); - pollTransaction.mockRejectedValue( - new TransactionPollException(txId, 'unknown', scope), + checkHorizonTransactionForTrack.mockResolvedValue('pending'); + + for ( + let attempt = 0; + attempt <= AppConfig.transaction.trackTransactionMaxReschedules; + attempt += 1 + ) { + await handler.handle({ + jsonrpc: '2.0', + id: attempt + 1, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIds: [accountId], + attempt, + }, + }); + } + + expect(checkHorizonTransactionForTrack).toHaveBeenCalledTimes( + AppConfig.transaction.trackTransactionMaxReschedules + 1, + ); + expect(scheduleBackgroundEvent).toHaveBeenCalledTimes( + AppConfig.transaction.trackTransactionMaxReschedules, + ); + expect(updateKeyringTransactionStatus).not.toHaveBeenCalled(); + expect(synchronize).toHaveBeenCalledTimes(1); + }); + + it('settles keyring row as failed when Horizon track check reports failed', async () => { + const { + handler, + checkHorizonTransactionForTrack, + synchronize, + updateKeyringTransactionStatus, + findKeyringTransactionByTransactionId, + } = setup(); + findKeyringTransactionByTransactionId.mockResolvedValue( + createPersistedKeyringTransaction(), ); + checkHorizonTransactionForTrack.mockResolvedValue('failed'); await handler.handle({ jsonrpc: '2.0', @@ -250,15 +355,19 @@ describe('TrackTransactionHandler', () => { }, }); - expect(updateKeyringTransactionStatus).not.toHaveBeenCalled(); + expect(updateKeyringTransactionStatus).toHaveBeenCalledWith({ + txId, + accountIds: [accountId], + status: TransactionStatus.Failed, + }); expect(synchronize).toHaveBeenCalledTimes(1); expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); }); - it('leaves pending on unexpected poll error and still synchronizes', async () => { + it('leaves pending on unavailable Horizon track check and still synchronizes', async () => { const { handler, - pollTransaction, + checkHorizonTransactionForTrack, synchronize, updateKeyringTransactionStatus, findKeyringTransactionByTransactionId, @@ -266,7 +375,7 @@ describe('TrackTransactionHandler', () => { findKeyringTransactionByTransactionId.mockResolvedValue( createPersistedKeyringTransaction(), ); - pollTransaction.mockRejectedValue(new Error('unexpected poll failure')); + checkHorizonTransactionForTrack.mockResolvedValue('unavailable'); await handler.handle({ jsonrpc: '2.0', @@ -281,6 +390,7 @@ describe('TrackTransactionHandler', () => { expect(updateKeyringTransactionStatus).not.toHaveBeenCalled(); expect(synchronize).toHaveBeenCalledTimes(1); + expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); }); it('syncs from persisted keyring transaction account without findByIds', async () => { @@ -289,7 +399,7 @@ describe('TrackTransactionHandler', () => { account, findByIds, findById, - pollTransaction, + checkHorizonTransactionForTrack, findKeyringTransactionByTransactionId, synchronize, updateKeyringTransactionStatus, @@ -298,7 +408,7 @@ describe('TrackTransactionHandler', () => { findKeyringTransactionByTransactionId.mockResolvedValue(persisted); findByIds.mockResolvedValue([]); findById.mockResolvedValue(account); - pollTransaction.mockResolvedValue(txId); + checkHorizonTransactionForTrack.mockResolvedValue('confirmed'); await handler.handle({ jsonrpc: '2.0', @@ -326,7 +436,7 @@ describe('TrackTransactionHandler', () => { handler, findByIds, findById, - pollTransaction, + checkHorizonTransactionForTrack, findKeyringTransactionByTransactionId, synchronize, } = setup(); @@ -334,7 +444,7 @@ describe('TrackTransactionHandler', () => { createPersistedKeyringTransaction('deadbeef-dead-4ead-8ead-deadbeefdead'), ); findById.mockResolvedValue(undefined); - pollTransaction.mockResolvedValue(txId); + checkHorizonTransactionForTrack.mockResolvedValue('confirmed'); await handler.handle({ jsonrpc: '2.0', @@ -352,8 +462,8 @@ describe('TrackTransactionHandler', () => { }); it('skips sync when no persisted row exists', async () => { - const { handler, pollTransaction, synchronize } = setup(); - pollTransaction.mockResolvedValue(txId); + const { handler, checkHorizonTransactionForTrack, synchronize } = setup(); + checkHorizonTransactionForTrack.mockResolvedValue('confirmed'); await handler.handle({ jsonrpc: '2.0', @@ -366,7 +476,7 @@ describe('TrackTransactionHandler', () => { }, }); - expect(pollTransaction).toHaveBeenCalledWith(txId, scope); + expect(checkHorizonTransactionForTrack).toHaveBeenCalledWith(txId, scope); expect(synchronize).not.toHaveBeenCalled(); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts index 3fbccb19..33b43b20 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts @@ -13,13 +13,13 @@ import { } from './api'; import { CronjobBaseHandler } from './base'; import type { KnownCaip2ChainId } from '../../api'; +import { AppConfig } from '../../config'; import { KEYRING_ACCOUNT_TYPE, METAMASK_ORIGIN } from '../../constants'; import type { AccountService, StellarKeyringAccount, } from '../../services/account'; import type { NetworkService } from '../../services/network'; -import { TransactionPollException } from '../../services/network/exceptions'; import type { OnChainAccountService } from '../../services/on-chain-account'; import type { TransactionService } from '../../services/transaction'; import type { ILogger } from '../../utils/logger'; @@ -31,14 +31,16 @@ import { } from '../../utils/snap'; /** - * Polls Soroban RPC for transaction settlement first, then updates keyring status and runs - * {@link OnChainAccountService.synchronize}. The persisted keyring transaction in snap state - * (by hash) is the source of truth for which account to sync. + * Tracks transaction settlement via Horizon inclusion. Each cron run calls + * {@link NetworkService.checkHorizonTransactionForTrack} once; reschedules via + * `scheduleBackgroundEvent` when the result is `'pending'`, then syncs before settling + * Confirmed. The persisted keyring transaction in snap state (by hash) is the source of truth for + * which account to sync. */ export class TrackTransactionHandler extends CronjobBaseHandler { static async scheduleBackgroundEvent( params: TrackTransactionParams, - duration: Duration = Duration.OneSecond, + duration: Duration = Duration.TwoSeconds, ): Promise { await scheduleBackgroundEvent({ method: BackgroundEventMethod.TrackTransaction, @@ -88,34 +90,56 @@ export class TrackTransactionHandler extends CronjobBaseHandler { - const { txId, scope, accountIds, attempt: attemptRaw } = request.params; + const { txId, scope, accountIds, attempt = 0 } = request.params; this.logger.debug('Tracking transaction', { txId, scope, - attempt: attemptRaw ?? 0, + attempt, }); - // When no row exists in snap state, we still poll (inclusion can still be observed) and - // `updateKeyringTransactionStatus` still runs via cron `accountIds` when the poll is - // terminal. Whether to short-circuit or reschedule in that case is unresolved. + // When no row exists in snap state, we still check Horizon (inclusion can still be observed) + // and `updateKeyringTransactionStatus` still runs via cron `accountIds` when terminal. // TODO: Revisit behavior when `findKeyringTransactionByTransactionId` - // returns undefined (e.g. early-exit poll, stronger logging, or reschedule policy). + // returns undefined (e.g. early-exit, stronger logging, or reschedule policy). const persistedKeyringTransaction = await this.#transactionService.findKeyringTransactionByTransactionId( txId, ); + const accountsToSync = await this.#resolveAccountsForSynchronize({ + persistedKeyringTransaction, + }); + + const trackStatus = + await this.#networkService.checkHorizonTransactionForTrack(txId, scope); + + if (trackStatus === 'pending') { + const rescheduled = await this.#rescheduleWhenHorizonNotIndexed({ + txId, + scope, + accountIds, + attempt, + }); + if (rescheduled) { + return; + } + } + let keyringStatus: | TransactionStatus.Confirmed | TransactionStatus.Failed | null = null; - try { - await this.#networkService.pollTransaction(txId, scope); - this.logger.info('TrackTransaction: RPC settled; synchronizing', { + + if (trackStatus === 'confirmed') { + this.logger.info('TrackTransaction: Horizon settled; synchronizing', { txId, scope, }); + await this.#synchronizeIfNeeded(accountsToSync, scope, { + txId, + persistedAccountId: persistedKeyringTransaction?.account, + }); keyringStatus = TransactionStatus.Confirmed; // TODO: we hardcode the account type, and orgin for now, @@ -125,50 +149,81 @@ export class TrackTransactionHandler extends CronjobBaseHandler 0) { + await this.#synchronizeAccounts(accountsToSync, scope); } + } - // TODO: Consider skipping synchronize when the keyring row stayed - // pending (no terminal poll result) to avoid redundant on-chain refreshes. - const accountsToSync = await this.#resolveAccountsForSynchronize({ - persistedKeyringTransaction, - }); - if (accountsToSync.length > 0) { - await this.#synchronizeAccounts(accountsToSync, scope); - } else { - this.logger.warn( - 'TrackTransaction: account not found when tracking the transaction, unable to sync', + /** + * Reschedules the track job when Horizon has not indexed the tx yet and budget remains. + * + * @param params - Inputs for the follow-up background event. + * @param params.txId - Transaction hash to keep tracking. + * @param params.scope - CAIP-2 chain id for the network. + * @param params.accountIds - Keyring account ids passed through to settlement. + * @param params.attempt - Current track cron attempt (matches serialized `attempt` param). + * @returns True when a follow-up background event was scheduled. + */ + async #rescheduleWhenHorizonNotIndexed(params: { + txId: string; + scope: KnownCaip2ChainId; + accountIds: readonly string[]; + attempt: number; + }): Promise { + const { txId, scope, accountIds, attempt } = params; + const maxReschedules = AppConfig.transaction.trackTransactionMaxReschedules; + + if (attempt < maxReschedules) { + this.logger.debug( + 'TrackTransaction: Horizon not indexed; scheduling reschedule', + { txId, scope, attempt, maxReschedules }, + ); + await TrackTransactionHandler.scheduleBackgroundEvent( { txId, scope, - persistedAccountId: persistedKeyringTransaction?.account, + accountIds: [...accountIds], + attempt: attempt + 1, }, + Duration.TwoSeconds, ); + return true; } + + this.logger.warn( + 'TrackTransaction: Horizon not indexed after max reschedules; leaving keyring transaction pending', + { + txId, + scope, + attempt, + maxReschedules, + }, + ); + return false; } /** @@ -197,6 +252,26 @@ export class TrackTransactionHandler extends CronjobBaseHandler { + if (accounts.length > 0) { + await this.#synchronizeAccounts(accounts, scope); + return; + } + + this.logger.warn( + 'TrackTransaction: account not found when tracking the transaction, unable to sync', + { + txId: context.txId, + scope, + persistedAccountId: context.persistedAccountId, + }, + ); + } + async #synchronizeAccounts( accounts: StellarKeyringAccount[], scope: KnownCaip2ChainId, diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts index e8739e31..58e13d3b 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts @@ -717,6 +717,60 @@ describe('NetworkService', () => { }); }); + describe('checkHorizonTransactionForTrack', () => { + it('returns confirmed when Horizon reports success', async () => { + jest + .spyOn(networkService, 'getHorizonTransactionInclusionStatus') + .mockResolvedValue('success'); + + const result = await networkService.checkHorizonTransactionForTrack( + testTransactionHash, + scope, + ); + + expect(result).toBe('confirmed'); + }); + + it('returns pending when Horizon has not indexed the tx', async () => { + jest + .spyOn(networkService, 'getHorizonTransactionInclusionStatus') + .mockResolvedValue('pending'); + + const result = await networkService.checkHorizonTransactionForTrack( + testTransactionHash, + scope, + ); + + expect(result).toBe('pending'); + }); + + it('returns failed when Horizon reports a failed ledger outcome', async () => { + jest + .spyOn(networkService, 'getHorizonTransactionInclusionStatus') + .mockResolvedValue('failed'); + + const result = await networkService.checkHorizonTransactionForTrack( + testTransactionHash, + scope, + ); + + expect(result).toBe('failed'); + }); + + it('returns unavailable when Horizon check throws', async () => { + jest + .spyOn(networkService, 'getHorizonTransactionInclusionStatus') + .mockRejectedValue(new Error('timeout')); + + const result = await networkService.checkHorizonTransactionForTrack( + testTransactionHash, + scope, + ); + + expect(result).toBe('unavailable'); + }); + }); + describe('send', () => { it('returns transaction hash when pollTransaction is false', async () => { const { sendTransactionSpy, pollTransactionSpy } = getRpcServerSpies(); diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index ddb1e6b2..9fba4c6f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -9,8 +9,11 @@ import { } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; -import type { AssetDataResponse } from './api'; import { KnownRpcError } from './api'; +import type { + AssetDataResponse, + HorizonTransactionTrackCheckStatus, +} from './api'; import { AccountLoadException, AccountNotActivatedException, @@ -185,6 +188,46 @@ export class NetworkService { } } + /** + * Reads Horizon once to decide whether the track-transaction cron should reschedule, settle, or + * stop. Returns `'pending'` when the tx is not indexed yet (Horizon 404). + * + * Unlike {@link pollTransaction}, this does not loop: the track-transaction cron handler + * reschedules via `scheduleBackgroundEvent` until + * {@link AppConfig.transaction.trackTransactionMaxReschedules}. + * + * @param transactionHash - Hash returned from `sendTransaction`. + * @param scope - The CAIP-2 chain ID. + * @returns Explicit reschedule / terminal outcome for one cron cycle. + */ + async checkHorizonTransactionForTrack( + transactionHash: string, + scope: KnownCaip2ChainId, + ): Promise { + try { + const inclusionStatus = await this.getHorizonTransactionInclusionStatus( + transactionHash, + scope, + ); + + if (inclusionStatus === 'pending') { + return 'pending'; + } + + if (inclusionStatus === 'success') { + return 'confirmed'; + } + + return 'failed'; + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to check Horizon transaction for track job', + error, + ); + return 'unavailable'; + } + } + /** * Polls Soroban RPC until the transaction reaches a terminal status, then returns the hash on * success or throws. diff --git a/merged-packages/stellar-wallet-snap/src/services/network/api.ts b/merged-packages/stellar-wallet-snap/src/services/network/api.ts index 9cb281f3..cd211ed2 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/api.ts @@ -32,3 +32,14 @@ export type AssetDataResponse = { // CAIP-19 classic asset id (`…/asset:CODE-ISSUER`) from RPC / Stellar asset contract assetId: KnownCaip19AssetId; }; + +/** + * Horizon inclusion outcome for one track-transaction cron read. The handler reschedules when + * the status is `pending` and the attempt budget allows. The transaction hash is always the cron + * `txId` passed into {@link NetworkService.checkHorizonTransactionForTrack}. + */ +export type HorizonTransactionTrackCheckStatus = + | 'pending' + | 'confirmed' + | 'failed' + | 'unavailable'; diff --git a/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts index 262b2f80..50dce4a4 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts @@ -18,7 +18,10 @@ export class BaseFeeFetchException extends NetworkServiceException { } } -/** Thrown when transaction polling does not result in SUCCESS (e.g. failed or unknown status). */ +/** + * Thrown when Soroban RPC {@link NetworkService.pollTransaction} does not result in SUCCESS + * (e.g. failed or unknown status). + */ export class TransactionPollException extends NetworkServiceException { readonly transactionHash: string; diff --git a/merged-packages/stellar-wallet-snap/src/utils/snap.ts b/merged-packages/stellar-wallet-snap/src/utils/snap.ts index 723fdc45..98585fc0 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/snap.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/snap.ts @@ -16,6 +16,7 @@ import { type Serializable, serialize, deserialize } from './serialization'; export enum Duration { OneSecond = 'PT1S', + TwoSeconds = 'PT2S', FiveSeconds = 'PT5S', TwentySeconds = 'PT20S', ThirtySeconds = 'PT30S', From fabf03aadc416ca533e6af033cbc384a90e4809f Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 2 Jun 2026 16:34:02 +0200 Subject: [PATCH 265/384] chore: handle comments --- .../src/handlers/clientRequest/api.ts | 23 +++++- .../clientRequest/getAccountAssetInfo.ts | 72 +++++++------------ .../src/services/account-asset-info/api.ts | 24 ------- .../services/account-asset-info/exceptions.ts | 6 -- .../src/services/account-asset-info/index.ts | 6 -- 5 files changed, 47 insertions(+), 84 deletions(-) delete mode 100644 merged-packages/stellar-wallet-snap/src/services/account-asset-info/api.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/services/account-asset-info/exceptions.ts delete mode 100644 merged-packages/stellar-wallet-snap/src/services/account-asset-info/index.ts diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts index 145f0fbd..74edde08 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts @@ -1,4 +1,5 @@ import { AssetStruct, FeeType } from '@metamask/keyring-api'; +import type { FungibleAssetMetadata } from '@metamask/snaps-sdk'; import type { Infer } from '@metamask/superstruct'; import { enums, @@ -35,7 +36,6 @@ import { ValidStellarAmountStruct, SwapTransactionXdrStruct, } from '../../api'; -import { AccountAssetInfoEntryStruct } from '../../services/account-asset-info'; import { isSep41Id } from '../../utils'; /** @@ -141,6 +141,27 @@ export const ChangeTrustOptJsonRpcResponseStruct = object({ transactionId: optional(StellarTransactionHashStruct), }); +/** + * Optional per-asset fields for chains that use trust lines (Stellar classic). + */ +export const AccountAssetInfoExtraStruct = object({ + limit: optional(string()), + authorized: optional(boolean()), + sponsored: optional(boolean()), +}); + +export type AccountAssetInfoExtra = Infer; + +export type AccountAssetInfoEntry = { + metadata: FungibleAssetMetadata; + extra?: AccountAssetInfoExtra; +}; + +export const AccountAssetInfoEntryStruct = object({ + metadata: type({}), + extra: optional(AccountAssetInfoExtraStruct), +}); + const GetAccountAssetInfoParamsStruct = object({ accountId: UuidStruct, scope: KnownCaip2ChainIdStruct, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts index 208a4cca..8eb50eb1 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts @@ -1,8 +1,9 @@ import { FungibleAssetMetadataStruct } from '@metamask/snaps-sdk'; -import type { Json, JsonRpcRequest } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; import { ensureError } from '@metamask/utils'; import type { + AccountAssetInfoEntry, GetAccountAssetInfoJsonRpcRequest, GetAccountAssetInfoJsonRpcResponse, } from './api'; @@ -12,13 +13,9 @@ import { } from './api'; import { BaseClientRequestHandler } from './base'; import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; -import type { AccountAssetInfoEntry } from '../../services/account-asset-info'; -import type { AccountAssetInfoExtra } from '../../services/account-asset-info/api'; -import { GetAccountAssetInfoException } from '../../services/account-asset-info/exceptions'; import type { AssetMetadataService } from '../../services/asset-metadata/AssetMetadataService'; import type { AccountNotActivatedException } from '../../services/network/exceptions'; import type { OnChainAccount } from '../../services/on-chain-account'; -import type { SpendableBalance } from '../../services/on-chain-account/api'; import { createPrefixedLogger, isClassicAssetId, @@ -32,6 +29,13 @@ import type { } from '../accountResolver'; import { RESOLVE_ACCOUNT_FULL_FROM_KEYRING_STATE } from '../accountResolver'; +class GetAccountAssetInfoException extends Error { + constructor(accountId: string) { + super(`Failed to get account asset info for account ${accountId}`); + this.name = 'GetAccountAssetInfoException'; + } +} + export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< GetAccountAssetInfoJsonRpcRequest, GetAccountAssetInfoJsonRpcResponse @@ -116,12 +120,6 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< return this.#buildAccountAssetInfoResponse(accountId, assets, null); } - async handle( - request: GetAccountAssetInfoJsonRpcRequest | JsonRpcRequest | Json, - ): Promise { - return super.handle(request); - } - async #buildAccountAssetInfoResponse( accountId: string, assets: KnownCaip19AssetIdOrSlip44Id[], @@ -161,11 +159,22 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< onChainAccount === null || !isClassicAssetId(assetId) ? onChainRow : onChainAccount.getRawAsset(assetId); - const extra = buildAccountAssetInfoExtra( - assetId, - onChainRowForExtra, - decimals, - ); + + let extra: AccountAssetInfoEntry['extra']; + if ( + isClassicAssetId(assetId) && + onChainRowForExtra?.limit !== undefined + ) { + extra = { + limit: toDisplayBalance(onChainRowForExtra.limit, decimals), + ...(onChainRowForExtra.authorized === undefined + ? {} + : { authorized: onChainRowForExtra.authorized }), + ...(onChainRowForExtra.sponsored === undefined + ? {} + : { sponsored: onChainRowForExtra.sponsored }), + }; + } result[assetId] = { metadata: assetMetadata, @@ -183,34 +192,3 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< } } } - -/** - * Builds optional trust-line extra fields for classic Stellar assets. - * - * @param assetId - CAIP-19 asset id. - * @param onChainRow - On-chain balance row, if any. - * @param decimals - Asset display decimals. - * @returns Trust-line extra fields, or undefined when not applicable. - */ -function buildAccountAssetInfoExtra( - assetId: KnownCaip19AssetIdOrSlip44Id, - onChainRow: SpendableBalance | undefined, - decimals: number, -): AccountAssetInfoExtra | undefined { - if (!isClassicAssetId(assetId) || onChainRow === undefined) { - return undefined; - } - if (onChainRow.limit === undefined) { - return undefined; - } - - return { - limit: toDisplayBalance(onChainRow.limit, decimals), - ...(onChainRow.authorized === undefined - ? {} - : { authorized: onChainRow.authorized }), - ...(onChainRow.sponsored === undefined - ? {} - : { sponsored: onChainRow.sponsored }), - }; -} diff --git a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/api.ts b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/api.ts deleted file mode 100644 index 1b210fb0..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/api.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { FungibleAssetMetadata } from '@metamask/snaps-sdk'; -import type { Infer } from '@metamask/superstruct'; -import { boolean, object, optional, string, type } from '@metamask/superstruct'; - -/** - * Optional per-asset fields for chains that use trust lines (Stellar classic). - */ -export const AccountAssetInfoExtraStruct = object({ - limit: optional(string()), - authorized: optional(boolean()), - sponsored: optional(boolean()), -}); - -export type AccountAssetInfoExtra = Infer; - -export type AccountAssetInfoEntry = { - metadata: FungibleAssetMetadata; - extra?: AccountAssetInfoExtra; -}; - -export const AccountAssetInfoEntryStruct = object({ - metadata: type({}), - extra: optional(AccountAssetInfoExtraStruct), -}); diff --git a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/exceptions.ts deleted file mode 100644 index f2968eca..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/exceptions.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class GetAccountAssetInfoException extends Error { - constructor(accountId: string) { - super(`Failed to get account asset info for account ${accountId}`); - this.name = 'GetAccountAssetInfoException'; - } -} diff --git a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/index.ts b/merged-packages/stellar-wallet-snap/src/services/account-asset-info/index.ts deleted file mode 100644 index 058c6243..00000000 --- a/merged-packages/stellar-wallet-snap/src/services/account-asset-info/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - AccountAssetInfoExtraStruct, - AccountAssetInfoEntryStruct, -} from './api'; -export type { AccountAssetInfoEntry, AccountAssetInfoExtra } from './api'; -export { GetAccountAssetInfoException } from './exceptions'; From 7e093d7ca74f61aa6099b88ba8117236ed0f499e Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Wed, 3 Jun 2026 09:18:47 +0200 Subject: [PATCH 266/384] chore: handle comment 2 --- .../stellar-wallet-snap/src/context.ts | 1 - .../src/handlers/clientRequest/api.test.ts | 5 +- .../src/handlers/clientRequest/api.ts | 13 +---- .../clientRequest/getAccountAssetInfo.test.ts | 57 +++++-------------- 4 files changed, 16 insertions(+), 60 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index cec6979f..760ddaa6 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -274,7 +274,6 @@ const computeFeeHandler = new ComputeFeeHandler({ const getAccountAssetInfoHandler = new GetAccountAssetInfoHandler({ logger, accountResolver, - assetMetadataService, }); const clientRequestMethodHandlers: Record< diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts index 13f4f13a..b8c330b3 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts @@ -696,10 +696,7 @@ describe('GetAccountAssetInfoJsonRpcResponseStruct', () => { expect(() => assert( { - [classicAssetId]: { - metadata: { name: 'USD Coin', symbol: 'USDC', units: [] }, - extra: { limit: '1' }, - }, + [classicAssetId]: { limit: '1' }, }, GetAccountAssetInfoJsonRpcResponseStruct, ), diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts index 74edde08..9281198e 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts @@ -1,5 +1,4 @@ import { AssetStruct, FeeType } from '@metamask/keyring-api'; -import type { FungibleAssetMetadata } from '@metamask/snaps-sdk'; import type { Infer } from '@metamask/superstruct'; import { enums, @@ -152,16 +151,6 @@ export const AccountAssetInfoExtraStruct = object({ export type AccountAssetInfoExtra = Infer; -export type AccountAssetInfoEntry = { - metadata: FungibleAssetMetadata; - extra?: AccountAssetInfoExtra; -}; - -export const AccountAssetInfoEntryStruct = object({ - metadata: type({}), - extra: optional(AccountAssetInfoExtraStruct), -}); - const GetAccountAssetInfoParamsStruct = object({ accountId: UuidStruct, scope: KnownCaip2ChainIdStruct, @@ -190,7 +179,7 @@ export const GetAccountAssetInfoJsonRpcRequestStruct = assign( */ export const GetAccountAssetInfoJsonRpcResponseStruct = record( string(), - AccountAssetInfoEntryStruct, + AccountAssetInfoExtraStruct, ); /** diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts index 874dbeab..15377cab 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.test.ts @@ -3,19 +3,10 @@ import { BigNumber } from 'bignumber.js'; import type { GetAccountAssetInfoJsonRpcResponse } from './api'; import { ClientRequestMethod } from './api'; import { GetAccountAssetInfoHandler } from './getAccountAssetInfo'; -import { - type KnownCaip19AssetIdOrSlip44Id, - type KnownCaip19ClassicAssetId, - KnownCaip2ChainId, -} from '../../api'; +import { type KnownCaip19ClassicAssetId, KnownCaip2ChainId } from '../../api'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; -import { - createMockAssetMetadataService, - generateMockKeyringAssetMetadata, - USDC_CLASSIC, -} from '../../services/asset-metadata/__mocks__/assets.fixtures'; -import type { KeyringAssetMetadataByAssetId } from '../../services/asset-metadata/api'; +import { USDC_CLASSIC } from '../../services/asset-metadata/__mocks__/assets.fixtures'; import { OnChainAccountService } from '../../services/on-chain-account'; import { createMockAccountWithBalances, @@ -74,20 +65,6 @@ describe('GetAccountAssetInfoHandler', () => { 'resolveOnChainAccountByKeyringAccountId', ); - const { service: assetMetadataService, getAssetsMetadataByAssetIdsSpy } = - createMockAssetMetadataService(); - const mockKeyringAssetMetadata = generateMockKeyringAssetMetadata(); - getAssetsMetadataByAssetIdsSpy.mockImplementation( - async (assetIds: KnownCaip19AssetIdOrSlip44Id[]) => { - const metadataByAssetId = {} as KeyringAssetMetadataByAssetId; - for (const assetId of assetIds) { - metadataByAssetId[assetId] = - mockKeyringAssetMetadata[assetId] ?? null; - } - return metadataByAssetId; - }, - ); - const accountResolver = new AccountResolver({ accountService, onChainAccountService, @@ -97,7 +74,6 @@ describe('GetAccountAssetInfoHandler', () => { const handler = new GetAccountAssetInfoHandler({ logger, accountResolver, - assetMetadataService, }); return { @@ -110,7 +86,7 @@ describe('GetAccountAssetInfoHandler', () => { jest.restoreAllMocks(); }); - it('returns metadata and trustline extra for a classic asset with limit', async () => { + it('returns trustline fields for a classic asset with limit', async () => { const { handler, resolveOnChainAccountByKeyringAccountIdSpy } = setup(); const onChainAccount = createTestOnChainAccount( 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', @@ -138,17 +114,14 @@ describe('GetAccountAssetInfoHandler', () => { }, })) as GetAccountAssetInfoJsonRpcResponse; - expect(result[USDC_CLASSIC]).toMatchObject({ - metadata: { symbol: 'USDC' }, - extra: { - limit: '1', - authorized: true, - sponsored: false, - }, + expect(result[USDC_CLASSIC]).toStrictEqual({ + limit: '1', + authorized: true, + sponsored: false, }); }); - it('returns extra with zero limit for classic tombstone rows', async () => { + it('returns zero limit for classic tombstone rows', async () => { const { handler, resolveOnChainAccountByKeyringAccountIdSpy } = setup(); const onChainAccount = createTestOnChainAccount( 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', @@ -174,10 +147,10 @@ describe('GetAccountAssetInfoHandler', () => { }, })) as GetAccountAssetInfoJsonRpcResponse; - expect(result[USDC_CLASSIC]?.extra).toStrictEqual({ limit: '0' }); + expect(result[USDC_CLASSIC]).toStrictEqual({ limit: '0' }); }); - it('omits extra when classic asset has no on-chain row', async () => { + it('returns empty trustline entry when classic asset has no on-chain row', async () => { const { handler, resolveOnChainAccountByKeyringAccountIdSpy } = setup(); const onChainAccount = createTestOnChainAccount( 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', @@ -197,8 +170,7 @@ describe('GetAccountAssetInfoHandler', () => { }, })) as GetAccountAssetInfoJsonRpcResponse; - expect(result[USDC_CLASSIC]?.metadata).toBeDefined(); - expect(result[USDC_CLASSIC]?.extra).toBeUndefined(); + expect(result[USDC_CLASSIC]).toStrictEqual({}); }); it('tolerates unactivated accounts with null on-chain state', async () => { @@ -216,11 +188,10 @@ describe('GetAccountAssetInfoHandler', () => { }, })) as GetAccountAssetInfoJsonRpcResponse; - expect(result[USDC_CLASSIC]?.metadata).toBeDefined(); - expect(result[USDC_CLASSIC]?.extra).toBeUndefined(); + expect(result[USDC_CLASSIC]).toStrictEqual({}); }); - it('returns native slip44 metadata when on-chain account exists', async () => { + it('omits non-classic assets from the response', async () => { const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); const { handler, resolveOnChainAccountByKeyringAccountIdSpy } = setup(); const onChainAccount = createTestOnChainAccount( @@ -245,7 +216,7 @@ describe('GetAccountAssetInfoHandler', () => { }, })) as GetAccountAssetInfoJsonRpcResponse; - expect(result).toHaveProperty(slipId); + expect(result).toStrictEqual({}); }); it('throws when on-chain account resolution fails', async () => { From e9d03b1e794ef543b8943d4442e417260da8971c Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Wed, 3 Jun 2026 09:23:03 +0200 Subject: [PATCH 267/384] chore: simplify logic in buildAccountAssetInfoResponse --- .../clientRequest/getAccountAssetInfo.ts | 73 ++++++------------- 1 file changed, 21 insertions(+), 52 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts index 8eb50eb1..4a69d77d 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts @@ -1,9 +1,8 @@ -import { FungibleAssetMetadataStruct } from '@metamask/snaps-sdk'; import type { Json } from '@metamask/utils'; import { ensureError } from '@metamask/utils'; import type { - AccountAssetInfoEntry, + AccountAssetInfoExtra, GetAccountAssetInfoJsonRpcRequest, GetAccountAssetInfoJsonRpcResponse, } from './api'; @@ -13,13 +12,12 @@ import { } from './api'; import { BaseClientRequestHandler } from './base'; import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; -import type { AssetMetadataService } from '../../services/asset-metadata/AssetMetadataService'; +import { STELLAR_DECIMAL_PLACES } from '../../constants'; import type { AccountNotActivatedException } from '../../services/network/exceptions'; import type { OnChainAccount } from '../../services/on-chain-account'; import { createPrefixedLogger, isClassicAssetId, - isSep41Id, toDisplayBalance, type ILogger, } from '../../utils'; @@ -40,8 +38,6 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< GetAccountAssetInfoJsonRpcRequest, GetAccountAssetInfoJsonRpcResponse > { - readonly #assetMetadataService: AssetMetadataService; - readonly #logger: ILogger; #pendingRequest?: GetAccountAssetInfoJsonRpcRequest; @@ -49,11 +45,9 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< constructor({ logger, accountResolver, - assetMetadataService, }: { logger: ILogger; accountResolver: AccountResolver; - assetMetadataService: AssetMetadataService; }) { const prefixedLogger = createPrefixedLogger( logger, @@ -66,7 +60,6 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< responseStruct: GetAccountAssetInfoJsonRpcResponseStruct, resolveAccountOptions: RESOLVE_ACCOUNT_FULL_FROM_KEYRING_STATE, }); - this.#assetMetadataService = assetMetadataService; this.#logger = prefixedLogger; } @@ -82,11 +75,11 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< } /** - * Returns fungible metadata and optional trust-line fields for the requested assets. + * Returns trust-line fields for requested Stellar classic assets. * * @param resolved - Keyring account and persisted on-chain snapshot. * @param request - JSON-RPC request with accountId, scope, and assets. - * @returns Per-asset metadata and optional trust-line extra fields. + * @returns Per-asset trust-line fields keyed by classic asset id. */ protected async execute( resolved: ResolvedActivatedAccount, @@ -101,11 +94,11 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< } /** - * Returns fungible metadata without trust-line extras when the account is not activated. + * Returns empty trust-line entries when the account is not activated. * Tolerates unactivated accounts for portfolio-import UX instead of showing the activation prompt. * * @param _error - The account not activated error. - * @returns Per-asset metadata without on-chain trust-line fields. + * @returns Per-asset trust-line fields without on-chain data. */ protected override async handleAccountNotActivatedError( _error: AccountNotActivatedException, @@ -124,61 +117,37 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< accountId: string, assets: KnownCaip19AssetIdOrSlip44Id[], onChainAccount: OnChainAccount | null, - ): Promise> { + ): Promise> { const result = {} as Record< KnownCaip19AssetIdOrSlip44Id, - AccountAssetInfoEntry + AccountAssetInfoExtra >; try { - const assetsMetadata = - await this.#assetMetadataService.getAssetsMetadataByAssetIds(assets); - for (const assetId of assets) { - const assetMetadata = assetsMetadata[assetId]; - if ( - assetMetadata === undefined || - assetMetadata === null || - !FungibleAssetMetadataStruct.is(assetMetadata) || - assetMetadata.units[0]?.decimals === undefined - ) { + if (!isClassicAssetId(assetId)) { continue; } - const onChainRow = + const assetData = onChainAccount === null ? undefined - : onChainAccount.getAsset(assetId); - - if (isSep41Id(assetId) && !onChainRow?.balance.gt(0)) { - continue; - } - - const { decimals } = assetMetadata.units[0]; - const onChainRowForExtra = - onChainAccount === null || !isClassicAssetId(assetId) - ? onChainRow : onChainAccount.getRawAsset(assetId); - let extra: AccountAssetInfoEntry['extra']; - if ( - isClassicAssetId(assetId) && - onChainRowForExtra?.limit !== undefined - ) { - extra = { - limit: toDisplayBalance(onChainRowForExtra.limit, decimals), - ...(onChainRowForExtra.authorized === undefined - ? {} - : { authorized: onChainRowForExtra.authorized }), - ...(onChainRowForExtra.sponsored === undefined - ? {} - : { sponsored: onChainRowForExtra.sponsored }), - }; + if (assetData?.limit === undefined) { + result[assetId] = {}; + continue; } + const decimals = assetData.decimals ?? STELLAR_DECIMAL_PLACES; result[assetId] = { - metadata: assetMetadata, - ...(extra === undefined ? {} : { extra }), + limit: toDisplayBalance(assetData.limit, decimals), + ...(assetData.authorized === undefined + ? {} + : { authorized: assetData.authorized }), + ...(assetData.sponsored === undefined + ? {} + : { sponsored: assetData.sponsored }), }; } From 580447f184c82199c325bddaf9fd003b0eba9f02 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Wed, 3 Jun 2026 10:50:10 +0200 Subject: [PATCH 268/384] fix: fix comments --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../ConfirmSendTransaction/ConfirmSendTransaction.tsx | 11 ++++++----- .../ConfirmSignChangeTrustOptIn.tsx | 11 ++++++----- .../ConfirmSignChangeTrustOptOut.tsx | 11 ++++++----- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 764e67cb..864e39af 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "rnecWdWBbtseCn4pl7lcpJOnkP9Ks5+o1Q/1Cc1yFWc=", + "shasum": "SK2tUsswSpgryEIHWiEXfwq0H/0eMiD3mHvH0Dau1nU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx index f0faafdc..a6317db5 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx @@ -94,7 +94,12 @@ export const ConfirmSendTransaction = ({ return ( - {hasEnabledTransactionScan(preferences) ? ( + + {transactionsFetchStatus !== FetchStatus.Error && + hasEnabledTransactionScan(preferences) ? ( ) : null} - {null} {t(`confirmation.transaction.title`)} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx index b577260f..75d18d74 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx @@ -76,7 +76,12 @@ export const ConfirmSignChangeTrustOptIn = ({ return ( - {hasEnabledTransactionScan(preferences) ? ( + + {transactionsFetchStatus !== FetchStatus.Error && + hasEnabledTransactionScan(preferences) ? ( ) : null} - {null} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx index c710912e..bf101ccc 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx @@ -76,7 +76,12 @@ export const ConfirmSignChangeTrustOptOut = ({ return ( - {hasEnabledTransactionScan(preferences) ? ( + + {transactionsFetchStatus !== FetchStatus.Error && + hasEnabledTransactionScan(preferences) ? ( ) : null} - {null} From ee078ac33307b1675d9e62a106467d70236ddc9d Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Wed, 3 Jun 2026 12:15:46 +0200 Subject: [PATCH 269/384] chore: handle comment --- .../src/handlers/clientRequest/base.ts | 4 +- .../src/handlers/clientRequest/confirmSend.ts | 2 + .../clientRequest/getAccountAssetInfo.ts | 130 ++++++++---------- .../handlers/clientRequest/onAmountInput.ts | 2 + 4 files changed, 67 insertions(+), 71 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/base.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/base.ts index f21cd39f..231e3832 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/base.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/base.ts @@ -93,7 +93,7 @@ export abstract class BaseClientRequestHandler< return await this.execute(resolvedAccount, request); } catch (error: unknown) { if (error instanceof AccountNotActivatedException) { - return this.handleAccountNotActivatedError(error); + return this.handleAccountNotActivatedError(error, request); } throw error; } @@ -118,9 +118,11 @@ export abstract class BaseClientRequestHandler< * Rethrows the error to be handled by the caller. * * @param error - The account not activated error. + * @param _request - The JSON-RPC request that triggered resolution. */ protected async handleAccountNotActivatedError( error: AccountNotActivatedException, + _request: RequestType, ): Promise { await this.#showAccountNotActivatedAlert(error.address); throw error; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts index 8d9db190..e0cc054e 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts @@ -310,10 +310,12 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< * Instead of showing the account not activated alert, it returns an invalid response. * * @param _error - The error to handle. + * @param _request - The JSON-RPC request (unused for this handler). * @returns The invalid response when the account is not activated. */ protected override async handleAccountNotActivatedError( _error: AccountNotActivatedException, + _request: ConfirmSendJsonRpcRequest, ): Promise { return { valid: false, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts index 4a69d77d..5a10ae9f 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts @@ -1,6 +1,3 @@ -import type { Json } from '@metamask/utils'; -import { ensureError } from '@metamask/utils'; - import type { AccountAssetInfoExtra, GetAccountAssetInfoJsonRpcRequest, @@ -27,21 +24,12 @@ import type { } from '../accountResolver'; import { RESOLVE_ACCOUNT_FULL_FROM_KEYRING_STATE } from '../accountResolver'; -class GetAccountAssetInfoException extends Error { - constructor(accountId: string) { - super(`Failed to get account asset info for account ${accountId}`); - this.name = 'GetAccountAssetInfoException'; - } -} - export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< GetAccountAssetInfoJsonRpcRequest, GetAccountAssetInfoJsonRpcResponse > { readonly #logger: ILogger; - #pendingRequest?: GetAccountAssetInfoJsonRpcRequest; - constructor({ logger, accountResolver, @@ -63,17 +51,6 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< this.#logger = prefixedLogger; } - protected override async handleRequest( - request: GetAccountAssetInfoJsonRpcRequest, - ): Promise { - this.#pendingRequest = request; - try { - return await super.handleRequest(request); - } finally { - this.#pendingRequest = undefined; - } - } - /** * Returns trust-line fields for requested Stellar classic assets. * @@ -86,11 +63,7 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< request: GetAccountAssetInfoJsonRpcRequest, ): Promise { const { assets } = request.params; - return this.#buildAccountAssetInfoResponse( - resolved.account.id, - assets, - resolved.onChainAccount, - ); + return this.#buildAccountAssetInfoResponse(resolved.onChainAccount, assets); } /** @@ -98,66 +71,83 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< * Tolerates unactivated accounts for portfolio-import UX instead of showing the activation prompt. * * @param _error - The account not activated error. + * @param request - The JSON-RPC request with assets to describe. * @returns Per-asset trust-line fields without on-chain data. */ protected override async handleAccountNotActivatedError( _error: AccountNotActivatedException, + request: GetAccountAssetInfoJsonRpcRequest, ): Promise { - const request = this.#pendingRequest; - if (request === undefined) { - throw new Error( - 'Missing request context for unactivated account handling', - ); - } - const { accountId, assets } = request.params; - return this.#buildAccountAssetInfoResponse(accountId, assets, null); + const { assets } = request.params; + return this.#buildEmptyTrustLineEntries(assets); } - async #buildAccountAssetInfoResponse( - accountId: string, + #buildEmptyTrustLineEntries( assets: KnownCaip19AssetIdOrSlip44Id[], - onChainAccount: OnChainAccount | null, - ): Promise> { + ): Record { const result = {} as Record< KnownCaip19AssetIdOrSlip44Id, AccountAssetInfoExtra >; - try { - for (const assetId of assets) { - if (!isClassicAssetId(assetId)) { - continue; - } + for (const assetId of assets) { + if (!isClassicAssetId(assetId)) { + continue; + } + result[assetId] = {}; + } + + return result; + } + + #buildAccountAssetInfoResponse( + onChainAccount: OnChainAccount, + assets: KnownCaip19AssetIdOrSlip44Id[], + ): Record { + const result = {} as Record< + KnownCaip19AssetIdOrSlip44Id, + AccountAssetInfoExtra + >; - const assetData = - onChainAccount === null - ? undefined - : onChainAccount.getRawAsset(assetId); + for (const assetId of assets) { + if (!isClassicAssetId(assetId)) { + continue; + } - if (assetData?.limit === undefined) { - result[assetId] = {}; - continue; - } + // Use getRawAsset (not getAsset): trust-line UX needs tombstones and + // zero-limit rows that getAsset filters out for spendable-balance flows. + const assetData = onChainAccount.getRawAsset(assetId); - const decimals = assetData.decimals ?? STELLAR_DECIMAL_PLACES; - result[assetId] = { - limit: toDisplayBalance(assetData.limit, decimals), - ...(assetData.authorized === undefined - ? {} - : { authorized: assetData.authorized }), - ...(assetData.sponsored === undefined - ? {} - : { sponsored: assetData.sponsored }), - }; + if (assetData?.limit === undefined) { + this.#logger.logErrorWithDetails( + 'Data error: classic asset missing trust-line limit in on-chain snapshot', + { + assetId, + reason: + assetData === undefined + ? 'No stored row for this classic asset id (not synced or never trusted)' + : 'Stored row exists but limit field is undefined', + remark: + 'Returning empty trust-line entry; portfolio may treat asset as untrusted', + todo: 'Todo: re-fetch from horizon', + }, + ); + result[assetId] = {}; + continue; } - return result; - } catch (error: unknown) { - this.#logger.logErrorWithDetails( - 'Failed to get account asset info', - ensureError(error).message, - ); - throw new GetAccountAssetInfoException(accountId); + const decimals = assetData.decimals ?? STELLAR_DECIMAL_PLACES; + result[assetId] = { + limit: toDisplayBalance(assetData.limit, decimals), + ...(assetData.authorized === undefined + ? {} + : { authorized: assetData.authorized }), + ...(assetData.sponsored === undefined + ? {} + : { sponsored: assetData.sponsored }), + }; } + + return result; } } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts index 6eb604e9..bb9d9ac7 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts @@ -148,10 +148,12 @@ export class OnAmountInputHandler extends BaseClientRequestHandler< * Instead of showing the account not activated alert, it returns an invalid response. * * @param _error - The error to handle. + * @param _request - The JSON-RPC request (unused for this handler). * @returns The invalid response when the account is not activated. */ protected override async handleAccountNotActivatedError( _error: AccountNotActivatedException, + _request: OnAmountInputJsonRpcRequest, ): Promise { return { valid: false, From 98ef090fcd39eae5fa134a361a238d282d5089bf Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Wed, 3 Jun 2026 12:36:30 +0200 Subject: [PATCH 270/384] chore: todo is a todo --- .../src/handlers/clientRequest/getAccountAssetInfo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts index 5a10ae9f..2092b1f3 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/getAccountAssetInfo.ts @@ -119,6 +119,7 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< const assetData = onChainAccount.getRawAsset(assetId); if (assetData?.limit === undefined) { + // TODO: re-fetch from horizon when classic asset row is missing or has no limit. this.#logger.logErrorWithDetails( 'Data error: classic asset missing trust-line limit in on-chain snapshot', { @@ -129,7 +130,6 @@ export class GetAccountAssetInfoHandler extends BaseClientRequestHandler< : 'Stored row exists but limit field is undefined', remark: 'Returning empty trust-line entry; portfolio may treat asset as untrusted', - todo: 'Todo: re-fetch from horizon', }, ); result[assetId] = {}; From 8b3bc462a9666f86366350e1d1932cd3dabc3727 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:47:46 +0800 Subject: [PATCH 271/384] chore: use native raw balance (#80) ## Explanation ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- .../src/handlers/keyring/keyring.test.ts | 2 +- .../src/handlers/keyring/keyring.ts | 20 ++++++--- .../OnChainAccountSynchronizeService.test.ts | 13 ++++-- .../OnChainAccountSynchronizeService.ts | 41 +++++++++++-------- 4 files changed, 48 insertions(+), 28 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 494aae85..ead71c21 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -643,7 +643,7 @@ describe('KeyringHandler', () => { ]); expect(result).toStrictEqual({ - [slipId]: { unit: 'XLM', amount: '0.000001' }, + [slipId]: { unit: 'XLM', amount: '1.000001' }, }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 423aa4e0..8bf8d3df 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -62,12 +62,12 @@ import type { KnownCaip2ChainId, } from '../../api'; import { AppConfig } from '../../config'; +import { NATIVE_ASSET_SYMBOL } from '../../constants'; import type { AccountService, StellarKeyringAccount, } from '../../services/account'; import { AccountNotFoundException } from '../../services/account/exceptions'; -import { getNativeAssetMetadata } from '../../services/asset-metadata/utils'; import type { OnChainAccount, OnChainAccountService, @@ -449,7 +449,7 @@ export class KeyringHandler implements Keyring { const nativeAssetId = assets.find(isSlip44Id); if (nativeAssetId !== undefined) { assetBalances[nativeAssetId] = { - unit: getNativeAssetMetadata(scope).symbol ?? '', + unit: NATIVE_ASSET_SYMBOL, amount: '0', }; } @@ -463,10 +463,18 @@ export class KeyringHandler implements Keyring { continue; } - assetBalances[assetId] = { - unit: asset.symbol ?? '', - amount: toDisplayBalance(asset.balance, asset.decimals), - }; + if (isSlip44Id(assetId)) { + // We show the raw native balance, not the spendable balance for XLM + assetBalances[assetId] = { + unit: NATIVE_ASSET_SYMBOL, + amount: toDisplayBalance(onChainAccount.nativeRawBalance), + }; + } else { + assetBalances[assetId] = { + unit: asset.symbol ?? '', + amount: toDisplayBalance(asset.balance, asset.decimals), + }; + } } return assetBalances; } catch (error: unknown) { diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts index 3601241c..2803637f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts @@ -17,6 +17,7 @@ import { import { OnChainAccount } from './OnChainAccount'; import { OnChainAccountRepository } from './OnChainAccountRepository'; import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; +import { NATIVE_ASSET_SYMBOL } from '../../constants'; import { bufferToUint8Array } from '../../utils/buffer'; import { generateStellarKeyringAccount } from '../account/__mocks__/account.fixtures'; import { @@ -346,6 +347,7 @@ describe('OnChainAccountSynchronizeService', () => { expect.objectContaining({ balances: { [keyringAccount.id]: expect.objectContaining({ + [NATIVE]: { unit: NATIVE_ASSET_SYMBOL, amount: '1' }, [sep41Id]: { unit: 'USDC', amount: '5' }, }), }, @@ -357,7 +359,10 @@ describe('OnChainAccountSynchronizeService', () => { KeyringEvent.AccountAssetListUpdated, { assets: { - [keyringAccount.id]: { added: [sep41Id], removed: [] }, + [keyringAccount.id]: { + added: expect.arrayContaining([NATIVE, sep41Id]), + removed: [], + }, }, }, ); @@ -425,7 +430,7 @@ describe('OnChainAccountSynchronizeService', () => { KeyringEvent.AccountAssetListUpdated, { assets: { - [keyringAccount.id]: { added: [], removed: [sep41Id] }, + [keyringAccount.id]: { added: [NATIVE], removed: [sep41Id] }, }, }, ); @@ -882,13 +887,13 @@ describe('OnChainAccountSynchronizeService', () => { // 1st `AccountAssetListUpdated` (after sync 1): USDC trustline becomes visible (limit > 0, balance 0). expect(assetListDeltaFromNthAssetEmit(0)).toStrictEqual({ - added: [USDC_CLASSIC], + added: expect.arrayContaining([NATIVE, USDC_CLASSIC]), removed: [], }); // 2nd `AccountAssetListUpdated` (after sync 4): stale baseline vs current → EURC added, USDC removed from list. expect(assetListDeltaFromNthAssetEmit(1)).toStrictEqual({ - added: [EURC_CLASSIC], + added: expect.arrayContaining([NATIVE, EURC_CLASSIC]), removed: [USDC_CLASSIC], }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts index 30475679..0bbf7ac5 100644 --- a/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.ts @@ -13,6 +13,7 @@ import type { KnownCaip19Sep41AssetId, KnownCaip2ChainId, } from '../../api'; +import { NATIVE_ASSET_SYMBOL } from '../../constants'; import type { ILogger } from '../../utils'; import { createPrefixedLogger, @@ -540,7 +541,7 @@ export class OnChainAccountSynchronizeService { // - XLM (native), USDC classic trustline, EURC classic trustline, SOLBTC SEP-41. // ------------------------- Sync 1 ------------------------------------------ // - Sync 1 (success): payload received by client: - // XLM=10, USDC=25, EURC=0, SOLBTC=5. + // XLM=10 (raw total), USDC=25, EURC=0, SOLBTC=5. // ------------------------- Sync 2 ------------------------------------------ // - Sync 2 (client misses event): chain updates to // XLM=11, USDC=30, EURC trustline removed (it was already zero), SOLBTC=0. @@ -552,17 +553,21 @@ export class OnChainAccountSynchronizeService { // Because SEP-41 zero balances are persisted, payload still includes SOLBTC=0. // ------------------------- Sync 4 ------------------------------------------ // - Sync 4 (success): we still emit full balances for on-chain view + latest snapshot, - // so payload includes XLM=9, USDC=30, SOLBTC=0. + // so payload includes XLM=9 (raw total), USDC=30, SOLBTC=0. // Classic trustlines removed on chain are persisted as internal tombstones (`limit` 0), // so sync 4 can still send balance `0` for those asset ids if the client missed earlier events. - balanceChanges[assetId as string] = this.#buildBalancePayloadFromEntries( - onChainEntry, - latestStateEntry, - ); - - if (assetId === nativeAssetId) { - continue; - } + balanceChanges[assetId as string] = + assetId === nativeAssetId + ? { + unit: NATIVE_ASSET_SYMBOL, + amount: toDisplayBalance( + synchronizedOnChainAccount.nativeRawBalance, + ), + } + : this.#buildBalancePayloadFromEntries( + onChainEntry, + latestStateEntry, + ); const isVisibleFromState = this.#isAssetVisible( assetId, @@ -579,15 +584,17 @@ export class OnChainAccountSynchronizeService { } } + // Native is always persisted on activated accounts; + if (!addedAssets.includes(nativeAssetId)) { + addedAssets.push(nativeAssetId); + } + return { balanceChanges, - assetListChanges: - addedAssets.length > 0 || removedAssets.length > 0 - ? { - added: addedAssets, - removed: removedAssets, - } - : null, + assetListChanges: { + added: addedAssets, + removed: removedAssets, + }, }; } From 9edcdba4c69003da2ffcd5e7c974e9c0434a7a82 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:31:45 +0800 Subject: [PATCH 272/384] feat: transaction scanning patch 1 - refactor transaction object and add get transaction(s) network utils (#87) ## Explanation This PR refactors transaction deserialization into the Transaction model (via factory methods) and adds Horizon-backed transaction fetch/scan utilities to NetworkService, s upporting transaction scanning and richer on-chain metadata (e.g., fee_charged). ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- .../stellar-wallet-snap/src/api/xdr.ts | 16 +- .../stellar-wallet-snap/src/constants.ts | 16 + .../stellar-wallet-snap/src/context.ts | 2 - .../transactionRefresher.test.ts | 59 +-- .../transactionRefresher.ts | 13 +- .../src/handlers/keyring/exceptions.ts | 2 + .../handlers/keyring/signTransaction.test.ts | 64 +--- .../src/handlers/keyring/signTransaction.ts | 15 +- .../services/network/NetworkService.test.ts | 347 +++++++++++++++++- .../src/services/network/NetworkService.ts | 188 +++++++++- .../src/services/network/exceptions.ts | 7 + .../services/transaction/Transaction.test.ts | 133 +++++++ .../src/services/transaction/Transaction.ts | 141 ++++++- .../transaction/TransactionBuilder.test.ts | 22 -- .../transaction/TransactionBuilder.ts | 31 -- .../transaction/TransactionService.ts | 4 +- .../__mocks__/transaction.fixtures.ts | 67 +++- .../src/services/transaction/exceptions.ts | 8 + 18 files changed, 954 insertions(+), 181 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/api/xdr.ts b/merged-packages/stellar-wallet-snap/src/api/xdr.ts index 1d0df95d..c6017782 100644 --- a/merged-packages/stellar-wallet-snap/src/api/xdr.ts +++ b/merged-packages/stellar-wallet-snap/src/api/xdr.ts @@ -2,9 +2,9 @@ import { nonempty, refine, string } from '@metamask/superstruct'; import { base64 } from '@metamask/utils'; import { FeeBumpTransaction, - Networks, - TransactionBuilder as StellarSdkTransactionBuilder, hash, + Networks, + TransactionBuilder as StellarTransactionBuilder, xdr, } from '@stellar/stellar-sdk'; @@ -35,15 +35,11 @@ export const XdrStruct = refine( * @returns Operation type strings in envelope order. */ function getTransactionOperationTypes(value: string): string[] { - const transaction = StellarSdkTransactionBuilder.fromXDR( - value, - Networks.PUBLIC, - ); + const decoded = StellarTransactionBuilder.fromXDR(value, Networks.PUBLIC); const operations = - transaction instanceof FeeBumpTransaction - ? transaction.innerTransaction.operations - : transaction.operations; - + decoded instanceof FeeBumpTransaction + ? decoded.innerTransaction.operations + : decoded.operations; return operations.map((operation) => operation.type); } diff --git a/merged-packages/stellar-wallet-snap/src/constants.ts b/merged-packages/stellar-wallet-snap/src/constants.ts index d625a821..c8709101 100644 --- a/merged-packages/stellar-wallet-snap/src/constants.ts +++ b/merged-packages/stellar-wallet-snap/src/constants.ts @@ -77,6 +77,22 @@ export const KEYRING_ACCOUNT_TYPE = XlmAccountType.Account; export const METAMASK_ORIGIN = 'metamask'; /** + * The maximum page size for the transactions. + * + * @see https://developers.stellar.org/docs/data/apis/horizon/api-reference/get-transactions-by-account-id + */ +export const MAX_TRANSACTIONS_PAGE_SIZE = 200; + +/** + * Maximum number of pages remaining to fetch in this run. + * This keeps scans responsive for high-activity accounts by avoiding full-history fetches at once. + * Callers persist a Horizon paging token (from {@link Transaction.rawData}) between runs to continue incremental sync. + * + * @see {@link NetworkService.getTransactions} + */ +export const MAX_TRANSACTION_SCAN_PAGES = 2; + +/* * The key for the memo required attribute. * It is used to check if the account requires a memo based on the SEP-0029 standard. * diff --git a/merged-packages/stellar-wallet-snap/src/context.ts b/merged-packages/stellar-wallet-snap/src/context.ts index d75aca89..900c285e 100644 --- a/merged-packages/stellar-wallet-snap/src/context.ts +++ b/merged-packages/stellar-wallet-snap/src/context.ts @@ -141,7 +141,6 @@ const accountResolver = new AccountResolver({ const signTransactionHandler = new SignTransactionHandler({ logger, accountResolver, - transactionBuilder, transactionService, confirmationUIController, }); @@ -192,7 +191,6 @@ const confirmationScanRefresher = new ConfirmationScanRefresher({ const confirmationTransactionRefresher = new ConfirmationTransactionRefresher({ logger, transactionService, - transactionBuilder, assetMetadataService, accountResolver, }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.test.ts index 73de690e..6b6961ac 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.test.ts @@ -5,11 +5,7 @@ import { ConfirmationContextRefresherKey } from './api'; import { ConfirmationTransactionRefresher } from './transactionRefresher'; import { KnownCaip2ChainId } from '../../../api'; import type { AssetMetadataService } from '../../../services/asset-metadata'; -import type { - Transaction, - TransactionBuilder, - TransactionService, -} from '../../../services/transaction'; +import type { TransactionService } from '../../../services/transaction'; import { buildMockClassicTransaction } from '../../../services/transaction/__mocks__/transaction.fixtures'; import { FetchStatus } from '../../../ui/confirmation/api'; import { getSlip44AssetId } from '../../../utils'; @@ -83,9 +79,6 @@ describe('ConfirmationTransactionRefresher', () => { .fn() .mockResolvedValue({ onChainAccount: { accountId, scope } }), }; - const transactionBuilder = { - deserialize: jest.fn().mockReturnValue(transaction), - }; const transactionService = { createValidatedSendTransaction: jest.fn().mockResolvedValue(transaction), createValidatedChangeTrustTransaction: jest @@ -99,7 +92,6 @@ describe('ConfirmationTransactionRefresher', () => { const refresher = new ConfirmationTransactionRefresher({ logger, accountResolver: accountResolver as unknown as AccountResolver, - transactionBuilder: transactionBuilder as unknown as TransactionBuilder, transactionService: transactionService as unknown as TransactionService, assetMetadataService: assetMetadataService as unknown as AssetMetadataService, @@ -108,7 +100,6 @@ describe('ConfirmationTransactionRefresher', () => { return { refresher, accountResolver, - transactionBuilder, transactionService, assetMetadataService, }; @@ -202,22 +193,40 @@ describe('ConfirmationTransactionRefresher', () => { }); it('marks the transaction invalid when the original envelope has expired', async () => { - const { refresher, transactionBuilder, transactionService } = setup(); + const { refresher, transactionService } = setup(); // The stored XDR being signed is expired, even though the rebuilt draft would be valid. - // `expirationTime` is a Unix timestamp in seconds. - transactionBuilder.deserialize.mockReturnValueOnce({ - expirationTime: Math.floor(Date.now() / 1000) - 1000, - } as unknown as Transaction); - - const result = await refresher.refresh(createTransactionContext()); - - expect( - transactionService.createValidatedSendTransaction, - ).not.toHaveBeenCalled(); - expect(result).toStrictEqual({ - result: { transactionsFetchStatus: FetchStatus.Error }, - reschedule: false, - }); + const mockNow = 1_700_000_000_000; + jest.useFakeTimers(); + jest.setSystemTime(mockNow); + + try { + const expiredTransaction = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { destination: toAddress, asset: 'native', amount: '1' }, + }, + ], + { networkPassphrase: Networks.TESTNET, timeout: 1 }, + ); + jest.advanceTimersByTime(2000); + + const result = await refresher.refresh( + createTransactionContext({ + transaction: expiredTransaction.getRaw().toXDR(), + }), + ); + + expect( + transactionService.createValidatedSendTransaction, + ).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + result: { transactionsFetchStatus: FetchStatus.Error }, + reschedule: false, + }); + } finally { + jest.useRealTimers(); + } }); it('does not re-fetch once the transaction is already marked invalid', () => { diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts index d21644c9..e54b1379 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/refreshConfirmationContext/transactionRefresher.ts @@ -8,9 +8,9 @@ import { type IConfirmationContextRefresher, } from './api'; import type { AssetMetadataService } from '../../../services/asset-metadata'; -import type { - TransactionBuilder, - TransactionService, +import { + Transaction, + type TransactionService, } from '../../../services/transaction'; import { assertTransactionTimeBound } from '../../../services/transaction/utils'; import type { ContextWithTransactionValidation } from '../../../ui/confirmation/api'; @@ -41,8 +41,6 @@ export class ConfirmationTransactionRefresher implements IConfirmationContextRef readonly #transactionService: TransactionService; - readonly #transactionBuilder: TransactionBuilder; - readonly #assetMetadataService: AssetMetadataService; readonly #accountResolver: AccountResolver; @@ -52,18 +50,15 @@ export class ConfirmationTransactionRefresher implements IConfirmationContextRef constructor({ logger, transactionService, - transactionBuilder, assetMetadataService, accountResolver, }: { logger: ILogger; transactionService: TransactionService; - transactionBuilder: TransactionBuilder; assetMetadataService: AssetMetadataService; accountResolver: AccountResolver; }) { this.#transactionService = transactionService; - this.#transactionBuilder = transactionBuilder; this.#assetMetadataService = assetMetadataService; this.#accountResolver = accountResolver; this.#logger = createPrefixedLogger( @@ -123,7 +118,7 @@ export class ConfirmationTransactionRefresher implements IConfirmationContextRef // Deserialize the envelope awaiting signature and assert its own time bound. // The draft rebuilt below gets a fresh timeout, so validating that draft would // miss expiry of the transaction the user is actually looking at. - const transaction = this.#transactionBuilder.deserialize({ + const transaction = Transaction.fromXdr({ xdr: transactionXdr, scope, }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts index bb26d105..1df21b3c 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/exceptions.ts @@ -20,6 +20,7 @@ import { TransactionSendException, } from '../../services/network/exceptions'; import { + TransactionDeserializationException, TransactionScopeNotMatchException, TransactionValidationException, } from '../../services/transaction/exceptions'; @@ -123,6 +124,7 @@ export function toSep43Error(error: unknown): Sep43Error { // (both extend AccountServiceException) — typically caused by a bad // `opts.address` from the dapp. wrapped instanceof AccountServiceException || + wrapped instanceof TransactionDeserializationException || wrapped instanceof TransactionValidationException || wrapped instanceof TransactionScopeNotMatchException ) { diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts index 227f9816..d2e9b284 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.test.ts @@ -9,7 +9,10 @@ import { generateStellarKeyringAccount } from '../../services/account/__mocks__/ import { SimulationException } from '../../services/network/exceptions'; import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; import type { Transaction } from '../../services/transaction'; -import { TransactionService } from '../../services/transaction'; +import { + TransactionService, + Transaction as WrappedTransaction, +} from '../../services/transaction'; import { buildMockClassicTransaction, createMockTransactionService, @@ -38,8 +41,7 @@ describe('SignTransactionHandler', () => { 0, ); - const { transactionBuilder, transactionService } = - createMockTransactionService(); + const { transactionService } = createMockTransactionService(); const { accountService, onChainAccountService, walletService } = mockOnChainAccountService(); const accountResolver = new AccountResolver({ @@ -72,7 +74,6 @@ describe('SignTransactionHandler', () => { const handler = new SignTransactionHandler({ logger, accountResolver, - transactionBuilder, transactionService, confirmationUIController, }); @@ -81,7 +82,6 @@ describe('SignTransactionHandler', () => { handler, mockAccount, wallet, - transactionBuilder, transactionService, renderConfirmationDialog, }; @@ -129,23 +129,18 @@ describe('SignTransactionHandler', () => { }); it('returns signedTxXdr and signerAddress on confirm', async () => { - const { - handler, - mockAccount, - wallet, - transactionBuilder, - renderConfirmationDialog, - } = setupHandler(); + const { handler, mockAccount, wallet, renderConfirmationDialog } = + setupHandler(); const transaction = buildMainnetPaymentFromWallet(wallet.address); const xdr = transaction.getRaw().toXDR(); - jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); const signSpy = jest.spyOn(wallet, 'signTransaction'); renderConfirmationDialog.mockResolvedValue(true); const result = await handler.handle(buildRequest(mockAccount.id, xdr)); + const signedTransaction = signSpy.mock.calls[0]?.[0] as Transaction; - expect(signSpy).toHaveBeenCalledWith(transaction); + expect(signSpy).toHaveBeenCalledTimes(1); expect(renderConfirmationDialog).toHaveBeenCalledWith( expect.objectContaining({ renderOptions: { @@ -159,23 +154,17 @@ describe('SignTransactionHandler', () => { }), ); expect(result).toStrictEqual({ - signedTxXdr: transaction.getRaw().toXDR(), + signedTxXdr: signedTransaction.getRaw().toXDR(), signerAddress: wallet.address, }); }); it('returns error -4 when user rejects', async () => { - const { - handler, - mockAccount, - wallet, - transactionBuilder, - renderConfirmationDialog, - } = setupHandler(); + const { handler, mockAccount, wallet, renderConfirmationDialog } = + setupHandler(); const transaction = buildMainnetPaymentFromWallet(wallet.address); const xdr = transaction.getRaw().toXDR(); - jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); const signSpy = jest.spyOn(wallet, 'signTransaction'); renderConfirmationDialog.mockResolvedValue(false); @@ -203,13 +192,8 @@ describe('SignTransactionHandler', () => { }); it('returns error -3 when the transaction scope does not match the request scope', async () => { - const { - handler, - mockAccount, - wallet, - transactionBuilder, - renderConfirmationDialog, - } = setupHandler(); + const { handler, mockAccount, wallet, renderConfirmationDialog } = + setupHandler(); // Build a TESTNET transaction but request signing on MAINNET scope. const testnetTx = buildMockClassicTransaction( @@ -228,25 +212,21 @@ describe('SignTransactionHandler', () => { source: { accountId: wallet.address, sequence: '1' }, }, ); - jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(testnetTx); + const fromXdrSpy = jest + .spyOn(WrappedTransaction, 'fromXdr') + .mockReturnValue(testnetTx); - const result = await handler.handle( - buildRequest(mockAccount.id, testnetTx.getRaw().toXDR()), - ); + const result = await handler.handle(buildRequest(mockAccount.id, 'AAAA')); expect(result).toMatchObject({ error: { code: Sep43ErrorCode.InvalidRequest }, }); expect(renderConfirmationDialog).not.toHaveBeenCalled(); + fromXdrSpy.mockRestore(); }); it('returns error -3 when the wallet does not participate in the transaction', async () => { - const { - handler, - mockAccount, - transactionBuilder, - renderConfirmationDialog, - } = setupHandler(); + const { handler, mockAccount, renderConfirmationDialog } = setupHandler(); const strangerTx = buildMockClassicTransaction( [ @@ -267,8 +247,6 @@ describe('SignTransactionHandler', () => { }, }, ); - jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(strangerTx); - const result = await handler.handle( buildRequest(mockAccount.id, strangerTx.getRaw().toXDR()), ); @@ -377,13 +355,11 @@ describe('SignTransactionHandler', () => { handler, mockAccount, wallet, - transactionBuilder, transactionService, renderConfirmationDialog, } = setupHandler(); const transaction = buildMainnetPaymentFromWallet(wallet.address); - jest.spyOn(transactionBuilder, 'deserialize').mockReturnValue(transaction); jest .spyOn(transactionService, 'computingFee') .mockRejectedValueOnce(new SimulationException('contract not found')); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts index d503ec11..ecce4e69 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/keyring/signTransaction.ts @@ -9,12 +9,8 @@ import type { AccountResolver } from '../accountResolver'; import { BaseSep43KeyringHandler } from './base'; import type { Sep43Error } from './exceptions'; import type { StellarKeyringAccount } from '../../services/account'; -import type { - Transaction, - TransactionBuilder, - TransactionService, -} from '../../services/transaction'; -import { OperationMapper } from '../../services/transaction'; +import type { TransactionService } from '../../services/transaction'; +import { OperationMapper, Transaction } from '../../services/transaction'; import { assertAccountInvolvesTransaction, assertTransactionScope, @@ -41,8 +37,6 @@ export class SignTransactionHandler extends BaseSep43KeyringHandler< SignTransactionRequest, SignTransactionResponse > { - readonly #transactionBuilder: TransactionBuilder; - readonly #transactionService: TransactionService; readonly #confirmationUIController: ConfirmationUXController; @@ -50,13 +44,11 @@ export class SignTransactionHandler extends BaseSep43KeyringHandler< constructor({ logger, accountResolver, - transactionBuilder, transactionService, confirmationUIController, }: { logger: ILogger; accountResolver: AccountResolver; - transactionBuilder: TransactionBuilder; transactionService: TransactionService; confirmationUIController: ConfirmationUXController; }) { @@ -67,7 +59,6 @@ export class SignTransactionHandler extends BaseSep43KeyringHandler< requestStruct: SignTransactionRequestStruct, responseStruct: SignTransactionResponseStruct, }); - this.#transactionBuilder = transactionBuilder; this.#transactionService = transactionService; this.#confirmationUIController = confirmationUIController; } @@ -83,7 +74,7 @@ export class SignTransactionHandler extends BaseSep43KeyringHandler< // Deserializing validates that the transaction is well-formed and scope-compatible. // We intentionally skip balance and operation-level checks here; // callers must validate those before requesting a signature. - const transaction = this.#transactionBuilder.deserialize({ xdr, scope }); + const transaction = Transaction.fromXdr({ xdr, scope }); // verify the transaction scope matches the requested scope assertTransactionScope(transaction, scope); diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts index e8739e31..1bf64904 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts @@ -1,3 +1,4 @@ +import { TransactionStatus } from '@metamask/keyring-api'; import { Account, Contract, @@ -19,6 +20,7 @@ import { BaseFeeFetchException, NetworkServiceException, SimulationException, + TransactionNotFoundException, TransactionPollException, TransactionRetryableException, TransactionSendException, @@ -38,6 +40,8 @@ import { createMockAccountWithBalances } from '../on-chain-account/__mocks__/onC import { OnChainAccount } from '../on-chain-account/OnChainAccount'; import { buildMockClassicTransaction, + buildMockHorizonTransactionPage, + buildMockHorizonTransactionRecord, buildMockInvokeHostFunctionTransaction, } from '../transaction/__mocks__/transaction.fixtures'; import { InvalidInvokeContractStructureException } from '../transaction/exceptions'; @@ -94,16 +98,39 @@ describe('NetworkService', () => { 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as KnownCaip19Sep41AssetId; const createMockTransaction = (accountId?: string) => { - return buildMockClassicTransaction([ - { - type: 'payment', - params: { - destination: accountId ?? generateStellarAddress(), - asset: 'native', - amount: '1', + return buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: accountId ?? generateStellarAddress(), + asset: 'native', + amount: '1', + }, }, + ], + { + networkPassphrase: Networks.PUBLIC, }, - ]); + ); + }; + + const mockHorizonAccountTransactions = ( + call: jest.Mock, + ): jest.SpyInstance => { + return jest + .spyOn(StellarHorizon.Server.prototype, 'transactions') + .mockReturnValue({ + forAccount: jest.fn().mockReturnValue({ + order: jest.fn().mockReturnValue({ + cursor: jest.fn().mockReturnValue({ + limit: jest.fn().mockReturnValue({ + includeFailed: jest.fn().mockReturnValue({ call }), + }), + }), + }), + }), + } as never); }; const createMockInvokeHostFunctionTransaction = (accountId?: string) => { @@ -717,6 +744,310 @@ describe('NetworkService', () => { }); }); + describe('getTransaction', () => { + it('returns mapped transaction from Horizon record', async () => { + const tx = createMockTransaction(); + const horizonRecord = buildMockHorizonTransactionRecord({ + transaction: tx, + feeCharged: '321', + }); + const call = jest.fn().mockResolvedValue(horizonRecord); + const transactionsSpy = jest + .spyOn(StellarHorizon.Server.prototype, 'transactions') + .mockReturnValue({ + transaction: jest.fn().mockReturnValue({ call }), + } as never); + + const result = await networkService.getTransaction(tx.id, scope); + + expect(result).toBeInstanceOf(Transaction); + expect(result.id).toBe(tx.id); + expect(result.feeCharged.toFixed(0)).toBe('321'); + expect(result.status).toBe(TransactionStatus.Confirmed); + transactionsSpy.mockRestore(); + }); + + it('throws TransactionNotFoundException when record is not found', async () => { + const call = jest + .fn() + .mockRejectedValue(new NotFoundError('not found', {})); + const transactionsSpy = jest + .spyOn(StellarHorizon.Server.prototype, 'transactions') + .mockReturnValue({ + transaction: jest.fn().mockReturnValue({ call }), + } as never); + + await expect( + networkService.getTransaction(testTransactionHash, scope), + ).rejects.toThrow(TransactionNotFoundException); + + transactionsSpy.mockRestore(); + }); + }); + + describe('getTransactions', () => { + it('returns only source-account transactions when includeSelfTransactionsOnly is true', async () => { + const accountAddress = generateStellarAddress(); + const txA = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: generateStellarAddress(), + asset: 'native', + amount: '1', + }, + }, + ], + { + networkPassphrase: Networks.PUBLIC, + source: { accountId: accountAddress, sequence: '1' }, + }, + ); + const txBSource = generateStellarAddress(); + const txB = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: generateStellarAddress(), + asset: 'native', + amount: '1', + }, + }, + ], + { + networkPassphrase: Networks.PUBLIC, + source: { accountId: txBSource, sequence: '1' }, + }, + ); + const records = [ + buildMockHorizonTransactionRecord({ + transaction: txA, + sourceAccount: accountAddress, + pagingToken: '11', + }), + buildMockHorizonTransactionRecord({ + transaction: txB, + sourceAccount: txBSource, + pagingToken: '22', + }), + ]; + const call = jest + .fn() + .mockResolvedValue(buildMockHorizonTransactionPage(records)); + const transactionsSpy = mockHorizonAccountTransactions(call); + + const result = await networkService.getTransactions({ + accountAddress, + lastScanToken: '', + scope, + order: 'asc', + includeSelfTransactionsOnly: true, + pageSize: 10, + maxScan: 1, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.sourceAccount).toBe(accountAddress); + expect(result[0]?.rawData?.paging_token).toBe('11'); + transactionsSpy.mockRestore(); + }); + + it('returns all account records when includeSelfTransactionsOnly is false', async () => { + const accountAddress = generateStellarAddress(); + const txA = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: generateStellarAddress(), + asset: 'native', + amount: '1', + }, + }, + ], + { + networkPassphrase: Networks.PUBLIC, + source: { accountId: accountAddress, sequence: '1' }, + }, + ); + const txBSource = generateStellarAddress(); + const txB = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: generateStellarAddress(), + asset: 'native', + amount: '1', + }, + }, + ], + { + networkPassphrase: Networks.PUBLIC, + source: { accountId: txBSource, sequence: '1' }, + }, + ); + const records = [ + buildMockHorizonTransactionRecord({ + transaction: txA, + sourceAccount: accountAddress, + pagingToken: '33', + }), + buildMockHorizonTransactionRecord({ + transaction: txB, + sourceAccount: txBSource, + pagingToken: '44', + }), + ]; + const call = jest + .fn() + .mockResolvedValue(buildMockHorizonTransactionPage(records)); + const transactionsSpy = mockHorizonAccountTransactions(call); + + const result = await networkService.getTransactions({ + accountAddress, + lastScanToken: '', + scope, + order: 'desc', + includeSelfTransactionsOnly: false, + pageSize: 10, + maxScan: 1, + }); + + expect(result).toHaveLength(2); + expect(result.at(-1)?.rawData?.paging_token).toBe('44'); + transactionsSpy.mockRestore(); + }); + + it('returns transactions from up to maxScan pages in one call', async () => { + const accountAddress = generateStellarAddress(); + const txA = createMockTransaction(accountAddress); + const txB = createMockTransaction(accountAddress); + const txC = createMockTransaction(accountAddress); + + const page1Records = [ + buildMockHorizonTransactionRecord({ + transaction: txA, + pagingToken: '11', + }), + ]; + const page2Records = [ + buildMockHorizonTransactionRecord({ + transaction: txB, + pagingToken: '22', + }), + ]; + const page3Records = [ + buildMockHorizonTransactionRecord({ + transaction: txC, + pagingToken: '33', + }), + ]; + + const page3Next = jest + .fn() + .mockResolvedValue(buildMockHorizonTransactionPage([])); + const page3 = buildMockHorizonTransactionPage(page3Records, page3Next); + const page2Next = jest.fn().mockResolvedValue(page3); + const page2 = buildMockHorizonTransactionPage(page2Records, page2Next); + const page1Next = jest.fn().mockResolvedValue(page2); + const page1 = buildMockHorizonTransactionPage(page1Records, page1Next); + + const call = jest.fn().mockResolvedValue(page1); + const transactionsSpy = mockHorizonAccountTransactions(call); + + const result = await networkService.getTransactions({ + accountAddress, + lastScanToken: '', + scope, + order: 'asc', + includeSelfTransactionsOnly: false, + pageSize: 1, + maxScan: 3, + }); + + expect(result).toHaveLength(3); + expect(result.map((transaction) => transaction.id)).toStrictEqual([ + txA.id, + txB.id, + txC.id, + ]); + expect(result.at(-1)?.rawData?.paging_token).toBe('33'); + expect(page1Next).toHaveBeenCalledTimes(1); + expect(page2Next).toHaveBeenCalledTimes(1); + expect(page3Next).not.toHaveBeenCalled(); + transactionsSpy.mockRestore(); + }); + + it('returns empty array when the first page has no records', async () => { + const accountAddress = generateStellarAddress(); + const lastScanToken = 'cursor-abc'; + const call = jest + .fn() + .mockResolvedValue(buildMockHorizonTransactionPage([])); + const transactionsSpy = mockHorizonAccountTransactions(call); + + const result = await networkService.getTransactions({ + accountAddress, + lastScanToken, + scope, + order: 'asc', + maxScan: 1, + }); + + expect(result).toHaveLength(0); + transactionsSpy.mockRestore(); + }); + + it('fetches one page when maxScan is zero', async () => { + const accountAddress = generateStellarAddress(); + const tx = createMockTransaction(accountAddress); + const records = [ + buildMockHorizonTransactionRecord({ + transaction: tx, + sourceAccount: accountAddress, + pagingToken: '55', + }), + ]; + const call = jest + .fn() + .mockResolvedValue(buildMockHorizonTransactionPage(records)); + const transactionsSpy = mockHorizonAccountTransactions(call); + + const result = await networkService.getTransactions({ + accountAddress, + lastScanToken: '', + scope, + order: 'asc', + pageSize: 10, + maxScan: 0, + }); + + expect(call).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + expect(result[0]?.rawData?.paging_token).toBe('55'); + transactionsSpy.mockRestore(); + }); + + it('throws NetworkServiceException when Horizon page fetch fails', async () => { + const call = jest.fn().mockRejectedValue(new Error('Horizon error')); + const transactionsSpy = mockHorizonAccountTransactions(call); + + await expect( + networkService.getTransactions({ + accountAddress: generateStellarAddress(), + lastScanToken: '', + scope, + order: 'asc', + }), + ).rejects.toThrow(NetworkServiceException); + + transactionsSpy.mockRestore(); + }); + }); + describe('send', () => { it('returns transaction hash when pollTransaction is false', async () => { const { sendTransactionSpy, pollTransactionSpy } = getRpcServerSpies(); diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index ddb1e6b2..b89e9a8c 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -5,7 +5,6 @@ import { Horizon as StellarHorizon, NotFoundError, rpc, - TransactionBuilder as StellarSdkTransactionBuilder, } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; @@ -18,6 +17,7 @@ import { BaseFeeFetchException, NetworkServiceException, SimulationException, + TransactionNotFoundException, TransactionPollException, TransactionRetryableException, TransactionSendException, @@ -29,7 +29,6 @@ import { StellarRouterContract, } from './MultiCall'; import { - caip2ChainIdToNetwork, extractAssetDataFromContractData, isAccountNotFoundError, sep41MulticallCellToBalance, @@ -41,7 +40,11 @@ import type { import { KnownCaip2ChainId } from '../../api'; import type { NetworkConfig } from '../../config'; import { AppConfig } from '../../config'; -import { STELLAR_DECIMAL_PLACES } from '../../constants'; +import { + MAX_TRANSACTION_SCAN_PAGES, + MAX_TRANSACTIONS_PAGE_SIZE, + STELLAR_DECIMAL_PLACES, +} from '../../constants'; import type { ILogger, Serializable } from '../../utils'; import { isSameStr, @@ -711,7 +714,7 @@ export class NetworkService { simulateResponse, ); - return new Transaction(simulatedTransaction.build()); + return Transaction.fromRaw(simulatedTransaction.build()); } catch (error: unknown) { this.#logger.logErrorWithDetails('Failed to simulate transaction', error); return rethrowIfInstanceElseThrow( @@ -785,12 +788,177 @@ export class NetworkService { }, )(); - return new Transaction( - StellarSdkTransactionBuilder.fromXDR( - cachedXdr, - caip2ChainIdToNetwork(scope), - ), - ); + return Transaction.fromXdr({ + xdr: cachedXdr, + scope, + }); + } + + /** + * Fetches a single transaction by hash from Horizon and maps it to the internal + * {@link Transaction} model (including on-chain `fee_charged`). + * + * @param transactionHash - Stellar transaction hash (`hex`). + * @param scope - CAIP-2 network scope used to choose the Horizon client and decode envelope XDR. + * @returns The mapped {@link Transaction}. + * @throws {NetworkServiceException} When the transaction cannot be fetched or mapped. + */ + async getTransaction( + transactionHash: string, + scope: KnownCaip2ChainId, + ): Promise { + try { + const client = this.#getHorizonClient(scope); + const result = await client + .transactions() + .transaction(transactionHash) + .call(); + return this.#toTransaction(result, scope); + } catch (error: unknown) { + this.#logger.logErrorWithDetails('Failed to fetch transaction', error); + if (error instanceof NotFoundError) { + throw new TransactionNotFoundException(transactionHash); + } + throw new NetworkServiceException( + `Failed to fetch transaction ${transactionHash}`, + ); + } + } + + /** + * Scans account transactions from Horizon with cursor-based pagination. + * + * The scan starts at `lastScanToken` and fetches up to `maxScan` pages. Each + * {@link Transaction} includes the source Horizon record on {@link Transaction.rawData} + * (including `paging_token`) so callers can choose which cursor to persist for the next run. + * + * When `includeSelfTransactionsOnly` is true, filtered rows are omitted from the result; + * callers that need a scan cursor must derive it from returned transactions (or pass + * `includeSelfTransactionsOnly: false` and filter locally). An empty result does not + * expose a paging token — callers should retain their previous cursor in that case. + * + * @param params - Scan parameters. + * @param params.accountAddress - Stellar account id (`G...`) to query. + * @param params.lastScanToken - Horizon cursor token from the previous scan (or empty string for initial scan). + * @param params.scope - CAIP-2 network scope. + * @param params.order - Horizon sort order (`asc` for catch-up scans, `desc` for initial recent-first scans). + * @param params.pageSize - Maximum records per page (`MAX_TRANSACTIONS_PAGE_SIZE` by default). + * @param params.maxScan - Maximum page count to fetch in this call (`MAX_TRANSACTION_SCAN_PAGES` by default). Values below 1 still fetch one page. @see {@link MAX_TRANSACTION_SCAN_PAGES} + * @param params.includeSelfTransactionsOnly - Whether to keep only records whose source account matches `accountAddress`. + * @param params.includeFailed - Whether to include failed transactions. Defaults to true. + * @returns Mapped transactions with optional Horizon metadata on each item. + * @throws {NetworkServiceException} When Horizon fetch fails. + */ + async getTransactions(params: { + accountAddress: string; + lastScanToken: string; + scope: KnownCaip2ChainId; + order?: 'asc' | 'desc'; + pageSize?: number; + maxScan?: number; + includeSelfTransactionsOnly?: boolean; + includeFailed?: boolean; + }): Promise { + const { + accountAddress, + lastScanToken, + scope, + order = 'asc', + pageSize = MAX_TRANSACTIONS_PAGE_SIZE, + maxScan = MAX_TRANSACTION_SCAN_PAGES, + includeSelfTransactionsOnly = true, + includeFailed = true, + } = params; + + // Clamp so callers cannot skip the initial Horizon request (e.g. maxScan: 0). + let maxScanRemaining = Math.max(maxScan, 1); + + try { + const client = this.#getHorizonClient(scope); + + const initialTransactionsResponse = await client + .transactions() + .forAccount(accountAddress) + .order(order) + .cursor(lastScanToken) + .limit(pageSize) + .includeFailed(includeFailed) + .call(); + + let transactions = this.#toTransactions( + initialTransactionsResponse.records, + scope, + accountAddress, + includeSelfTransactionsOnly, + ); + + maxScanRemaining -= 1; + + // When a page is full, Horizon likely has more records available. + // Continue pagination (bounded by `maxScan`) and aggregate those pages. + let currentResponse = initialTransactionsResponse; + while ( + maxScanRemaining > 0 && + currentResponse.records.length === pageSize + ) { + currentResponse = await currentResponse.next(); + + if (currentResponse.records.length === 0) { + break; + } + + transactions = transactions.concat( + this.#toTransactions( + currentResponse.records, + scope, + accountAddress, + includeSelfTransactionsOnly, + ), + ); + + maxScanRemaining -= 1; + } + + return transactions; + } catch (error: unknown) { + this.#logger.logErrorWithDetails('Failed to fetch transactions', error); + return rethrowIfInstanceElseThrow( + error, + [NetworkServiceException], + new NetworkServiceException('Failed to fetch transactions'), + ); + } + } + + #toTransactions( + transactions: StellarHorizon.ServerApi.TransactionRecord[], + scope: KnownCaip2ChainId, + accountAddress: string, + includeSelfTransactionsOnly: boolean, + ): Transaction[] { + const result: Transaction[] = []; + + for (const transaction of transactions) { + if ( + (includeSelfTransactionsOnly && + transaction.source_account === accountAddress) || + !includeSelfTransactionsOnly + ) { + result.push(this.#toTransaction(transaction, scope)); + } + } + + return result; + } + + #toTransaction( + horizonTransaction: StellarHorizon.ServerApi.TransactionRecord, + scope: KnownCaip2ChainId, + ): Transaction { + return Transaction.fromHorizon({ + horizonTransaction, + scope, + }); } #getSendRpcErrorCode(rpcError: rpc.Api.SendTransactionResponse): string { diff --git a/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts index 262b2f80..67e0a99d 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts @@ -75,6 +75,13 @@ export class TransactionSendException extends NetworkServiceException { /** Submit failed with a code the caller may recover from by fixing sequence and retrying (e.g. `txBadSeq`). */ export class TransactionRetryableException extends TransactionSendException {} +/** Thrown when a transaction is not found. */ +export class TransactionNotFoundException extends NetworkServiceException { + constructor(transactionHash: string) { + super(`Transaction ${transactionHash} not found`); + } +} + /** Thrown when a transaction simulation fails. */ export class SimulationException extends NetworkServiceException { constructor(message: string) { diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts index 39bcf569..b5985ed3 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.test.ts @@ -1,3 +1,5 @@ +import { TransactionStatus } from '@metamask/keyring-api'; +import type { Horizon } from '@stellar/stellar-sdk'; import { Account, Asset, @@ -10,7 +12,9 @@ import { } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; +import { TransactionDeserializationException } from './exceptions'; import { Transaction } from './Transaction'; +import { KnownCaip2ChainId } from '../../api'; describe('Transaction', () => { it('reports operationCount equal to transactionOperations length for a classic transaction', () => { @@ -264,4 +268,133 @@ describe('Transaction', () => { expect(new Transaction(inner).expirationTime).toBeUndefined(); }); }); + + describe('factory methods', () => { + it('creates a transaction from XDR', () => { + const source = Keypair.random(); + const dest = Keypair.random().publicKey(); + const inner = new StellarTransactionBuilder( + new Account(source.publicKey(), '1'), + { fee: '100', networkPassphrase: Networks.TESTNET }, + ) + .addOperation( + Operation.payment({ + destination: dest, + asset: Asset.native(), + amount: '1', + }), + ) + .setTimeout(60) + .build(); + + const wrapped = Transaction.fromXdr({ + xdr: inner.toXDR(), + scope: KnownCaip2ChainId.Testnet, + }); + + expect(wrapped.id).toBe(inner.hash().toString('hex')); + expect(wrapped.totalFee.toFixed(0)).toBe('100'); + expect(wrapped.feeCharged.toFixed(0)).toBe('100'); + }); + + it('uses Horizon fee_charged when created from Horizon record', () => { + const source = Keypair.random(); + const dest = Keypair.random().publicKey(); + const inner = new StellarTransactionBuilder( + new Account(source.publicKey(), '1'), + { fee: '100', networkPassphrase: Networks.TESTNET }, + ) + .addOperation( + Operation.payment({ + destination: dest, + asset: Asset.native(), + amount: '1', + }), + ) + .setTimeout(60) + .build(); + + const horizonRecord = { + // eslint-disable-next-line @typescript-eslint/naming-convention + envelope_xdr: inner.toXDR(), + // eslint-disable-next-line @typescript-eslint/naming-convention + fee_charged: '300', + successful: true, + } as Horizon.ServerApi.TransactionRecord; + + const wrapped = Transaction.fromHorizon({ + horizonTransaction: horizonRecord, + scope: KnownCaip2ChainId.Testnet, + }); + + expect(wrapped.totalFee.toFixed(0)).toBe('100'); + expect(wrapped.feeCharged.toFixed(0)).toBe('300'); + expect(wrapped.status).toBe(TransactionStatus.Confirmed); + expect(wrapped.rawData).toBe(horizonRecord); + }); + + it('maps Horizon successful flag to failed status', () => { + const source = Keypair.random(); + const dest = Keypair.random().publicKey(); + const inner = new StellarTransactionBuilder( + new Account(source.publicKey(), '1'), + { fee: '100', networkPassphrase: Networks.TESTNET }, + ) + .addOperation( + Operation.payment({ + destination: dest, + asset: Asset.native(), + amount: '1', + }), + ) + .setTimeout(60) + .build(); + + const horizonRecord = { + // eslint-disable-next-line @typescript-eslint/naming-convention + envelope_xdr: inner.toXDR(), + // eslint-disable-next-line @typescript-eslint/naming-convention + fee_charged: '100', + successful: false, + } as Horizon.ServerApi.TransactionRecord; + + const wrapped = Transaction.fromHorizon({ + horizonTransaction: horizonRecord, + scope: KnownCaip2ChainId.Testnet, + }); + + expect(wrapped.status).toBe(TransactionStatus.Failed); + }); + + it('defaults status to submitted for unsigned envelopes', () => { + const source = Keypair.random(); + const dest = Keypair.random().publicKey(); + const inner = new StellarTransactionBuilder( + new Account(source.publicKey(), '1'), + { fee: '100', networkPassphrase: Networks.TESTNET }, + ) + .addOperation( + Operation.payment({ + destination: dest, + asset: Asset.native(), + amount: '1', + }), + ) + .setTimeout(60) + .build(); + + expect(Transaction.fromRaw(inner).status).toBe( + TransactionStatus.Submitted, + ); + }); + + it('throws TransactionDeserializationException for invalid XDR', () => { + expect(() => + Transaction.fromXdr({ + xdr: 'not-an-xdr', + scope: KnownCaip2ChainId.Testnet, + }), + ).toThrow(TransactionDeserializationException); + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts index e154cf53..dfdf3a9e 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts @@ -1,30 +1,53 @@ +import { TransactionStatus } from '@metamask/keyring-api'; import type { Transaction as StellarTransaction, Operation, + Horizon, +} from '@stellar/stellar-sdk'; +import { + FeeBumpTransaction, + TransactionBuilder as StellarTransactionBuilder, } from '@stellar/stellar-sdk'; -import { FeeBumpTransaction } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; +import { TransactionDeserializationException } from './exceptions'; import { parseExpirationMaxTime } from './utils'; import type { KnownCaip2ChainId } from '../../api'; import { bufferToUint8Array } from '../../utils'; -import { networkToCaip2ChainId } from '../network/utils'; +import { caip2ChainIdToNetwork, networkToCaip2ChainId } from '../network/utils'; /** - * Wrapper around a Stellar transaction. Exposes fee, operation count, and network passphrase - * for callers. + * Wrapper around a Stellar transaction. Exposes fees, operation metadata, and network/scope + * accessors for callers. */ export class Transaction { readonly #inner: StellarTransaction | FeeBumpTransaction; + readonly #feeCharged: BigNumber; + readonly #operationTypes: Set = new Set(); readonly #participatingAccounts: Set = new Set(); readonly #invokedByAccounts: Set = new Set(); - constructor(inner: StellarTransaction | FeeBumpTransaction) { + readonly #status: TransactionStatus = TransactionStatus.Submitted; + + readonly #rawData: Horizon.ServerApi.TransactionRecord | undefined; + + constructor( + inner: StellarTransaction | FeeBumpTransaction, + options?: { + feeCharged?: BigNumber; + status?: TransactionStatus; + rawData?: Horizon.ServerApi.TransactionRecord; + }, + ) { this.#inner = inner; + // if the fee charged is not provided, use the fee of the transaction as default. + this.#feeCharged = options?.feeCharged ?? new BigNumber(inner.fee); + this.#status = options?.status ?? TransactionStatus.Submitted; + this.#rawData = options?.rawData; this.#initialize(); } @@ -125,6 +148,17 @@ export class Transaction { return new BigNumber(raw.fee); } + /** + * Actual fee charged by the network in stroops. + * For unsigned/local transactions this equals {@link totalFee}. For on-chain Horizon transactions + * this can be different and is sourced from `fee_charged`. + * + * @returns The actual charged fee as BigNumber. + */ + get feeCharged(): BigNumber { + return this.#feeCharged; + } + /** * The number of operations on the wrapped envelope (inner transaction for fee bumps). * Uses the same source as {@link Transaction.transactionOperations} so counts stay aligned. @@ -218,6 +252,38 @@ export class Transaction { return Array.from(this.#participatingAccounts.values()); } + /** + * The transaction ID. + * Equivalent to TransactionHash. + * + * @returns The transaction ID. + */ + get id(): string { + return this.#inner.hash().toString('hex'); + } + + /** + * Keyring transaction status for this envelope. + * Defaults to {@link TransactionStatus.Submitted} for unsigned/local envelopes; + * {@link Transaction.fromHorizon} sets {@link TransactionStatus.Confirmed} or + * {@link TransactionStatus.Failed} from the Horizon record. + * + * @returns The transaction status. + */ + get status(): TransactionStatus { + return this.#status; + } + + /** + * Horizon transaction record when built via {@link Transaction.fromHorizon}. + * Includes `paging_token` for cursor-based scans. Undefined for unsigned/XDR envelopes. + * + * @returns The raw Horizon transaction response, if available. + */ + get rawData(): Horizon.ServerApi.TransactionRecord | undefined { + return this.#rawData; + } + /** * Checks if the transaction is from the given account. * @@ -271,4 +337,69 @@ export class Transaction { } return raw.operations; } + + /** + * Creates a wrapped transaction from a Stellar SDK transaction. + * + * @param transaction - Stellar SDK transaction. + * @returns Wrapped transaction. + */ + static fromRaw( + transaction: StellarTransaction | FeeBumpTransaction, + ): Transaction { + return new Transaction(transaction); + } + + /** + * Creates a wrapped transaction from envelope XDR. + * + * @param params - XDR parsing input. + * @param params.xdr - Envelope XDR. + * @param params.scope - CAIP-2 network scope. + * @returns Wrapped transaction. + */ + static fromXdr(params: { + xdr: string; + scope: KnownCaip2ChainId; + }): Transaction { + const { xdr, scope } = params; + try { + const decoded = StellarTransactionBuilder.fromXDR( + xdr, + caip2ChainIdToNetwork(scope), + ); + return Transaction.fromRaw(decoded); + } catch { + throw new TransactionDeserializationException(); + } + } + + /** + * Creates a wrapped transaction from Horizon transaction record. + * + * @param params - Horizon parsing input. + * @param params.horizonTransaction - Horizon transaction record. + * @param params.scope - CAIP-2 network scope. + * @returns Wrapped transaction with `feeCharged` and `status` from Horizon. + */ + static fromHorizon(params: { + horizonTransaction: Horizon.ServerApi.TransactionRecord; + scope: KnownCaip2ChainId; + }): Transaction { + const { horizonTransaction, scope } = params; + const wrapped = Transaction.fromXdr({ + xdr: horizonTransaction.envelope_xdr, + scope, + }); + + const status = horizonTransaction.successful + ? TransactionStatus.Confirmed + : TransactionStatus.Failed; + + return new Transaction(wrapped.getRaw(), { + feeCharged: new BigNumber(horizonTransaction.fee_charged), + status, + rawData: horizonTransaction, + }); + } } diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts index a958aa9d..71957585 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.test.ts @@ -269,28 +269,6 @@ describe('TransactionBuilder', () => { }); }); - describe('deserialize', () => { - it('builds a transaction from an XDR string', () => { - const transaction = transactionBuilder.changeTrust({ - baseFee: '100', - scope: KnownCaip2ChainId.Mainnet, - assetId: testAsset, - onChainAccount: testOnChainAccount, - }); - - const fromXDRTransaction = transactionBuilder.deserialize({ - xdr: transaction.getRaw().toXDR(), - scope: KnownCaip2ChainId.Mainnet, - }); - - expect(fromXDRTransaction).toBeInstanceOf(Transaction); - expect(fromXDRTransaction.totalFee).toStrictEqual(new BigNumber(100)); - expect(fromXDRTransaction.operationCount).toBe(1); - expect(fromXDRTransaction.network).toStrictEqual(Networks.PUBLIC); - expect(fromXDRTransaction.getRaw()).toBeInstanceOf(StellarTransaction); - }); - }); - describe('sep41Transfer', () => { const sep41AssetId = `stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J` as const; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts index febf67f7..d1b3507b 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts @@ -293,37 +293,6 @@ export class TransactionBuilder { } } - /** - * Deserializes a transaction from XDR. - * - * @param params - Options object. - * @param params.xdr - The XDR string. - * @param params.scope - The CAIP-2 chain ID. - * @returns A transaction. - * @throws {TransactionBuilderException} If deserializing fails. - */ - deserialize(params: { xdr: string; scope: KnownCaip2ChainId }): Transaction { - try { - const { xdr, scope } = params; - const decodedTransaction = StellarSdkTransactionBuilder.fromXDR( - xdr, - caip2ChainIdToNetwork(scope), - ); - - const transaction = new Transaction(decodedTransaction); - - return transaction; - } catch (error: unknown) { - this.#logger.logErrorWithDetails( - 'Failed to deserialize transaction', - error, - ); - throw new TransactionBuilderException( - 'Failed to deserialize transaction', - ); - } - } - /** * Rebuilds a transaction with a new sequence number (e.g. after `txBadSeq`). * diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index ac588c6f..fe6ea425 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -8,7 +8,7 @@ import { groupBy } from 'lodash'; import type { KeyringTransactionRequest } from './KeyringTransactionBuilder'; import { KeyringTransactionBuilder } from './KeyringTransactionBuilder'; -import type { Transaction } from './Transaction'; +import { Transaction } from './Transaction'; import type { TransactionBuilder } from './TransactionBuilder'; import type { TransactionRepository } from './TransactionRepository'; import type { @@ -358,7 +358,7 @@ export class TransactionService { }): Promise { const { onChainAccount, scope, xdr } = params; - const transaction = this.#transactionBuilder.deserialize({ + const transaction = Transaction.fromXdr({ xdr, scope, }); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts index 2edb9232..f7c8c3a3 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts @@ -1,5 +1,6 @@ import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; import { TransactionStatus, TransactionType } from '@metamask/keyring-api'; +import type { AuthFlag, Horizon } from '@stellar/stellar-sdk'; import { Account, Asset, @@ -9,7 +10,6 @@ import { Networks, Operation, TransactionBuilder as StellarTransactionBuilder, - type AuthFlag, } from '@stellar/stellar-sdk'; import type { KnownCaip19AssetIdOrSlip44Id } from '../../../api'; @@ -491,3 +491,68 @@ export function buildMockInvokeHostFunctionTransaction( const built = builder.setTimeout(options.timeout ?? 60).build(); return new Transaction(built); } + +export type BuildMockHorizonTransactionRecordOptions = { + transaction?: Transaction; + sourceAccount?: string; + pagingToken?: string; + feeCharged?: string; + successful?: boolean; +}; + +/** + * Builds a minimal Horizon transaction record for tests. + * + * @param options - Optional record overrides. + * @returns Horizon transaction record with envelope XDR and fee metadata. + */ +export function buildMockHorizonTransactionRecord( + options: BuildMockHorizonTransactionRecordOptions = {}, +): Horizon.ServerApi.TransactionRecord { + const transaction = + options.transaction ?? + buildMockClassicTransaction([ + { + type: 'payment', + params: { + destination: generateStellarAddress(), + asset: 'native', + amount: '1', + }, + }, + ]); + + /* eslint-disable @typescript-eslint/naming-convention */ + return { + envelope_xdr: transaction.getRaw().toXDR(), + fee_charged: options.feeCharged ?? transaction.totalFee.toFixed(0), + paging_token: options.pagingToken ?? '1', + source_account: options.sourceAccount ?? transaction.sourceAccount, + successful: options.successful ?? true, + } as Horizon.ServerApi.TransactionRecord; + /* eslint-enable @typescript-eslint/naming-convention */ +} + +/** + * Builds a Horizon transaction page-like response for tests. + * + * @param records - Current page records. + * @param next - Optional function used by callers for pagination. + * @returns Transaction page response shape with `records` and `next`. + */ +export function buildMockHorizonTransactionPage( + records: Horizon.ServerApi.TransactionRecord[], + next?: () => Promise<{ records: Horizon.ServerApi.TransactionRecord[] }>, +): { + records: Horizon.ServerApi.TransactionRecord[]; + next: () => Promise<{ records: Horizon.ServerApi.TransactionRecord[] }>; +} { + return { + records, + next: + next ?? + (async () => { + return { records: [] }; + }), + }; +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts index 07a2ea7b..03ee4007 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts @@ -11,6 +11,14 @@ export class TransactionBuilderException extends Error { } } +/** Thrown when deserializing a transaction fails. */ +export class TransactionDeserializationException extends Error { + constructor(message: string = 'Failed to deserialize transaction') { + super(message); + this.name = 'TransactionDeserializationException'; + } +} + /** Base for all transaction validation errors (simulation, trustlines, balances). */ export class TransactionValidationException extends Error { constructor(message: string) { From 81f40e749089c83c4885a9ecef2fe82f76c518f6 Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Thu, 4 Jun 2026 16:17:39 +0200 Subject: [PATCH 273/384] feat: align computeFee and signAndSendTransaction param struct with Solana snap --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/clientRequest/api.test.ts | 31 ++++++++++++++----- .../src/handlers/clientRequest/api.ts | 22 +++++++------ .../handlers/clientRequest/computeFee.test.ts | 4 --- .../signAndSendTransaction.test.ts | 4 --- 5 files changed, 37 insertions(+), 26 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 864e39af..69e950de 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "SK2tUsswSpgryEIHWiEXfwq0H/0eMiD3mHvH0Dau1nU=", + "shasum": "ddfQTU/LvfdL5VxTp/9MrUYxtD21ggvNaemmJyltoqo=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts index b8c330b3..b62e8600 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.test.ts @@ -172,7 +172,7 @@ describe('SignAndSendTransactionJsonRpcResponseStruct', () => { describe('SignAndSendTransactionJsonRpcRequestStruct', () => { const transaction = buildTestInvokeXdr(); - it('accepts a valid signAndSendTransaction JSON-RPC request', () => { + it('accepts a valid signAndSendTransaction JSON-RPC request without options', () => { expect(() => assert( { @@ -183,9 +183,6 @@ describe('SignAndSendTransactionJsonRpcRequestStruct', () => { accountId, scope, transaction, - options: { - type: 'swap', - }, }, }, SignAndSendTransactionJsonRpcRequestStruct, @@ -193,7 +190,7 @@ describe('SignAndSendTransactionJsonRpcRequestStruct', () => { ).not.toThrow(); }); - it('accepts an empty transaction type', () => { + it('accepts a signAndSendTransaction JSON-RPC request without an options type', () => { expect(() => assert( { @@ -205,7 +202,7 @@ describe('SignAndSendTransactionJsonRpcRequestStruct', () => { scope, transaction, options: { - type: '', + visible: false, }, }, }, @@ -242,7 +239,25 @@ describe('SignAndSendTransactionJsonRpcRequestStruct', () => { describe('ComputeFeeJsonRpcRequestStruct', () => { const transaction = buildTestInvokeXdr(); - it('accepts an empty transaction type', () => { + it('accepts a computeFee JSON-RPC request without options', () => { + expect(() => + assert( + { + jsonrpc: '2.0', + id: 1, + method: 'computeFee', + params: { + accountId, + scope, + transaction, + }, + }, + ComputeFeeJsonRpcRequestStruct, + ), + ).not.toThrow(); + }); + + it('accepts a computeFee JSON-RPC request without an options type', () => { expect(() => assert( { @@ -254,7 +269,7 @@ describe('ComputeFeeJsonRpcRequestStruct', () => { scope, transaction, options: { - type: '', + feeLimit: 1, }, }, }, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts index 9281198e..bb5eacbe 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts @@ -193,10 +193,12 @@ export const SignAndSendTransactionJsonRpcRequestStruct = assign( transaction: SwapTransactionXdrStruct, accountId: UuidStruct, scope: KnownCaip2ChainIdStruct, - options: object({ - visible: optional(boolean()), - type: string(), - }), + options: optional( + object({ + visible: optional(boolean()), + type: optional(string()), + }), + ), }), }), ); @@ -389,11 +391,13 @@ export const ComputeFeeJsonRpcRequestStruct = assign( transaction: SwapTransactionXdrStruct, accountId: UuidStruct, scope: KnownCaip2ChainIdStruct, - options: object({ - visible: optional(boolean()), - type: string(), - feeLimit: optional(min(integer(), 0)), - }), + options: optional( + object({ + visible: optional(boolean()), + type: optional(string()), + feeLimit: optional(min(integer(), 0)), + }), + ), }), }), ); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.test.ts index eb5b3c0c..2b9d316d 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.test.ts @@ -106,10 +106,6 @@ describe('ComputeFeeHandler', () => { accountId, scope, transaction: xdr, - options: { - type: '', - feeLimit: 1, - }, }, }; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.test.ts index 8062bfa6..8068ccc7 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.test.ts @@ -130,9 +130,6 @@ describe('SignAndSendTransactionHandler', () => { accountId, scope, transaction: xdr, - options: { - type: 'swap', - }, }, }; @@ -230,7 +227,6 @@ describe('SignAndSendTransactionHandler', () => { params: { ...request.params, options: { - ...request.params.options, visible: false, }, }, From 6826e5a17ccba38fc15786aaf4de43829a461f29 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:42:35 +0800 Subject: [PATCH 274/384] chore: refactor --- .../clientRequest/changeTrustOpt.test.ts | 6 +- .../handlers/clientRequest/changeTrustOpt.ts | 63 +-- .../clientRequest/confirmSend.test.ts | 4 +- .../src/handlers/clientRequest/confirmSend.ts | 68 +-- .../signAndSendTransaction.test.ts | 4 +- .../clientRequest/signAndSendTransaction.ts | 8 +- .../src/handlers/cronjob/api.test.ts | 37 +- .../src/handlers/cronjob/api.ts | 16 +- .../handlers/cronjob/trackTransaction.test.ts | 401 +++++++++++------- .../src/handlers/cronjob/trackTransaction.ts | 304 ++++++------- .../src/services/account/AccountService.ts | 14 + .../src/services/network/NetworkService.ts | 45 +- .../src/services/network/api.ts | 11 - .../transaction/TransactionRepository.ts | 8 +- .../transaction/TransactionService.test.ts | 195 +++------ .../transaction/TransactionService.ts | 76 +--- 16 files changed, 557 insertions(+), 703 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts index 7f293459..e8e7e627 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts @@ -152,7 +152,7 @@ describe('ChangeTrustOptHandler', () => { ); const savePendingKeyringTransaction = jest.spyOn( TransactionService.prototype, - 'savePendingKeyringTransaction', + 'savePendingKeyringTransactionSafe', ); const { service: assetMetadataService } = createMockAssetMetadataService(); @@ -296,7 +296,7 @@ describe('ChangeTrustOptHandler', () => { ).toHaveBeenCalledWith({ txId: '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0e1', scope, - accountIds: [account.id], + accountIdsOrAddresses: [account.id], }); }); @@ -411,7 +411,7 @@ describe('ChangeTrustOptHandler', () => { ).toHaveBeenCalledWith({ txId: '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0e1', scope, - accountIds: [account.id], + accountIdsOrAddresses: [account.id], }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts index 91cf68c1..4b652df5 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts @@ -15,10 +15,6 @@ import { type ResolvedActivatedAccount, } from '../accountResolver'; import { BaseClientRequestHandler } from './base'; -import type { - KnownCaip19AssetIdOrSlip44Id, - KnownCaip2ChainId, -} from '../../api'; import { METAMASK_ORIGIN } from '../../constants'; import type { StellarKeyringAccount } from '../../services/account'; import type { @@ -170,19 +166,27 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< transaction, }); - await this.#savePendingTransaction({ - transactionId, - scope, - assetId, - account, - assetMetadata, - action, + await this.#transactionService.savePendingKeyringTransactionSafe({ + type: + action === ChangeTrustOptAction.Add + ? KeyringTransactionType.ChangeTrustOptIn + : KeyringTransactionType.ChangeTrustOptOut, + request: { + txId: transactionId, + account, + scope, + asset: { + type: assetId, + symbol: assetMetadata.symbol, + }, + }, }); await TrackTransactionHandler.scheduleBackgroundEvent({ txId: transactionId, + // Change trust affects only the sender account. + accountIdsOrAddresses: [account.id], scope, - accountIds: [account.id], }); return { @@ -191,41 +195,6 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< }; } - async #savePendingTransaction(params: { - transactionId: string; - scope: KnownCaip2ChainId; - assetId: KnownCaip19AssetIdOrSlip44Id; - account: StellarKeyringAccount; - assetMetadata: StellarAssetMetadata; - action: ChangeTrustOptAction; - }): Promise { - try { - const { transactionId, scope, assetId, account, assetMetadata, action } = - params; - await this.#transactionService.savePendingKeyringTransaction({ - type: - action === ChangeTrustOptAction.Add - ? KeyringTransactionType.ChangeTrustOptIn - : KeyringTransactionType.ChangeTrustOptOut, - request: { - txId: transactionId, - account, - scope, - asset: { - type: assetId, - symbol: assetMetadata.symbol, - }, - }, - }); - } catch (error: unknown) { - this.logger.logErrorWithDetails( - 'Failed to save pending transaction', - error, - ); - // we should not throw error here, as we want to continue the flow even if the pending transaction is not saved - } - } - async #confirmChangeTrustOpt(params: { request: ChangeTrustOptJsonRpcRequest; account: StellarKeyringAccount; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts index 1ce48d9c..2574f5fa 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts @@ -139,7 +139,7 @@ describe('ConfirmSendHandler', () => { .mockResolvedValue(transactionId); const savePendingKeyringTransaction = jest.spyOn( TransactionService.prototype, - 'savePendingKeyringTransaction', + 'savePendingKeyringTransactionSafe', ); const signTransactionSpy = jest.spyOn(wallet, 'signTransaction'); const scheduleBackgroundEvent = jest @@ -339,7 +339,7 @@ describe('ConfirmSendHandler', () => { expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ txId: transactionId, scope, - accountIds: [account.id], + accountIdsOrAddresses: [account.id, destinationAddress], }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts index 641296e4..6f5010a9 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts @@ -11,10 +11,7 @@ import { ConfirmSendJsonRpcResponseStruct, MultiChainSendErrorCodes, } from './api'; -import type { - KnownCaip19AssetIdOrSlip44Id, - KnownCaip2ChainId, -} from '../../api'; +import type { KnownCaip2ChainId } from '../../api'; import { METAMASK_ORIGIN } from '../../constants'; import type { StellarKeyringAccount } from '../../services/account'; import type { @@ -182,23 +179,25 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< pollTransaction: false, }); - await this.#savePendingTransaction({ - txId: transactionId, - account: stellarKeyringAccount, - scope, - toAddress, - amount, - asset: { - type: assetId, - symbol, + await this.#transactionService.savePendingKeyringTransactionSafe({ + type: KeyringTransactionType.Send, + request: { + txId: transactionId, + account: stellarKeyringAccount, + scope, + toAddress, + amount, + asset: { + type: assetId, + symbol, + }, }, }); await TrackTransactionHandler.scheduleBackgroundEvent({ txId: transactionId, + accountIdsOrAddresses: [stellarKeyringAccount.id, toAddress], scope, - // TODO: we should depend on the transaction instead of passing an account id here - accountIds: [stellarKeyringAccount.id], }); return { @@ -283,45 +282,6 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< ); } - async #savePendingTransaction({ - txId, - account, - scope, - toAddress, - amount, - asset, - }: { - txId: string; - account: StellarKeyringAccount; - scope: KnownCaip2ChainId; - toAddress: string; - amount: string; - asset: { - type: KnownCaip19AssetIdOrSlip44Id; - symbol: string; - }; - }): Promise { - try { - await this.#transactionService.savePendingKeyringTransaction({ - type: KeyringTransactionType.Send, - request: { - txId, - account, - scope, - toAddress, - amount, - asset, - }, - }); - } catch (error: unknown) { - this.#logger.logErrorWithDetails( - 'Failed to save pending transaction', - error, - ); - // we should not throw error here, as we want to continue the flow even if the pending transaction is not saved - } - } - /** * Override the base handler to return invalid when the account is not activated. * Instead of showing the account not activated alert, it returns an invalid response. diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.test.ts index 8062bfa6..ab49c610 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.test.ts @@ -212,7 +212,7 @@ describe('SignAndSendTransactionHandler', () => { expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ scope, txId: transactionId, - accountIds: [account.id], + accountIdsOrAddresses: [account.id], }); }); @@ -251,7 +251,7 @@ describe('SignAndSendTransactionHandler', () => { expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ scope, txId: transactionId, - accountIds: [account.id], + accountIdsOrAddresses: [account.id], }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts index 8e720321..0817f6bf 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts @@ -133,11 +133,13 @@ export class SignAndSendTransactionHandler extends BaseClientRequestHandler< transaction, }); - // Track the transaction after a transaction + // Schedule the track-transaction background event. await TrackTransactionHandler.scheduleBackgroundEvent({ scope, txId: transactionHash, - accountIds: [account.id], + // Same-chain swaps reuse the sender address as the receiver; cross-chain swaps + // use a non-Stellar receiver, so only the sender account id is tracked. + accountIdsOrAddresses: [account.id], }); return { @@ -169,7 +171,7 @@ export class SignAndSendTransactionHandler extends BaseClientRequestHandler< 'Failed to save pending transaction', error, ); - // we should not throw error here, as we want to continue the flow even if the pending transaction is not saved + // Do not throw here; continue even if the pending transaction was not saved. } } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts index 510cff8b..7c5ab3ed 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.test.ts @@ -153,14 +153,20 @@ describe('Cronjob API structs', () => { }); describe('TrackTransactionJsonRpcRequestStruct', () => { + const txId = + '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0e1'; + const senderAccountId = '4dd94666-52a0-4478-91f8-979292f91fae'; + const receiverAddress = + 'GDTF7ERUQVTX23ZD6NY5XRYC5IQAKWFVTQ6IXSMEZWGVNDDGPYCVHRZP'; + it('accepts track transaction requests', () => { const value = { ...jsonRpcBase, method: BackgroundEventMethod.TrackTransaction, params: { - txId: 'tx-id', + txId, scope: KnownCaip2ChainId.Mainnet, - accountIds: ['4dd94666-52a0-4478-91f8-979292f91fae'], + accountIdsOrAddresses: [senderAccountId], }, }; assert(value, TrackTransactionJsonRpcRequestStruct); @@ -168,23 +174,40 @@ describe('Cronjob API structs', () => { ...jsonRpcBase, method: BackgroundEventMethod.TrackTransaction, params: { - txId: 'tx-id', + txId, scope: KnownCaip2ChainId.Mainnet, - accountIds: ['4dd94666-52a0-4478-91f8-979292f91fae'], + accountIdsOrAddresses: [senderAccountId], }, }); }); - it('rejects invalid accountIds values', () => { + it('accepts sender and receiver address in accountIdsOrAddresses', () => { + const value = { + ...jsonRpcBase, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope: KnownCaip2ChainId.Mainnet, + accountIdsOrAddresses: [senderAccountId, receiverAddress], + }, + }; + assert(value, TrackTransactionJsonRpcRequestStruct); + expect(value.params.accountIdsOrAddresses).toStrictEqual([ + senderAccountId, + receiverAddress, + ]); + }); + + it('rejects invalid accountIdsOrAddresses values', () => { expect(() => assert( { ...jsonRpcBase, method: BackgroundEventMethod.TrackTransaction, params: { - txId: 'tx-id', + txId, scope: KnownCaip2ChainId.Mainnet, - accountIds: ['invalid-uuid'], + accountIdsOrAddresses: ['invalid-uuid'], }, }, TrackTransactionJsonRpcRequestStruct, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts index 0d30bd51..4d831061 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/api.ts @@ -11,6 +11,7 @@ import { optional, size, string, + tuple, type, union, } from '@metamask/superstruct'; @@ -19,9 +20,12 @@ import type { Json, JsonRpcRequest } from '@metamask/utils'; import { JsonRpcRequestStruct, KnownCaip2ChainIdStruct, + StellarAddressStruct, + StellarTransactionHashStruct, UuidStruct, } from '../../api'; import { ConfirmationContextRefresherKeyStruct } from './refreshConfirmationContext/api'; +import { AppConfig } from '../../config'; import { ConfirmationInterfaceKeyStruct } from '../../ui/confirmation/api'; /** @@ -58,11 +62,17 @@ export const RefreshConfirmationContextJsonRpcRequestStruct = assign( ); export const TrackTransactionParamsStruct = type({ - txId: nonempty(string()), + txId: StellarTransactionHashStruct, + // First entry: sender account UUID. Optional second entry: receiver Stellar address. + accountIdsOrAddresses: union([ + nonempty(tuple([UuidStruct])), + nonempty(tuple([UuidStruct, StellarAddressStruct])), + ]), scope: KnownCaip2ChainIdStruct, - accountIds: nonempty(array(UuidStruct)), /** Reschedule counter; omitted on first schedule (treated as 0). */ - attempt: optional(size(integer(), 0, 30)), + attempt: optional( + size(integer(), 0, AppConfig.transaction.trackTransactionMaxReschedules), + ), }); export const SyncAccountParamsStruct = object({ diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts index c7c55335..bc30f75a 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts @@ -8,15 +8,28 @@ import { BackgroundEventMethod } from './api'; import { TrackTransactionHandler } from './trackTransaction'; import { KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; +import { KEYRING_ACCOUNT_TYPE, METAMASK_ORIGIN } from '../../constants'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; import { InMemoryCache } from '../../services/cache'; -import { NetworkService } from '../../services/network'; +import { + NetworkService, + NetworkServiceException, + TransactionNotFoundException, +} from '../../services/network'; import { OnChainAccountService } from '../../services/on-chain-account'; import { TransactionService } from '../../services/transaction'; -import { createMockTransactionService } from '../../services/transaction/__mocks__/transaction.fixtures'; +import { + buildMockClassicTransaction, + createMockTransactionService, +} from '../../services/transaction/__mocks__/transaction.fixtures'; +import { Transaction } from '../../services/transaction/Transaction'; import { logger, noOpLogger } from '../../utils/logger'; -import { Duration, scheduleBackgroundEvent } from '../../utils/snap'; +import { + Duration, + scheduleBackgroundEvent, + trackTransactionFinalized, +} from '../../utils/snap'; jest.mock('../../utils/logger'); jest.mock('../../utils/snap', () => { @@ -24,6 +37,7 @@ jest.mock('../../utils/snap', () => { return { ...actual, scheduleBackgroundEvent: jest.fn().mockResolvedValue('scheduled'), + trackTransactionFinalized: jest.fn().mockResolvedValue(undefined), getClientStatus: jest .fn() .mockResolvedValue({ active: true, locked: false }), @@ -31,13 +45,18 @@ jest.mock('../../utils/snap', () => { }); describe('TrackTransactionHandler', () => { - const txId = 'abc123'; + const txId = + '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0e1'; const scope = KnownCaip2ChainId.Testnet; const accountId = '22222222-2222-4222-8222-222222222222'; + const receiverAddress = + 'GDTF7ERUQVTX23ZD6NY5XRYC5IQAKWFVTQ6IXSMEZWGVNDDGPYCVHRZP'; beforeEach(() => { jest.mocked(scheduleBackgroundEvent).mockClear(); jest.mocked(scheduleBackgroundEvent).mockResolvedValue('scheduled'); + jest.mocked(trackTransactionFinalized).mockClear(); + jest.mocked(trackTransactionFinalized).mockResolvedValue(undefined); }); function createPersistedKeyringTransaction( @@ -57,6 +76,20 @@ describe('TrackTransactionHandler', () => { }; } + function createNetworkTransaction(status: TransactionStatus): Transaction { + const built = buildMockClassicTransaction([ + { + type: 'payment', + params: { + destination: receiverAddress, + asset: 'native', + amount: '1', + }, + }, + ]); + return new Transaction(built.getRaw(), { status }); + } + function setup() { const account = generateStellarKeyringAccount( accountId, @@ -64,18 +97,24 @@ describe('TrackTransactionHandler', () => { 'entropy-source-1', 0, ); - - const findByIds = jest - .spyOn(AccountService.prototype, 'findByIds') - .mockResolvedValue([account]); + const receiverAccount = generateStellarKeyringAccount( + '33333333-3333-4333-8333-333333333333', + receiverAddress, + 'entropy-source-2', + 1, + ); const findById = jest .spyOn(AccountService.prototype, 'findById') .mockResolvedValue(account); - const checkHorizonTransactionForTrack = jest.spyOn( + const findByAddressAndScope = jest + .spyOn(AccountService.prototype, 'findByAddressAndScope') + .mockResolvedValue(null); + + const getTransaction = jest.spyOn( NetworkService.prototype, - 'checkHorizonTransactionForTrack', + 'getTransaction', ); const findKeyringTransactionByTransactionId = jest.spyOn( @@ -83,17 +122,17 @@ describe('TrackTransactionHandler', () => { 'findKeyringTransactionByTransactionId', ); - const synchronize = jest - .spyOn(OnChainAccountService.prototype, 'synchronize') + const save = jest + .spyOn(TransactionService.prototype, 'save') .mockResolvedValue(undefined); - const updateKeyringTransactionStatus = jest - .spyOn(TransactionService.prototype, 'updateKeyringTransactionStatus') + const synchronize = jest + .spyOn(OnChainAccountService.prototype, 'synchronize') .mockResolvedValue(undefined); const { transactionService } = createMockTransactionService(); - findKeyringTransactionByTransactionId.mockResolvedValue(undefined); + findKeyringTransactionByTransactionId.mockResolvedValue(null); const networkCache = new InMemoryCache(noOpLogger); @@ -117,30 +156,21 @@ describe('TrackTransactionHandler', () => { return { handler, account, - findByIds, + receiverAccount, findById, - checkHorizonTransactionForTrack, + findByAddressAndScope, + getTransaction, findKeyringTransactionByTransactionId, + save, synchronize, - updateKeyringTransactionStatus, }; } - it('loads persisted keyring transaction from state before Horizon track check', async () => { - const { - handler, - checkHorizonTransactionForTrack, - findKeyringTransactionByTransactionId, - } = setup(); - const callOrder: string[] = []; - findKeyringTransactionByTransactionId.mockImplementation(async () => { - callOrder.push('findPersisted'); - return undefined; - }); - checkHorizonTransactionForTrack.mockImplementation(async () => { - callOrder.push('horizon'); - return 'confirmed'; - }); + it('fetches transaction from network before synchronizing', async () => { + const { handler, getTransaction } = setup(); + getTransaction.mockResolvedValue( + createNetworkTransaction(TransactionStatus.Confirmed), + ); await handler.handle({ jsonrpc: '2.0', @@ -149,34 +179,35 @@ describe('TrackTransactionHandler', () => { params: { txId, scope, - accountIds: [accountId], + accountIdsOrAddresses: [accountId], }, }); - expect(callOrder).toStrictEqual(['findPersisted', 'horizon']); + expect(getTransaction).toHaveBeenCalledWith(txId, scope); }); - it('syncs before settling confirmed when Horizon track check confirms', async () => { + it('updates keyring status then syncs when transaction is confirmed', async () => { const { handler, account, - checkHorizonTransactionForTrack, + getTransaction, + save, synchronize, - updateKeyringTransactionStatus, findKeyringTransactionByTransactionId, } = setup(); - findKeyringTransactionByTransactionId.mockResolvedValue( - createPersistedKeyringTransaction(), + const persisted = createPersistedKeyringTransaction(); + findKeyringTransactionByTransactionId.mockResolvedValue(persisted); + getTransaction.mockResolvedValue( + createNetworkTransaction(TransactionStatus.Confirmed), ); - checkHorizonTransactionForTrack.mockResolvedValue('confirmed'); const callOrder: string[] = []; + save.mockImplementation(async () => { + callOrder.push('save'); + }); synchronize.mockImplementation(async () => { callOrder.push('sync'); }); - updateKeyringTransactionStatus.mockImplementation(async () => { - callOrder.push('settle'); - }); await handler.handle({ jsonrpc: '2.0', @@ -185,34 +216,35 @@ describe('TrackTransactionHandler', () => { params: { txId, scope, - accountIds: [accountId], + accountIdsOrAddresses: [accountId], }, }); - expect(checkHorizonTransactionForTrack).toHaveBeenCalledWith(txId, scope); - expect(callOrder).toStrictEqual(['sync', 'settle']); - expect(updateKeyringTransactionStatus).toHaveBeenCalledWith({ - txId, - accountIds: [accountId], + expect(callOrder).toStrictEqual(['save', 'sync']); + expect(save).toHaveBeenCalledWith({ + ...persisted, status: TransactionStatus.Confirmed, + events: [ + ...persisted.events, + { + status: TransactionStatus.Confirmed, + timestamp: expect.any(Number), + }, + ], + }); + expect(trackTransactionFinalized).toHaveBeenCalledWith({ + origin: METAMASK_ORIGIN, + accountType: KEYRING_ACCOUNT_TYPE, + chainIdCaip: scope, }); expect(synchronize).toHaveBeenCalledTimes(1); expect(synchronize).toHaveBeenCalledWith([account], scope); expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); }); - it('reschedules when Horizon track check returns pending on first attempt', async () => { - const { - handler, - checkHorizonTransactionForTrack, - synchronize, - updateKeyringTransactionStatus, - findKeyringTransactionByTransactionId, - } = setup(); - findKeyringTransactionByTransactionId.mockResolvedValue( - createPersistedKeyringTransaction(), - ); - checkHorizonTransactionForTrack.mockResolvedValue('pending'); + it('reschedules when transaction is not found on first attempt', async () => { + const { handler, getTransaction, save, synchronize } = setup(); + getTransaction.mockRejectedValue(new TransactionNotFoundException(txId)); await handler.handle({ jsonrpc: '2.0', @@ -221,18 +253,18 @@ describe('TrackTransactionHandler', () => { params: { txId, scope, - accountIds: [accountId], + accountIdsOrAddresses: [accountId], }, }); - expect(updateKeyringTransactionStatus).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); expect(synchronize).not.toHaveBeenCalled(); expect(scheduleBackgroundEvent).toHaveBeenCalledWith({ method: BackgroundEventMethod.TrackTransaction, params: { txId, scope, - accountIds: [accountId], + accountIdsOrAddresses: [accountId], attempt: 1, }, duration: Duration.TwoSeconds, @@ -242,17 +274,18 @@ describe('TrackTransactionHandler', () => { it('settles confirmed after reschedule then confirmed across cron runs', async () => { const { handler, - checkHorizonTransactionForTrack, + getTransaction, + save, synchronize, - updateKeyringTransactionStatus, findKeyringTransactionByTransactionId, } = setup(); - findKeyringTransactionByTransactionId.mockResolvedValue( - createPersistedKeyringTransaction(), - ); - checkHorizonTransactionForTrack - .mockResolvedValueOnce('pending') - .mockResolvedValueOnce('confirmed'); + const persisted = createPersistedKeyringTransaction(); + findKeyringTransactionByTransactionId.mockResolvedValue(persisted); + getTransaction + .mockRejectedValueOnce(new TransactionNotFoundException(txId)) + .mockResolvedValueOnce( + createNetworkTransaction(TransactionStatus.Confirmed), + ); await handler.handle({ jsonrpc: '2.0', @@ -261,12 +294,12 @@ describe('TrackTransactionHandler', () => { params: { txId, scope, - accountIds: [accountId], + accountIdsOrAddresses: [accountId], }, }); - expect(checkHorizonTransactionForTrack).toHaveBeenCalledTimes(1); - expect(updateKeyringTransactionStatus).not.toHaveBeenCalled(); + expect(getTransaction).toHaveBeenCalledTimes(1); + expect(save).not.toHaveBeenCalled(); expect(scheduleBackgroundEvent).toHaveBeenCalledTimes(1); await handler.handle({ @@ -276,32 +309,24 @@ describe('TrackTransactionHandler', () => { params: { txId, scope, - accountIds: [accountId], + accountIdsOrAddresses: [accountId], attempt: 1, }, }); - expect(checkHorizonTransactionForTrack).toHaveBeenCalledTimes(2); + expect(getTransaction).toHaveBeenCalledTimes(2); expect(synchronize).toHaveBeenCalledTimes(1); - expect(updateKeyringTransactionStatus).toHaveBeenCalledWith({ - txId, - accountIds: [accountId], - status: TransactionStatus.Confirmed, - }); + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + id: txId, + status: TransactionStatus.Confirmed, + }), + ); }); - it('leaves pending when Horizon keeps returning pending after max reschedules', async () => { - const { - handler, - checkHorizonTransactionForTrack, - synchronize, - updateKeyringTransactionStatus, - findKeyringTransactionByTransactionId, - } = setup(); - findKeyringTransactionByTransactionId.mockResolvedValue( - createPersistedKeyringTransaction(), - ); - checkHorizonTransactionForTrack.mockResolvedValue('pending'); + it('stops rescheduling after max attempts when transaction is still not found', async () => { + const { handler, getTransaction, save, synchronize } = setup(); + getTransaction.mockRejectedValue(new TransactionNotFoundException(txId)); for ( let attempt = 0; @@ -315,34 +340,35 @@ describe('TrackTransactionHandler', () => { params: { txId, scope, - accountIds: [accountId], + accountIdsOrAddresses: [accountId], attempt, }, }); } - expect(checkHorizonTransactionForTrack).toHaveBeenCalledTimes( + expect(getTransaction).toHaveBeenCalledTimes( AppConfig.transaction.trackTransactionMaxReschedules + 1, ); expect(scheduleBackgroundEvent).toHaveBeenCalledTimes( AppConfig.transaction.trackTransactionMaxReschedules, ); - expect(updateKeyringTransactionStatus).not.toHaveBeenCalled(); - expect(synchronize).toHaveBeenCalledTimes(1); + expect(save).not.toHaveBeenCalled(); + expect(synchronize).not.toHaveBeenCalled(); }); - it('settles keyring row as failed when Horizon track check reports failed', async () => { + it('updates keyring status to failed and syncs when transaction failed', async () => { const { handler, - checkHorizonTransactionForTrack, + getTransaction, + save, synchronize, - updateKeyringTransactionStatus, findKeyringTransactionByTransactionId, } = setup(); - findKeyringTransactionByTransactionId.mockResolvedValue( - createPersistedKeyringTransaction(), + const persisted = createPersistedKeyringTransaction(); + findKeyringTransactionByTransactionId.mockResolvedValue(persisted); + getTransaction.mockResolvedValue( + createNetworkTransaction(TransactionStatus.Failed), ); - checkHorizonTransactionForTrack.mockResolvedValue('failed'); await handler.handle({ jsonrpc: '2.0', @@ -351,31 +377,25 @@ describe('TrackTransactionHandler', () => { params: { txId, scope, - accountIds: [accountId], + accountIdsOrAddresses: [accountId], }, }); - expect(updateKeyringTransactionStatus).toHaveBeenCalledWith({ - txId, - accountIds: [accountId], - status: TransactionStatus.Failed, - }); + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + id: txId, + status: TransactionStatus.Failed, + }), + ); expect(synchronize).toHaveBeenCalledTimes(1); expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); }); - it('leaves pending on unavailable Horizon track check and still synchronizes', async () => { - const { - handler, - checkHorizonTransactionForTrack, - synchronize, - updateKeyringTransactionStatus, - findKeyringTransactionByTransactionId, - } = setup(); - findKeyringTransactionByTransactionId.mockResolvedValue( - createPersistedKeyringTransaction(), + it('skips synchronization when transaction status is not terminal', async () => { + const { handler, getTransaction, save, synchronize } = setup(); + getTransaction.mockResolvedValue( + createNetworkTransaction(TransactionStatus.Submitted), ); - checkHorizonTransactionForTrack.mockResolvedValue('unavailable'); await handler.handle({ jsonrpc: '2.0', @@ -384,31 +404,92 @@ describe('TrackTransactionHandler', () => { params: { txId, scope, - accountIds: [accountId], + accountIdsOrAddresses: [accountId], }, }); - expect(updateKeyringTransactionStatus).not.toHaveBeenCalled(); - expect(synchronize).toHaveBeenCalledTimes(1); + expect(save).not.toHaveBeenCalled(); + expect(synchronize).not.toHaveBeenCalled(); expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); }); - it('syncs from persisted keyring transaction account without findByIds', async () => { + it('reschedules when network service throws NetworkServiceException', async () => { + const { handler, getTransaction, save, synchronize } = setup(); + getTransaction.mockRejectedValue( + new NetworkServiceException('Failed to fetch transaction'), + ); + + await handler.handle({ + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIdsOrAddresses: [accountId], + }, + }); + + expect(save).not.toHaveBeenCalled(); + expect(synchronize).not.toHaveBeenCalled(); + expect(scheduleBackgroundEvent).toHaveBeenCalledTimes(1); + }); + + it('syncs sender from accountIdsOrAddresses via findById', async () => { + const { handler, account, findById, getTransaction, synchronize } = setup(); + getTransaction.mockResolvedValue( + createNetworkTransaction(TransactionStatus.Confirmed), + ); + + await handler.handle({ + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIdsOrAddresses: [accountId], + }, + }); + + expect(findById).toHaveBeenCalledWith(accountId); + expect(synchronize).toHaveBeenCalledWith([account], scope); + }); + + it('does not synchronize when sender account is not found', async () => { + const { handler, findById, getTransaction, synchronize } = setup(); + findById.mockResolvedValue(undefined); + getTransaction.mockResolvedValue( + createNetworkTransaction(TransactionStatus.Confirmed), + ); + + await handler.handle({ + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIdsOrAddresses: [accountId], + }, + }); + + expect(synchronize).not.toHaveBeenCalled(); + }); + + it('includes receiver account in sync when address is provided and found', async () => { const { handler, account, - findByIds, - findById, - checkHorizonTransactionForTrack, - findKeyringTransactionByTransactionId, + receiverAccount, + findByAddressAndScope, + getTransaction, synchronize, - updateKeyringTransactionStatus, } = setup(); - const persisted = createPersistedKeyringTransaction(); - findKeyringTransactionByTransactionId.mockResolvedValue(persisted); - findByIds.mockResolvedValue([]); - findById.mockResolvedValue(account); - checkHorizonTransactionForTrack.mockResolvedValue('confirmed'); + findByAddressAndScope.mockResolvedValue(receiverAccount); + getTransaction.mockResolvedValue( + createNetworkTransaction(TransactionStatus.Confirmed), + ); await handler.handle({ jsonrpc: '2.0', @@ -417,34 +498,26 @@ describe('TrackTransactionHandler', () => { params: { txId, scope, - accountIds: [accountId], + accountIdsOrAddresses: [accountId, receiverAddress], }, }); - expect(findById).toHaveBeenCalledWith(accountId); - expect(findByIds).not.toHaveBeenCalled(); - expect(synchronize).toHaveBeenCalledWith([account], scope); - expect(updateKeyringTransactionStatus).toHaveBeenCalledWith({ - txId, - accountIds: [accountId], - status: TransactionStatus.Confirmed, - }); + expect(findByAddressAndScope).toHaveBeenCalledWith(receiverAddress, scope); + expect(synchronize).toHaveBeenCalledWith([account, receiverAccount], scope); }); - it('does not synchronize when persisted tx references missing keyring account', async () => { + it('syncs only sender when receiver address is not in keyring', async () => { const { handler, - findByIds, - findById, - checkHorizonTransactionForTrack, - findKeyringTransactionByTransactionId, + account, + findByAddressAndScope, + getTransaction, synchronize, } = setup(); - findKeyringTransactionByTransactionId.mockResolvedValue( - createPersistedKeyringTransaction('deadbeef-dead-4ead-8ead-deadbeefdead'), + findByAddressAndScope.mockResolvedValue(null); + getTransaction.mockResolvedValue( + createNetworkTransaction(TransactionStatus.Confirmed), ); - findById.mockResolvedValue(undefined); - checkHorizonTransactionForTrack.mockResolvedValue('confirmed'); await handler.handle({ jsonrpc: '2.0', @@ -453,17 +526,19 @@ describe('TrackTransactionHandler', () => { params: { txId, scope, - accountIds: [accountId], + accountIdsOrAddresses: [accountId, receiverAddress], }, }); - expect(findByIds).not.toHaveBeenCalled(); - expect(synchronize).not.toHaveBeenCalled(); + expect(findByAddressAndScope).toHaveBeenCalledWith(receiverAddress, scope); + expect(synchronize).toHaveBeenCalledWith([account], scope); }); - it('skips sync when no persisted row exists', async () => { - const { handler, checkHorizonTransactionForTrack, synchronize } = setup(); - checkHorizonTransactionForTrack.mockResolvedValue('confirmed'); + it('continues synchronization when keyring transaction row is missing', async () => { + const { handler, account, getTransaction, save, synchronize } = setup(); + getTransaction.mockResolvedValue( + createNetworkTransaction(TransactionStatus.Confirmed), + ); await handler.handle({ jsonrpc: '2.0', @@ -472,11 +547,11 @@ describe('TrackTransactionHandler', () => { params: { txId, scope, - accountIds: [accountId], + accountIdsOrAddresses: [accountId], }, }); - expect(checkHorizonTransactionForTrack).toHaveBeenCalledWith(txId, scope); - expect(synchronize).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); + expect(synchronize).toHaveBeenCalledWith([account], scope); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts index 33b43b20..bac816a9 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts @@ -1,7 +1,4 @@ -import { - TransactionStatus, - type Transaction as KeyringTransaction, -} from '@metamask/keyring-api'; +import { TransactionStatus } from '@metamask/keyring-api'; import type { TrackTransactionJsonRpcRequest, @@ -12,14 +9,15 @@ import { TrackTransactionJsonRpcRequestStruct, } from './api'; import { CronjobBaseHandler } from './base'; -import type { KnownCaip2ChainId } from '../../api'; +import { type KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; -import { KEYRING_ACCOUNT_TYPE, METAMASK_ORIGIN } from '../../constants'; -import type { - AccountService, - StellarKeyringAccount, -} from '../../services/account'; -import type { NetworkService } from '../../services/network'; +import { METAMASK_ORIGIN } from '../../constants'; +import type { AccountService } from '../../services/account'; +import { + NetworkServiceException, + TransactionNotFoundException, + type NetworkService, +} from '../../services/network'; import type { OnChainAccountService } from '../../services/on-chain-account'; import type { TransactionService } from '../../services/transaction'; import type { ILogger } from '../../utils/logger'; @@ -31,11 +29,11 @@ import { } from '../../utils/snap'; /** - * Tracks transaction settlement via Horizon inclusion. Each cron run calls - * {@link NetworkService.checkHorizonTransactionForTrack} once; reschedules via - * `scheduleBackgroundEvent` when the result is `'pending'`, then syncs before settling - * Confirmed. The persisted keyring transaction in snap state (by hash) is the source of truth for - * which account to sync. + * Tracks transaction settlement via Horizon. Each cron run fetches the transaction once + * via {@link NetworkService.getTransaction}, reschedules via `scheduleBackgroundEvent` + * when Horizon has not indexed it yet or the request fails, then synchronizes keyring + * accounts when the status is terminal ({@link TransactionStatus.Confirmed} or + * {@link TransactionStatus.Failed}). */ export class TrackTransactionHandler extends CronjobBaseHandler { static async scheduleBackgroundEvent( @@ -85,12 +83,13 @@ export class TrackTransactionHandler extends CronjobBaseHandler { - const { txId, scope, accountIds, attempt = 0 } = request.params; + // Superstruct has already validated accountIdsOrAddresses: the first entry is the sender UUID. + const { scope, txId, accountIdsOrAddresses, attempt = 0 } = request.params; this.logger.debug('Tracking transaction', { txId, @@ -98,84 +97,59 @@ export class TrackTransactionHandler extends CronjobBaseHandler 0) { - await this.#synchronizeAccounts(accountsToSync, scope); - } } /** @@ -184,117 +158,117 @@ export class TrackTransactionHandler extends CronjobBaseHandler { - const { txId, scope, accountIds, attempt } = params; + async #rescheduleWhenHorizonNotIndexed( + params: TrackTransactionParams, + ): Promise { + const { txId, scope, attempt = 0, accountIdsOrAddresses } = params; const maxReschedules = AppConfig.transaction.trackTransactionMaxReschedules; if (attempt < maxReschedules) { - this.logger.debug( - 'TrackTransaction: Horizon not indexed; scheduling reschedule', - { txId, scope, attempt, maxReschedules }, - ); + this.logger.debug('Retrying transaction tracking job', { + txId, + scope, + attempt, + maxReschedules, + }); + await TrackTransactionHandler.scheduleBackgroundEvent( { txId, scope, - accountIds: [...accountIds], + accountIdsOrAddresses, attempt: attempt + 1, }, Duration.TwoSeconds, ); - return true; - } - - this.logger.warn( - 'TrackTransaction: Horizon not indexed after max reschedules; leaving keyring transaction pending', - { - txId, - scope, - attempt, - maxReschedules, - }, - ); - return false; - } - - /** - * Resolves keyring accounts to sync from the persisted keyring transaction only. - * - * @param params - Resolution inputs. - * @param params.persistedKeyringTransaction - Pending row from snap state, if any. - * @returns Accounts to pass to {@link OnChainAccountService.synchronize}. - */ - async #resolveAccountsForSynchronize(params: { - persistedKeyringTransaction: KeyringTransaction | undefined; - }): Promise { - const { persistedKeyringTransaction } = params; - - if (!persistedKeyringTransaction) { - return []; - } - - const account = await this.#accountService.findById( - persistedKeyringTransaction.account, - ); - if (account) { - return [account]; + return; } - return []; + this.logger.warn('Max tracking attempts reached', { + txId, + scope, + attempt, + maxReschedules, + }); } - async #synchronizeIfNeeded( - accounts: StellarKeyringAccount[], + async #synchronize( scope: KnownCaip2ChainId, - context: { txId: string; persistedAccountId: string | undefined }, + status: TransactionStatus.Confirmed | TransactionStatus.Failed, + txId: string, + accountIdsOrAddresses: TrackTransactionParams['accountIdsOrAddresses'], ): Promise { - if (accounts.length > 0) { - await this.#synchronizeAccounts(accounts, scope); + // TODO: Consider removing this transaction status update later; the synchronize cron job may handle it. + await this.#updateKeyringTransactionStatus(txId, status); + + // The first entry is the sender account UUID (validated by Superstruct). + const senderAccountId = accountIdsOrAddresses[0]; + const senderAccount = await this.#accountService.findById(senderAccountId); + if (!senderAccount) { + this.logger.warn('Sender account not found, skipping synchronization', { + txId, + scope, + senderAccountId, + }); return; } - this.logger.warn( - 'TrackTransaction: account not found when tracking the transaction, unable to sync', - { - txId: context.txId, + await trackTransactionFinalized({ + origin: METAMASK_ORIGIN, + accountType: senderAccount.type, + chainIdCaip: scope, + }); + + const accountsToSynchronize = [senderAccount]; + + // The optional second entry is the receiver Stellar address. + const receiverAccountAddress = accountIdsOrAddresses[1]; + if ( + receiverAccountAddress && + receiverAccountAddress !== senderAccount.address + ) { + const receiverAccount = await this.#accountService.findByAddressAndScope( + receiverAccountAddress, scope, - persistedAccountId: context.persistedAccountId, - }, - ); - } + ); + // The receiver may not be in the keyring; absence is not an error. + if (receiverAccount) { + accountsToSynchronize.push(receiverAccount); + } + } - async #synchronizeAccounts( - accounts: StellarKeyringAccount[], - scope: KnownCaip2ChainId, - ): Promise { - await this.#onChainAccountService.synchronize(accounts, scope); + await this.#onChainAccountService.synchronize(accountsToSynchronize, scope); } - async #settleKeyringRow( + async #updateKeyringTransactionStatus( txId: string, - accountIds: readonly string[], - keyringStatus: TransactionStatus.Confirmed | TransactionStatus.Failed, + status: TransactionStatus.Confirmed | TransactionStatus.Failed, ): Promise { - try { - await this.#transactionService.updateKeyringTransactionStatus({ + const keyringTransaction = + await this.#transactionService.findKeyringTransactionByTransactionId( txId, - accountIds, - status: keyringStatus, - }); - } catch (error: unknown) { - this.logger.logErrorWithDetails( - 'TrackTransaction: failed to update keyring transaction status', - error, ); + if (keyringTransaction === null) { + this.logger.warn( + 'Keyring transaction not found; skipping transaction status update', + { + txId, + status, + }, + ); + return; } + + await this.#transactionService.save({ + ...keyringTransaction, + status, + events: [ + ...keyringTransaction.events, + { status, timestamp: Math.floor(Date.now() / 1000) }, + ], + }); } } diff --git a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts index 8f396bfa..15fbb9d4 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/AccountService.ts @@ -298,6 +298,20 @@ export class AccountService { return await this.#accountsRepository.findByIds(ids); } + /** + * Finds a Stellar account by address and scope. + * + * @param address - The address of the account to find. + * @param scope - The scope of the account to find. + * @returns A Promise that resolves to the account if found, otherwise `null`. + */ + async findByAddressAndScope( + address: StellarAddress, + scope: KnownCaip2ChainId, + ): Promise { + return await this.#accountsRepository.findByAddressAndScope(address, scope); + } + /** * Finds a Stellar account by ID. * diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index 57ea1eee..fc00c4eb 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -9,10 +9,7 @@ import { import { BigNumber } from 'bignumber.js'; import { KnownRpcError } from './api'; -import type { - AssetDataResponse, - HorizonTransactionTrackCheckStatus, -} from './api'; +import type { AssetDataResponse } from './api'; import { AccountLoadException, AccountNotActivatedException, @@ -191,46 +188,6 @@ export class NetworkService { } } - /** - * Reads Horizon once to decide whether the track-transaction cron should reschedule, settle, or - * stop. Returns `'pending'` when the tx is not indexed yet (Horizon 404). - * - * Unlike {@link pollTransaction}, this does not loop: the track-transaction cron handler - * reschedules via `scheduleBackgroundEvent` until - * {@link AppConfig.transaction.trackTransactionMaxReschedules}. - * - * @param transactionHash - Hash returned from `sendTransaction`. - * @param scope - The CAIP-2 chain ID. - * @returns Explicit reschedule / terminal outcome for one cron cycle. - */ - async checkHorizonTransactionForTrack( - transactionHash: string, - scope: KnownCaip2ChainId, - ): Promise { - try { - const inclusionStatus = await this.getHorizonTransactionInclusionStatus( - transactionHash, - scope, - ); - - if (inclusionStatus === 'pending') { - return 'pending'; - } - - if (inclusionStatus === 'success') { - return 'confirmed'; - } - - return 'failed'; - } catch (error: unknown) { - this.#logger.logErrorWithDetails( - 'Failed to check Horizon transaction for track job', - error, - ); - return 'unavailable'; - } - } - /** * Polls Soroban RPC until the transaction reaches a terminal status, then returns the hash on * success or throws. diff --git a/merged-packages/stellar-wallet-snap/src/services/network/api.ts b/merged-packages/stellar-wallet-snap/src/services/network/api.ts index cd211ed2..9cb281f3 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/api.ts @@ -32,14 +32,3 @@ export type AssetDataResponse = { // CAIP-19 classic asset id (`…/asset:CODE-ISSUER`) from RPC / Stellar asset contract assetId: KnownCaip19AssetId; }; - -/** - * Horizon inclusion outcome for one track-transaction cron read. The handler reschedules when - * the status is `pending` and the attempt budget allows. The transaction hash is always the cron - * `txId` passed into {@link NetworkService.checkHorizonTransactionForTrack}. - */ -export type HorizonTransactionTrackCheckStatus = - | 'pending' - | 'confirmed' - | 'failed' - | 'unavailable'; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts index 49ff0ff5..bb2a6f23 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts @@ -38,11 +38,9 @@ export class TransactionRepository { * accounts in snap state. * * @param txId - Transaction hash (`Transaction.id`). - * @returns The matching transaction, or `undefined` when none is stored. + * @returns The matching transaction, or `null` when none is stored. */ - async findByTransactionId( - txId: string, - ): Promise { + async findByTransactionId(txId: string): Promise { const transactionsByAccount = await this.#state.getKey< TransactionStateValue['transactions'] >(this.#stateKey); @@ -53,7 +51,7 @@ export class TransactionRepository { return found; } } - return undefined; + return null; } /** diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts index c6e6c209..0eff816a 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts @@ -13,7 +13,6 @@ import { TransactionScopeNotMatchException } from './exceptions'; import { KeyringTransactionType } from './KeyringTransactionBuilder'; import type { Transaction } from './Transaction'; import { TransactionBuilder } from './TransactionBuilder'; -import { TransactionRepository } from './TransactionRepository'; import type { KnownCaip19ClassicAssetId } from '../../api'; import { KnownCaip2ChainId } from '../../api'; import { getSlip44AssetId, getSnapProvider } from '../../utils'; @@ -252,165 +251,81 @@ describe('TransactionService', () => { }); }); - describe('updateKeyringTransactionStatus', () => { - let findByIdAmongAccountsSpy: jest.SpiedFunction< - TransactionRepository['findByIdAmongAccounts'] - >; - - let findByTransactionIdSpy: jest.SpiedFunction< - TransactionRepository['findByTransactionId'] - >; - - beforeEach(() => { - findByIdAmongAccountsSpy = jest.spyOn( - TransactionRepository.prototype, - 'findByIdAmongAccounts', - ); - findByTransactionIdSpy = jest.spyOn( - TransactionRepository.prototype, - 'findByTransactionId', - ); - }); - - afterEach(() => { - findByIdAmongAccountsSpy.mockRestore(); - findByTransactionIdSpy.mockRestore(); - }); - - it('updates persisted transaction to confirmed and emits keyring event', async () => { + describe('savePendingKeyringTransactionSafe', () => { + it('returns saved transaction when savePendingKeyringTransaction succeeds', async () => { const { transactionService } = createMockTransactionService(); const [account] = generateMockStellarKeyringAccounts( 1, - 'settle-entropy', + 'safe-save-entropy', ) as [StellarKeyringAccount]; - const txId = 'settle-tx-hash-1'; - const existing = generateMockTransactions(1, { - id: txId, - account: account.id, - scope: KnownCaip2ChainId.Mainnet, - status: TransactionStatus.Unconfirmed, - })[0] as KeyringTransaction; - - findByIdAmongAccountsSpy.mockResolvedValue(existing); - - jest.mocked(emitSnapKeyringEvent).mockClear(); - - await transactionService.updateKeyringTransactionStatus({ - txId, - accountIds: [account.id], - status: TransactionStatus.Confirmed, - }); + const txId = + '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0e1'; + const savePendingKeyringTransactionSpy = jest + .spyOn(transactionService, 'savePendingKeyringTransaction') + .mockResolvedValue( + generateMockTransactions(1, { + id: txId, + account: account.id, + scope: KnownCaip2ChainId.Mainnet, + })[0] as KeyringTransaction, + ); - expect(findByTransactionIdSpy).not.toHaveBeenCalled(); - expect(jest.mocked(emitSnapKeyringEvent)).toHaveBeenCalledTimes(1); - expect(jest.mocked(emitSnapKeyringEvent)).toHaveBeenCalledWith( - getSnapProvider(), - KeyringEvent.AccountTransactionsUpdated, + const result = await transactionService.savePendingKeyringTransactionSafe( { - transactions: { - [account.id]: [ - expect.objectContaining({ - id: txId, - status: TransactionStatus.Confirmed, - events: expect.arrayContaining([ - expect.objectContaining({ - status: TransactionStatus.Unconfirmed, - }), - expect.objectContaining({ - status: TransactionStatus.Confirmed, - }), - ]), - }), - ], + type: KeyringTransactionType.Send, + request: { + txId, + account, + scope: KnownCaip2ChainId.Mainnet, + toAddress: account.address, + amount: '1', + asset: { + type: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + symbol: 'XLM', + }, }, }, ); - }); - - it('resolves transaction by hash alone without searching account id list', async () => { - const { transactionService } = createMockTransactionService(); - const [account] = generateMockStellarKeyringAccounts( - 1, - 'settle-entropy-by-hash', - ) as [StellarKeyringAccount]; - - const txId = 'settle-tx-hash-by-id'; - const existing = generateMockTransactions(1, { - id: txId, - account: account.id, - scope: KnownCaip2ChainId.Mainnet, - status: TransactionStatus.Unconfirmed, - })[0] as KeyringTransaction; - - findByTransactionIdSpy.mockResolvedValue(existing); - jest.mocked(emitSnapKeyringEvent).mockClear(); - - await transactionService.updateKeyringTransactionStatus({ - txId, - accountIds: [], - status: TransactionStatus.Confirmed, - }); - - expect(findByIdAmongAccountsSpy).not.toHaveBeenCalled(); - expect(jest.mocked(emitSnapKeyringEvent)).toHaveBeenCalledTimes(1); - }); - - it('does nothing when no transaction matches txId', async () => { - const { transactionService } = createMockTransactionService(); - - findByIdAmongAccountsSpy.mockResolvedValue(undefined); - - jest.mocked(emitSnapKeyringEvent).mockClear(); - - await transactionService.updateKeyringTransactionStatus({ - txId: 'missing-hash', - accountIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'], - status: TransactionStatus.Confirmed, - }); - - expect(findByTransactionIdSpy).not.toHaveBeenCalled(); - expect(jest.mocked(emitSnapKeyringEvent)).not.toHaveBeenCalled(); + expect(savePendingKeyringTransactionSpy).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual( + expect.objectContaining({ + id: txId, + account: account.id, + }), + ); }); - it('does not emit twice when already confirmed', async () => { + it('returns null when savePendingKeyringTransaction throws', async () => { const { transactionService } = createMockTransactionService(); const [account] = generateMockStellarKeyringAccounts( 1, - 'settle-entropy-2', + 'safe-save-error-entropy', ) as [StellarKeyringAccount]; - const txId = 'settle-tx-hash-2'; - const confirmed = generateMockTransactions(1, { - id: txId, - account: account.id, - scope: KnownCaip2ChainId.Mainnet, - status: TransactionStatus.Confirmed, - events: [ - { - status: TransactionStatus.Unconfirmed, - timestamp: 1, - }, - { - status: TransactionStatus.Confirmed, - timestamp: 2, - }, - ], - })[0] as KeyringTransaction; - - findByIdAmongAccountsSpy.mockResolvedValue(confirmed); - - jest.mocked(emitSnapKeyringEvent).mockClear(); + jest + .spyOn(transactionService, 'savePendingKeyringTransaction') + .mockRejectedValue(new Error('save failed')); - await transactionService.updateKeyringTransactionStatus({ - txId, - accountIds: [account.id], - status: TransactionStatus.Confirmed, - }); + const result = await transactionService.savePendingKeyringTransactionSafe( + { + type: KeyringTransactionType.Send, + request: { + txId: '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0e1', + account, + scope: KnownCaip2ChainId.Mainnet, + toAddress: account.address, + amount: '1', + asset: { + type: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + symbol: 'XLM', + }, + }, + }, + ); - expect(findByTransactionIdSpy).not.toHaveBeenCalled(); - expect(jest.mocked(emitSnapKeyringEvent)).not.toHaveBeenCalled(); + expect(result).toBeNull(); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index fe6ea425..ad5f7bc7 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -1,6 +1,5 @@ import { KeyringEvent, - TransactionStatus, type Transaction as KeyringTransaction, } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; @@ -446,66 +445,35 @@ export class TransactionService { } /** - * Loads a persisted keyring transaction by Stellar transaction hash from snap state. + * Saves a pending keyring transaction without failing the caller when persistence errors. * - * @param txId - Transaction hash (`Transaction.id`). - * @returns The stored keyring transaction, or `undefined` when none exists. + * @param request - Pending transaction payload to persist. + * @returns The saved keyring transaction, or `null` when persistence fails. */ - async findKeyringTransactionByTransactionId( - txId: string, - ): Promise { - return await this.#transactionRepository.findByTransactionId(txId); + async savePendingKeyringTransactionSafe( + request: KeyringTransactionRequest, + ): Promise { + try { + return await this.savePendingKeyringTransaction(request); + } catch (error: unknown) { + this.#logger.logErrorWithDetails( + 'Failed to save pending transaction', + error, + ); + return null; + } } /** - * Updates a persisted keyring transaction to a terminal status and emits - * {@link KeyringEvent.AccountTransactionsUpdated} so the extension Activity list can leave - * the "pending" state after Horizon inclusion (or failure). + * Loads a persisted keyring transaction by Stellar transaction hash from snap state. * - * @param params - Status update parameters. - * @param params.txId - Transaction hash (`Transaction.id`). - * @param params.accountIds - When non-empty, only these keyring account buckets are searched - * (typical track job). When empty, all persisted account buckets are searched by hash. - * @param params.status - {@link TransactionStatus.Confirmed} or {@link TransactionStatus.Failed}. + * @param txId - Transaction hash (`Transaction.id`). + * @returns The stored keyring transaction, or `null` when none exists. */ - async updateKeyringTransactionStatus(params: { - txId: string; - accountIds: readonly string[]; - status: TransactionStatus.Confirmed | TransactionStatus.Failed; - }): Promise { - const { txId, accountIds, status } = params; - - const existing = - accountIds.length > 0 - ? await this.#transactionRepository.findByIdAmongAccounts( - txId, - accountIds, - ) - : await this.#transactionRepository.findByTransactionId(txId); - - if (!existing) { - this.#logger.debug( - 'updateKeyringTransactionStatus: no matching persisted transaction', - { txId, accountIds }, - ); - return; - } - - if ( - existing.status === TransactionStatus.Confirmed || - existing.status === TransactionStatus.Failed - ) { - return; - } - - const timestamp = Math.floor(Date.now() / 1000); - const updated: KeyringTransaction = { - ...existing, - status, - events: [...existing.events, { status, timestamp }], - }; - - await this.save(updated); + async findKeyringTransactionByTransactionId( + txId: string, + ): Promise { + return await this.#transactionRepository.findByTransactionId(txId); } /** From dc9b4720733f34692bb91af42b6a277750b545b6 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:44:34 +0800 Subject: [PATCH 275/384] chore: rollback unuse change --- .../src/services/network/NetworkService.ts | 2 +- .../stellar-wallet-snap/src/services/network/exceptions.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts index fc00c4eb..b89e9a8c 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -8,8 +8,8 @@ import { } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; -import { KnownRpcError } from './api'; import type { AssetDataResponse } from './api'; +import { KnownRpcError } from './api'; import { AccountLoadException, AccountNotActivatedException, diff --git a/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts index 775288a7..67e0a99d 100644 --- a/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/network/exceptions.ts @@ -18,10 +18,7 @@ export class BaseFeeFetchException extends NetworkServiceException { } } -/** - * Thrown when Soroban RPC {@link NetworkService.pollTransaction} does not result in SUCCESS - * (e.g. failed or unknown status). - */ +/** Thrown when transaction polling does not result in SUCCESS (e.g. failed or unknown status). */ export class TransactionPollException extends NetworkServiceException { readonly transactionHash: string; From acbcd0a43d7a7d3960b70bd1cc8727c2de1895b9 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:47:47 +0800 Subject: [PATCH 276/384] fix: add config --- merged-packages/stellar-wallet-snap/.env.example | 3 +++ merged-packages/stellar-wallet-snap/src/config.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/merged-packages/stellar-wallet-snap/.env.example b/merged-packages/stellar-wallet-snap/.env.example index 02a57d36..8915a006 100644 --- a/merged-packages/stellar-wallet-snap/.env.example +++ b/merged-packages/stellar-wallet-snap/.env.example @@ -69,3 +69,6 @@ SECURITY_ALERTS_API_BASE_URL=https://security-alerts.api.cx.metamask.io # Simulation fee multiplier #SIMULATION_FEE_MULTIPLIER= + +# Maximum background reschedules for the track-transaction cron job while Horizon has not +#TRACK_TRANSACTION_MAX_RESCHEDULES=10 \ No newline at end of file diff --git a/merged-packages/stellar-wallet-snap/src/config.ts b/merged-packages/stellar-wallet-snap/src/config.ts index eba8399d..0ad4f1e7 100644 --- a/merged-packages/stellar-wallet-snap/src/config.ts +++ b/merged-packages/stellar-wallet-snap/src/config.ts @@ -170,6 +170,8 @@ export const AppConfig = create( pollingAttempts: process.env.TRANSACTION_POLLING_ATTEMPTS, baseFeeMultiplier: process.env.BASE_FEE_MULTIPLIER, simulationFeeMultiplier: process.env.SIMULATION_FEE_MULTIPLIER, + trackTransactionMaxReschedules: + process.env.TRACK_TRANSACTION_MAX_RESCHEDULES, }, api: { tokenApi: { From 5b223aa5d4910e095b913ee0a097cc3b78e68971 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:55:29 +0800 Subject: [PATCH 277/384] fix: comment --- .../handlers/cronjob/trackTransaction.test.ts | 75 ++++++++++++++++++- .../src/handlers/cronjob/trackTransaction.ts | 9 ++- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts index bc30f75a..dec0b472 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.test.ts @@ -61,13 +61,14 @@ describe('TrackTransactionHandler', () => { function createPersistedKeyringTransaction( account: string = accountId, + status: TransactionStatus = TransactionStatus.Unconfirmed, ): KeyringTransaction { return { type: TransactionType.Send, id: txId, account, chain: scope, - status: TransactionStatus.Unconfirmed, + status, timestamp: 1, from: [], to: [], @@ -554,4 +555,76 @@ describe('TrackTransactionHandler', () => { expect(save).not.toHaveBeenCalled(); expect(synchronize).toHaveBeenCalledWith([account], scope); }); + + it('skips keyring save when transaction is already confirmed but still syncs', async () => { + const { + handler, + account, + getTransaction, + save, + synchronize, + findKeyringTransactionByTransactionId, + } = setup(); + findKeyringTransactionByTransactionId.mockResolvedValue( + createPersistedKeyringTransaction(accountId, TransactionStatus.Confirmed), + ); + getTransaction.mockResolvedValue( + createNetworkTransaction(TransactionStatus.Confirmed), + ); + + await handler.handle({ + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIdsOrAddresses: [accountId], + }, + }); + + expect(save).not.toHaveBeenCalled(); + expect(trackTransactionFinalized).toHaveBeenCalledWith({ + origin: METAMASK_ORIGIN, + accountType: KEYRING_ACCOUNT_TYPE, + chainIdCaip: scope, + }); + expect(synchronize).toHaveBeenCalledWith([account], scope); + }); + + it('skips keyring save when transaction is already failed but still syncs', async () => { + const { + handler, + account, + getTransaction, + save, + synchronize, + findKeyringTransactionByTransactionId, + } = setup(); + findKeyringTransactionByTransactionId.mockResolvedValue( + createPersistedKeyringTransaction(accountId, TransactionStatus.Failed), + ); + getTransaction.mockResolvedValue( + createNetworkTransaction(TransactionStatus.Failed), + ); + + await handler.handle({ + jsonrpc: '2.0', + id: 1, + method: BackgroundEventMethod.TrackTransaction, + params: { + txId, + scope, + accountIdsOrAddresses: [accountId], + }, + }); + + expect(save).not.toHaveBeenCalled(); + expect(trackTransactionFinalized).toHaveBeenCalledWith({ + origin: METAMASK_ORIGIN, + accountType: KEYRING_ACCOUNT_TYPE, + chainIdCaip: scope, + }); + expect(synchronize).toHaveBeenCalledWith([account], scope); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts index bac816a9..0557d868 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/cronjob/trackTransaction.ts @@ -251,9 +251,14 @@ export class TrackTransactionHandler extends CronjobBaseHandler Date: Fri, 5 Jun 2026 16:57:20 +0800 Subject: [PATCH 278/384] fix: skip memo require validation for self payment / swap (#91) ## Explanation This PR updates the Stellar transaction simulation layer to not require an envelope memo for self-payments / self path-payments even when the involved account has memo_required set (SEP-29), aligning memo enforcement with inbound transfers from other accounts. ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- .../transaction/TransactionSimulator.test.ts | 56 +++++++++++++++++++ .../transaction/simulation/simulators.ts | 26 +++++---- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts index 490afe6c..a29af72c 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts @@ -831,6 +831,32 @@ describe('TransactionSimulator', () => { }), ).toHaveLength(2); }); + + it('succeeds for self-payment when destination account requires memo and envelope has no memo', () => { + const wallet = getTestWallet(); + const onChainAccount = destOnChainAccountRequiresMemo(wallet.address); + + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: wallet.address, + asset: 'native', + amount: '10', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect( + simulator.simulate(tx, onChainAccount, { + expectedOPTypes: [SupportedOperations.Payment], + }), + ).toHaveLength(2); + }); }); describe('pathPayment', () => { @@ -910,6 +936,36 @@ describe('TransactionSimulator', () => { ).toHaveLength(2); }); + it('succeeds for self path payment when destination account requires memo and envelope has no memo', () => { + const wallet = getTestWallet(); + const onChainAccountRequiresMemo = destOnChainAccountRequiresMemo( + wallet.address, + ); + + const tx = buildMockClassicTransaction( + [ + { + type: 'pathPaymentStrictSend', + params: { + source: wallet.address, + sendAsset: 'native', + sendAmount: '10', + destination: wallet.address, + destAsset: MOCK_USDC_ASSET, + destMin: '5', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect( + simulator.simulate(tx, onChainAccountRequiresMemo, { + expectedOPTypes: [SupportedOperations.PathPayment], + }), + ).toHaveLength(2); + }); + it('succeeds for strict send when source pays native and destination receives a credit asset', () => { const wallet = getTestWallet(); const onChainAccount = onChainFromMockBalances(wallet.address, '1', { diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts index bcda010f..183377f8 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts @@ -227,11 +227,14 @@ export class PaymentOPSimulator implements OperationSimulator { ); } - assertMemoWhenDestinationRequires( - ctx.transaction, - destId, - dest.requiresMemo, - ); + // SEP-29 memo_required applies to inbound payments from other accounts; skip for self-payments. + if (sourceId !== destId) { + assertMemoWhenDestinationRequires( + ctx.transaction, + destId, + dest.requiresMemo, + ); + } validateDebit({ account: source, @@ -313,11 +316,14 @@ export class PathPaymentOPSimulator implements OperationSimulator { op, ); - assertMemoWhenDestinationRequires( - ctx.transaction, - destId, - dest.requiresMemo, - ); + // SEP-29 memo_required applies to inbound payments from other accounts; skip for self-payments. + if (sourceId !== destId) { + assertMemoWhenDestinationRequires( + ctx.transaction, + destId, + dest.requiresMemo, + ); + } validateDebit({ account: source, From 35efc1b07b5aeec50f3c2987339b35c99711b122 Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Fri, 5 Jun 2026 13:37:34 +0200 Subject: [PATCH 279/384] fix: return required balance for fee exception --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../handlers/clientRequest/computeFee.test.ts | 74 ++++++++++++++++++- .../src/handlers/clientRequest/computeFee.ts | 59 +++++++++++---- .../transaction/TransactionService.ts | 1 + .../src/services/transaction/exceptions.ts | 17 ++++- .../transaction/simulation/simulators.ts | 6 +- 6 files changed, 139 insertions(+), 20 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 69e950de..3900a8e4 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "ddfQTU/LvfdL5VxTp/9MrUYxtD21ggvNaemmJyltoqo=", + "shasum": "nKVY+mkyEJTOcarN7U0sapIExAZVgHMtbZcsatJx9ro=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.test.ts index 2b9d316d..a10c64b5 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.test.ts @@ -17,7 +17,11 @@ import { horizonSource, mockOnChainAccountService, } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; -import { TransactionService } from '../../services/transaction'; +import { + InsufficientBalanceException, + InsufficientBalanceToCoverFeeException, + TransactionService, +} from '../../services/transaction'; import { buildMockInvokeHostFunctionTransaction, createMockTransactionService, @@ -179,4 +183,72 @@ describe('ComputeFeeHandler', () => { 'Invalid swap transaction', ); }); + + it('returns the required native fee when balance is insufficient to cover fees', async () => { + const { handler, request, createValidatedSwapTransaction } = setup(); + createValidatedSwapTransaction.mockRejectedValueOnce( + new InsufficientBalanceToCoverFeeException('100', '12500000'), + ); + + const result = await handler.handle(request); + + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: NATIVE_ASSET_SYMBOL, + type: KnownCaip19Slip44IdMap[scope], + amount: '1.25', + fungible: true, + }, + }, + ]); + }); + + it('returns the required native fee when native balance is insufficient for the swap', async () => { + const { handler, request, createValidatedSwapTransaction } = setup(); + createValidatedSwapTransaction.mockRejectedValueOnce( + new InsufficientBalanceException( + '100', + '50000000', + KnownCaip19Slip44IdMap[scope], + ), + ); + + const result = await handler.handle(request); + + expect(result).toStrictEqual([ + { + type: FeeType.Base, + asset: { + unit: NATIVE_ASSET_SYMBOL, + type: KnownCaip19Slip44IdMap[scope], + amount: '5', + fungible: true, + }, + }, + ]); + }); + + it('rethrows when balance is insufficient for a non-native asset', async () => { + const { handler, request, createValidatedSwapTransaction } = setup(); + const nonSlip44AssetId = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + const error = new InsufficientBalanceException( + '100', + '50000000', + nonSlip44AssetId, + ); + createValidatedSwapTransaction.mockRejectedValueOnce(error); + + await expect(handler.handle(request)).rejects.toBe(error); + }); + + it('rethrows when InsufficientBalanceException has no assetId', async () => { + const { handler, request, createValidatedSwapTransaction } = setup(); + const error = new InsufficientBalanceException('100', '50000000'); + createValidatedSwapTransaction.mockRejectedValueOnce(error); + + await expect(handler.handle(request)).rejects.toBe(error); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.ts index 7a424145..04882e44 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/computeFee.ts @@ -1,4 +1,5 @@ import { FeeType } from '@metamask/keyring-api'; +import { BigNumber } from 'bignumber.js'; import type { ComputeFeeJsonRpcRequest, @@ -15,7 +16,12 @@ import { import { BaseClientRequestHandler } from './base'; import { KnownCaip19Slip44IdMap } from '../../api'; import { NATIVE_ASSET_SYMBOL } from '../../constants'; +import { + InsufficientBalanceException, + InsufficientBalanceToCoverFeeException, +} from '../../services/transaction'; import type { TransactionService } from '../../services/transaction/TransactionService'; +import { isSlip44Id } from '../../utils'; import { toDisplayBalance } from '../../utils/currency'; import { createPrefixedLogger } from '../../utils/logger'; import type { ILogger } from '../../utils/logger'; @@ -73,23 +79,44 @@ export class ComputeFeeHandler extends BaseClientRequestHandler< const { onChainAccount } = resolved; const { transaction: transactionBase64Xdr, scope } = request.params; - const transaction = - await this.#transactionService.createValidatedSwapTransaction({ - xdr: transactionBase64Xdr, - scope, - onChainAccount, - }); + try { + const transaction = + await this.#transactionService.createValidatedSwapTransaction({ + xdr: transactionBase64Xdr, + scope, + onChainAccount, + }); - return [ - { - type: FeeType.Base, - asset: { - unit: NATIVE_ASSET_SYMBOL, - type: KnownCaip19Slip44IdMap[scope], - amount: toDisplayBalance(transaction.totalFee), - fungible: true as const, + return [ + { + type: FeeType.Base, + asset: { + unit: NATIVE_ASSET_SYMBOL, + type: KnownCaip19Slip44IdMap[scope], + amount: toDisplayBalance(transaction.totalFee), + fungible: true as const, + }, }, - }, - ]; + ]; + } catch (error) { + if ( + (error instanceof InsufficientBalanceException && + isSlip44Id(error.assetId)) || + error instanceof InsufficientBalanceToCoverFeeException + ) { + return [ + { + type: FeeType.Base, + asset: { + unit: NATIVE_ASSET_SYMBOL, + type: KnownCaip19Slip44IdMap[scope], + amount: toDisplayBalance(new BigNumber(error.required)), + fungible: true as const, + }, + }, + ]; + } + throw error; + } } } diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index fe6ea425..f8af10fc 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -252,6 +252,7 @@ export class TransactionService { throw new InsufficientBalanceException( onChainAccount.accountId, amount.toString(), + assetId, ); } diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts index 03ee4007..9b052d96 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts @@ -166,10 +166,16 @@ export class UpdateTrustlineException extends TransactionValidationException { * for fees, reserves, and native outflows (all in stroops). */ export class InsufficientBalanceToCoverFeeException extends TransactionValidationException { + readonly balance: string; + + readonly required: string; + constructor(balance: string, required: string) { super( `Insufficient native balance for transaction: ${balance} stroops available is less than ${required} stroops required`, ); + this.balance = balance; + this.required = required; } } @@ -185,10 +191,19 @@ export class InsufficientBalanceToCoverBaseReserveException extends TransactionV * by the transaction (amounts in the asset's smallest units). */ export class InsufficientBalanceException extends TransactionValidationException { - constructor(balance: string, required: string) { + readonly balance: string; + + readonly required: string; + + readonly assetId: string; + + constructor(balance: string, required: string, assetId?: string) { super( `Insufficient asset balance for transaction: ${balance} available is less than ${required} required`, ); + this.balance = balance; + this.required = required; + this.assetId = assetId ?? ''; } } diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts index 183377f8..35a5322a 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts @@ -90,6 +90,7 @@ function validateDebit(params: { throw new InsufficientBalanceException( spendable.toString(), amount.toString(), + assetId, ); } return; @@ -109,6 +110,7 @@ function validateDebit(params: { throw new InsufficientBalanceException( line.balance.toString(), amount.toString(), + assetId, ); } } @@ -421,7 +423,7 @@ export class PathPaymentOPSimulator implements OperationSimulator { export class CreateAccountOPSimulator implements OperationSimulator { validate(ctx: ValidateContext, op: Operation.CreateAccount): void { - const { state, opIndex } = ctx; + const { state, opIndex, scope } = ctx; if (typeof op.destination !== 'string' || op.destination.length === 0) { throw new TransactionValidationException( `CreateAccount at index ${opIndex} has no destination`, @@ -443,6 +445,7 @@ export class CreateAccountOPSimulator implements OperationSimulator { throw new InsufficientBalanceException( spendable.toString(), startingBalance.toString(), + getSlip44AssetId(scope), ); } @@ -650,6 +653,7 @@ export class InvokeHostFunctionOPSimulator implements OperationSimulator { throw new InsufficientBalanceException( onChainBalance.toString(), amount.toString(), + assetId, ); } } From b3f07aaf15972b1cf2f8d25cf2d0ae6be04ef0d1 Mon Sep 17 00:00:00 2001 From: Julink Date: Fri, 5 Jun 2026 14:19:13 +0200 Subject: [PATCH 280/384] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/services/transaction/TransactionService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts index f8af10fc..5de20f6c 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.ts @@ -250,7 +250,7 @@ export class TransactionService { // so we can fail early here. if (onChainAccount.getRawAsset(assetId)?.balance.lt(amount)) { throw new InsufficientBalanceException( - onChainAccount.accountId, + onChainAccount.getRawAsset(assetId)?.balance.toString() ?? '0', amount.toString(), assetId, ); From 276570626e965e4fcb1a84ffcd15549afbee74ec Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Fri, 5 Jun 2026 14:38:17 +0200 Subject: [PATCH 281/384] chore: restore wrongly removed accepted shape --- .../stellar-wallet-snap/snap.manifest.json | 2 +- merged-packages/stellar-wallet-snap/src/api/xdr.ts | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 69e950de..57ea30cd 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "ddfQTU/LvfdL5VxTp/9MrUYxtD21ggvNaemmJyltoqo=", + "shasum": "Y+2RFW7vZJdSdWk37SGfapd6v4jjU78oDN0wDhSsDFw=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/api/xdr.ts b/merged-packages/stellar-wallet-snap/src/api/xdr.ts index c6017782..3a6baa0c 100644 --- a/merged-packages/stellar-wallet-snap/src/api/xdr.ts +++ b/merged-packages/stellar-wallet-snap/src/api/xdr.ts @@ -84,11 +84,12 @@ export const SwapTransactionXdrStruct = refine( const operationTypes = getTransactionOperationTypes(value); const [firstOperation, secondOperation, thirdOperation] = operationTypes; - // Soroban swap route or bridge deposit route. + // Soroban swap route or bridge deposit route or swap without a fee if ( operationTypes.length === 1 && (firstOperation === 'invokeHostFunction' || - firstOperation === 'payment') + firstOperation === 'payment' || + isPathPaymentOperation(firstOperation)) ) { return true; } @@ -102,6 +103,15 @@ export const SwapTransactionXdrStruct = refine( return true; } + // Swap and change trust without a fee + if ( + operationTypes.length === 2 && + firstOperation === 'changeTrust' && + isPathPaymentOperation(secondOperation) + ) { + return true; + } + // Classic route requiring a new destination-asset trustline first. if ( operationTypes.length === 3 && From b831d616e4e8a3fbc572f65375552219639c0dbe Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Fri, 5 Jun 2026 14:40:55 +0200 Subject: [PATCH 282/384] chore: update the unit tests --- .../stellar-wallet-snap/src/api/xdr.test.ts | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/src/api/xdr.test.ts b/merged-packages/stellar-wallet-snap/src/api/xdr.test.ts index 6a036c83..a452cd26 100644 --- a/merged-packages/stellar-wallet-snap/src/api/xdr.test.ts +++ b/merged-packages/stellar-wallet-snap/src/api/xdr.test.ts @@ -51,6 +51,24 @@ describe('SwapTransactionXdrStruct', () => { amount: '1', }), ]), + buildTransactionXdr([ + Operation.pathPaymentStrictSend({ + sendAsset: Asset.native(), + sendAmount: '10', + destination, + destAsset: usdc, + destMin: '5', + }), + ]), + buildTransactionXdr([ + Operation.pathPaymentStrictReceive({ + sendAsset: Asset.native(), + sendMax: '10', + destination, + destAsset: usdc, + destAmount: '5', + }), + ]), buildTransactionXdr([ Operation.pathPaymentStrictSend({ sendAsset: Asset.native(), @@ -77,25 +95,18 @@ describe('SwapTransactionXdrStruct', () => { destAsset: usdc, destMin: '5', }), - Operation.payment({ - destination: feeDestination, - asset: Asset.native(), - amount: '1', - }), ]), - ])('accepts a valid swap transaction XDR', (xdr) => { - expect(() => assert(xdr, SwapTransactionXdrStruct)).not.toThrow(); - }); - - it.each([ - 'not-xdr', buildTransactionXdr([ - Operation.pathPaymentStrictSend({ + Operation.changeTrust({ + asset: usdc, + limit: '1000', + }), + Operation.pathPaymentStrictReceive({ sendAsset: Asset.native(), - sendAmount: '10', - destination, + sendMax: '10', + destination: sourceAddress, destAsset: usdc, - destMin: '5', + destAmount: '5', }), ]), buildTransactionXdr([ @@ -110,7 +121,18 @@ describe('SwapTransactionXdrStruct', () => { destAsset: usdc, destMin: '5', }), + Operation.payment({ + destination: feeDestination, + asset: Asset.native(), + amount: '1', + }), ]), + ])('accepts a valid swap transaction XDR', (xdr) => { + expect(() => assert(xdr, SwapTransactionXdrStruct)).not.toThrow(); + }); + + it.each([ + 'not-xdr', buildTransactionXdr([ Operation.pathPaymentStrictSend({ sendAsset: Asset.native(), From 4c311f4b991c55c2913674cd34301317245c2f1b Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Fri, 5 Jun 2026 14:53:15 +0200 Subject: [PATCH 283/384] chore: fix comments --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../transaction/TransactionSimulator.test.ts | 13 ++++++++++++- .../src/services/transaction/exceptions.ts | 11 +++++++++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 3900a8e4..f594a37d 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "nKVY+mkyEJTOcarN7U0sapIExAZVgHMtbZcsatJx9ro=", + "shasum": "+11EcYy1qTviJ3cGJ/8lcvNEVnSaoYVkEb9NKcqpCJ0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts index a29af72c..98b26d85 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts @@ -1343,9 +1343,20 @@ describe('TransactionSimulator', () => { mainnetSimulatorTxOptions(wallet.address, '1'), ); - expect(() => simulator.simulate(tx, onChainAccount)).toThrow( + let error: unknown; + try { + simulator.simulate(tx, onChainAccount); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf( InsufficientBalanceToCoverBaseReserveException, ); + expect(error).toMatchObject({ + balance: '999900', + required: '5000000', + }); }); it('succeeds when removing an existing trustline with zero balance', () => { diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts index 9b052d96..ce979387 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts @@ -180,15 +180,22 @@ export class InsufficientBalanceToCoverFeeException extends TransactionValidatio } export class InsufficientBalanceToCoverBaseReserveException extends TransactionValidationException { + readonly balance: string; + + readonly required: string; + constructor(balance: string, required: string) { super( `Insufficient native balance for transaction for base reserve: ${balance} stroops available is less than ${required} stroops required`, ); + this.balance = balance; + this.required = required; } } /** - * Thrown when the account's spendable balance for a non-native asset is below the amount required - * by the transaction (amounts in the asset's smallest units). + * Thrown when the account's spendable balance for an asset is below the amount required + * by the transaction. The asset can be native or non-native, and amounts are in + * the asset's smallest units. */ export class InsufficientBalanceException extends TransactionValidationException { readonly balance: string; From 2166e87945c3cdb775ad127df149114ab92732a5 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:25:12 +0800 Subject: [PATCH 284/384] feat: transaction scan batch 2 (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Explanation This PR continues the “transaction scan” work by centralizing Stellar operation type string literals into a shared enum and updating transaction-related code to use it, while also adding a small utility for working with record-of-arrays state. **Changes:** - Add `StellarOperationType` (and `TransactionOrder`) enums and replace several hard-coded operation type strings with enum references across transaction parsing/mapping/simulation and XDR validation. - Add `pushToRecordArray` helper plus unit tests. - Introduce small type aliases (`KeyringAccountId`, `TransactionId`) to clarify intent in state and API typing. ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- .../src/api/transactionHash.ts | 3 + .../stellar-wallet-snap/src/api/xdr.ts | 17 ++--- .../clientRequest/signAndSendTransaction.ts | 9 +-- .../src/services/account/api.ts | 4 +- .../services/transaction/OperationMapper.ts | 70 ++++++++++--------- .../src/services/transaction/Transaction.ts | 13 ++-- .../transaction/TransactionSimulator.ts | 5 +- .../src/services/transaction/api.ts | 42 +++++++++++ .../src/services/transaction/index.ts | 1 + .../transaction/simulation/simulators.ts | 15 ++-- .../src/utils/array.test.ts | 16 ++++- .../stellar-wallet-snap/src/utils/array.ts | 16 +++++ .../src/utils/caip.test.ts | 32 +++++++++ .../stellar-wallet-snap/src/utils/caip.ts | 21 ++++++ 14 files changed, 203 insertions(+), 61 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/api.ts diff --git a/merged-packages/stellar-wallet-snap/src/api/transactionHash.ts b/merged-packages/stellar-wallet-snap/src/api/transactionHash.ts index 66b16ea7..b567253f 100644 --- a/merged-packages/stellar-wallet-snap/src/api/transactionHash.ts +++ b/merged-packages/stellar-wallet-snap/src/api/transactionHash.ts @@ -1,3 +1,4 @@ +import type { Infer } from '@metamask/superstruct'; import { definePattern } from '@metamask/utils'; /** @@ -7,3 +8,5 @@ export const StellarTransactionHashStruct = definePattern( 'StellarTransactionHash', /^[0-9a-f]{64}$/iu, ); + +export type TransactionId = Infer; diff --git a/merged-packages/stellar-wallet-snap/src/api/xdr.ts b/merged-packages/stellar-wallet-snap/src/api/xdr.ts index 3a6baa0c..fd47cfeb 100644 --- a/merged-packages/stellar-wallet-snap/src/api/xdr.ts +++ b/merged-packages/stellar-wallet-snap/src/api/xdr.ts @@ -8,6 +8,7 @@ import { xdr, } from '@stellar/stellar-sdk'; +import { StellarOperationType } from '../services/transaction/api'; import { bufferToUint8Array } from '../utils/buffer'; /** @@ -51,8 +52,8 @@ function getTransactionOperationTypes(value: string): string[] { */ function isPathPaymentOperation(operationType: string | undefined): boolean { return ( - operationType === 'pathPaymentStrictSend' || - operationType === 'pathPaymentStrictReceive' + operationType === StellarOperationType.PathPaymentStrictSend || + operationType === StellarOperationType.PathPaymentStrictReceive ); } @@ -87,8 +88,8 @@ export const SwapTransactionXdrStruct = refine( // Soroban swap route or bridge deposit route or swap without a fee if ( operationTypes.length === 1 && - (firstOperation === 'invokeHostFunction' || - firstOperation === 'payment' || + (firstOperation === StellarOperationType.InvokeHostFunction || + firstOperation === StellarOperationType.Payment || isPathPaymentOperation(firstOperation)) ) { return true; @@ -98,7 +99,7 @@ export const SwapTransactionXdrStruct = refine( if ( operationTypes.length === 2 && isPathPaymentOperation(firstOperation) && - secondOperation === 'payment' + secondOperation === StellarOperationType.Payment ) { return true; } @@ -106,7 +107,7 @@ export const SwapTransactionXdrStruct = refine( // Swap and change trust without a fee if ( operationTypes.length === 2 && - firstOperation === 'changeTrust' && + firstOperation === StellarOperationType.ChangeTrust && isPathPaymentOperation(secondOperation) ) { return true; @@ -115,9 +116,9 @@ export const SwapTransactionXdrStruct = refine( // Classic route requiring a new destination-asset trustline first. if ( operationTypes.length === 3 && - firstOperation === 'changeTrust' && + firstOperation === StellarOperationType.ChangeTrust && isPathPaymentOperation(secondOperation) && - thirdOperation === 'payment' + thirdOperation === StellarOperationType.Payment ) { return true; } diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts index 0817f6bf..69a3d3fb 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts @@ -22,6 +22,7 @@ import { import { BaseClientRequestHandler } from './base'; import { METAMASK_ORIGIN, NATIVE_ASSET_SYMBOL } from '../../constants'; import type { StellarKeyringAccount } from '../../services/account'; +import { StellarOperationType } from '../../services/transaction/api'; import { KeyringTransactionType, type PendingTransactionRequest, @@ -210,8 +211,8 @@ export class SignAndSendTransactionHandler extends BaseClientRequestHandler< ): PendingSwapDetails | null { const pathPaymentOperation = transaction.transactionOperations.find( (operation) => - operation.type === 'pathPaymentStrictSend' || - operation.type === 'pathPaymentStrictReceive', + operation.type === StellarOperationType.PathPaymentStrictSend || + operation.type === StellarOperationType.PathPaymentStrictReceive, ); if (pathPaymentOperation === undefined) { @@ -221,7 +222,7 @@ export class SignAndSendTransactionHandler extends BaseClientRequestHandler< const sourceAddress = pathPaymentOperation.source ?? transaction.sourceAccount; const send = - pathPaymentOperation.type === 'pathPaymentStrictSend' + pathPaymentOperation.type === StellarOperationType.PathPaymentStrictSend ? { asset: pathPaymentOperation.sendAsset, amount: pathPaymentOperation.sendAmount, @@ -231,7 +232,7 @@ export class SignAndSendTransactionHandler extends BaseClientRequestHandler< amount: pathPaymentOperation.sendMax, }; const receive = - pathPaymentOperation.type === 'pathPaymentStrictSend' + pathPaymentOperation.type === StellarOperationType.PathPaymentStrictSend ? { asset: pathPaymentOperation.destAsset, amount: pathPaymentOperation.destMin, diff --git a/merged-packages/stellar-wallet-snap/src/services/account/api.ts b/merged-packages/stellar-wallet-snap/src/services/account/api.ts index cf07355c..d2fb8597 100644 --- a/merged-packages/stellar-wallet-snap/src/services/account/api.ts +++ b/merged-packages/stellar-wallet-snap/src/services/account/api.ts @@ -1,7 +1,9 @@ import type { KeyringAccount, EntropySourceId } from '@metamask/keyring-api'; +export type KeyringAccountId = string; + export type KeyringAccountState = { - keyringAccounts: Record; + keyringAccounts: Record; }; /** Stellar BIP44 derivation path (e.g. `m/44'/148'` or `m/44'/148'/0'`). */ diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts index d027b647..c583e69f 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/OperationMapper.ts @@ -3,6 +3,7 @@ import type { Asset, Operation } from '@stellar/stellar-sdk'; import { LiquidityPoolAsset, LiquidityPoolId, xdr } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; +import { StellarOperationType } from './api'; import type { Transaction } from './Transaction'; import type { KnownCaip2ChainId } from '../../api'; import { bufferToUint8Array } from '../../utils'; @@ -66,9 +67,9 @@ export type ReadableTransactionJson = { }; const SOROBAN_OPERATION_TYPES = new Set([ - 'invokeHostFunction', - 'extendFootprintTtl', - 'restoreFootprint', + StellarOperationType.InvokeHostFunction, + StellarOperationType.ExtendFootprintTtl, + StellarOperationType.RestoreFootprint, ]); /** Stellar account auth flags for {@link Operation.setOptions} `setFlags` / `clearFlags`. */ @@ -166,7 +167,7 @@ export class OperationMapper { } #mapSorobanPlaceholder(operation: Operation): ReadableOperationField[] { - if (operation.type === 'invokeHostFunction') { + if (operation.type === StellarOperationType.InvokeHostFunction) { const hostOp = operation; const rows: ReadableOperationField[] = []; try { @@ -210,11 +211,11 @@ export class OperationMapper { } return rows; } - if (operation.type === 'extendFootprintTtl') { + if (operation.type === StellarOperationType.ExtendFootprintTtl) { const extendOp = operation; return [this.#field('extendTo', extendOp.extendTo, 'number')]; } - if (operation.type === 'restoreFootprint') { + if (operation.type === StellarOperationType.RestoreFootprint) { return [this.#field('note', 'Soroban restoreFootprint.', 'text')]; } return [ @@ -227,8 +228,9 @@ export class OperationMapper { } #mapClassicParams(operation: Operation): ReadableOperationField[] { + /* eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- enum cases mirror SDK `operation.type` literals */ switch (operation.type) { - case 'payment': { + case StellarOperationType.Payment: { const payment = operation; return [ this.#field('destination', payment.destination, 'address'), @@ -239,7 +241,7 @@ export class OperationMapper { ), ]; } - case 'createAccount': { + case StellarOperationType.CreateAccount: { const createAccount = operation; return [ this.#field('destination', createAccount.destination, 'address'), @@ -250,7 +252,7 @@ export class OperationMapper { ), ]; } - case 'changeTrust': { + case StellarOperationType.ChangeTrust: { const changeTrust = operation; return [ // we don't use assetWithAmount here because the line is not necessarily a classic asset @@ -259,13 +261,13 @@ export class OperationMapper { this.#field('limit', changeTrust.limit, 'amount'), ]; } - case 'accountMerge': { + case StellarOperationType.AccountMerge: { const accountMerge = operation; return [ this.#field('destination', accountMerge.destination, 'address'), ]; } - case 'pathPaymentStrictReceive': { + case StellarOperationType.PathPaymentStrictReceive: { const pathReceive = operation; return [ @@ -287,7 +289,7 @@ export class OperationMapper { ), ]; } - case 'pathPaymentStrictSend': { + case StellarOperationType.PathPaymentStrictSend: { const pathSend = operation; return [ this.#field( @@ -309,7 +311,7 @@ export class OperationMapper { ), ]; } - case 'manageSellOffer': { + case StellarOperationType.ManageSellOffer: { const sellOffer = operation; return [ this.#field( @@ -322,7 +324,7 @@ export class OperationMapper { this.#field('offerId', sellOffer.offerId, 'text'), ]; } - case 'manageBuyOffer': { + case StellarOperationType.ManageBuyOffer: { const buyOffer = operation; return [ this.#field( @@ -335,7 +337,7 @@ export class OperationMapper { this.#field('offerId', buyOffer.offerId, 'text'), ]; } - case 'createPassiveSellOffer': { + case StellarOperationType.CreatePassiveSellOffer: { const passiveOffer = operation; return [ this.#field( @@ -347,7 +349,7 @@ export class OperationMapper { this.#field('price', passiveOffer.price, 'price'), ]; } - case 'setOptions': { + case StellarOperationType.SetOptions: { const setOptions = operation; const rows: ReadableOperationField[] = []; if (setOptions.inflationDest !== undefined) { @@ -443,7 +445,7 @@ export class OperationMapper { } return rows; } - case 'allowTrust': { + case StellarOperationType.AllowTrust: { const allowTrustOp = operation; const rows: ReadableOperationField[] = [ this.#field('trustor', allowTrustOp.trustor, 'address'), @@ -459,7 +461,7 @@ export class OperationMapper { } return rows; } - case 'manageData': { + case StellarOperationType.ManageData: { const manageDataOp = operation; return [ this.#field('name', manageDataOp.name, 'text'), @@ -472,13 +474,13 @@ export class OperationMapper { ), ]; } - case 'bumpSequence': { + case StellarOperationType.BumpSequence: { const bumpSequence = operation; return [this.#field('bumpTo', bumpSequence.bumpTo, 'text')]; } - case 'inflation': + case StellarOperationType.Inflation: return []; - case 'createClaimableBalance': { + case StellarOperationType.CreateClaimableBalance: { const createCb = operation; return [ this.#field( @@ -496,21 +498,21 @@ export class OperationMapper { ), ]; } - case 'claimClaimableBalance': { + case StellarOperationType.ClaimClaimableBalance: { const claimCb = operation; return [this.#field('balanceId', claimCb.balanceId, 'text')]; } - case 'beginSponsoringFutureReserves': { + case StellarOperationType.BeginSponsoringFutureReserves: { const beginSponsor = operation; return [ this.#field('sponsoredId', beginSponsor.sponsoredId, 'address'), ]; } - case 'endSponsoringFutureReserves': + case StellarOperationType.EndSponsoringFutureReserves: return []; - case 'revokeSponsorship': + case StellarOperationType.RevokeSponsorship: return this.#mapRevokeSponsorship(operation); - case 'clawback': { + case StellarOperationType.Clawback: { const clawback = operation; return [ this.#field( @@ -521,11 +523,11 @@ export class OperationMapper { this.#field('from', clawback.from, 'address'), ]; } - case 'clawbackClaimableBalance': { + case StellarOperationType.ClawbackClaimableBalance: { const clawbackCb = operation; return [this.#field('balanceId', clawbackCb.balanceId, 'text')]; } - case 'setTrustLineFlags': { + case StellarOperationType.SetTrustLineFlags: { const trustFlags = operation; const setFlagLabels: string[] = []; const clearFlagLabels: string[] = []; @@ -556,7 +558,7 @@ export class OperationMapper { } return rows; } - case 'liquidityPoolDeposit': { + case StellarOperationType.LiquidityPoolDeposit: { const poolDeposit = operation; return [ this.#field('liquidityPoolId', poolDeposit.liquidityPoolId, 'text'), @@ -566,7 +568,7 @@ export class OperationMapper { this.#field('maxPrice', poolDeposit.maxPrice, 'price'), ]; } - case 'liquidityPoolWithdraw': { + case StellarOperationType.LiquidityPoolWithdraw: { const poolWithdraw = operation; return [ this.#field('liquidityPoolId', poolWithdraw.liquidityPoolId, 'text'), @@ -575,9 +577,9 @@ export class OperationMapper { this.#field('minAmountB', poolWithdraw.minAmountB, 'amount'), ]; } - case 'invokeHostFunction': - case 'extendFootprintTtl': - case 'restoreFootprint': + case StellarOperationType.InvokeHostFunction: + case StellarOperationType.ExtendFootprintTtl: + case StellarOperationType.RestoreFootprint: return [ this.#field( 'note', @@ -586,7 +588,7 @@ export class OperationMapper { ), ]; default: { - const unknownOp = operation as Operation; + const unknownOp = operation; return [ this.#field( 'note', diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts index dfdf3a9e..5a3a21f0 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/Transaction.ts @@ -10,6 +10,7 @@ import { } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; +import { StellarOperationType } from './api'; import { TransactionDeserializationException } from './exceptions'; import { parseExpirationMaxTime } from './utils'; import type { KnownCaip2ChainId } from '../../api'; @@ -66,11 +67,13 @@ export class Transaction { // Destination of the operation should count as participating in the transaction. // For now, we only support payment related operations - if (operation.type === 'pathPaymentStrictSend') { + if (operation.type === StellarOperationType.PathPaymentStrictSend) { this.#participatingAccounts.add(operation.destination); - } else if (operation.type === 'pathPaymentStrictReceive') { + } else if ( + operation.type === StellarOperationType.PathPaymentStrictReceive + ) { this.#participatingAccounts.add(operation.destination); - } else if (operation.type === 'payment') { + } else if (operation.type === StellarOperationType.Payment) { this.#participatingAccounts.add(operation.destination); } @@ -193,7 +196,7 @@ export class Transaction { * @returns True if the transaction has a create account operation, false otherwise. */ get hasCreateAccount(): boolean { - return this.#operationTypes.has('createAccount'); + return this.#operationTypes.has(StellarOperationType.CreateAccount); } /** @@ -202,7 +205,7 @@ export class Transaction { * @returns True if the transaction has an invoke host function operation, false otherwise. */ get hasInvokeHostFunction(): boolean { - return this.#operationTypes.has('invokeHostFunction'); + return this.#operationTypes.has(StellarOperationType.InvokeHostFunction); } /** diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts index 01c97d11..6b59c06e 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.ts @@ -1,6 +1,7 @@ import type { Operation } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; +import { StellarOperationType } from './api'; import { InsufficientBalanceToCoverFeeException, TransactionValidationException, @@ -413,8 +414,8 @@ export class TransactionSimulator { return SupportedOperations.Payment; } if ( - op.type === 'pathPaymentStrictReceive' || - op.type === 'pathPaymentStrictSend' + op.type === StellarOperationType.PathPaymentStrictReceive || + op.type === StellarOperationType.PathPaymentStrictSend ) { return SupportedOperations.PathPayment; } diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/api.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/api.ts new file mode 100644 index 00000000..30c982a9 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/api.ts @@ -0,0 +1,42 @@ +/** + * The order of onChain transactions to fetch. + */ +export enum TransactionOrder { + ASC = 'asc', + DESC = 'desc', +} + +/** + * The type of Stellar operation. + * + * @see https://stellar.org/developers/guides/concepts/list-of-operations.html + */ +export enum StellarOperationType { + AccountMerge = 'accountMerge', + AllowTrust = 'allowTrust', + BeginSponsoringFutureReserves = 'beginSponsoringFutureReserves', + BumpSequence = 'bumpSequence', + ChangeTrust = 'changeTrust', + ClaimClaimableBalance = 'claimClaimableBalance', + Clawback = 'clawback', + ClawbackClaimableBalance = 'clawbackClaimableBalance', + CreateAccount = 'createAccount', + CreateClaimableBalance = 'createClaimableBalance', + CreatePassiveSellOffer = 'createPassiveSellOffer', + EndSponsoringFutureReserves = 'endSponsoringFutureReserves', + ExtendFootprintTtl = 'extendFootprintTtl', + Inflation = 'inflation', + InvokeHostFunction = 'invokeHostFunction', + LiquidityPoolDeposit = 'liquidityPoolDeposit', + LiquidityPoolWithdraw = 'liquidityPoolWithdraw', + ManageBuyOffer = 'manageBuyOffer', + ManageData = 'manageData', + ManageSellOffer = 'manageSellOffer', + PathPaymentStrictReceive = 'pathPaymentStrictReceive', + PathPaymentStrictSend = 'pathPaymentStrictSend', + Payment = 'payment', + RestoreFootprint = 'restoreFootprint', + RevokeSponsorship = 'revokeSponsorship', + SetOptions = 'setOptions', + SetTrustLineFlags = 'setTrustLineFlags', +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/index.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/index.ts index 3778432a..f1d557c0 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/index.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/index.ts @@ -6,3 +6,4 @@ export * from './TransactionRepository'; export * from './TransactionService'; export * from './TransactionSimulator'; export * from './KeyringTransactionBuilder'; +export * from './api'; diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts index 35a5322a..ca97493e 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts @@ -2,6 +2,7 @@ import type { Operation } from '@stellar/stellar-sdk'; import { Asset } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; +import { StellarOperationType } from '../api'; import type { OperationSimulator, ApplyContext, @@ -23,6 +24,7 @@ import { BASE_RESERVE_STROOPS, MAX_INT64 } from '../../../constants'; import { getSlip44AssetId, isSlip44Id, + stellarAssetToCaip19, toCaip19ClassicAssetId, toSmallestUnit, } from '../../../utils'; @@ -57,10 +59,7 @@ function classicAssetToId( scope: KnownCaip2ChainId, ): ClassicAssetId { if (asset instanceof Asset) { - if (asset.isNative()) { - return getSlip44AssetId(scope); - } - return toCaip19ClassicAssetId(scope, asset.getCode(), asset.getIssuer()); + return stellarAssetToCaip19(asset, scope); } throw new TransactionValidationException( 'Only native or alphanum Asset payments are supported for sequential validation', @@ -386,7 +385,9 @@ export class PathPaymentOPSimulator implements OperationSimulator { const source = getAccount(state, sourceId); const sendAssetId = classicAssetToId(op.sendAsset, scope); const sendAmount = - op.type === 'pathPaymentStrictSend' ? op.sendAmount : op.sendMax; + op.type === StellarOperationType.PathPaymentStrictSend + ? op.sendAmount + : op.sendMax; return { source, @@ -410,7 +411,9 @@ export class PathPaymentOPSimulator implements OperationSimulator { const dest = getAccount(state, destination); const destAssetId = classicAssetToId(op.destAsset, scope); const destAmount = - op.type === 'pathPaymentStrictSend' ? op.destMin : op.destAmount; + op.type === StellarOperationType.PathPaymentStrictSend + ? op.destMin + : op.destAmount; return { dest, diff --git a/merged-packages/stellar-wallet-snap/src/utils/array.test.ts b/merged-packages/stellar-wallet-snap/src/utils/array.test.ts index 3a1aafeb..8010caab 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/array.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/array.test.ts @@ -1,4 +1,4 @@ -import { entries, keys, values } from './array'; +import { entries, keys, pushToRecordArray, values } from './array'; describe('entries', () => { it('returns key-value tuples for a partial record', () => { @@ -36,3 +36,17 @@ describe('values', () => { expect(values({})).toStrictEqual([]); }); }); + +describe('pushToRecordArray', () => { + it('pushes a value to a record array', () => { + const record = { a: [1] }; + pushToRecordArray(record, 'a', 2); + expect(record).toStrictEqual({ a: [1, 2] }); + }); + + it('creates a new array if the key does not exist', () => { + const record = {} as Record; + pushToRecordArray(record, 'a', 2); + expect(record).toStrictEqual({ a: [2] }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/utils/array.ts b/merged-packages/stellar-wallet-snap/src/utils/array.ts index f2d821c5..03e69056 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/array.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/array.ts @@ -29,3 +29,19 @@ export function keys(obj: Record): Key[] { export function values(obj: Record): Value[] { return Object.values(obj); } + +/** + * Pushes a value to an array in a record. + * If the key does not exist, it will be created. + * + * @param record - The record to push the value to. + * @param key - The key of the record to push the value to. + * @param value - The value to push to the array. + */ +export function pushToRecordArray( + record: Partial>, + key: Key, + value: Value, +): void { + (record[key] ??= []).push(value); +} diff --git a/merged-packages/stellar-wallet-snap/src/utils/caip.test.ts b/merged-packages/stellar-wallet-snap/src/utils/caip.test.ts index 8d6f25a3..acb7c68e 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/caip.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/caip.test.ts @@ -1,3 +1,5 @@ +import { Asset } from '@stellar/stellar-sdk'; + import { AssetType, KnownCaip19Slip44IdMap, KnownCaip2ChainId } from '../api'; import { getAssetReference, @@ -6,6 +8,7 @@ import { isSep41Id, isSlip44Id, parseClassicAssetCodeIssuer, + stellarAssetToCaip19, toCaip19ClassicAssetId, toCaip19Sep41AssetId, toCaipAssetReference, @@ -155,3 +158,32 @@ describe('parseClassicAssetCodeIssuer', () => { ); }); }); + +describe('stellarAssetToCaip19', () => { + it('converts a native asset to a slip44 asset id', () => { + expect( + stellarAssetToCaip19(Asset.native(), KnownCaip2ChainId.Mainnet), + ).toBe(SLIP44_ASSET_ID); + }); + + it('converts a classic asset to a classic asset id', () => { + expect( + stellarAssetToCaip19( + new Asset( + 'USDC', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ), + KnownCaip2ChainId.Mainnet, + ), + ).toBe(CLASSIC_ASSET_ID); + }); + + it('throws an error if the asset is not a valid Stellar asset', () => { + expect(() => + stellarAssetToCaip19( + 'invalid' as unknown as Asset, + KnownCaip2ChainId.Mainnet, + ), + ).toThrow('Invalid asset'); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/utils/caip.ts b/merged-packages/stellar-wallet-snap/src/utils/caip.ts index eee7bb87..732841f1 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/caip.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/caip.ts @@ -1,4 +1,5 @@ import { parseCaipAssetType } from '@metamask/utils'; +import { Asset } from '@stellar/stellar-sdk'; import type { KnownCaip19AssetIdOrSlip44Id, @@ -153,3 +154,23 @@ export function parseClassicAssetCodeIssuer(assetReference: string): { } return { assetCode, assetIssuer }; } + +/** + * Converts the given Stellar asset to a CAIP-19 asset ID. + * + * @param asset - The Stellar asset. + * @param scope - The CAIP-2 chain ID. + * @returns The CAIP-19 asset ID. + */ +export function stellarAssetToCaip19( + asset: Asset, + scope: KnownCaip2ChainId, +): KnownCaip19ClassicAssetId | KnownCaip19Slip44Id { + if (!(asset instanceof Asset)) { + throw new Error(`Invalid asset`); + } + if (asset.isNative()) { + return getSlip44AssetId(scope); + } + return toCaip19ClassicAssetId(scope, asset.getCode(), asset.getIssuer()); +} From 685ed56aac3074ab5f613b4921075aa82d96206e Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:18:09 +0800 Subject: [PATCH 285/384] Feat/transcation scan 3 - add transaction mapper (#97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Explanation This PR extends the Snap’s transaction handling by introducing a `TransactionMapper` that classifies/massages Horizon on-chain transactions into MetaMask keyring transaction shapes (send/swap/change-trust/receive), while also standardizing keyring “asset” payloads to include `unit`, `amount`, and `fungible` fields and adding some supporting formatting utilities/constants. **Changes:** - Added `TransactionMapper` (+ tests + Horizon fixtures) to map Horizon transactions into keyring transactions and to skip dust payments. - Expanded transaction utilities with operation/transaction-type detectors (swap/receive/change-trust/dust/status helpers). - Standardized keyring transaction request `asset` shapes and added a shared `removeTrailingZeros` helper (plus tests) and a dust-amount constant. ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- .../stellar-wallet-snap/src/constants.ts | 5 + .../clientRequest/changeTrustOpt.test.ts | 8 +- .../handlers/clientRequest/changeTrustOpt.ts | 5 +- .../clientRequest/confirmSend.test.ts | 5 +- .../src/handlers/clientRequest/confirmSend.ts | 5 +- .../KeyringTransactionBuilder.test.ts | 83 ++- .../transaction/KeyringTransactionBuilder.ts | 276 +++++--- .../transaction/TransactionMapper.test.ts | 448 +++++++++++++ .../services/transaction/TransactionMapper.ts | 467 ++++++++++++++ .../transaction/TransactionService.test.ts | 15 +- .../horizon-transaction-responses.fixtures.ts | 589 ++++++++++++++++++ .../src/services/transaction/exceptions.ts | 7 + .../src/services/transaction/utils.ts | 274 ++++++++ .../src/utils/currency.test.ts | 12 + .../stellar-wallet-snap/src/utils/currency.ts | 16 +- 15 files changed, 2100 insertions(+), 115 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.test.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.ts create mode 100644 merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/horizon-transaction-responses.fixtures.ts diff --git a/merged-packages/stellar-wallet-snap/src/constants.ts b/merged-packages/stellar-wallet-snap/src/constants.ts index c8709101..627ef8f7 100644 --- a/merged-packages/stellar-wallet-snap/src/constants.ts +++ b/merged-packages/stellar-wallet-snap/src/constants.ts @@ -107,3 +107,8 @@ export const MEMO_REQUIRED_KEY = 'config.memo_required'; * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0029.md */ export const ACCOUNT_REQUIRES_MEMO = 'MQ=='; + +/** + * The dust payment amount for the Stellar network. + */ +export const DUST_XLM_AMOUNT = '0.0000001'; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts index e8e7e627..71c1a786 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts @@ -287,7 +287,9 @@ describe('ChangeTrustOptHandler', () => { scope, asset: { type: assetId, - symbol: 'USDC', + unit: 'USDC', + amount: '0', + fungible: true, }, }, }); @@ -402,7 +404,9 @@ describe('ChangeTrustOptHandler', () => { scope, asset: { type: assetId, - symbol: assetMetadata.symbol, + unit: assetMetadata.symbol, + amount: '0', + fungible: true, }, }, }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts index 4b652df5..ca7ee947 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts @@ -177,7 +177,10 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< scope, asset: { type: assetId, - symbol: assetMetadata.symbol, + unit: assetMetadata.symbol, + // Change trust does not affect the amount. + amount: '0', + fungible: true as const, }, }, }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts index 2574f5fa..f13d4e55 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts @@ -329,10 +329,11 @@ describe('ConfirmSendHandler', () => { account, scope, toAddress: destinationAddress, - amount: '1', asset: { type: assetId, - symbol: 'USDC', + unit: 'USDC', + amount: '1', + fungible: true, }, }, }); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts index 6f5010a9..ca8cf632 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts @@ -186,10 +186,11 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< account: stellarKeyringAccount, scope, toAddress, - amount, asset: { type: assetId, - symbol, + unit: symbol, + amount, + fungible: true as const, }, }, }); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.test.ts index 7a88b4a7..4fb36a13 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.test.ts @@ -21,10 +21,13 @@ describe('KeyringTransactionBuilder', () => { address: 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', } as StellarKeyringAccount; const scope = KnownCaip2ChainId.Mainnet; - const nativeAsset = { - type: 'stellar:pubnet/slip44:148' as const, - symbol: 'XLM', - }; + const nativeAsset = (amount: string) => + ({ + type: 'stellar:pubnet/slip44:148' as const, + unit: 'XLM', + amount, + fungible: true as const, + }) as const; beforeEach(() => { jest.useFakeTimers(); @@ -45,8 +48,7 @@ describe('KeyringTransactionBuilder', () => { account, scope, toAddress: 'GBQ67YZIDIMGS4UE2VXW4BBLRW6QJJQ6D6L5AXR5TBKX2L6IY3LCLTTR', - amount: '1230000', - asset: nativeAsset, + asset: nativeAsset('1230000'), }, }); @@ -97,7 +99,9 @@ describe('KeyringTransactionBuilder', () => { scope, asset: { type: 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', - symbol: 'USDC', + unit: 'USDC', + amount: '0', + fungible: true as const, }, }, }); @@ -144,7 +148,9 @@ describe('KeyringTransactionBuilder', () => { status: TransactionStatus.Confirmed, asset: { type: 'stellar:pubnet/asset:USDT-GCEZWKPH6X7R2SDYB7V4LGAU5N5LE6L4P7J6LQEXAMPLE1234567890', - symbol: 'USDT', + unit: 'USDT', + amount: '0', + fungible: true as const, }, }, }); @@ -187,7 +193,10 @@ describe('KeyringTransactionBuilder', () => { txId: 'tx-pending-1', account, scope, - asset: nativeAsset, + asset: { + type: 'stellar:pubnet/slip44:148', + symbol: 'XLM', + }, }, }); @@ -227,6 +236,62 @@ describe('KeyringTransactionBuilder', () => { }); }); + it('creates a swap transaction with expected keyring fields', () => { + const builder = new KeyringTransactionBuilder(); + + const transaction = builder.createTransaction({ + type: KeyringTransactionType.Swap, + request: { + txId: 'tx-swap-1', + account, + scope, + toAddress: account.address, + fromAsset: nativeAsset('10000000'), + toAsset: { + type: 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + unit: 'USDC', + amount: '5000000', + fungible: true as const, + }, + }, + }); + + expect(transaction).toStrictEqual({ + type: TransactionType.Swap, + id: 'tx-swap-1', + from: [ + { + address: account.address, + asset: { + unit: 'XLM', + type: 'stellar:pubnet/slip44:148', + amount: '10000000', + fungible: true, + }, + }, + ], + to: [ + { + address: account.address, + asset: { + unit: 'USDC', + type: 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + amount: '5000000', + fungible: true, + }, + }, + ], + events: [ + { status: TransactionStatus.Unconfirmed, timestamp: fixedTimestamp }, + ], + chain: scope, + status: TransactionStatus.Unconfirmed, + account: account.id, + timestamp: fixedTimestamp, + fees: [], + }); + }); + it('creates a detailed pending transaction when transaction details are available', () => { const builder = new KeyringTransactionBuilder(); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.ts index 93d00d0b..93285dd9 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/KeyringTransactionBuilder.ts @@ -10,34 +10,53 @@ import type { KnownCaip19AssetIdOrSlip44Id } from '../../api/asset'; import type { StellarKeyringAccount } from '../account/api'; export enum KeyringTransactionType { + Swap = 'swap', + BridgeSend = 'bridgeSend', ChangeTrustOptIn = 'changeTrustOptIn', ChangeTrustOptOut = 'changeTrustOptOut', Send = 'send', Pending = 'pending', + Unknown = 'unknown', } +export type KeyringTransactionAsset = { + unit: string; + type: KnownCaip19AssetIdOrSlip44Id; + amount: string; + fungible: true; +}; + export type SendTransactionRequest = { txId: string; account: StellarKeyringAccount; scope: KnownCaip2ChainId; toAddress: string; - amount: string; - asset: { - type: KnownCaip19AssetIdOrSlip44Id; - symbol: string; - }; + asset: KeyringTransactionAsset; + status?: TransactionStatus; + timestamp?: KeyringTransaction['timestamp']; + fees?: KeyringTransaction['fees']; +}; + +export type SwapTransactionRequest = { + txId: string; + account: StellarKeyringAccount; + scope: KnownCaip2ChainId; + toAddress: string; + fromAsset: KeyringTransactionAsset; + toAsset: KeyringTransactionAsset; status?: TransactionStatus; + timestamp?: KeyringTransaction['timestamp']; + fees?: KeyringTransaction['fees']; }; export type ChangeTrustTransactionRequest = { txId: string; account: StellarKeyringAccount; scope: KnownCaip2ChainId; - asset: { - type: KnownCaip19AssetIdOrSlip44Id; - symbol: string; - }; + asset: KeyringTransactionAsset; status?: TransactionStatus; + timestamp?: KeyringTransaction['timestamp']; + fees?: KeyringTransaction['fees']; }; export type PendingTransactionRequest = { @@ -60,6 +79,18 @@ export type PendingTransactionRequest = { } ); +export type UnknownTransactionRequest = { + txId: string; + account: StellarKeyringAccount; + transactionType?: TransactionType; + scope: KnownCaip2ChainId; + status?: TransactionStatus; + from: KeyringTransaction['from']; + to?: KeyringTransaction['to']; + fees?: KeyringTransaction['fees']; + timestamp?: KeyringTransaction['timestamp']; +}; + export type KeyringTransactionRequest = | { type: KeyringTransactionType.ChangeTrustOptIn; @@ -76,6 +107,18 @@ export type KeyringTransactionRequest = | { type: KeyringTransactionType.Pending; request: PendingTransactionRequest; + } + | { + type: KeyringTransactionType.Unknown; + request: UnknownTransactionRequest; + } + | { + type: KeyringTransactionType.Swap; + request: SwapTransactionRequest; + } + | { + type: KeyringTransactionType.BridgeSend; + request: UnknownTransactionRequest; }; export class KeyringTransactionBuilder { @@ -83,14 +126,16 @@ export class KeyringTransactionBuilder { switch (request.type) { case KeyringTransactionType.ChangeTrustOptOut: case KeyringTransactionType.ChangeTrustOptIn: - return this.#createChangeTrustTransaction( - request.request, - request.type, - ); + return this.#createChangeTrustTransaction(request.request); case KeyringTransactionType.Send: return this.#createSendTransaction(request.request); + case KeyringTransactionType.Swap: + return this.#createSwapTransaction(request.request); case KeyringTransactionType.Pending: return this.#createPendingTransaction(request.request); + case KeyringTransactionType.Unknown: + case KeyringTransactionType.BridgeSend: + return this.#createUnknownTransaction(request.request); default: throw new KeyringTransactionBuilderException( `Invalid transaction type`, @@ -100,51 +145,56 @@ export class KeyringTransactionBuilder { #createChangeTrustTransaction( request: ChangeTrustTransactionRequest, - _type: - | KeyringTransactionType.ChangeTrustOptIn - | KeyringTransactionType.ChangeTrustOptOut, ): KeyringTransaction { - const timestamp = this.#getCreateTime(); - const { txId, account, scope, asset } = request; + const { + txId, + account, + scope, + asset, + status = TransactionStatus.Unconfirmed, + } = request; + const timestamp = this.#resolveTimestamp(request.timestamp); - return { + return this.#buildKeyringTransaction({ // TODO: Add the correct type type: TransactionType.Unknown, id: txId, - from: [ - { - address: account.address, - asset: { - unit: asset.symbol, - type: asset.type, - amount: '0', - fungible: true, - }, - }, - ], - to: [ - { - address: account.address, - asset: { - unit: asset.symbol, - type: asset.type, - amount: '0', - fungible: true, - }, - }, - ], - events: [ - { - status: request.status ?? TransactionStatus.Unconfirmed, - timestamp, - }, - ], - chain: scope, - status: request.status ?? TransactionStatus.Unconfirmed, - account: account.id, + account, + scope, + from: [{ address: account.address, asset }], + to: [{ address: account.address, asset }], + status, timestamp, - fees: [], - }; + fees: request.fees ?? [], + }); + } + + #createUnknownTransaction( + request: UnknownTransactionRequest, + ): KeyringTransaction { + const { + fees = [], + txId, + account, + scope, + transactionType = TransactionType.Unknown, + from = [], + to = [], + status = TransactionStatus.Unconfirmed, + } = request; + const timestamp = this.#resolveTimestamp(request.timestamp); + + return this.#buildKeyringTransaction({ + type: transactionType, + id: txId, + account, + scope, + from, + to, + status, + timestamp, + fees, + }); } #createPendingTransaction( @@ -156,29 +206,26 @@ export class KeyringTransactionBuilder { // if the request has from and to, it is a pending classic swap transaction if ('from' in request) { - return { + return this.#buildKeyringTransaction({ type: request.transactionType, id: txId, + account, + scope, from: request.from, to: request.to, - events: [ - { - status, - timestamp, - }, - ], - chain: scope, status, - account: account.id, timestamp, fees: request.fees ?? [], - }; + }); } const { asset } = request; - return { + + return this.#buildKeyringTransaction({ type: TransactionType.Unknown, id: txId, + account, + scope, from: [ { address: account.address, @@ -201,63 +248,112 @@ export class KeyringTransactionBuilder { }, }, ], - events: [ - { - status, - timestamp, - }, - ], - chain: scope, status, - account: account.id, timestamp, fees: [], - }; + }); } #createSendTransaction(request: SendTransactionRequest): KeyringTransaction { - const timestamp = this.#getCreateTime(); - const { txId, account, scope, toAddress, amount, asset } = request; + const { txId, account, scope, toAddress, asset, fees = [] } = request; + const status = request.status ?? TransactionStatus.Unconfirmed; + const timestamp = this.#resolveTimestamp(request.timestamp); - return { + return this.#buildKeyringTransaction({ type: TransactionType.Send, id: txId, + account, + scope, + from: [{ address: account.address, asset }], + to: [{ address: toAddress, asset }], + status, + timestamp, + fees, + }); + } + + #createSwapTransaction(request: SwapTransactionRequest): KeyringTransaction { + const { + txId, + account, + scope, + toAddress, + fromAsset, + toAsset, + fees = [], + } = request; + const status = request.status ?? TransactionStatus.Unconfirmed; + const timestamp = this.#resolveTimestamp(request.timestamp); + + return this.#buildKeyringTransaction({ + type: TransactionType.Swap, + id: txId, + account, + scope, from: [ { address: account.address, - asset: { - unit: asset.symbol, - type: asset.type, - amount, - fungible: true, - }, + asset: fromAsset, }, ], to: [ { address: toAddress, - asset: { - unit: asset.symbol, - type: asset.type, - amount, - fungible: true, - }, + asset: toAsset, }, ], + status, + timestamp, + fees, + }); + } + + #buildKeyringTransaction({ + type, + id, + account, + scope, + from, + to, + status, + timestamp, + fees, + }: { + type: TransactionType; + id: string; + account: StellarKeyringAccount; + scope: KnownCaip2ChainId; + from: KeyringTransaction['from']; + to: KeyringTransaction['to']; + status: TransactionStatus; + timestamp: number; + fees: KeyringTransaction['fees']; + }): KeyringTransaction { + return { + type, + id, + from, + to, events: [ { - status: TransactionStatus.Unconfirmed, + status, timestamp, }, ], chain: scope, - status: TransactionStatus.Unconfirmed, + status, account: account.id, timestamp, - fees: [], + fees, }; } + #resolveTimestamp( + timestamp: KeyringTransaction['timestamp'] | undefined, + ): number { + return timestamp ?? this.#getCreateTime(); + } + #getCreateTime(): number { return Math.floor(Date.now() / 1000); // seconds since epoch } diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.test.ts new file mode 100644 index 00000000..9467f59c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.test.ts @@ -0,0 +1,448 @@ +import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; +import { + FeeType, + TransactionStatus, + TransactionType, +} from '@metamask/keyring-api'; +import type { Horizon } from '@stellar/stellar-sdk'; +import { Networks } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; + +import { + addChangeTrustResponse, + removeChangeTrustResponse, + swapTransactionWithFeeCollectResponse, + swapTransactionWithoutFeeCollectResponse, + contractInvokeTransactionResponse, + sendTransactionResponse, + spamTransactionResponse, + createAccountTransactionResponse, + receivePaymentTransactionResponse, + receiveCreateAccountTransactionResponse, +} from './__mocks__/horizon-transaction-responses.fixtures'; +import { + buildMockClassicTransaction, + generateMockTransactions, +} from './__mocks__/transaction.fixtures'; +import { TransactionMapperException } from './exceptions'; +import { KeyringTransactionBuilder } from './KeyringTransactionBuilder'; +import { Transaction } from './Transaction'; +import { TransactionMapper } from './TransactionMapper'; +import { KnownCaip2ChainId } from '../../api'; +import { NATIVE_ASSET_SYMBOL } from '../../constants'; +import { + getSlip44AssetId, + toCaip19ClassicAssetId, + toDisplayBalance, +} from '../../utils'; +import { generateStellarKeyringAccount } from '../account/__mocks__/account.fixtures'; + +function toHorizonTransaction( + transaction: Transaction, + overrides: Partial = {}, +): Horizon.ServerApi.TransactionRecord { + const inner = transaction.getRaw(); + + return { + id: inner.hash().toString('hex'), + hash: inner.hash().toString('hex'), + // eslint-disable-next-line @typescript-eslint/naming-convention -- Horizon API field names + envelope_xdr: inner.toXDR(), + // eslint-disable-next-line @typescript-eslint/naming-convention -- Horizon API field names + fee_charged: inner.fee, + successful: true, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Horizon API field names + created_at: '2026-01-15T00:00:00.000Z', + // eslint-disable-next-line @typescript-eslint/naming-convention -- Horizon API field names + paging_token: 'scan-token-1', + ...overrides, + } as Horizon.ServerApi.TransactionRecord; +} + +describe('TransactionMapper', () => { + const scope = KnownCaip2ChainId.Mainnet; + const accountAddress = + 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO'; + const destinationAddress = + 'GDTF7ERUQVTX23ZD6NY5XRYC5IQAKWFVTQ6IXSMEZWGVNDDGPYCVHRZP'; + + const setup = () => { + const keyringAccount = generateStellarKeyringAccount( + 'account-id-1', + accountAddress, + 'test-entropy', + 0, + ); + + const keyringTransactionBuilder = new KeyringTransactionBuilder(); + const transactionMapper = new TransactionMapper({ + keyringTransactionBuilder, + }); + + return { keyringAccount, transactionMapper }; + }; + + const nativeAsset = getSlip44AssetId(scope); + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-01-15T00:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('throws when transaction raw data is missing', () => { + const { keyringAccount, transactionMapper } = setup(); + const built = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: destinationAddress, + asset: 'native', + amount: '1', + }, + }, + ], + { + networkPassphrase: Networks.PUBLIC, + source: { accountId: accountAddress, sequence: '1' }, + }, + ); + + expect(() => + transactionMapper.mapTransaction({ + transaction: built, + keyringAccount, + }), + ).toThrow(TransactionMapperException); + }); + + it.each([ + { + testCase: 'swap transaction with fee collect', + response: swapTransactionWithFeeCollectResponse, + fromAsset: toCaip19ClassicAssetId( + scope, + 'USDC', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ), + fromAssetSymbol: 'USDC', + fromAmount: '0.1', + toAsset: nativeAsset, + toAssetSymbol: NATIVE_ASSET_SYMBOL, + toAmount: '0.5152298', + toAddress: accountAddress, + fromAddress: accountAddress, + txnType: TransactionType.Swap, + }, + { + testCase: 'swap transaction without fee collect', + response: swapTransactionWithoutFeeCollectResponse, + fromAsset: nativeAsset, + fromAssetSymbol: NATIVE_ASSET_SYMBOL, + fromAmount: '1', + toAsset: toCaip19ClassicAssetId( + scope, + 'USDC', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ), + toAssetSymbol: 'USDC', + toAmount: '0.1564188', + toAddress: accountAddress, + fromAddress: accountAddress, + txnType: TransactionType.Swap, + }, + { + testCase: 'add change trust transaction', + response: addChangeTrustResponse, + fromAsset: toCaip19ClassicAssetId( + scope, + 'AFR', + 'GBX6YI45VU7WNAAKA3RBFDR3I3UKNFHTJPQ5F6KOOKSGYIAM4TRQN54W', + ), + fromAssetSymbol: 'AFR', + fromAmount: '0', + toAsset: toCaip19ClassicAssetId( + scope, + 'AFR', + 'GBX6YI45VU7WNAAKA3RBFDR3I3UKNFHTJPQ5F6KOOKSGYIAM4TRQN54W', + ), + toAssetSymbol: 'AFR', + toAmount: '0', + toAddress: accountAddress, + fromAddress: accountAddress, + txnType: TransactionType.Unknown, + }, + { + testCase: 'remove change trust transaction', + response: removeChangeTrustResponse, + fromAsset: toCaip19ClassicAssetId( + scope, + 'AFR', + 'GBX6YI45VU7WNAAKA3RBFDR3I3UKNFHTJPQ5F6KOOKSGYIAM4TRQN54W', + ), + fromAssetSymbol: 'AFR', + fromAmount: '0', + toAsset: toCaip19ClassicAssetId( + scope, + 'AFR', + 'GBX6YI45VU7WNAAKA3RBFDR3I3UKNFHTJPQ5F6KOOKSGYIAM4TRQN54W', + ), + toAssetSymbol: 'AFR', + toAmount: '0', + toAddress: accountAddress, + fromAddress: accountAddress, + txnType: TransactionType.Unknown, + }, + { + testCase: 'send transaction', + response: sendTransactionResponse, + fromAsset: nativeAsset, + fromAssetSymbol: NATIVE_ASSET_SYMBOL, + fromAmount: '0.00001', + toAsset: nativeAsset, + toAssetSymbol: NATIVE_ASSET_SYMBOL, + toAmount: '0.00001', + toAddress: 'GB327AMKGJDXEMQREZRRVW7Y6KEKWPOWTJKCCYUQK7KKXVMCTNZEOYXU', + fromAddress: accountAddress, + txnType: TransactionType.Send, + }, + { + testCase: 'send transaction via create account', + response: createAccountTransactionResponse, + fromAsset: nativeAsset, + fromAssetSymbol: NATIVE_ASSET_SYMBOL, + fromAmount: '3', + toAsset: nativeAsset, + toAssetSymbol: NATIVE_ASSET_SYMBOL, + toAmount: '3', + toAddress: 'GCLVE5C7MNJRQCUM5AOKJT64SKNPKHW2VZL4VVS7EKDVYWIDUN5PECZW', + fromAddress: accountAddress, + txnType: TransactionType.Send, + }, + { + testCase: 'receive payment transaction', + response: receivePaymentTransactionResponse, + fromAsset: toCaip19ClassicAssetId( + scope, + 'SHX', + 'GDSTRSHXHGJ7ZIVRBXEYE5Q74XUVCUSEKEBR7UCHEUUEK72N7I7KJ6JH', + ), + fromAssetSymbol: 'SHX', + fromAmount: '5', + toAsset: toCaip19ClassicAssetId( + scope, + 'SHX', + 'GDSTRSHXHGJ7ZIVRBXEYE5Q74XUVCUSEKEBR7UCHEUUEK72N7I7KJ6JH', + ), + toAssetSymbol: 'SHX', + toAmount: '5', + toAddress: accountAddress, + fromAddress: 'GCLVE5C7MNJRQCUM5AOKJT64SKNPKHW2VZL4VVS7EKDVYWIDUN5PECZW', + txnType: TransactionType.Receive, + }, + { + testCase: 'receive payment transaction via create account', + response: receiveCreateAccountTransactionResponse, + fromAsset: nativeAsset, + fromAssetSymbol: NATIVE_ASSET_SYMBOL, + fromAmount: '11.76', + toAsset: nativeAsset, + toAssetSymbol: NATIVE_ASSET_SYMBOL, + toAmount: '11.76', + toAddress: accountAddress, + fromAddress: 'GCXZDLDI4BO3RHIYBS22RZXB5LGRRLTZUSPG6ANQQ36TVL2ASHC4ONZO', + txnType: TransactionType.Receive, + }, + ])( + 'maps a $testCase from Horizon', + ({ + response, + fromAsset, + fromAssetSymbol, + fromAmount, + toAsset, + toAssetSymbol, + toAmount, + txnType, + fromAddress, + toAddress, + }) => { + const { keyringAccount, transactionMapper } = setup(); + + const transaction = Transaction.fromHorizon({ + horizonTransaction: response, + scope, + }); + + const keyringTransaction = transactionMapper.mapTransaction({ + transaction, + keyringAccount, + }); + + const timestamp = new Date(response.created_at).getTime() / 1000; + + expect(keyringTransaction).toStrictEqual({ + type: txnType, + id: response.id, + from: [ + { + address: fromAddress, + asset: { + unit: fromAssetSymbol, + type: fromAsset, + amount: fromAmount, + fungible: true, + }, + }, + ], + to: [ + { + address: toAddress, + asset: { + unit: toAssetSymbol, + type: toAsset, + amount: toAmount, + fungible: true, + }, + }, + ], + events: [{ status: TransactionStatus.Confirmed, timestamp }], + chain: scope, + status: TransactionStatus.Confirmed, + account: keyringAccount.id, + timestamp, + fees: [ + { + type: FeeType.Base, + asset: { + unit: NATIVE_ASSET_SYMBOL, + type: nativeAsset, + amount: toDisplayBalance(new BigNumber(response.fee_charged)), + fungible: true, + }, + }, + ], + }); + }, + ); + + it.each([contractInvokeTransactionResponse])( + 'maps an unrecognized transaction as unknown', + (response) => { + const { keyringAccount, transactionMapper } = setup(); + + const transaction = Transaction.fromHorizon({ + horizonTransaction: response, + scope, + }); + + const keyringTransaction = transactionMapper.mapTransaction({ + transaction, + keyringAccount, + }); + + const timestamp = new Date(response.created_at).getTime() / 1000; + + expect(keyringTransaction).toStrictEqual({ + type: TransactionType.Unknown, + id: transaction.id, + from: [], + to: [], + events: [{ status: TransactionStatus.Confirmed, timestamp }], + chain: scope, + status: TransactionStatus.Confirmed, + account: keyringAccount.id, + timestamp, + fees: [ + { + type: FeeType.Base, + asset: { + unit: NATIVE_ASSET_SYMBOL, + type: nativeAsset, + amount: toDisplayBalance(new BigNumber(response.fee_charged)), + fungible: true, + }, + }, + ], + }); + }, + ); + + it('returns undefined for spam transactions', () => { + const { keyringAccount, transactionMapper } = setup(); + + const transaction = Transaction.fromHorizon({ + horizonTransaction: spamTransactionResponse, + scope, + }); + + expect( + transactionMapper.mapTransaction({ + transaction, + keyringAccount, + }), + ).toBeUndefined(); + }); + + it('merges pending state when transactionFromState is provided', () => { + const { keyringAccount, transactionMapper } = setup(); + const built = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: destinationAddress, + asset: 'native', + amount: '1', + }, + }, + ], + { + networkPassphrase: Networks.PUBLIC, + source: { accountId: accountAddress, sequence: '1' }, + }, + ); + const transaction = Transaction.fromHorizon({ + horizonTransaction: toHorizonTransaction(built), + scope, + }); + const [pendingFromState] = generateMockTransactions(1, { + id: transaction.id, + account: keyringAccount.id, + scope, + type: TransactionType.Send, + status: TransactionStatus.Unconfirmed, + timestamp: 1700000000, + }) as [KeyringTransaction]; + + const keyringTransaction = transactionMapper.mapTransaction({ + transaction, + keyringAccount, + transactionFromState: pendingFromState, + }); + + expect(keyringTransaction).toStrictEqual({ + ...pendingFromState, + status: TransactionStatus.Confirmed, + fees: [ + { + type: FeeType.Base, + asset: { + unit: NATIVE_ASSET_SYMBOL, + type: nativeAsset, + amount: '0.00002', + fungible: true, + }, + }, + ], + events: [ + ...pendingFromState.events, + { status: TransactionStatus.Confirmed, timestamp: 1768435200 }, + ], + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.ts new file mode 100644 index 00000000..f7bdb665 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionMapper.ts @@ -0,0 +1,467 @@ +import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; +import { TransactionType } from '@metamask/keyring-api'; +import type { Operation } from '@stellar/stellar-sdk'; +import { Asset } from '@stellar/stellar-sdk'; + +import { StellarOperationType } from './api'; +import { TransactionMapperException } from './exceptions'; +import type { + KeyringTransactionAsset, + KeyringTransactionBuilder, +} from './KeyringTransactionBuilder'; +import { KeyringTransactionType } from './KeyringTransactionBuilder'; +import type { Transaction } from './Transaction'; +import { + isAddChangeTrustTransaction, + isDustPaymentTransaction, + isReceiveTransaction, + isRemoveChangeTrustTransaction, + isSendTransaction, + isSwapTransaction, + isReceiveOperation, +} from './utils'; +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip2ChainId, +} from '../../api'; +import { NATIVE_ASSET_SYMBOL } from '../../constants'; +import { + getSlip44AssetId, + removeTrailingZeros, + stellarAssetToCaip19, + toDisplayBalance, +} from '../../utils'; +import type { StellarKeyringAccount } from '../account/api'; + +export class TransactionMapper { + readonly #keyringTransactionBuilder: KeyringTransactionBuilder; + + constructor({ + keyringTransactionBuilder, + }: { + keyringTransactionBuilder: KeyringTransactionBuilder; + }) { + this.#keyringTransactionBuilder = keyringTransactionBuilder; + } + + mapTransaction({ + transaction, + keyringAccount, + transactionFromState, + }: { + transaction: Transaction; + keyringAccount: StellarKeyringAccount; + transactionFromState?: KeyringTransaction; + }): KeyringTransaction | undefined { + if (!transaction.rawData || !transaction.id) { + throw new TransactionMapperException( + 'Transaction raw data and id are required; this transaction does not appear to be sourced from an on-chain transaction record', + ); + } + + if (transactionFromState) { + // In Stellar, if a transaction is still pending, it will not able to fetch. + // Therefore, we are safe to assume when we get a onchain transaction, we can just update the status and fees. + return this.#mapUpdatedTransaction(transaction, transactionFromState); + } + + if (isDustPaymentTransaction(transaction, keyringAccount.address)) { + return undefined; // Skip dust payment transactions. + } + + return this.#mapTransaction(transaction, keyringAccount); + } + + #mapTransaction( + transaction: Transaction, + keyringAccount: StellarKeyringAccount, + ): KeyringTransaction { + const { address } = keyringAccount; + + // For any contract based transaction, we treat it as unknown. + if (transaction.hasInvokeHostFunction) { + return this.#mapUnknownTransaction(transaction, keyringAccount); + } + + // Swap transaction: if the transaction has a path payment strict send operation + // and sender and destination are the same address. + if (isSwapTransaction(transaction, address)) { + return this.#mapSwapTransaction(transaction, keyringAccount); + } + + // Send transaction: if all operation are payment operations or create account operation. + if (isSendTransaction(transaction, address)) { + return this.#mapSendTransaction(transaction, keyringAccount); + } + + // Add change trust transaction: if all operations are change trust operations and limit is MAX_INT64. + if (isAddChangeTrustTransaction(transaction, address)) { + return this.#mapChangeTrustTransaction( + transaction, + keyringAccount, + KeyringTransactionType.ChangeTrustOptIn, + ); + } + + // Remove change trust transaction: if all operations are change trust operations and limit is 0. + if (isRemoveChangeTrustTransaction(transaction, address)) { + return this.#mapChangeTrustTransaction( + transaction, + keyringAccount, + KeyringTransactionType.ChangeTrustOptOut, + ); + } + + // Receive transaction: if any operation credits the account (payment, create account, + // path payment strict receive, or path payment strict send where destination is the account), + // regardless of source. + // Self-swap and self-send will not fall into this category, as they are classified as swap and send respectively. + if (isReceiveTransaction(transaction, address)) { + return this.#mapReceiveTransaction(transaction, keyringAccount); + } + + // TODO: add bridge send transaction + // Fallback to unknown transaction if the transaction is not recognized. + return this.#mapUnknownTransaction(transaction, keyringAccount); + } + + #mapUpdatedTransaction( + transaction: Transaction, + transactionFromState: KeyringTransaction, + ): KeyringTransaction { + return { + ...transactionFromState, + fees: this.#getBaseFees(transaction), + events: [ + ...transactionFromState.events, + { + status: transaction.status, + timestamp: this.#getCreateTime(transaction), + }, + ], + status: transaction.status, + }; + } + + #commonOnChainFields( + transaction: Transaction, + keyringAccount: StellarKeyringAccount, + ): { + txId: string; + account: StellarKeyringAccount; + scope: KnownCaip2ChainId; + status: Transaction['status']; + fees: KeyringTransaction['fees']; + timestamp: number; + } { + return { + txId: transaction.id, + account: keyringAccount, + scope: transaction.scope, + status: transaction.status, + fees: this.#getBaseFees(transaction), + timestamp: this.#getCreateTime(transaction), + }; + } + + #getFirstSendOperationDetails(transaction: Transaction): { + toAddress: string; + asset: KeyringTransactionAsset; + } | null { + const { scope } = transaction; + const [firstOperation] = transaction.transactionOperations; + + if ( + firstOperation && + firstOperation.type === StellarOperationType.Payment + ) { + const { destination: toAddress, asset, amount } = firstOperation; + + return { + toAddress, + asset: this.#assetToKeyringAssetRow(asset, scope, amount), + }; + } + + if ( + firstOperation && + firstOperation.type === StellarOperationType.CreateAccount + ) { + return { + toAddress: firstOperation.destination, + asset: this.#assetToKeyringAssetRow( + Asset.native(), + scope, + firstOperation.startingBalance, + ), + }; + } + + return null; + } + + #mapSendTransaction( + transaction: Transaction, + keyringAccount: StellarKeyringAccount, + ): KeyringTransaction { + // If there are multiple payment or create-account operations, + // we only pick the first one for the send transaction details. + const sendDetails = this.#getFirstSendOperationDetails(transaction); + + if (sendDetails) { + return this.#keyringTransactionBuilder.createTransaction({ + type: KeyringTransactionType.Send, + request: { + ...this.#commonOnChainFields(transaction, keyringAccount), + ...sendDetails, + }, + }); + } + + throw new TransactionMapperException('Unable to map a send transaction'); + } + + #mapUnknownTransaction( + transaction: Transaction, + keyringAccount: StellarKeyringAccount, + transactionType: TransactionType = TransactionType.Unknown, + ): KeyringTransaction { + return this.#keyringTransactionBuilder.createTransaction({ + type: KeyringTransactionType.Unknown, + request: { + ...this.#commonOnChainFields(transaction, keyringAccount), + transactionType, + from: [], + to: [], + }, + }); + } + + #mapSwapTransaction( + transaction: Transaction, + keyringAccount: StellarKeyringAccount, + ): KeyringTransaction { + const swapOperation = transaction.transactionOperations.find( + (operation) => + operation.type === StellarOperationType.PathPaymentStrictSend, + ); + + if (swapOperation) { + const { scope } = transaction; + const { + destination: toAddress, + sendAsset, + destAsset, + sendAmount, + destMin, + } = swapOperation; + + return this.#keyringTransactionBuilder.createTransaction({ + type: KeyringTransactionType.Swap, + request: { + ...this.#commonOnChainFields(transaction, keyringAccount), + toAddress, + fromAsset: this.#assetToKeyringAssetRow(sendAsset, scope, sendAmount), + toAsset: this.#assetToKeyringAssetRow(destAsset, scope, destMin), + }, + }); + } + + throw new TransactionMapperException('Unable to map a swap transaction'); + } + + #mapChangeTrustTransaction( + transaction: Transaction, + keyringAccount: StellarKeyringAccount, + type: + | KeyringTransactionType.ChangeTrustOptIn + | KeyringTransactionType.ChangeTrustOptOut, + ): KeyringTransaction { + const operationTypes = transaction.transactionOperations; + const { scope } = transaction; + // If there are multiple change trust operations, + // we only pick the first one for the activity details. + const [firstOperation] = operationTypes; + if ( + firstOperation && + firstOperation.type === StellarOperationType.ChangeTrust + ) { + const asset = firstOperation.line; + if (!(asset instanceof Asset)) { + throw new TransactionMapperException( + `ChangeTrust line must be Stellar SAC Asset or Stellar Classic Asset`, + ); + } + + return this.#keyringTransactionBuilder.createTransaction({ + type, + request: { + ...this.#commonOnChainFields(transaction, keyringAccount), + asset: this.#assetToKeyringAssetRow(asset, scope, '0'), + }, + }); + } + throw new TransactionMapperException( + 'Unable to map a change trust transaction', + ); + } + + #mapReceiveTransaction( + transaction: Transaction, + keyringAccount: StellarKeyringAccount, + ): KeyringTransaction { + // We currently take the first receive operation asset (deduplicated by asset id). + const [receiveAsset] = this.#extractReceiveOperationAssetAndAmount( + transaction, + keyringAccount.address, + transaction.scope, + ); + + if (receiveAsset) { + return this.#keyringTransactionBuilder.createTransaction({ + type: KeyringTransactionType.Unknown, + request: { + ...this.#commonOnChainFields(transaction, keyringAccount), + transactionType: TransactionType.Receive, + from: [ + { + // We assume the source account is the sender of the fund. + address: transaction.sourceAccount, + asset: receiveAsset, + }, + ], + to: [ + { + address: keyringAccount.address, + asset: receiveAsset, + }, + ], + }, + }); + } + throw new TransactionMapperException('Unable to map a receive transaction'); + } + + #getBaseFees(transaction: Transaction): KeyringTransaction['fees'] { + return [ + { + type: 'base', + asset: { + unit: NATIVE_ASSET_SYMBOL, + type: getSlip44AssetId(transaction.scope), + // Horizon returns the fee charged in the smallest unit of the asset. + amount: toDisplayBalance(transaction.feeCharged), + fungible: true, + }, + }, + ]; + } + + #getCreateTime(transaction: Transaction): number { + if (!transaction.rawData?.created_at) { + return Math.floor(Date.now() / 1000); // seconds since epoch + } + // transaction.rawData?.created_at expected to be a UTC time string. + return Math.floor( + new Date(transaction.rawData?.created_at).getTime() / 1000, + ); // seconds since epoch + } + + #assetToKeyringAssetRow( + asset: Asset, + scope: KnownCaip2ChainId, + amount: string, + ): KeyringTransactionAsset { + return { + unit: asset.getCode(), + type: stellarAssetToCaip19(asset, scope), + // Horizon returns the amount with trailing zeros - "1.0000000" instead of "1". + amount: removeTrailingZeros(amount), + fungible: true as const, + }; + } + + /** + * Collects unique assets credited by receive operations (deduplicated by CAIP-19 id). + * + * @param transaction - Stellar transaction to extract receive operation assets from. + * @param accountAddress - Stellar address to check for incoming credits. + * @param scope - CAIP-2 chain used to encode asset ids. + * @returns Deduplicated receive-operation assets (amounts are not summed when multiple operations credit the same asset). + */ + #extractReceiveOperationAssetAndAmount( + transaction: Transaction, + accountAddress: string, + scope: KnownCaip2ChainId, + ): KeyringTransactionAsset[] { + const operationTypes = transaction.transactionOperations; + const assetMap = new Map< + KnownCaip19AssetIdOrSlip44Id, + KeyringTransactionAsset + >(); + + operationTypes.forEach((operation) => { + if (!isReceiveOperation(operation, accountAddress)) { + return; + } + + const receiveOperationAsset = this.#getReceiveOperationAsset( + operation, + scope, + ); + if (!receiveOperationAsset) { + return; + } + + assetMap.set(receiveOperationAsset.type, receiveOperationAsset); + }); + + return Array.from(assetMap.values()); + } + + /** + * Resolves the asset and amount credited by a receive operation. + * + * @param operation - Receive-capable operation (payment, create account, or path payment). + * @param scope - CAIP-2 chain used to encode asset ids. + * @returns Asset code, CAIP-19 id, and amount credited by the operation, or `null` when unsupported. + */ + #getReceiveOperationAsset( + operation: Operation, + scope: KnownCaip2ChainId, + ): KeyringTransactionAsset | null { + if (operation.type === StellarOperationType.Payment) { + return this.#assetToKeyringAssetRow( + operation.asset, + scope, + operation.amount, + ); + } + + if (operation.type === StellarOperationType.CreateAccount) { + return this.#assetToKeyringAssetRow( + Asset.native(), + scope, + operation.startingBalance, + ); + } + + if (operation.type === StellarOperationType.PathPaymentStrictReceive) { + return this.#assetToKeyringAssetRow( + operation.destAsset, + scope, + operation.destAmount, + ); + } + + if (operation.type === StellarOperationType.PathPaymentStrictSend) { + return this.#assetToKeyringAssetRow( + operation.destAsset, + scope, + operation.destMin, + ); + } + + return null; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts index 0eff816a..59bf4da9 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/TransactionService.test.ts @@ -66,10 +66,11 @@ describe('TransactionService', () => { account: fromAccount, scope: KnownCaip2ChainId.Mainnet, toAddress: toAccount.address, - amount: '10000000', asset: { type: getSlip44AssetId(KnownCaip2ChainId.Mainnet), - symbol: 'XLM', + unit: 'XLM', + amount: '10000000', + fungible: true as const, }, }, }); @@ -279,10 +280,11 @@ describe('TransactionService', () => { account, scope: KnownCaip2ChainId.Mainnet, toAddress: account.address, - amount: '1', asset: { type: getSlip44AssetId(KnownCaip2ChainId.Mainnet), - symbol: 'XLM', + unit: 'XLM', + amount: '1', + fungible: true as const, }, }, }, @@ -316,10 +318,11 @@ describe('TransactionService', () => { account, scope: KnownCaip2ChainId.Mainnet, toAddress: account.address, - amount: '1', asset: { type: getSlip44AssetId(KnownCaip2ChainId.Mainnet), - symbol: 'XLM', + unit: 'XLM', + amount: '1', + fungible: true as const, }, }, }, diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/horizon-transaction-responses.fixtures.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/horizon-transaction-responses.fixtures.ts new file mode 100644 index 00000000..fb226772 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/__mocks__/horizon-transaction-responses.fixtures.ts @@ -0,0 +1,589 @@ +import type { Horizon } from '@stellar/stellar-sdk'; + +/* eslint-disable @typescript-eslint/naming-convention -- Horizon API field names */ +export const swapTransactionWithFeeCollectResponse = { + _links: { + self: { + href: 'https://horizon.stellar.org/transactions/6df5828617df879ba9d93eb551b83514dd7006113978d9babe932f0ad25fe268', + }, + account: { + href: 'https://horizon.stellar.org/accounts/GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + }, + ledger: { + href: 'https://horizon.stellar.org/ledgers/62891903', + }, + operations: { + href: 'https://horizon.stellar.org/transactions/6df5828617df879ba9d93eb551b83514dd7006113978d9babe932f0ad25fe268/operations{?cursor,limit,order}', + templated: true, + }, + effects: { + href: 'https://horizon.stellar.org/transactions/6df5828617df879ba9d93eb551b83514dd7006113978d9babe932f0ad25fe268/effects{?cursor,limit,order}', + templated: true, + }, + precedes: { + href: 'https://horizon.stellar.org/transactions?order=asc\u0026cursor=270118666569011200', + }, + succeeds: { + href: 'https://horizon.stellar.org/transactions?order=desc\u0026cursor=270118666569011200', + }, + transaction: { + href: 'https://horizon.stellar.org/transactions/6df5828617df879ba9d93eb551b83514dd7006113978d9babe932f0ad25fe268', + }, + }, + id: '6df5828617df879ba9d93eb551b83514dd7006113978d9babe932f0ad25fe268', + paging_token: '270118666569011200', + successful: true, + hash: '6df5828617df879ba9d93eb551b83514dd7006113978d9babe932f0ad25fe268', + ledger: 62891903, + created_at: '2026-06-05T11:34:38Z', + source_account: 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + source_account_sequence: '262764252333342960', + fee_account: 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + fee_charged: '200', + max_fee: '220', + operation_count: 2, + envelope_xdr: + 'AAAAAgAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAANwDpYayAAAA8AAAAAEAAAAAAAAAAAAAAABqIrRoAAAAAAAAAAIAAAAAAAAADQAAAAFVU0RDAAAAADuZETgO/piLoKiQDrHP5E82b32+lGvtB3JA9/Yk3xXFAAAAAAAPQkAAAAAAP0E2QJSc+GJxMnTRfmMMrnZZXknl8DvX1mAEQlMPSLYAAAAAAAAAAABOnioAAAAAAAAAAAAAAAEAAAAARXd0NbEgRq6+e2KTd0q07wVC7AfjxEaLrXOy+mXjFlQAAAAAAAAAAAAAs7IAAAAAAAAAAVMPSLYAAABAkarvPnQYXclN6ZSxFgiRXYLQv2TvccpmHB4Qfn9OtLp1gAecYHLDxFiBZ8ftHMEtNrE53zNtNCFgzzi5+jawBw==', + result_xdr: + 'AAAAAAAAAMgAAAAAAAAAAgAAAAAAAAANAAAAAAAAAAEAAAABAAAAAM1vW5lXUjPlDdxBhG2Jq85TuMRq9zfoBr/W7y0H8JH3AAAAAG3bNdUAAAAAAAAAAABQOOcAAAABVVNEQwAAAAA7mRE4Dv6Yi6CokA6xz+RPNm99vpRr7QdyQPf2JN8VxQAAAAAAD0JAAAAAAD9BNkCUnPhicTJ00X5jDK52WV5J5fA719ZgBEJTD0i2AAAAAAAAAAAAUDjnAAAAAAAAAAEAAAAAAAAAAA==', + fee_meta_xdr: + 'AAAAAgAAAAMDv3McAAAAAAAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAAA10l1zA6WGsgAAAO8AAAAGAAAAAQAAAADEccZDcGLJUGqJNC5TihraQE0vQc8dOiVfQyH3xuDhdQAAAAAAAAAJbG9ic3RyLmNvAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAO/cxwAAAAAaiGAkgAAAAAAAAABA7+nfwAAAAAAAAAAP0E2QJSc+GJxMnTRfmMMrnZZXknl8DvX1mAEQlMPSLYAAAAANdJcqwOlhrIAAADvAAAABgAAAAEAAAAAxHHGQ3BiyVBqiTQuU4oa2kBNL0HPHTolX0Mh98bg4XUAAAAAAAAACWxvYnN0ci5jbwAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADv3McAAAAAGohgJIAAAAA', + memo_type: 'none', + signatures: [ + 'karvPnQYXclN6ZSxFgiRXYLQv2TvccpmHB4Qfn9OtLp1gAecYHLDxFiBZ8ftHMEtNrE53zNtNCFgzzi5+jawBw==', + ], + preconditions: { + timebounds: { + min_time: '0', + max_time: '1780659304', + }, + }, +} as unknown as Horizon.ServerApi.TransactionRecord; + +export const swapTransactionWithoutFeeCollectResponse = { + _links: { + self: { + href: 'https://horizon.stellar.org/transactions/eae4a5f1b6b305e220212bce9a2cc8b378b245024d7ab48af58a9a82185798f6', + }, + account: { + href: 'https://horizon.stellar.org/accounts/GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + }, + ledger: { + href: 'https://horizon.stellar.org/ledgers/61195698', + }, + operations: { + href: 'https://horizon.stellar.org/transactions/eae4a5f1b6b305e220212bce9a2cc8b378b245024d7ab48af58a9a82185798f6/operations{?cursor,limit,order}', + templated: true, + }, + effects: { + href: 'https://horizon.stellar.org/transactions/eae4a5f1b6b305e220212bce9a2cc8b378b245024d7ab48af58a9a82185798f6/effects{?cursor,limit,order}', + templated: true, + }, + precedes: { + href: 'https://horizon.stellar.org/transactions?order=asc\u0026cursor=262833521566416896', + }, + succeeds: { + href: 'https://horizon.stellar.org/transactions?order=desc\u0026cursor=262833521566416896', + }, + transaction: { + href: 'https://horizon.stellar.org/transactions/eae4a5f1b6b305e220212bce9a2cc8b378b245024d7ab48af58a9a82185798f6', + }, + }, + id: 'eae4a5f1b6b305e220212bce9a2cc8b378b245024d7ab48af58a9a82185798f6', + paging_token: '262833521566416896', + successful: true, + hash: 'eae4a5f1b6b305e220212bce9a2cc8b378b245024d7ab48af58a9a82185798f6', + ledger: 61195698, + created_at: '2026-02-12T06:37:49Z', + source_account: 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + source_account_sequence: '262764252333342729', + fee_account: 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + fee_charged: '100', + max_fee: '11891', + operation_count: 1, + envelope_xdr: + 'AAAAAgAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAALnMDpYayAAAACQAAAAEAAAAAAAAAAAAAAABpjXXmAAAAAAAAAAEAAAAAAAAADQAAAAAAAAAAAJiWgAAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAAFVU0RDAAAAADuZETgO/piLoKiQDrHP5E82b32+lGvtB3JA9/Yk3xXFAAAAAAAX3hwAAAAAAAAAAAAAAAFTD0i2AAAAQI16gjkJIu8HeYDqT5FMd4Q4FJBc2DoC0aMyK+mR/IZwrlZaCAi2btaNSffEl3nWWaOiCczACKZTbVNqapD+IQU=', + result_xdr: + 'AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAANAAAAAAAAAAEAAAABAAAAAGR+DTg0ZfnnSdDFIyItExAEaWivs+X0CbJSIApXhQ3tAAAAAGyvHUgAAAABVVNEQwAAAAA7mRE4Dv6Yi6CokA6xz+RPNm99vpRr7QdyQPf2JN8VxQAAAAAAGBvUAAAAAAAAAAAAmJaAAAAAAD9BNkCUnPhicTJ00X5jDK52WV5J5fA719ZgBEJTD0i2AAAAAVVTREMAAAAAO5kROA7+mIugqJAOsc/kTzZvfb6Ua+0HckD39iTfFcUAAAAAABgb1AAAAAA=', + fee_meta_xdr: + 'AAAAAgAAAAMDpcWMAAAAAAAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAAAE3N91A6WGsgAAAAgAAAAEAAAAAQAAAADEccZDcGLJUGqJNC5TihraQE0vQc8dOiVfQyH3xuDhdQAAAAAAAAAJbG9ic3RyLmNvAAAAAQAAAAAAAAAAAAABAAAAAACF0bMAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAOlxGIAAAAAaY1teQAAAAAAAAABA6XFsgAAAAAAAAAAP0E2QJSc+GJxMnTRfmMMrnZZXknl8DvX1mAEQlMPSLYAAAAABNzfEQOlhrIAAAAIAAAABAAAAAEAAAAAxHHGQ3BiyVBqiTQuU4oa2kBNL0HPHTolX0Mh98bg4XUAAAAAAAAACWxvYnN0ci5jbwAAAAEAAAAAAAAAAAAAAQAAAAAAhdGzAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADpcRiAAAAAGmNbXkAAAAA', + memo_type: 'none', + signatures: [ + 'jXqCOQki7wd5gOpPkUx3hDgUkFzYOgLRozIr6ZH8hnCuVloICLZu1o1J98SXedZZo6IJzMAIplNtU2pqkP4hBQ==', + ], + preconditions: { + timebounds: { + min_time: '0', + max_time: '1770878438', + }, + }, +} as unknown as Horizon.ServerApi.TransactionRecord; + +export const addChangeTrustResponse = { + _links: { + self: { + href: 'https://horizon.stellar.org/transactions/f68c5c95c412090252b3b51009eab31074df6a2fada5e23145fc037067c4934b', + }, + account: { + href: 'https://horizon.stellar.org/accounts/GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + }, + ledger: { + href: 'https://horizon.stellar.org/ledgers/62952644', + }, + operations: { + href: 'https://horizon.stellar.org/transactions/f68c5c95c412090252b3b51009eab31074df6a2fada5e23145fc037067c4934b/operations{?cursor,limit,order}', + templated: true, + }, + effects: { + href: 'https://horizon.stellar.org/transactions/f68c5c95c412090252b3b51009eab31074df6a2fada5e23145fc037067c4934b/effects{?cursor,limit,order}', + templated: true, + }, + precedes: { + href: 'https://horizon.stellar.org/transactions?order=asc\u0026cursor=270379547177148416', + }, + succeeds: { + href: 'https://horizon.stellar.org/transactions?order=desc\u0026cursor=270379547177148416', + }, + transaction: { + href: 'https://horizon.stellar.org/transactions/f68c5c95c412090252b3b51009eab31074df6a2fada5e23145fc037067c4934b', + }, + }, + id: 'f68c5c95c412090252b3b51009eab31074df6a2fada5e23145fc037067c4934b', + paging_token: '270379547177148416', + successful: true, + hash: 'f68c5c95c412090252b3b51009eab31074df6a2fada5e23145fc037067c4934b', + ledger: 62952644, + created_at: '2026-06-09T13:44:25Z', + source_account: 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + source_account_sequence: '262764252333343029', + fee_account: 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + fee_charged: '100', + max_fee: '120', + operation_count: 1, + envelope_xdr: + 'AAAAAgAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAHgDpYayAAABNQAAAAEAAAAAAAAAAAAAAABqKBldAAAAAAAAAAEAAAAAAAAABgAAAAFBRlIAAAAAAG/sI52tP2aACgbiEo47RuimlPNL4dL5TnKkbCAM5OMGf/////////8AAAAAAAAAAVMPSLYAAABAFPkhRhLqD+VuR8Q4FWdgxbu48XFcxp6hgAiJbhFYFACd6tsGpLcE0Cs06DteAadf/1wTaoYo3XEJfKmI4thmCg==', + result_xdr: 'AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAAGAAAAAAAAAAA=', + fee_meta_xdr: + 'AAAAAgAAAAMDwJPTAAAAAAAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAAA1iS38A6WGsgAAATQAAAAIAAAAAQAAAADEccZDcGLJUGqJNC5TihraQE0vQc8dOiVfQyH3xuDhdQAAAAAAAAAJbG9ic3RyLmNvAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAPAk9MAAAAAaigTRwAAAAAAAAABA8CUxAAAAAAAAAAAP0E2QJSc+GJxMnTRfmMMrnZZXknl8DvX1mAEQlMPSLYAAAAANYktmAOlhrIAAAE0AAAACAAAAAEAAAAAxHHGQ3BiyVBqiTQuU4oa2kBNL0HPHTolX0Mh98bg4XUAAAAAAAAACWxvYnN0ci5jbwAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADwJPTAAAAAGooE0cAAAAA', + memo_type: 'none', + signatures: [ + 'FPkhRhLqD+VuR8Q4FWdgxbu48XFcxp6hgAiJbhFYFACd6tsGpLcE0Cs06DteAadf/1wTaoYo3XEJfKmI4thmCg==', + ], + preconditions: { + timebounds: { + min_time: '0', + max_time: '1781012829', + }, + }, +} as unknown as Horizon.ServerApi.TransactionRecord; + +export const removeChangeTrustResponse = { + _links: { + self: { + href: 'https://horizon.stellar.org/transactions/5ce2d6c118dc4aa6164c5fa8b69fdad8af1e1faff7b67a2ec9cb66a652163a99', + }, + account: { + href: 'https://horizon.stellar.org/accounts/GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + }, + ledger: { + href: 'https://horizon.stellar.org/ledgers/62949429', + }, + operations: { + href: 'https://horizon.stellar.org/transactions/5ce2d6c118dc4aa6164c5fa8b69fdad8af1e1faff7b67a2ec9cb66a652163a99/operations{?cursor,limit,order}', + templated: true, + }, + effects: { + href: 'https://horizon.stellar.org/transactions/5ce2d6c118dc4aa6164c5fa8b69fdad8af1e1faff7b67a2ec9cb66a652163a99/effects{?cursor,limit,order}', + templated: true, + }, + precedes: { + href: 'https://horizon.stellar.org/transactions?order=asc\u0026cursor=270365738857349120', + }, + succeeds: { + href: 'https://horizon.stellar.org/transactions?order=desc\u0026cursor=270365738857349120', + }, + transaction: { + href: 'https://horizon.stellar.org/transactions/5ce2d6c118dc4aa6164c5fa8b69fdad8af1e1faff7b67a2ec9cb66a652163a99', + }, + }, + id: '5ce2d6c118dc4aa6164c5fa8b69fdad8af1e1faff7b67a2ec9cb66a652163a99', + paging_token: '270365738857349120', + successful: true, + hash: '5ce2d6c118dc4aa6164c5fa8b69fdad8af1e1faff7b67a2ec9cb66a652163a99', + ledger: 62949429, + created_at: '2026-06-09T08:35:09Z', + source_account: 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + source_account_sequence: '262764252333343025', + fee_account: 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + fee_charged: '100', + max_fee: '120', + operation_count: 1, + envelope_xdr: + 'AAAAAgAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAHgDpYayAAABMQAAAAEAAAAAAAAAAAAAAABqJ9DnAAAAAAAAAAEAAAAAAAAABgAAAAFBRlIAAAAAAG/sI52tP2aACgbiEo47RuimlPNL4dL5TnKkbCAM5OMGAAAAAAAAAAAAAAAAAAAAAVMPSLYAAABAWyxTwqBPBOk2blRtwq0WxP/6QkTjLqgX+DB+ztWeDDca8E//dE3y9nVvDJ21nV0k/db5BWSUUQP5MKBeO9VWAA==', + result_xdr: 'AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAAGAAAAAAAAAAA=', + fee_meta_xdr: + 'AAAAAgAAAAMDwIF5AAAAAAAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAAA2IceuA6WGsgAAATAAAAAIAAAAAQAAAADEccZDcGLJUGqJNC5TihraQE0vQc8dOiVfQyH3xuDhdQAAAAAAAAAJbG9ic3RyLmNvAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAPAgXkAAAAAaieo/QAAAAAAAAABA8CINQAAAAAAAAAAP0E2QJSc+GJxMnTRfmMMrnZZXknl8DvX1mAEQlMPSLYAAAAANiHHSgOlhrIAAAEwAAAACAAAAAEAAAAAxHHGQ3BiyVBqiTQuU4oa2kBNL0HPHTolX0Mh98bg4XUAAAAAAAAACWxvYnN0ci5jbwAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADwIF5AAAAAGonqP0AAAAA', + memo_type: 'none', + signatures: [ + 'WyxTwqBPBOk2blRtwq0WxP/6QkTjLqgX+DB+ztWeDDca8E//dE3y9nVvDJ21nV0k/db5BWSUUQP5MKBeO9VWAA==', + ], + preconditions: { + timebounds: { + min_time: '0', + max_time: '1780994279', + }, + }, +} as unknown as Horizon.ServerApi.TransactionRecord; + +export const sendTransactionResponse = { + _links: { + self: { + href: 'https://horizon.stellar.org/transactions/c8aa500c242cc3cbb4051ec4dc8215fa269ddf37760a5265c59d0bbd6ad77372', + }, + account: { + href: 'https://horizon.stellar.org/accounts/GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + }, + ledger: { + href: 'https://horizon.stellar.org/ledgers/62947705', + }, + operations: { + href: 'https://horizon.stellar.org/transactions/c8aa500c242cc3cbb4051ec4dc8215fa269ddf37760a5265c59d0bbd6ad77372/operations{?cursor,limit,order}', + templated: true, + }, + effects: { + href: 'https://horizon.stellar.org/transactions/c8aa500c242cc3cbb4051ec4dc8215fa269ddf37760a5265c59d0bbd6ad77372/effects{?cursor,limit,order}', + templated: true, + }, + precedes: { + href: 'https://horizon.stellar.org/transactions?order=asc\u0026cursor=270358334333763584', + }, + succeeds: { + href: 'https://horizon.stellar.org/transactions?order=desc\u0026cursor=270358334333763584', + }, + transaction: { + href: 'https://horizon.stellar.org/transactions/c8aa500c242cc3cbb4051ec4dc8215fa269ddf37760a5265c59d0bbd6ad77372', + }, + }, + id: 'c8aa500c242cc3cbb4051ec4dc8215fa269ddf37760a5265c59d0bbd6ad77372', + paging_token: '270358334333763584', + successful: true, + hash: 'c8aa500c242cc3cbb4051ec4dc8215fa269ddf37760a5265c59d0bbd6ad77372', + ledger: 62947705, + created_at: '2026-06-09T05:47:41Z', + source_account: 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + source_account_sequence: '262764252333343024', + fee_account: 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + fee_charged: '100', + max_fee: '120', + operation_count: 1, + envelope_xdr: + 'AAAAAgAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAHgDpYayAAABMAAAAAEAAAAAAAAAAAAAAABqJ6moAAAAAAAAAAEAAAAAAAAAAQAAAAB3r4GKMkdyMhEmYxrb+PKIqz3WmlQhYpBX1KvVgptyRwAAAAAAAAAAAAAAZAAAAAAAAAABUw9ItgAAAEAtK3jP9oB2i9UBU3MwOdHC3sfKCU7uWppczvT5E9I/C9TJefGdL0I1gvp4UgtcgBq6nYPrhxZekJzYf7AbukAH', + result_xdr: 'AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAA=', + fee_meta_xdr: + 'AAAAAgAAAAMDwH+RAAAAAAAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAAA2Ich2A6WGsgAAAS8AAAAIAAAAAQAAAADEccZDcGLJUGqJNC5TihraQE0vQc8dOiVfQyH3xuDhdQAAAAAAAAAJbG9ic3RyLmNvAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAPAf5EAAAAAaied7gAAAAAAAAABA8CBeQAAAAAAAAAAP0E2QJSc+GJxMnTRfmMMrnZZXknl8DvX1mAEQlMPSLYAAAAANiHIEgOlhrIAAAEvAAAACAAAAAEAAAAAxHHGQ3BiyVBqiTQuU4oa2kBNL0HPHTolX0Mh98bg4XUAAAAAAAAACWxvYnN0ci5jbwAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADwH+RAAAAAGonne4AAAAA', + memo_type: 'none', + signatures: [ + 'LSt4z/aAdovVAVNzMDnRwt7HyglO7lqaXM70+RPSPwvUyXnxnS9CNYL6eFILXIAaup2D64cWXpCc2H+wG7pABw==', + ], + preconditions: { + timebounds: { + min_time: '0', + max_time: '1780984232', + }, + }, +} as unknown as Horizon.ServerApi.TransactionRecord; + +export const createAccountTransactionResponse = { + _links: { + self: { + href: 'https://horizon.stellar.org/transactions/c5b838af1ecd1dc835b5f86d29ee6161295cc5504098af67e1916ae673dbcc1f', + }, + account: { + href: 'https://horizon.stellar.org/accounts/GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + }, + ledger: { + href: 'https://horizon.stellar.org/ledgers/62953267', + }, + operations: { + href: 'https://horizon.stellar.org/transactions/c5b838af1ecd1dc835b5f86d29ee6161295cc5504098af67e1916ae673dbcc1f/operations{?cursor,limit,order}', + templated: true, + }, + effects: { + href: 'https://horizon.stellar.org/transactions/c5b838af1ecd1dc835b5f86d29ee6161295cc5504098af67e1916ae673dbcc1f/effects{?cursor,limit,order}', + templated: true, + }, + precedes: { + href: 'https://horizon.stellar.org/transactions?order=asc\u0026cursor=270382222941630464', + }, + succeeds: { + href: 'https://horizon.stellar.org/transactions?order=desc\u0026cursor=270382222941630464', + }, + transaction: { + href: 'https://horizon.stellar.org/transactions/c5b838af1ecd1dc835b5f86d29ee6161295cc5504098af67e1916ae673dbcc1f', + }, + }, + id: 'c5b838af1ecd1dc835b5f86d29ee6161295cc5504098af67e1916ae673dbcc1f', + paging_token: '270382222941630464', + successful: true, + hash: 'c5b838af1ecd1dc835b5f86d29ee6161295cc5504098af67e1916ae673dbcc1f', + ledger: 62953267, + created_at: '2026-06-09T14:44:57Z', + source_account: 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + source_account_sequence: '262764252333343030', + fee_account: 'GA7UCNSASSOPQYTRGJ2NC7TDBSXHMWK6JHS7AO6X2ZQAIQSTB5ELNFSO', + fee_charged: '100', + max_fee: '120', + operation_count: 1, + envelope_xdr: + 'AAAAAgAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAHgDpYayAAABNgAAAAEAAAAAAAAAAAAAAABqKCeSAAAAAAAAAAEAAAAAAAAAAAAAAACXUnRfY1MYCozoHKTP3JKa9R7arlfK1l8ih1xZA6N68gAAAAABycOAAAAAAAAAAAFTD0i2AAAAQOfyu3bfm9JEEGwAAjsuor8fqHAXuw2z9tWKiBfBm8iD3bMvT61nZ5DOa54UEDqpf1ioNxxibF8oSefQqejrbw8=', + result_xdr: 'AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAA=', + fee_meta_xdr: + 'AAAAAgAAAAMDwJTEAAAAAAAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAAA1iS2YA6WGsgAAATUAAAAJAAAAAQAAAADEccZDcGLJUGqJNC5TihraQE0vQc8dOiVfQyH3xuDhdQAAAAAAAAAJbG9ic3RyLmNvAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAPAlMQAAAAAaigYuQAAAAAAAAABA8CXMwAAAAAAAAAAP0E2QJSc+GJxMnTRfmMMrnZZXknl8DvX1mAEQlMPSLYAAAAANYktNAOlhrIAAAE1AAAACQAAAAEAAAAAxHHGQ3BiyVBqiTQuU4oa2kBNL0HPHTolX0Mh98bg4XUAAAAAAAAACWxvYnN0ci5jbwAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADwJTEAAAAAGooGLkAAAAA', + memo_type: 'none', + signatures: [ + '5/K7dt+b0kQQbAACOy6ivx+ocBe7DbP21YqIF8GbyIPdsy9PrWdnkM5rnhQQOql/WKg3HGJsXyhJ59Cp6OtvDw==', + ], + preconditions: { + timebounds: { + min_time: '0', + max_time: '1781016466', + }, + }, +} as unknown as Horizon.ServerApi.TransactionRecord; + +export const receivePaymentTransactionResponse = { + _links: { + self: { + href: 'https://horizon.stellar.org/transactions/53842e80a16599310ee3b09ae602992277fb6c25d978aa90b446cd395e4c95cc', + }, + account: { + href: 'https://horizon.stellar.org/accounts/GCLVE5C7MNJRQCUM5AOKJT64SKNPKHW2VZL4VVS7EKDVYWIDUN5PECZW', + }, + ledger: { + href: 'https://horizon.stellar.org/ledgers/62953290', + }, + operations: { + href: 'https://horizon.stellar.org/transactions/53842e80a16599310ee3b09ae602992277fb6c25d978aa90b446cd395e4c95cc/operations{?cursor,limit,order}', + templated: true, + }, + effects: { + href: 'https://horizon.stellar.org/transactions/53842e80a16599310ee3b09ae602992277fb6c25d978aa90b446cd395e4c95cc/effects{?cursor,limit,order}', + templated: true, + }, + precedes: { + href: 'https://horizon.stellar.org/transactions?order=asc\u0026cursor=270382321725812736', + }, + succeeds: { + href: 'https://horizon.stellar.org/transactions?order=desc\u0026cursor=270382321725812736', + }, + transaction: { + href: 'https://horizon.stellar.org/transactions/53842e80a16599310ee3b09ae602992277fb6c25d978aa90b446cd395e4c95cc', + }, + }, + id: '53842e80a16599310ee3b09ae602992277fb6c25d978aa90b446cd395e4c95cc', + paging_token: '270382321725812736', + successful: true, + hash: '53842e80a16599310ee3b09ae602992277fb6c25d978aa90b446cd395e4c95cc', + ledger: 62953290, + created_at: '2026-06-09T14:47:14Z', + source_account: 'GCLVE5C7MNJRQCUM5AOKJT64SKNPKHW2VZL4VVS7EKDVYWIDUN5PECZW', + source_account_sequence: '270382222941356034', + fee_account: 'GCLVE5C7MNJRQCUM5AOKJT64SKNPKHW2VZL4VVS7EKDVYWIDUN5PECZW', + fee_charged: '100', + max_fee: '120', + operation_count: 1, + envelope_xdr: + 'AAAAAgAAAACXUnRfY1MYCozoHKTP3JKa9R7arlfK1l8ih1xZA6N68gAAAHgDwJczAAAAAgAAAAEAAAAAAAAAAAAAAABqKCggAAAAAAAAAAEAAAAAAAAAAQAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAAFTSFgAAAAAAOU4yPc5k/yisQ3JgnYf5elRUkRRAx/QRyUoRX9N+j6kAAAAAAL68IAAAAAAAAAAAQOjevIAAABAbsO0c1P2TfctrIcvD6p3wNkxNW3W1IrjjEl6uAWGnUnIyDDx2tBbUKXOX+frz29Z11P6UwLZ+Bt4EIFjyFBqAQ==', + result_xdr: 'AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAA=', + fee_meta_xdr: + 'AAAAAgAAAAMDwJc/AAAAAAAAAACXUnRfY1MYCozoHKTP3JKa9R7arlfK1l8ih1xZA6N68gAAAAABycMcA8CXMwAAAAEAAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAPAlz8AAAAAaignMAAAAAAAAAABA8CXSgAAAAAAAAAAl1J0X2NTGAqM6Bykz9ySmvUe2q5XytZfIodcWQOjevIAAAAAAcnCuAPAlzMAAAABAAAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADwJc/AAAAAGooJzAAAAAA', + memo_type: 'none', + signatures: [ + 'bsO0c1P2TfctrIcvD6p3wNkxNW3W1IrjjEl6uAWGnUnIyDDx2tBbUKXOX+frz29Z11P6UwLZ+Bt4EIFjyFBqAQ==', + ], + preconditions: { + timebounds: { + min_time: '0', + max_time: '1781016608', + }, + }, +} as unknown as Horizon.ServerApi.TransactionRecord; + +export const spamTransactionResponse = { + memo: 'claim your VTA airdrop', + memo_bytes: 'Y2xhaW0geW91ciBWVEEgYWlyZHJvcA==', + _links: { + self: { + href: 'https://horizon.stellar.org/transactions/d9281c9b5ed318671fcadf1a562bbc89ded0ff17bf8ca835e3e825631aafaa55', + }, + account: { + href: 'https://horizon.stellar.org/accounts/GDRMQMJNI55N5FWTA734ZICZ3JOEMYRXVLWXFU7HTRFKBQDFYZN5XFMS', + }, + ledger: { + href: 'https://horizon.stellar.org/ledgers/62946196', + }, + operations: { + href: 'https://horizon.stellar.org/transactions/d9281c9b5ed318671fcadf1a562bbc89ded0ff17bf8ca835e3e825631aafaa55/operations{?cursor,limit,order}', + templated: true, + }, + effects: { + href: 'https://horizon.stellar.org/transactions/d9281c9b5ed318671fcadf1a562bbc89ded0ff17bf8ca835e3e825631aafaa55/effects{?cursor,limit,order}', + templated: true, + }, + precedes: { + href: 'https://horizon.stellar.org/transactions?order=asc\u0026cursor=270351853227712512', + }, + succeeds: { + href: 'https://horizon.stellar.org/transactions?order=desc\u0026cursor=270351853227712512', + }, + transaction: { + href: 'https://horizon.stellar.org/transactions/d9281c9b5ed318671fcadf1a562bbc89ded0ff17bf8ca835e3e825631aafaa55', + }, + }, + id: 'd9281c9b5ed318671fcadf1a562bbc89ded0ff17bf8ca835e3e825631aafaa55', + paging_token: '270351853227712512', + successful: true, + hash: 'd9281c9b5ed318671fcadf1a562bbc89ded0ff17bf8ca835e3e825631aafaa55', + ledger: 62946196, + created_at: '2026-06-09T03:21:24Z', + source_account: 'GDRMQMJNI55N5FWTA734ZICZ3JOEMYRXVLWXFU7HTRFKBQDFYZN5XFMS', + source_account_sequence: '270146691229814170', + fee_account: 'GDRMQMJNI55N5FWTA734ZICZ3JOEMYRXVLWXFU7HTRFKBQDFYZN5XFMS', + fee_charged: '10000', + max_fee: '10000', + operation_count: 100, + envelope_xdr: + 'AAAAAgAAAADiyDEtR3reltMH98ygWdpcRmI3qu1y0+ecSqDAZcZb2wAAJxADv8D8AAANmgAAAAEAAAAAAAAAAAAAAABqJ423AAAAAQAAABZjbGFpbSB5b3VyIFZUQSBhaXJkcm9wAAAAAABkAAAAAAAAAAEAAAAAh1PYHfBJlIlYkjcvOfAxVq4n906urJ1LPX5w40jydLIAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAADjIXT/BVAqIW3h+8EOPSmk+iIy7sbwBVgSJo2QY6Q8dwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAEytzTZXXLg5hB4vCRy3XfwftJv5i9bz8PW2MIsOXh5iAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAqun0a7Y4nw8lDHi7FR5IuOBmKlM4E8zCT7WmjF3+VlQAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAABGPJ3ZWdedRsxgZCjE269h+SIs2N1McyT7Pp0fF+kp3QAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAHTMXagOG70ROyVYCK11jtYC//3OtW7ADb24rOovBjhDAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAU7nXtBSlDXTfYoJP07dOZo1iLxEOuKSI8vgIcizWtCQAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAACpSA10hqyn0PUK6uPWKAfuVdZY7y39jiXWPFNxL6fqSgAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAPZsHsd+h89Gembg9unTF5yOlhgm4kQanw3xX0c0FUzDAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAOgIDCigMA7bMX8cYaFgxGaqccs5Qs9DUsqjbeseu0H0AAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAArzix0t0S8X910jm/sceYO83ONFptla/Hx5SP+pyq7igAAAAAAAAAAAAAAAQAAAAAAAAABAAAAADe9ibUv8x706HiLiMhG17Suq3nAYNSmQ7XUjzPhGYTwAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAA4t8rEfcv8hSPrM4yfdKK9YFAWMsQXGsiDYE880MY/jwAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAABcnqluQzsEdC9+h3nCOom76jdAidN7SqQn340s0QsXigAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAA4hQZrPT+BjsIUC+Y5GFsjvEUR6lWFtByrlscEQxtjfAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAVIRfZCsAN1NQloJf4w8sHMUQ7bMYqsGe8kJU3j5KTVwAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAAAAAAAAAAAAAQAAAAAAAAABAAAAABwP2BAqX4fOzZj9NX6c9I6aWSf+eGWKSACGmlhvANaHAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAA46Ms0KaWpUWe2l2pLZHd8tSop5TaJ8DMVpkEzXd4MSQAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAACSdqzGjhVRBdMLvyCTKg6BBwfk0t7GNVSyq6n0d+nDJwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAFV+op0cctrRQOmm2j8KcIDydkgwJ1JXhin7Tz4jrtQ/AAAAAAAAAAAAAAABAAAAAAAAAAEAAAAACfJB3+Q1dw7kzfFoiSc+GxD6Tz7oalhBr3wue/v69ccAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAA1fY1wDUQ8hJgOnj7yS30xyt4v3eKLJrjpjauhydeXxQAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAGO/UOhcmTLXzngZTDEXDxauWRHATHOf9KsmZ33XHk7DAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAiIrCuH/Ovx7VzvaStDu6wuke2nbuJ+UxydMcNiGXPfwAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAABZcaf02pPV4KsEBGxqGhuRBYx+PLa3WKApq7aB7cThkgAAAAAAAAAAAAAAAQAAAAAAAAABAAAAABq4eCUKSTPo+t3Ac8/a1SKdHioQv0O1Q+jWpLs2TgM0AAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAf05AE75oEKO5uDgn0Wa0UFK9XfUqLWcMBXghYvTG6wEAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAACZodXguCmrZ7Qz/25kt0I6GViyoc6/MSZzGJrnZKn8NgAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAOsiqB2WrQTZTNvQoNpiSC2gcNnRkZNFsD83qLZmWReEAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAA6pc7S1+zosZQ0bEZxdPKYwgEfRPI7k+vpFHUgcMvvBsAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAADi9eiCWt35URadCSgmVAjP3vzyG+dkgx5inSyUca+BowAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAPKi7ASJXVjLW9F6G6+wmdhQYLBul0teTtHDIOMecCCUAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAA0ZjdjJmY7gxkbsUbtTiQ6jEivcBisdX7t7rxteWryr4AAAAAAAAAAAAAAAEAAAAAAAAAAQAAAADlEkxLpxKBlhXqLI4C3iI3ynIGhXMlla4cWNpdK4zLkAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAANgIMbdcrvksiI0cWYainEiO4vXmSQBuI9Md7UxIDKIqAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAHlFlOl8i2WX9vSuPF7zacEczULI5dSBlsGJVhhjxMIsAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAoNN6LkeVTDxu5ODoVkvID92miA2KozPvha7X5C2u/cwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAABqEYTJTgjNqLou9lkAg8I8Fv88uRzTxdZnHL4QWQYNvAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAZGHlngxJqT6SNJkfDOuZNGW3r2nNj+QAbEuxWN0KnUUAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAADmf6geEks6yALCPqjZr5x4PkWejuhKDUIZT+5hHY/oaAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAABvooKnMwMuBrP+Vb9PJHFhxRQs2lLvIkpPpYVl5sHIyAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAA7UKnlJgFub7VzybjWH/fqywtTCMvV1E4PaXnWznnuaYAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAD8JkHjjG1go3umlUWRRQVetblKdiRFrp7LqDVxP+sGRAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAABsegdAdfQ3nJkjL7eGg1WfE3N+REEjzjHfZnVVpFqU3AAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAJD4lxGPnql1+Cqa/9k+BmW4OdGmGz61poiQG9uQDqLMAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAD+3rbigZH1TvCk9S838XckfmsqmxLITCDsIrqAaDhAEgAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAMSqYPswvP/OjITC25T+Oo3vkRkjmYI6D86brKlWqhEwAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAA+s/joIQGYm76Y8HXXOYbqsWulfL1fm0cco2Qm5qv7c0AAAAAAAAAAAAAAAEAAAAAAAAAAQAAAACom3RAZdXtdZyJOtBb30uYhdU53l7j+Z8w3OYJyEnqLwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAD+0dbCKT63uz9mmffvjCC8+IEsRi89+kD5waviaRibsAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAA2J89i0uAeS0CjEo6ayAS/BU7x9tV+tSC6bzl2jAKWcMAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAABRBN0mR68GilZYLumaF0lrBp0Y5J6cVCTW+pxRMaqp+wAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAHUJm9Il40hzU+zPHQboxOLXoK0rczRge+f7FalzI+EcAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAdpPgwefFYQvzMQNpQVtduPH+yJAoH+3zuilWlEvgo4sAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAACRaGimtZ6N+orzFq5poYPqpss1a9Fbnj716MlqKajDEQAAAAAAAAAAAAAAAQAAAAAAAAABAAAAANcvStr8EJ1bl9CRmDjFGAJZuCnKfw3QwMo3IWnNLBugAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAjj0yEfcAq6HjeczxarvEuqBgcPDgtXNjSNTGbYWz3v0AAAAAAAAAAAAAAAEAAAAAAAAAAQAAAACk/5yXrBW7z2YEyEMwiROKZ397ZHb/u6L/+esQDbswbwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAOSdsZA1IkVbBdcjv9IxlmMTQpthPmtdqdUxNfaPhzE/AAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAO6t0gCnQB0rag0SqtAImVrvyyIGqS92mzLkQvARzR9sAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAABdlXV1V+sj1g0mVBiQl+NcQPWiAz1Ths7K1QGRBaqoDwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAALZfDILoyNexs1q20FpABQBbnRqAqKmgprL/OtkAuQZFAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAA6X7bMDn7JlDzQtpQMEOu2kAWMIqGKXhXMzkjUUbMhvwAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAABmEWwILqQzfYCzDsJZAR954bDmfSOlIuN7ClHZE8zfkwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAHh6Pgk2CLlwfphh2eqbkjv/easiXCXR5d3B9gaHt04aAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAYoE1DKys1Sx5JKo7Is7MzW2nTv0nsdMKbPrh+mfh1KkAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAC4ZM7t0ZbIkyu2qBUgrt/UVq7/Qqz6olV6a87/7GI21wAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAG4s7n2LK9OpxzBPJ/0e7pqgGBm5jeMkPu8qGTBncD+KAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAsx8fLk7NwV0YYVfeulEL38nW4X/0VlieODJ4ssxb/OkAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAC/EPH6kYMba+nulvSHovj7XfuwqvShr1/GKTecX7alXAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAN/I0pqe0dVJfcExIG43snUk1FNkqPuNgaqJCQOUBsb5AAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAmbLRRIUSbzDpceFmNIV/aNcEb/wlOT5BzDRPAejOMpkAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAlmg11ljGmNY9aH1TARnPBqNzCS1Lw2xiqZ6N/wnoqcQAAAAAAAAAAAAAAAQAAAAAAAAABAAAAABJaE1/Knknig5kTwfmkExq10eqYrXGon3z/SGVMMX1FAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAm4CTiRl0uSumGZ++Mp0EZXKr5K/fyU0VnevjxROLgEMAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAOAn2T68ITQCB/IWUBy0ESqrK6NawU/yMaaxHKEbMVNAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAJ5ycrBv2uHK55knBwIgbL++ygjBau05/GkMW5ZNl8EJAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAu07olGro/ZE3+bCi53xOEVan7xhX03GvmaFC7ktMDoYAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAD8ZZNhUS0HubW5P6Bs1m6lXzj4beIzM70P2neoeEZwwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAANuq4dDiQJmzPQwo6YYxX8OML+2CZSm7ZXzl0fUZK04rAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAeUmSWu+NjEEYGteROD6evO1d+YCCpDLboeUUrSr+n8YAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAyGN5CqBuKkY70l+S4KnMndjdKpFpr6/kggQKjlhMg4gAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAAjE//qs0QUAm7XuEZA9OUkRejUH8vmBleGqC8a+BAZzAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAEIIFeC0ZP/uLmcqs1S5TXTEn5BxMT6ptbNfyCZnj6SUAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAADwGsgqdrV9rLLvoJwqWHizGTik70HFRGUjAkIGedJLWwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAOgakairFD9C/Qh9LDHIpO9BoOvB7EYOkSPx9EcrKlTwAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAXt3OU+xTNHv102dQgvgrpdRlJAvdOPLnSkPZH3C6sqgAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAADlsfDDIT+RIi4E+wYdjeyDUm38KIsXbT2FpToxavOZRAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAKHT9wyETSBQTF290igdB3NAKoct6skXPIp8RlhBM8HgAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAA1k5Ip3cLwwGMav/etK1EDhqULmfYf19ENcIABK1AmPUAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAHKbb0HRy3X2OJyH3zqKEEAqNWXXTQd87KZWzuK07TFgAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAMh+VTgH8JsQcf0bteQroARKl5musV2mMCOD35z+e4RLAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAx3PraIbogU/BuBOA+IbZecXERmU5Q9dCXeM0ahG2Og4AAAAAAAAAAAAAAAEAAAAAAAAAAQAAAABibJWNeGz5Wpa73R0kSTardGZ6jfYSfUvDaXdEKK0dGAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAABDD055uITh5J6OhfCcVrjGmS0lkSJv8pr79YvVQB9iEAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAqRUcn+hFh4QEkzuzBLeoBihQeklI8uyQZmx/hOy/UFMAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAADp0HqKLrN/4sqzjZr3MUOKfiYxeTsqEUI8YUc7FNemBwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAG+DHIoH4Et109WUCl4SrhtepCCbaXuQsu6sUry5+Q6ZAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAezl77+cOjTJ6H4ksKv/vAyKnS4XEtfjw72AAEjBGUM8AAAAAAAAAAAAAAAEAAAAAAAAAAWXGW9sAAABAy+uW9EXFlWvcyptTBLmbxEQLobDf3JQQLI0YyiOnhLicvvhDXmQfFQbCj3eDEB/F4wdWxeNKMsok8A2ybaP0Bw==', + result_xdr: + 'AAAAAAAAJxAAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAAAAAABAAAAAAAAAAA=', + fee_meta_xdr: + 'AAAAAgAAAAMDwHuSAAAAAAAAAADiyDEtR3reltMH98ygWdpcRmI3qu1y0+ecSqDAZcZb2wAAAAAMzrVvA7/A/AAADZkAAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAPAe5IAAAAAaieGqQAAAAAAAAABA8B7lAAAAAAAAAAA4sgxLUd63pbTB/fMoFnaXEZiN6rtctPnnEqgwGXGW9sAAAAADM6OXwO/wPwAAA2ZAAAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADwHuSAAAAAGonhqkAAAAA', + memo_type: 'text', + signatures: [ + 'y+uW9EXFlWvcyptTBLmbxEQLobDf3JQQLI0YyiOnhLicvvhDXmQfFQbCj3eDEB/F4wdWxeNKMsok8A2ybaP0Bw==', + ], + preconditions: { + timebounds: { + min_time: '0', + max_time: '1780977079', + }, + }, +} as unknown as Horizon.ServerApi.TransactionRecord; + +export const contractInvokeTransactionResponse = { + _links: { + self: { + href: 'https://horizon.stellar.org/transactions/6dd990032dfe59b2f6fc67c67a2eabc0dea19e2078e2fd1de9b69879159354b0', + }, + account: { + href: 'https://horizon.stellar.org/accounts/GCQJ7S55OJ3X5Q3B5GVRW4Q5WHUSL47LDUXQKXR7MWSRE7QQITKSEBRQ', + }, + ledger: { + href: 'https://horizon.stellar.org/ledgers/62670258', + }, + operations: { + href: 'https://horizon.stellar.org/transactions/6dd990032dfe59b2f6fc67c67a2eabc0dea19e2078e2fd1de9b69879159354b0/operations{?cursor,limit,order}', + templated: true, + }, + effects: { + href: 'https://horizon.stellar.org/transactions/6dd990032dfe59b2f6fc67c67a2eabc0dea19e2078e2fd1de9b69879159354b0/effects{?cursor,limit,order}', + templated: true, + }, + precedes: { + href: 'https://horizon.stellar.org/transactions?order=asc\u0026cursor=269166708542713856', + }, + succeeds: { + href: 'https://horizon.stellar.org/transactions?order=desc\u0026cursor=269166708542713856', + }, + transaction: { + href: 'https://horizon.stellar.org/transactions/6dd990032dfe59b2f6fc67c67a2eabc0dea19e2078e2fd1de9b69879159354b0', + }, + }, + id: '6dd990032dfe59b2f6fc67c67a2eabc0dea19e2078e2fd1de9b69879159354b0', + paging_token: '269166708542713856', + successful: true, + hash: '6dd990032dfe59b2f6fc67c67a2eabc0dea19e2078e2fd1de9b69879159354b0', + ledger: 62670258, + created_at: '2026-05-21T14:03:55Z', + source_account: 'GCQJ7S55OJ3X5Q3B5GVRW4Q5WHUSL47LDUXQKXR7MWSRE7QQITKSEBRQ', + source_account_sequence: '240928234873577922', + fee_account: 'GCQJ7S55OJ3X5Q3B5GVRW4Q5WHUSL47LDUXQKXR7MWSRE7QQITKSEBRQ', + fee_charged: '222205', + max_fee: '362276', + operation_count: 1, + envelope_xdr: + 'AAAAAgAAAACgn8u9cnd+w2HpqxtyHbHpJfPrHS8FXj9lpRJ+EETVIgAFhyQDV/L0AABhwgAAAAEAAAAAAAAAAAAAAABqEGJFAAAAAAAAAAEAAAAAAAAAGAAAAAAAAAABl2X1uJV1ZMN1DTnAMGclVEeDnW6g/S1+07aaqea1gQoAAAAId2l0aGRyYXcAAAAFAAAACQAAAAAAAAAAAAAAAACWVpwAAAAJAAAAAAAAAGB1wAo1vN7vqAAAABIAAAABre/OWa7lKWj3YGHUlMJSW3Vln6QpamX0me8p5WR35JYAAAASAAAAAAAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAA0AAABBqc/RsCFZjtKcB7s37SUHHfVH5BH1Oe1iNnlMZt3RhJ4BTu4dqu7PI7YaQg7zd+mnuGygpZYT2BR+aZg4OWt8JgAAAAAAAAAAAAAAAQAAAAAAAAADAAAABgAAAAGXZfW4lXVkw3UNOcAwZyVUR4OdbqD9LX7Ttpqp5rWBCgAAABQAAAABAAAABgAAAAGt785ZruUpaPdgYdSUwlJbdWWfpClqZfSZ7ynlZHfklgAAABQAAAABAAAAB8x0rLVv0B71YLXUQ81Y/hHVrL7ODkABYS67MQIPyS+XAAAAAwAAAAEAAAAAP0E2QJSc+GJxMnTRfmMMrnZZXknl8DvX1mAEQlMPSLYAAAABVVNEQwAAAAA7mRE4Dv6Yi6CokA6xz+RPNm99vpRr7QdyQPf2JN8VxQAAAAYAAAABl2X1uJV1ZMN1DTnAMGclVEeDnW6g/S1+07aaqea1gQoAAAAQAAAAAQAAAAIAAAAPAAAADVdpdGhkcmF3Tm9uY2UAAAAAAAAJAAAAAAAAAGB1wAo1vN7vqAAAAAEAAAAGAAAAAa3vzlmu5Slo92Bh1JTCUlt1ZZ+kKWpl9JnvKeVkd+SWAAAAEAAAAAEAAAACAAAADwAAAAdCYWxhbmNlAAAAABIAAAABl2X1uJV1ZMN1DTnAMGclVEeDnW6g/S1+07aaqea1gQoAAAABAFJpQQAAAHQAAAHMAAAAAAAEAIQAAAABEETVIgAAAEANovL5MPX+nHcH/tLyxEyOXXFwv9U/MlmzzG1Xm6FtSYSO9hePO2Ya0VmGp4A/Y4PRp12SfpdHc/mQcezxsngK', + result_xdr: + 'AAAAAAADY/0AAAAAAAAAAQAAAAAAAAAYAAAAAEG9iKuo86BwX5CbrU+Z3/WX6oXYqiYG1LB1cev0MASVAAAAAA==', + fee_meta_xdr: + 'AAAAAgAAAAMDvEECAAAAAAAAAACgn8u9cnd+w2HpqxtyHbHpJfPrHS8FXj9lpRJ+EETVIgAAAAAyESMZA1fy9AAAYcEAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAO8QQIAAAAAag71nQAAAAAAAAABA7xFsgAAAAAAAAAAoJ/LvXJ3fsNh6asbch2x6SXz6x0vBV4/ZaUSfhBE1SIAAAAAMg0iMQNX8vQAAGHBAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADvEECAAAAAGoO9Z0AAAAA', + memo_type: 'none', + signatures: [ + 'DaLy+TD1/px3B/7S8sRMjl1xcL/VPzJZs8xtV5uhbUmEjvYXjztmGtFZhqeAP2OD0addkn6XR3P5kHHs8bJ4Cg==', + ], + preconditions: { + timebounds: { + min_time: '0', + max_time: '1779458629', + }, + }, +} as unknown as Horizon.ServerApi.TransactionRecord; + +export const receiveCreateAccountTransactionResponse = { + _links: { + self: { + href: 'https://horizon.stellar.org/transactions/407cf1c2f0fe8a3e88abd32cbfc7a6455e4d14c781da588098b60fb8c4a40ad7', + }, + account: { + href: 'https://horizon.stellar.org/accounts/GCXZDLDI4BO3RHIYBS22RZXB5LGRRLTZUSPG6ANQQ36TVL2ASHC4ONZO', + }, + ledger: { + href: 'https://horizon.stellar.org/ledgers/61179570', + }, + operations: { + href: 'https://horizon.stellar.org/transactions/407cf1c2f0fe8a3e88abd32cbfc7a6455e4d14c781da588098b60fb8c4a40ad7/operations{?cursor,limit,order}', + templated: true, + }, + effects: { + href: 'https://horizon.stellar.org/transactions/407cf1c2f0fe8a3e88abd32cbfc7a6455e4d14c781da588098b60fb8c4a40ad7/effects{?cursor,limit,order}', + templated: true, + }, + precedes: { + href: 'https://horizon.stellar.org/transactions?order=asc\u0026cursor=262764252333858816', + }, + succeeds: { + href: 'https://horizon.stellar.org/transactions?order=desc\u0026cursor=262764252333858816', + }, + transaction: { + href: 'https://horizon.stellar.org/transactions/407cf1c2f0fe8a3e88abd32cbfc7a6455e4d14c781da588098b60fb8c4a40ad7', + }, + }, + id: '407cf1c2f0fe8a3e88abd32cbfc7a6455e4d14c781da588098b60fb8c4a40ad7', + paging_token: '262764252333858816', + successful: true, + hash: '407cf1c2f0fe8a3e88abd32cbfc7a6455e4d14c781da588098b60fb8c4a40ad7', + ledger: 61179570, + created_at: '2026-02-11T04:29:26Z', + source_account: 'GCXZDLDI4BO3RHIYBS22RZXB5LGRRLTZUSPG6ANQQ36TVL2ASHC4ONZO', + source_account_sequence: '186837700216085701', + fee_account: 'GCXZDLDI4BO3RHIYBS22RZXB5LGRRLTZUSPG6ANQQ36TVL2ASHC4ONZO', + fee_charged: '100', + max_fee: '50000', + operation_count: 1, + envelope_xdr: + 'AAAAAgAAAACvkaxo4F24nRgMtajm4erNGK55pJ5vAbCG/TqvQJHFxwAAw1ACl8fmAAfcxQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAA/QTZAlJz4YnEydNF+YwyudlleSeXwO9fWYARCUw9ItgAAAAAHAm8AAAAAAAAAAAFAkcXHAAAAQD3XvfCJJ6+ujb9pBqcd9iaRqM/o8ASmBLWx0qeqbBSE640bUs33zgoeTpzb+1X0nd5FT5mzd6VZefARddOhDww=', + result_xdr: 'AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAA=', + fee_meta_xdr: + 'AAAAAgAAAAMDpYYsAAAAAAAAAACvkaxo4F24nRgMtajm4erNGK55pJ5vAbCG/TqvQJHFxwAAFAZIchDlApfH5gAH3MQAAAAGAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAOlhiwAAAAAaYwCmgAAAAAAAAABA6WGsgAAAAAAAAAAr5GsaOBduJ0YDLWo5uHqzRiueaSebwGwhv06r0CRxccAABQGSHIQgQKXx+YAB9zEAAAABgAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAADpYYsAAAAAGmMApoAAAAA', + memo_type: 'none', + signatures: [ + 'Pde98Iknr66Nv2kGpx32JpGoz+jwBKYEtbHSp6psFITrjRtSzffOCh5OnNv7VfSd3kVPmbN3pVl58BF106EPDA==', + ], + preconditions: { + timebounds: { + min_time: '0', + }, + }, +} as unknown as Horizon.ServerApi.TransactionRecord; + +/* eslint-enable @typescript-eslint/naming-convention */ diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts index ce979387..d43cd3c9 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/exceptions.ts @@ -231,3 +231,10 @@ export class KeyringTransactionBuilderException extends Error { this.name = 'KeyringTransactionBuilderException'; } } + +export class TransactionMapperException extends Error { + constructor(message: string) { + super(message); + this.name = 'TransactionMapperException'; + } +} diff --git a/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts b/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts index acf2252b..ac4f8186 100644 --- a/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/services/transaction/utils.ts @@ -1,6 +1,13 @@ +import { + TransactionStatus, + type Transaction as KeyringTransaction, +} from '@metamask/keyring-api'; import { parseCaipAssetType } from '@metamask/utils'; +import type { Operation } from '@stellar/stellar-sdk'; import { Asset } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; +import { StellarOperationType } from './api'; import { InvalidInvokeContractStructureException, RequiresMemoException, @@ -17,6 +24,8 @@ import type { KnownCaip19AssetIdOrSlip44Id, KnownCaip2ChainId, } from '../../api'; +import { SwapTransactionXdrStruct } from '../../api'; +import { DUST_XLM_AMOUNT } from '../../constants'; import { getSlip44AssetId, isClassicAssetId, @@ -264,3 +273,268 @@ export function parseExpirationMaxTime( } return parsed; } + +/** + * Detects if the operation is a path payment operation. + * + * @param operation - The operation to check. + * @returns Whether the operation is a path payment operation. + */ +export function isPathPaymentOperation( + operation: Operation, +): operation is + | Operation.PathPaymentStrictSend + | Operation.PathPaymentStrictReceive { + return ( + operation.type === StellarOperationType.PathPaymentStrictSend || + operation.type === StellarOperationType.PathPaymentStrictReceive + ); +} + +/** + * Detects if the transaction is a swap based on the operation types crafted by the Bridge API. + * + * @param transaction - The transaction to check. + * @param accountAddress - The Stellar address of the transaction owner. + * @returns Whether the transaction is a self-to-self bridge swap. + */ +export function isSwapTransaction( + transaction: Transaction, + accountAddress: string, +): boolean { + const isSwapXdr = SwapTransactionXdrStruct.is(transaction.getRaw().toXDR()); + const isSourceAccount = transaction.sourceAccount === accountAddress; + if (!isSwapXdr || !isSourceAccount) { + return false; + } + return transaction.transactionOperations.some( + (operation) => + isPathPaymentOperation(operation) && + operation.destination === accountAddress, + ); +} + +/** + * Detects if the transaction is a bridge send from the Bridge API XDR envelope. + * + * @param transaction - The transaction to check. + * @param accountAddress - The Stellar address of the transaction owner. + * @returns Whether the transaction is a single-operation bridge send. + */ +export function isBridgeSendTransaction( + transaction: Transaction, + accountAddress: string, +): boolean { + const isSwapXdr = SwapTransactionXdrStruct.is(transaction.getRaw().toXDR()); + const isSourceAccount = transaction.sourceAccount === accountAddress; + if (!isSwapXdr || !isSourceAccount) { + return false; + } + const operationTypes = transaction.transactionOperations; + const [firstOperation] = operationTypes; + if ( + operationTypes.length === 1 && + firstOperation && + [ + StellarOperationType.InvokeHostFunction, + StellarOperationType.Payment, + StellarOperationType.PathPaymentStrictSend, + StellarOperationType.PathPaymentStrictReceive, + ].includes(firstOperation.type as StellarOperationType) + ) { + return true; + } + return false; +} + +/** + * Detects if the transaction is a change-trust opt-in (limit > 0). + * + * @param transaction - The transaction to check. + * @param accountAddress - The Stellar address of the transaction owner. + * @returns Whether all operations are change-trust opt-ins for the account. + */ +export function isAddChangeTrustTransaction( + transaction: Transaction, + accountAddress: string, +): boolean { + const operationTypes = transaction.transactionOperations; + const isSourceAccount = transaction.sourceAccount === accountAddress; + if ( + isSourceAccount && + operationTypes.every( + (operation) => + operation.type === StellarOperationType.ChangeTrust && + // We consider any non-zero limit as an opt-in. + new BigNumber(operation.limit).isGreaterThan(0), + ) + ) { + return true; + } + + return false; +} + +/** + * Detects if the transaction is a change-trust opt-out (limit = 0). + * + * @param transaction - The transaction to check. + * @param accountAddress - The Stellar address of the transaction owner. + * @returns Whether all operations are change-trust removals for the account. + */ +export function isRemoveChangeTrustTransaction( + transaction: Transaction, + accountAddress: string, +): boolean { + const isSourceAccount = transaction.sourceAccount === accountAddress; + const operationTypes = transaction.transactionOperations; + if ( + isSourceAccount && + operationTypes.every( + (operation) => + operation.type === StellarOperationType.ChangeTrust && + new BigNumber(operation.limit).isZero(), + ) + ) { + return true; + } + return false; +} + +/** + * Detects if the transaction is a send (payment or create-account operations only). + * + * @param transaction - The transaction to check. + * @param accountAddress - The Stellar address of the transaction owner. + * @returns Whether the transaction is a send from the account. + */ +export function isSendTransaction( + transaction: Transaction, + accountAddress: string, +): boolean { + const operationTypes = transaction.transactionOperations; + const isSourceAccount = transaction.sourceAccount === accountAddress; + if ( + isSourceAccount && + operationTypes.every( + (operation) => + operation.type === StellarOperationType.Payment || + operation.type === StellarOperationType.CreateAccount, + ) + ) { + return true; + } + return false; +} + +/** + * Detects if the transaction is a dust payment transaction. + * + * @param transaction - The transaction to check. + * @param accountAddress - The Stellar address of the transaction owner. + * @returns Whether the transaction is a dust payment transaction. + */ +export function isDustPaymentTransaction( + transaction: Transaction, + accountAddress: string, +): boolean { + const operationTypes = transaction.transactionOperations; + if ( + operationTypes.some( + (operation) => + operation.type === StellarOperationType.Payment && + operation.destination === accountAddress && + operation.asset.isNative() && + operation.amount === DUST_XLM_AMOUNT, + ) + ) { + return true; + } + return false; +} + +/** + * Detects whether a Stellar operation credits the given account. + * + * A receive operation is one where `accountAddress` is the destination, + * regardless of who signed or sourced the transaction. + * + * @param operation - Stellar operation to evaluate. + * @param accountAddress - Stellar address that may receive funds from the operation. + * @returns Whether the operation credits `accountAddress`. + */ +export function isReceiveOperation( + operation: Operation, + accountAddress: string, +): operation is + | Operation.Payment + | Operation.CreateAccount + | Operation.PathPaymentStrictReceive + | Operation.PathPaymentStrictSend { + return ( + // Payment operation that sends to the account, regardless the source account. + (operation.type === StellarOperationType.Payment && + operation.destination === accountAddress) || + // Create account operation that creates the account, regardless the source account. + (operation.type === StellarOperationType.CreateAccount && + operation.destination === accountAddress) || + // Path payment strict receive operation that credits the account, regardless of the source account. + (operation.type === StellarOperationType.PathPaymentStrictReceive && + operation.destination === accountAddress) || + // Path payment strict send operation that credits the account, regardless of the source account. + (operation.type === StellarOperationType.PathPaymentStrictSend && + operation.destination === accountAddress) + ); +} + +/** + * Detects whether a transaction includes any operation that credits the given account. + * + * @param transaction - Wrapped on-chain transaction. + * @param accountAddress - Stellar address to check for incoming credits. + * @returns Whether at least one operation in the transaction credits `accountAddress`. + */ +export function isReceiveTransaction( + transaction: Transaction, + accountAddress: string, +): boolean { + const operationTypes = transaction.transactionOperations; + if ( + operationTypes.some((operation) => + isReceiveOperation(operation, accountAddress), + ) + ) { + return true; + } + return false; +} + +/** + * Detects if the transaction status is pending. + * + * @param status - The transaction status to check. + * @returns Whether the transaction status is pending. + */ +export function isPendingTransactionStatus( + status: KeyringTransaction['status'], +): boolean { + return ( + status === `${TransactionStatus.Submitted}` || + status === `${TransactionStatus.Unconfirmed}` + ); +} + +/** + * Detects if the transaction status is terminal (confirmed or failed). + * + * @param status - The transaction status to check. + * @returns Whether the transaction status is completed. + */ +export function isCompletedTransactionStatus( + status: KeyringTransaction['status'], +): boolean { + return ( + status === `${TransactionStatus.Failed}` || + status === `${TransactionStatus.Confirmed}` + ); +} diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts index 88729ef7..e43dbab9 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.test.ts @@ -9,6 +9,7 @@ import { normalizeAmount, tokenToFiat, toSmallestUnit, + removeTrailingZeros, } from './currency'; describe('toSmallestUnit', () => { @@ -50,6 +51,17 @@ describe('toDisplayBalance', () => { }); }); +describe('removeTrailingZeros', () => { + it('removes trailing zeros from a decimal number', () => { + expect(removeTrailingZeros('12.345000')).toBe('12.345'); + }); + + it('does not strip zeros from integer strings', () => { + expect(removeTrailingZeros('10')).toBe('10'); + expect(removeTrailingZeros('0')).toBe('0'); + }); +}); + describe('toSmallestUnit and normalizeAmount', () => { it('roundtrips for representative values', () => { const human = new BigNumber('12.3456789'); diff --git a/merged-packages/stellar-wallet-snap/src/utils/currency.ts b/merged-packages/stellar-wallet-snap/src/utils/currency.ts index f4b2a2a4..4262d0b4 100644 --- a/merged-packages/stellar-wallet-snap/src/utils/currency.ts +++ b/merged-packages/stellar-wallet-snap/src/utils/currency.ts @@ -69,10 +69,20 @@ export function toDisplayBalance( const fixed = normalizeAmount(amountInSmallestUnit, decimalPlaces).toFixed( decimalPlaces, ); - if (!fixed.includes('.')) { - return fixed; + return removeTrailingZeros(fixed); +} + +/** + * Removes trailing zeros from a decimal number. + * + * @param amount - The amount to remove trailing zeros from. + * @returns The amount with trailing zeros removed. + */ +export function removeTrailingZeros(amount: string): string { + if (!amount.includes('.')) { + return amount; } - const trimmed = fixed.replace(/0+$/u, '').replace(/\.$/u, ''); + const trimmed = amount.replace(/0+$/u, '').replace(/\.$/u, ''); return trimmed === '' ? '0' : trimmed; } From 6f9d9e9de1fc0b91ee445fe0756316fe2e52ce4a Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Wed, 10 Jun 2026 16:41:00 +0200 Subject: [PATCH 286/384] feat: refresh transaction on confirm --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../clientRequest/changeTrustOpt.test.ts | 83 +++++++++++++++- .../handlers/clientRequest/changeTrustOpt.ts | 79 ++++++++++++++- .../clientRequest/confirmSend.test.ts | 95 +++++++++++++++++++ .../src/handlers/clientRequest/confirmSend.ts | 61 ++++++++++-- .../clientRequest/transactionRefresh.ts | 27 ++++++ 6 files changed, 330 insertions(+), 17 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/handlers/clientRequest/transactionRefresh.ts diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index f594a37d..6d81a8d2 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "+11EcYy1qTviJ3cGJ/8lcvNEVnSaoYVkEb9NKcqpCJ0=", + "shasum": "G5ECx/cQfbsedMkjhx4L71VwAW243WhF5uQCSAOJX0E=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts index e8e7e627..bbfb4a9e 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts @@ -1,5 +1,6 @@ import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import { UserRejectedRequestError } from '@metamask/snaps-sdk'; +import { Networks } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; import { @@ -31,8 +32,14 @@ import { mockOnChainAccountService, } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; import { TransactionService } from '../../services/transaction'; -import { createMockTransactionService } from '../../services/transaction/__mocks__/transaction.fixtures'; -import { TrustlineNotFoundException } from '../../services/transaction/exceptions'; +import { + buildMockClassicTransaction, + createMockTransactionService, +} from '../../services/transaction/__mocks__/transaction.fixtures'; +import { + TransactionValidationException, + TrustlineNotFoundException, +} from '../../services/transaction/exceptions'; import { KeyringTransactionType } from '../../services/transaction/KeyringTransactionBuilder'; import { WalletService } from '../../services/wallet'; import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; @@ -244,6 +251,7 @@ describe('ChangeTrustOptHandler', () => { scope, limit: '1.5', }); + expect(createValidatedChangeTrustTransaction).toHaveBeenCalledTimes(2); expect(renderConfirmationDialog).toHaveBeenCalledWith( expect.objectContaining({ scope, @@ -371,6 +379,7 @@ describe('ChangeTrustOptHandler', () => { scope, limit: '0', }); + expect(createValidatedChangeTrustTransaction).toHaveBeenCalledTimes(2); expect(renderConfirmationDialog).toHaveBeenCalledWith( expect.objectContaining({ interfaceKey: ConfirmationInterfaceKey.ChangeTrustlineOptOut, @@ -439,6 +448,76 @@ describe('ChangeTrustOptHandler', () => { ).not.toHaveBeenCalled(); }); + it('throws when refreshed transaction fee is higher than confirmed fee', async () => { + const { + handler, + wallet, + createValidatedChangeTrustTransaction, + signTransactionSpy, + sendTransaction, + networkSendSpy, + savePendingKeyringTransaction, + } = setup(); + const confirmedTransaction = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + asset: { + code: 'USDC', + issuer: + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, + limit: '1.5', + }, + }, + ], + { + networkPassphrase: Networks.PUBLIC, + source: { + accountId: wallet.address, + sequence: '1', + }, + baseFeePerOperation: '100', + }, + ); + const higherFeeTransaction = buildMockClassicTransaction( + [ + { + type: 'changeTrust', + params: { + asset: { + code: 'USDC', + issuer: + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, + limit: '1.5', + }, + }, + ], + { + networkPassphrase: Networks.PUBLIC, + source: { + accountId: wallet.address, + sequence: '2', + }, + baseFeePerOperation: '200', + }, + ); + createValidatedChangeTrustTransaction + .mockResolvedValueOnce(confirmedTransaction) + .mockResolvedValueOnce(higherFeeTransaction); + + await expect(handler.handle(addRequest)).rejects.toThrow( + TransactionValidationException, + ); + + expect(signTransactionSpy).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + expect(networkSendSpy).not.toHaveBeenCalled(); + expect(savePendingKeyringTransaction).not.toHaveBeenCalled(); + }); + it('continues successfully when saving pending transaction fails', async () => { const { handler, transactionRepositorySaveManySpy, sendTransaction } = setup(); diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts index 4b652df5..9936efd5 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts @@ -10,6 +10,7 @@ import { ChangeTrustOptJsonRpcRequestStruct, ChangeTrustOptJsonRpcResponseStruct, } from './api'; +import { assertRefreshedTransactionFeeNotHigher } from './transactionRefresh'; import { type AccountResolver, type ResolvedActivatedAccount, @@ -95,7 +96,7 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< request: ChangeTrustOptJsonRpcRequest, ): Promise { const { scope, assetId, action } = request.params; - const { wallet, account, onChainAccount } = resolvedAccount; + const { account, onChainAccount } = resolvedAccount; // Quit early if add is redundant (classic line already present with limit > 0) if (action === ChangeTrustOptAction.Add) { @@ -157,13 +158,33 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< chainIdCaip: scope, }); - wallet.signTransaction(transaction); + const refreshed = await this.#refreshTransactionAfterConfirmation({ + request, + confirmedTransaction: transaction, + action, + limit: limitForTx, + }); + + if (refreshed === null) { + // The requested opt-in became redundant while the dialog was open; finish without submitting. + return { + status: true, + }; + } + + const { + wallet: refreshedWallet, + onChainAccount: refreshedOnChainAccount, + transaction: refreshedTransaction, + } = refreshed; + + refreshedWallet.signTransaction(refreshedTransaction); const transactionId = await this.#transactionService.sendTransaction({ - wallet, - onChainAccount, + wallet: refreshedWallet, + onChainAccount: refreshedOnChainAccount, scope, - transaction, + transaction: refreshedTransaction, }); await this.#transactionService.savePendingKeyringTransactionSafe({ @@ -195,6 +216,54 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< }; } + async #refreshTransactionAfterConfirmation(params: { + request: ChangeTrustOptJsonRpcRequest; + confirmedTransaction: Transaction; + action: ChangeTrustOptAction; + limit?: string; + }): Promise<{ + wallet: ResolvedActivatedAccount['wallet']; + onChainAccount: ResolvedActivatedAccount['onChainAccount']; + transaction: Transaction; + } | null> { + const { request, confirmedTransaction, action, limit } = params; + const { assetId } = request.params; + // Resolve again after the user confirms so sequence, balances, and fees are fresh before signing. + // sendTransaction still handles txBadSeq races that happen after this refresh. + const { wallet, onChainAccount } = await this.resolveAccount(request); + + if (action === ChangeTrustOptAction.Add) { + const asset = onChainAccount.getAsset(assetId); + if (asset?.limit?.gt(0)) { + return null; + } + } + + if ( + action === ChangeTrustOptAction.Delete && + !onChainAccount.hasAsset(assetId) + ) { + throw new TrustlineNotFoundException(assetId, onChainAccount.accountId); + } + + const refreshedTransaction = await this.#createTransaction({ + request, + onChainAccount, + limit, + }); + + assertRefreshedTransactionFeeNotHigher({ + confirmedTransaction, + refreshedTransaction, + }); + + return { + wallet, + onChainAccount, + transaction: refreshedTransaction, + }; + } + async #confirmChangeTrustOpt(params: { request: ChangeTrustOptJsonRpcRequest; account: StellarKeyringAccount; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts index 2574f5fa..4b935d2f 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts @@ -364,6 +364,101 @@ describe('ConfirmSendHandler', () => { expect(scheduleBackgroundEvent).not.toHaveBeenCalled(); }); + it('rebuilds the transaction after confirmation before signing', async () => { + const { + handler, + onChainAccount, + wallet, + transaction, + createValidatedSendTransaction, + signTransactionSpy, + sendTransaction, + } = setup(); + const refreshedTransaction = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: destinationAddress, + asset: { + code: 'USDC', + issuer: + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, + amount: '1', + }, + }, + ], + { + networkPassphrase: Networks.PUBLIC, + source: { + accountId: wallet.address, + sequence: '2', + }, + }, + ); + createValidatedSendTransaction + .mockResolvedValueOnce(transaction) + .mockResolvedValueOnce(refreshedTransaction); + + await handler.handle(baseRequest()); + + expect(createValidatedSendTransaction).toHaveBeenCalledTimes(2); + expect(signTransactionSpy).toHaveBeenCalledWith(refreshedTransaction); + expect(sendTransaction).toHaveBeenCalledWith({ + wallet, + onChainAccount, + scope, + transaction: refreshedTransaction, + pollTransaction: false, + }); + }); + + it('returns invalid when refreshed transaction fee is higher than confirmed fee', async () => { + const { + handler, + wallet, + transaction, + createValidatedSendTransaction, + signTransactionSpy, + sendTransaction, + } = setup(); + const higherFeeTransaction = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: destinationAddress, + asset: { + code: 'USDC', + issuer: + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }, + amount: '1', + }, + }, + ], + { + networkPassphrase: Networks.PUBLIC, + source: { + accountId: wallet.address, + sequence: '2', + }, + baseFeePerOperation: '300', + }, + ); + createValidatedSendTransaction + .mockResolvedValueOnce(transaction) + .mockResolvedValueOnce(higherFeeTransaction); + + expect(await handler.handle(baseRequest())).toStrictEqual({ + valid: false, + errors: [{ code: MultiChainSendErrorCodes.Invalid }], + }); + expect(signTransactionSpy).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + it('returns insufficient balance when createValidatedSendTransaction throws InsufficientBalanceException', async () => { const { handler, createValidatedSendTransaction } = setup(); createValidatedSendTransaction.mockRejectedValueOnce( diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts index 6f5010a9..1cec91c9 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts @@ -11,6 +11,7 @@ import { ConfirmSendJsonRpcResponseStruct, MultiChainSendErrorCodes, } from './api'; +import { assertRefreshedTransactionFeeNotHigher } from './transactionRefresh'; import type { KnownCaip2ChainId } from '../../api'; import { METAMASK_ORIGIN } from '../../constants'; import type { StellarKeyringAccount } from '../../services/account'; @@ -109,11 +110,7 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< request: ConfirmSendJsonRpcRequest, ): Promise { try { - const { - wallet, - onChainAccount, - account: stellarKeyringAccount, - } = resolved; + const { onChainAccount, account: stellarKeyringAccount } = resolved; const { amount, toAddress, assetId, scope } = request.params; const assetMetadata = await this.#assetMetadataService.resolve(assetId); const { decimals, symbol } = assetMetadata.units[0]; @@ -169,13 +166,23 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< chainIdCaip: scope, }); - wallet.signTransaction(transaction); + const { + wallet: refreshedWallet, + onChainAccount: refreshedOnChainAccount, + transaction: refreshedTransaction, + } = await this.#refreshTransactionAfterConfirmation({ + request, + confirmedTransaction: transaction, + amount: amountInSmallestUnit, + }); + + refreshedWallet.signTransaction(refreshedTransaction); const transactionId = await this.#transactionService.sendTransaction({ - wallet, - onChainAccount, + wallet: refreshedWallet, + onChainAccount: refreshedOnChainAccount, scope, - transaction, + transaction: refreshedTransaction, pollTransaction: false, }); @@ -237,6 +244,42 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< } } + async #refreshTransactionAfterConfirmation(params: { + request: ConfirmSendJsonRpcRequest; + confirmedTransaction: Transaction; + amount: BigNumber; + }): Promise<{ + wallet: ResolvedActivatedAccount['wallet']; + onChainAccount: ResolvedActivatedAccount['onChainAccount']; + transaction: Transaction; + }> { + const { request, confirmedTransaction, amount } = params; + const { assetId, toAddress, scope } = request.params; + // Resolve again after the user confirms so sequence, balances, and fees are fresh before signing. + // sendTransaction still handles txBadSeq races that happen after this refresh. + const { wallet, onChainAccount } = await this.resolveAccount(request); + + const refreshedTransaction = + await this.#transactionService.createValidatedSendTransaction({ + onChainAccount, + scope, + assetId, + amount, + destination: toAddress, + }); + + assertRefreshedTransactionFeeNotHigher({ + confirmedTransaction, + refreshedTransaction, + }); + + return { + wallet, + onChainAccount, + transaction: refreshedTransaction, + }; + } + async #confirmSend(params: { request: ConfirmSendJsonRpcRequest; account: StellarKeyringAccount; diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/transactionRefresh.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/transactionRefresh.ts new file mode 100644 index 00000000..34b2a337 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/transactionRefresh.ts @@ -0,0 +1,27 @@ +import { + type Transaction, + TransactionValidationException, +} from '../../services/transaction'; + +/** + * Guards the user-approved fee during submit-time transaction refresh. + * + * Fee is visible in the confirmation dialog, so we fail closed instead of + * silently signing a refreshed transaction with a higher fee than the user saw. + * + * @param params - Confirmed and refreshed transaction pair. + * @param params.confirmedTransaction - Transaction shown in the confirmation dialog. + * @param params.refreshedTransaction - Transaction rebuilt after confirmation from fresh on-chain state. + * @throws {TransactionValidationException} When the refreshed fee is higher than the confirmed fee. + */ +export function assertRefreshedTransactionFeeNotHigher(params: { + confirmedTransaction: Transaction; + refreshedTransaction: Transaction; +}): void { + const { confirmedTransaction, refreshedTransaction } = params; + if (refreshedTransaction.totalFee.gt(confirmedTransaction.totalFee)) { + throw new TransactionValidationException( + 'Refreshed transaction fee exceeds confirmed fee', + ); + } +} From 1cc6308452d22ba7ec0ec6bfdcbb397c74b89dab Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Thu, 11 Jun 2026 14:54:04 +0200 Subject: [PATCH 287/384] fix: fix comments --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../clientRequest/changeTrustOpt.test.ts | 50 ++++++++++++ .../handlers/clientRequest/changeTrustOpt.ts | 80 +++++++++++-------- .../src/handlers/clientRequest/confirmSend.ts | 4 +- .../{transactionRefresh.ts => utils.ts} | 4 + 5 files changed, 105 insertions(+), 35 deletions(-) rename merged-packages/stellar-wallet-snap/src/handlers/clientRequest/{transactionRefresh.ts => utils.ts} (77%) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 6d81a8d2..63706861 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "G5ECx/cQfbsedMkjhx4L71VwAW243WhF5uQCSAOJX0E=", + "shasum": "slrmncu1UubcIJIGl67wpjElTMW0gIKHhxLfSofq5F4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts index bbfb4a9e..0acd6a82 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts @@ -333,6 +333,56 @@ describe('ChangeTrustOptHandler', () => { ).not.toHaveBeenCalled(); }); + it('returns success without submitting when the trustline appears between confirmation and submission', async () => { + const { + handler, + wallet, + resolveOnChainAccountSpy, + onChainAccount, + createValidatedChangeTrustTransaction, + renderConfirmationDialog, + sendTransaction, + savePendingKeyringTransaction, + signTransactionSpy, + } = setup(); + + const rawAccountWithTrustline = createMockAccountWithBalances( + wallet.address, + '1', + { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 10, + assets: [trustlineAsset], + }, + ); + const onChainAccountWithTrustline = new OnChainAccount( + rawAccountWithTrustline, + scope, + horizonSource(rawAccountWithTrustline, scope), + ); + + // Preflight resolves an account without the trustline so the dialog is shown, + // but the post-confirmation refresh re-resolves an account that now has the + // trustline, making the opt-in redundant. + resolveOnChainAccountSpy + .mockReset() + .mockResolvedValueOnce(onChainAccount) + .mockResolvedValue(onChainAccountWithTrustline); + + const result = await handler.handle(addRequest); + + expect(result).toStrictEqual({ status: true }); + expect(renderConfirmationDialog).toHaveBeenCalledTimes(1); + // Only the pre-dialog build runs; the refresh bails out before rebuilding. + expect(createValidatedChangeTrustTransaction).toHaveBeenCalledTimes(1); + expect(signTransactionSpy).not.toHaveBeenCalled(); + expect(sendTransaction).not.toHaveBeenCalled(); + expect(savePendingKeyringTransaction).not.toHaveBeenCalled(); + expect( + TrackTransactionHandler.scheduleBackgroundEvent, + ).not.toHaveBeenCalled(); + }); + it('throws TrustlineNotFoundException for opt-out when trustline does not exist', async () => { const { handler, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts index 9936efd5..888f058b 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.ts @@ -10,7 +10,7 @@ import { ChangeTrustOptJsonRpcRequestStruct, ChangeTrustOptJsonRpcResponseStruct, } from './api'; -import { assertRefreshedTransactionFeeNotHigher } from './transactionRefresh'; +import { assertRefreshedTransactionFeeNotHigher } from './utils'; import { type AccountResolver, type ResolvedActivatedAccount, @@ -98,22 +98,11 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< const { scope, assetId, action } = request.params; const { account, onChainAccount } = resolvedAccount; - // Quit early if add is redundant (classic line already present with limit > 0) - if (action === ChangeTrustOptAction.Add) { - const asset = onChainAccount.getAsset(assetId); - if (asset?.limit?.gt(0)) { - return { - status: true, - }; - } - } - - // Quit early if the trustline does not exist for delete - if ( - action === ChangeTrustOptAction.Delete && - !onChainAccount.hasAsset(assetId) - ) { - throw new TrustlineNotFoundException(assetId, onChainAccount.accountId); + // Quit early if the opt-in is already redundant (throws for a missing opt-out trustline). + if (!this.#isChangeTrustOpNeeded(onChainAccount, request)) { + return { + status: true, + }; } // Safeguard to ensure we use the correct limit for delete @@ -161,7 +150,6 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< const refreshed = await this.#refreshTransactionAfterConfirmation({ request, confirmedTransaction: transaction, - action, limit: limitForTx, }); @@ -216,34 +204,58 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< }; } + /** + * Whether the change-trust operation still needs to run for the given on-chain state. + * + * Used both before showing the dialog and after confirmation (against freshly + * resolved state), so a redundant opt-in is short-circuited and a missing opt-out + * trustline is rejected consistently. + * + * @param onChainAccount - The on-chain account to evaluate. + * @param request - The change-trust request. + * @returns `false` when an opt-in is redundant (line already present with limit > 0), otherwise `true`. + * @throws {TrustlineNotFoundException} If an opt-out targets a trustline that does not exist. + */ + #isChangeTrustOpNeeded( + onChainAccount: OnChainAccount, + request: ChangeTrustOptJsonRpcRequest, + ): boolean { + const { assetId, action } = request.params; + + if (action === ChangeTrustOptAction.Add) { + const asset = onChainAccount.getAsset(assetId); + if (asset?.limit?.gt(0)) { + return false; + } + } + + if ( + action === ChangeTrustOptAction.Delete && + !onChainAccount.hasAsset(assetId) + ) { + throw new TrustlineNotFoundException(assetId, onChainAccount.accountId); + } + + return true; + } + async #refreshTransactionAfterConfirmation(params: { request: ChangeTrustOptJsonRpcRequest; confirmedTransaction: Transaction; - action: ChangeTrustOptAction; limit?: string; }): Promise<{ wallet: ResolvedActivatedAccount['wallet']; onChainAccount: ResolvedActivatedAccount['onChainAccount']; transaction: Transaction; } | null> { - const { request, confirmedTransaction, action, limit } = params; - const { assetId } = request.params; + const { request, confirmedTransaction, limit } = params; // Resolve again after the user confirms so sequence, balances, and fees are fresh before signing. // sendTransaction still handles txBadSeq races that happen after this refresh. const { wallet, onChainAccount } = await this.resolveAccount(request); - if (action === ChangeTrustOptAction.Add) { - const asset = onChainAccount.getAsset(assetId); - if (asset?.limit?.gt(0)) { - return null; - } - } - - if ( - action === ChangeTrustOptAction.Delete && - !onChainAccount.hasAsset(assetId) - ) { - throw new TrustlineNotFoundException(assetId, onChainAccount.accountId); + // The opt-in may have become redundant while the dialog was open (throws for a missing opt-out trustline). + if (!this.#isChangeTrustOpNeeded(onChainAccount, request)) { + return null; } const refreshedTransaction = await this.#createTransaction({ @@ -252,6 +264,8 @@ export class ChangeTrustOptHandler extends BaseClientRequestHandler< limit, }); + // Reject if the refreshed fee is higher than what the user approved, so we + // never sign a transaction that differs from what was shown on the confirmation screen. assertRefreshedTransactionFeeNotHigher({ confirmedTransaction, refreshedTransaction, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts index 1cec91c9..3d740f6c 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.ts @@ -11,7 +11,7 @@ import { ConfirmSendJsonRpcResponseStruct, MultiChainSendErrorCodes, } from './api'; -import { assertRefreshedTransactionFeeNotHigher } from './transactionRefresh'; +import { assertRefreshedTransactionFeeNotHigher } from './utils'; import type { KnownCaip2ChainId } from '../../api'; import { METAMASK_ORIGIN } from '../../constants'; import type { StellarKeyringAccount } from '../../services/account'; @@ -268,6 +268,8 @@ export class ConfirmSendHandler extends BaseClientRequestHandler< destination: toAddress, }); + // Reject if the refreshed fee is higher than what the user approved, so we + // never sign a transaction that differs from what was shown on the confirmation screen. assertRefreshedTransactionFeeNotHigher({ confirmedTransaction, refreshedTransaction, diff --git a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/transactionRefresh.ts b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/utils.ts similarity index 77% rename from merged-packages/stellar-wallet-snap/src/handlers/clientRequest/transactionRefresh.ts rename to merged-packages/stellar-wallet-snap/src/handlers/clientRequest/utils.ts index 34b2a337..f8ac502a 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/transactionRefresh.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/clientRequest/utils.ts @@ -18,6 +18,10 @@ export function assertRefreshedTransactionFeeNotHigher(params: { confirmedTransaction: Transaction; refreshedTransaction: Transaction; }): void { + // We only check the fee here, not the operations. That's fine for send and + // change-trust: the rebuild keeps the same asset, amount and destination from + // the request, so the only thing that can change is payment vs createAccount + // (when the destination gets funded/unfunded), and both move the same funds. const { confirmedTransaction, refreshedTransaction } = params; if (refreshedTransaction.totalFee.gt(confirmedTransaction.totalFee)) { throw new TransactionValidationException( From e5be1f7a3900687b45ec5aba1afbf174584e9846 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Thu, 11 Jun 2026 15:39:44 +0200 Subject: [PATCH 288/384] refactor: centralize confirmation alert priority into a single helper --- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../components/ConfirmationAlerts.test.tsx | 107 ++++++++++++++++++ .../components/ConfirmationAlerts.tsx | 56 +++++++++ .../src/ui/confirmation/components/index.ts | 1 + .../src/ui/confirmation/utils.test.ts | 78 +++++++++++++ .../src/ui/confirmation/utils.ts | 43 +++++++ .../ConfirmSendTransaction.tsx | 21 +--- .../ConfirmSignChangeTrustOptIn.tsx | 22 +--- .../ConfirmSignChangeTrustOptOut.tsx | 22 +--- 9 files changed, 298 insertions(+), 54 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.test.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.tsx diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index f594a37d..29936332 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "+11EcYy1qTviJ3cGJ/8lcvNEVnSaoYVkEb9NKcqpCJ0=", + "shasum": "ZR254f1e6nsnVLhA/6JY7NdKrl67zhGPN8Lj2kv1OQ8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.test.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.test.tsx new file mode 100644 index 00000000..209c752c --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.test.tsx @@ -0,0 +1,107 @@ +import type { + ComponentOrElement, + GetPreferencesResult, +} from '@metamask/snaps-sdk'; + +import { ConfirmationAlerts } from './ConfirmationAlerts'; +import { TransactionScanValidationType } from '../../../services/transaction-scan'; +import { FetchStatus } from '../api'; + +const preferences: GetPreferencesResult = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + useExternalPricingData: true, + showTestnets: true, +}; + +const maliciousScan = { + status: 'SUCCESS' as const, + estimatedChanges: { assets: [] }, + validation: { + type: TransactionScanValidationType.Malicious, + reason: 'known_attacker', + description: null, + }, + error: null, +}; + +function getType(component: ComponentOrElement | null): string | undefined { + return typeof component === 'object' && component !== null + ? component.type + : undefined; +} + +function getProps( + component: ComponentOrElement | null, +): Record | undefined { + const candidate = component as { props?: Record }; + return typeof component === 'object' && component !== null + ? candidate.props + : undefined; +} + +describe('ConfirmationAlerts', () => { + it('renders the validation banner when re-validation reports an error', () => { + const component = ConfirmationAlerts({ + preferences, + scan: null, + scanFetchStatus: FetchStatus.Fetched, + transactionsFetchStatus: FetchStatus.Error, + }); + + expect(getType(component)).toBe('Banner'); + expect(getProps(component)).toMatchObject({ + severity: 'danger', + title: 'Transaction is no longer valid', + }); + }); + + it('renders the scan banner when scan is enabled and there is no validation error', () => { + const component = ConfirmationAlerts({ + preferences, + scan: maliciousScan, + scanFetchStatus: FetchStatus.Fetched, + transactionsFetchStatus: FetchStatus.Fetched, + }); + + expect(getType(component)).toBe('Banner'); + expect(getProps(component)).toMatchObject({ + title: 'This is a deceptive request', + }); + }); + + it('renders nothing when scan is disabled and there is no validation error', () => { + const component = ConfirmationAlerts({ + preferences: { + ...preferences, + useSecurityAlerts: false, + simulateOnChainActions: false, + }, + scan: null, + scanFetchStatus: FetchStatus.Fetched, + transactionsFetchStatus: FetchStatus.Fetched, + }); + + expect(component).toBeNull(); + }); + + it('shows the validation banner (not the scan banner) when both would apply', () => { + const component = ConfirmationAlerts({ + preferences, + scan: maliciousScan, + scanFetchStatus: FetchStatus.Fetched, + transactionsFetchStatus: FetchStatus.Error, + }); + + expect(getProps(component)).toMatchObject({ + title: 'Transaction is no longer valid', + }); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.tsx new file mode 100644 index 00000000..a974642b --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationAlerts.tsx @@ -0,0 +1,56 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; + +import { TransactionAlert } from './TransactionAlert'; +import { TransactionValidationAlert } from './TransactionValidationAlert'; +import type { ConfirmationBaseProps, FetchStatus } from '../api'; +import { ConfirmationBanner, resolveConfirmationBanner } from '../utils'; + +type ConfirmationAlertsProps = { + preferences: ConfirmationBaseProps['preferences']; + scan: ConfirmationBaseProps['scan']; + scanFetchStatus: FetchStatus; + transactionsFetchStatus: FetchStatus; +}; + +/** + * Renders the single top-of-screen confirmation banner. + * + * Centralizes the validation-error vs. Blockaid-scan priority (see + * {@link resolveConfirmationBanner}) so the views never stack both banners and + * the rule lives in one place. + * + * @param props - The confirmation alert state. + * @param props.preferences - User preferences controlling scan behavior. + * @param props.scan - Latest transaction scan result. + * @param props.scanFetchStatus - Latest transaction scan fetch status. + * @param props.transactionsFetchStatus - Latest transaction re-validation fetch status. + * @returns The banner to render, or `null` when none applies. + */ +export const ConfirmationAlerts = ({ + preferences, + scan, + scanFetchStatus, + transactionsFetchStatus, +}: ConfirmationAlertsProps): ComponentOrElement | null => { + switch (resolveConfirmationBanner({ preferences, transactionsFetchStatus })) { + case ConfirmationBanner.TransactionValidation: + return ( + + ); + case ConfirmationBanner.TransactionScan: + return ( + + ); + case ConfirmationBanner.None: + default: + return null; + } +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts index 2f03a431..204010ef 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts @@ -3,3 +3,4 @@ export * from './AssetIcon'; export * from './Asset'; export * from './TransactionAlert'; export * from './TransactionValidationAlert'; +export * from './ConfirmationAlerts'; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts index df7d12f0..e3edf808 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts @@ -2,8 +2,10 @@ import type { GetPreferencesResult } from '@metamask/snaps-sdk'; import { FetchStatus } from './api'; import { + ConfirmationBanner, isConfirmDisabledByScan, isConfirmDisabledByTransactionValidation, + resolveConfirmationBanner, } from './utils'; import { TransactionScanValidationType } from '../../services/transaction-scan'; @@ -117,4 +119,80 @@ describe('confirmation utils', () => { expect(isConfirmDisabledByTransactionValidation(undefined)).toBe(false); }); }); + + describe('resolveConfirmationBanner', () => { + it('prioritizes the validation banner when re-validation reports an error', () => { + expect( + resolveConfirmationBanner({ + preferences, + transactionsFetchStatus: FetchStatus.Error, + }), + ).toBe(ConfirmationBanner.TransactionValidation); + }); + + it('prioritizes the validation banner even when scan is enabled', () => { + expect( + resolveConfirmationBanner({ + preferences: { + ...preferences, + useSecurityAlerts: true, + simulateOnChainActions: true, + }, + transactionsFetchStatus: FetchStatus.Error, + }), + ).toBe(ConfirmationBanner.TransactionValidation); + }); + + it('shows the scan banner when security alerts are enabled and there is no validation error', () => { + expect( + resolveConfirmationBanner({ + preferences: { + ...preferences, + useSecurityAlerts: true, + simulateOnChainActions: false, + }, + transactionsFetchStatus: FetchStatus.Fetched, + }), + ).toBe(ConfirmationBanner.TransactionScan); + }); + + it('shows the scan banner when simulation alerts are enabled', () => { + expect( + resolveConfirmationBanner({ + preferences: { + ...preferences, + useSecurityAlerts: false, + simulateOnChainActions: true, + }, + transactionsFetchStatus: FetchStatus.Initial, + }), + ).toBe(ConfirmationBanner.TransactionScan); + }); + + it('shows no banner when scan is disabled and there is no validation error', () => { + expect( + resolveConfirmationBanner({ + preferences: { + ...preferences, + useSecurityAlerts: false, + simulateOnChainActions: false, + }, + transactionsFetchStatus: FetchStatus.Fetched, + }), + ).toBe(ConfirmationBanner.None); + }); + + it('treats an undefined validation status as no validation error', () => { + expect( + resolveConfirmationBanner({ + preferences: { + ...preferences, + useSecurityAlerts: false, + simulateOnChainActions: false, + }, + transactionsFetchStatus: undefined, + }), + ).toBe(ConfirmationBanner.None); + }); + }); }); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts index 520a8abb..490fafa5 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts @@ -215,6 +215,49 @@ export function isConfirmDisabledByTransactionValidation( return transactionsFetchStatus === FetchStatus.Error; } +/** + * The single banner the confirmation screen may show at the top. + * + * The transaction-validation banner and the Blockaid scan banner are mutually + * exclusive: only one is ever rendered, and validation takes priority. + */ +export enum ConfirmationBanner { + None = 'none', + TransactionValidation = 'transaction-validation', + TransactionScan = 'transaction-scan', +} + +/** + * Resolves which top-of-screen banner the confirmation should display. + * + * Priority is explicit: a failed background re-validation (the transaction is no + * longer valid) outranks the Blockaid scan alert, so the two never stack. The + * scan banner is only considered when the user has security or simulation alerts + * enabled; the {@link TransactionAlert} component still decides its own content + * based on the scan result. + * + * @param params - Validation and scan-preference state. + * @param params.preferences - User preferences controlling scan behavior. + * @param params.transactionsFetchStatus - Latest transaction re-validation fetch status. + * @returns The single banner to render. + */ +export function resolveConfirmationBanner(params: { + preferences: GetPreferencesResult; + transactionsFetchStatus: FetchStatus | undefined; +}): ConfirmationBanner { + const { preferences, transactionsFetchStatus } = params; + + if (isConfirmDisabledByTransactionValidation(transactionsFetchStatus)) { + return ConfirmationBanner.TransactionValidation; + } + + if (hasEnabledTransactionScan(preferences)) { + return ConfirmationBanner.TransactionScan; + } + + return ConfirmationBanner.None; +} + /** * Display-friendly resolution of a Stellar operation `asset` reference. * Used by the confirmation UI to render assets and to look up prices. diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx index a6317db5..115c9d0a 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx @@ -27,19 +27,13 @@ import type { FeeData, } from '../../api'; import { FetchStatus } from '../../api'; -import { - Asset, - FeeRow, - TransactionAlert, - TransactionValidationAlert, -} from '../../components'; +import { Asset, ConfirmationAlerts, FeeRow } from '../../components'; import { getAccountExplorerUrl, getAccountName, getClassicAssetExplorerUrl, getNetworkName, getSepAssetExplorerUrl, - hasEnabledTransactionScan, isConfirmDisabledByScan, isConfirmDisabledByTransactionValidation, } from '../../utils'; @@ -94,19 +88,12 @@ export const ConfirmSendTransaction = ({ return ( - - {transactionsFetchStatus !== FetchStatus.Error && - hasEnabledTransactionScan(preferences) ? ( - - ) : null} {null} {t(`confirmation.transaction.title`)} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx index 75d18d74..2f71122e 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx @@ -26,17 +26,10 @@ import type { FeeData, } from '../../api'; import { FetchStatus } from '../../api'; -import { - Asset, - AssetIcon, - FeeRow, - TransactionAlert, - TransactionValidationAlert, -} from '../../components'; +import { Asset, AssetIcon, ConfirmationAlerts, FeeRow } from '../../components'; import { getAccountName, getClassicAssetExplorerUrl, - hasEnabledTransactionScan, isConfirmDisabledByScan, isConfirmDisabledByTransactionValidation, getNetworkName, @@ -76,19 +69,12 @@ export const ConfirmSignChangeTrustOptIn = ({ return ( - - {transactionsFetchStatus !== FetchStatus.Error && - hasEnabledTransactionScan(preferences) ? ( - - ) : null} {null} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx index bf101ccc..0fc632d9 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx @@ -26,17 +26,10 @@ import type { FeeData, } from '../../api'; import { FetchStatus } from '../../api'; -import { - Asset, - AssetIcon, - FeeRow, - TransactionAlert, - TransactionValidationAlert, -} from '../../components'; +import { Asset, AssetIcon, ConfirmationAlerts, FeeRow } from '../../components'; import { getAccountName, getClassicAssetExplorerUrl, - hasEnabledTransactionScan, isConfirmDisabledByScan, isConfirmDisabledByTransactionValidation, getNetworkName, @@ -76,19 +69,12 @@ export const ConfirmSignChangeTrustOptOut = ({ return ( - - {transactionsFetchStatus !== FetchStatus.Error && - hasEnabledTransactionScan(preferences) ? ( - - ) : null} {null} From bbd2326a781eba00742e09fbaf639dd4f1713e0c Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:53:34 +0800 Subject: [PATCH 289/384] chore: bump keyring API and keyring SDK (#102) ## Explanation This PR is bumping Keyring API to latest version which it doesnt impact to any current implementation and it works as usual ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- merged-packages/stellar-wallet-snap/package.json | 6 +++--- merged-packages/stellar-wallet-snap/snap.manifest.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/package.json b/merged-packages/stellar-wallet-snap/package.json index 34dc94f1..71377c1a 100644 --- a/merged-packages/stellar-wallet-snap/package.json +++ b/merged-packages/stellar-wallet-snap/package.json @@ -44,11 +44,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@metamask/key-tree": "^10.1.1", - "@metamask/keyring-api": "23.0.1", - "@metamask/keyring-snap-sdk": "^7.2.0", + "@metamask/keyring-api": "^23.3.0", + "@metamask/keyring-snap-sdk": "^9.0.2", "@metamask/snaps-cli": "^8.4.0", "@metamask/snaps-jest": "^10.1.0", - "@metamask/snaps-sdk": "^10.4.0", + "@metamask/snaps-sdk": "^11.1.0", "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.11.0", "@stellar/stellar-sdk": "^15.0.1", diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index f594a37d..11651872 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "+11EcYy1qTviJ3cGJ/8lcvNEVnSaoYVkEb9NKcqpCJ0=", + "shasum": "NlMPmtnFnTHyprM5seXIzNyqse2E16Y3m4+Cw3i2ynE=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -50,6 +50,6 @@ "scopes": ["stellar:pubnet"] } }, - "platformVersion": "10.3.0", + "platformVersion": "11.1.1", "manifestVersion": "0.1" } From 01f51f8b40b2e1f8b1c80987f28c6a8348878107 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Thu, 11 Jun 2026 17:16:33 +0200 Subject: [PATCH 290/384] feat: add friction for malicious transactions instead of blocking --- .../stellar-wallet-snap/locales/en.json | 22 ++- .../stellar-wallet-snap/locales/es.json | 22 ++- .../stellar-wallet-snap/messages.json | 22 ++- .../stellar-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/user-input/userInput.ts | 2 + .../src/ui/confirmation/api.ts | 7 + .../components/ConfirmationFooter.test.tsx | 89 +++++++++++ .../components/ConfirmationFooter.tsx | 56 +++++++ .../src/ui/confirmation/components/index.ts | 1 + .../src/ui/confirmation/controller.tsx | 83 ++--------- .../src/ui/confirmation/utils.test.ts | 102 +++++++------ .../src/ui/confirmation/utils.ts | 36 ++++- .../ConfirmSendTransaction.tsx | 32 ++-- .../ConfirmSignChangeTrustOptIn.tsx | 32 ++-- .../ConfirmSignChangeTrustOptOut.tsx | 32 ++-- .../ConfirmSignTransaction.tsx | 27 ++-- .../MaliciousAcknowledgementScreen.test.tsx | 101 +++++++++++++ .../MaliciousAcknowledgementScreen.tsx | 68 +++++++++ .../MaliciousAcknowledgement/constants.ts | 12 ++ .../MaliciousAcknowledgement/events.test.tsx | 140 ++++++++++++++++++ .../views/MaliciousAcknowledgement/events.tsx | 109 ++++++++++++++ .../src/ui/confirmation/views/render.tsx | 86 +++++++++++ 22 files changed, 876 insertions(+), 207 deletions(-) create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.test.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/MaliciousAcknowledgementScreen.test.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/MaliciousAcknowledgementScreen.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/constants.ts create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/events.test.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/events.tsx create mode 100644 merged-packages/stellar-wallet-snap/src/ui/confirmation/views/render.tsx diff --git a/merged-packages/stellar-wallet-snap/locales/en.json b/merged-packages/stellar-wallet-snap/locales/en.json index d8fabbbc..efb2bf57 100644 --- a/merged-packages/stellar-wallet-snap/locales/en.json +++ b/merged-packages/stellar-wallet-snap/locales/en.json @@ -43,6 +43,24 @@ "confirmation.cancelButton": { "message": "Cancel" }, + "confirmation.reviewAlertsButton": { + "message": "Review alert" + }, + "confirmation.maliciousAck.title": { + "message": "Malicious request" + }, + "confirmation.maliciousAck.description": { + "message": "If you confirm this request, you will probably lose your assets to a scammer." + }, + "confirmation.maliciousAck.checkbox": { + "message": "I have acknowledged the risk and still want to proceed" + }, + "confirmation.maliciousAck.proceed": { + "message": "Confirm" + }, + "confirmation.maliciousAck.back": { + "message": "Go back" + }, "confirmation.closeButton": { "message": "Close" }, @@ -155,10 +173,10 @@ "message": "Security Alerts found potential risk. Only continue if you trust this site and every address involved." }, "confirmation.validationErrorLearnMore": { - "message": "Learn more" + "message": "See details" }, "confirmation.validationErrorSecurityAdviced": { - "message": "Security advice by" + "message": "Powered by" }, "confirmation.transaction.accountmerge": { "message": "Merge account" diff --git a/merged-packages/stellar-wallet-snap/locales/es.json b/merged-packages/stellar-wallet-snap/locales/es.json index d8fabbbc..efb2bf57 100644 --- a/merged-packages/stellar-wallet-snap/locales/es.json +++ b/merged-packages/stellar-wallet-snap/locales/es.json @@ -43,6 +43,24 @@ "confirmation.cancelButton": { "message": "Cancel" }, + "confirmation.reviewAlertsButton": { + "message": "Review alert" + }, + "confirmation.maliciousAck.title": { + "message": "Malicious request" + }, + "confirmation.maliciousAck.description": { + "message": "If you confirm this request, you will probably lose your assets to a scammer." + }, + "confirmation.maliciousAck.checkbox": { + "message": "I have acknowledged the risk and still want to proceed" + }, + "confirmation.maliciousAck.proceed": { + "message": "Confirm" + }, + "confirmation.maliciousAck.back": { + "message": "Go back" + }, "confirmation.closeButton": { "message": "Close" }, @@ -155,10 +173,10 @@ "message": "Security Alerts found potential risk. Only continue if you trust this site and every address involved." }, "confirmation.validationErrorLearnMore": { - "message": "Learn more" + "message": "See details" }, "confirmation.validationErrorSecurityAdviced": { - "message": "Security advice by" + "message": "Powered by" }, "confirmation.transaction.accountmerge": { "message": "Merge account" diff --git a/merged-packages/stellar-wallet-snap/messages.json b/merged-packages/stellar-wallet-snap/messages.json index 75a7a1c9..e39efb5d 100644 --- a/merged-packages/stellar-wallet-snap/messages.json +++ b/merged-packages/stellar-wallet-snap/messages.json @@ -41,6 +41,24 @@ "confirmation.cancelButton": { "message": "Cancel" }, + "confirmation.reviewAlertsButton": { + "message": "Review alert" + }, + "confirmation.maliciousAck.title": { + "message": "Malicious request" + }, + "confirmation.maliciousAck.description": { + "message": "If you confirm this request, you will probably lose your assets to a scammer." + }, + "confirmation.maliciousAck.checkbox": { + "message": "I have acknowledged the risk and still want to proceed" + }, + "confirmation.maliciousAck.proceed": { + "message": "Confirm" + }, + "confirmation.maliciousAck.back": { + "message": "Go back" + }, "confirmation.closeButton": { "message": "Close" }, @@ -153,10 +171,10 @@ "message": "Security Alerts found potential risk. Only continue if you trust this site and every address involved." }, "confirmation.validationErrorLearnMore": { - "message": "Learn more" + "message": "See details" }, "confirmation.validationErrorSecurityAdviced": { - "message": "Security advice by" + "message": "Powered by" }, "confirmation.transaction.accountmerge": { "message": "Merge account" diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index f594a37d..8125dfdf 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "+11EcYy1qTviJ3cGJ/8lcvNEVnSaoYVkEb9NKcqpCJ0=", + "shasum": "eXMcfbZrI4vWl1+r062FKFoJHjJAJiv+NeXihkFaeZs=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts index 50a525b7..d55768ba 100644 --- a/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts +++ b/merged-packages/stellar-wallet-snap/src/handlers/user-input/userInput.ts @@ -7,6 +7,7 @@ import { createEventHandlers as createSignChangeTrustOptInEvents } from '../../u import { createEventHandlers as createSignChangeTrustOptOutEvents } from '../../ui/confirmation/views/ConfirmSignChangeTrustOptOut/events'; import { createEventHandlers as createSignMessageEvents } from '../../ui/confirmation/views/ConfirmSignMessage/events'; import { createEventHandlers as createSignTransactionEvents } from '../../ui/confirmation/views/ConfirmSignTransaction/events'; +import { createEventHandlers as createMaliciousAcknowledgementEvents } from '../../ui/confirmation/views/MaliciousAcknowledgement/events'; import { withCatchAndThrowSnapError, createPrefixedLogger, @@ -51,6 +52,7 @@ export class UserInputHandler { ...createSignChangeTrustOptInEvents(), ...createSignChangeTrustOptOutEvents(), ...createConfirmSendTransactionEvents(), + ...createMaliciousAcknowledgementEvents(), }; /** diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts index 1915b67d..cd6c8032 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/api.ts @@ -131,4 +131,11 @@ export type ConfirmationBaseProps = Partial & { networkImage: string | null; origin: string; feeData?: FeeData; + // Identifies the active view so shared event handlers (e.g. the malicious + // acknowledgement screen) can re-render the correct confirmation. + interfaceKey?: ConfirmationInterfaceKey; + // True while the malicious acknowledgement screen is shown over the confirmation. + acknowledgementScreen?: boolean; + // Whether the user has checked the "I acknowledge the risk" box on that screen. + acknowledged?: boolean; }; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.test.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.test.tsx new file mode 100644 index 00000000..ae6ee153 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.test.tsx @@ -0,0 +1,89 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; + +import { ConfirmationFooter } from './ConfirmationFooter'; +import { i18n } from '../../../utils'; +import { MaliciousAcknowledgementFormNames } from '../views/MaliciousAcknowledgement/constants'; + +const translate = i18n('en'); + +type Element = { + type?: string; + props?: Record; +}; + +/** + * Finds the first button element in the tree with the given name. + * + * @param node - The element to search. + * @param name - The button name to match. + * @returns The matching button props, or undefined. + */ +function findButton( + node: ComponentOrElement | null, + name: string, +): Record | undefined { + if (typeof node !== 'object' || node === null) { + return undefined; + } + const element = node as Element; + if (element.type === 'Button' && element.props?.name === name) { + return element.props; + } + const children = element.props?.children; + const list = Array.isArray(children) ? children : [children]; + for (const child of list) { + const found = findButton(child as ComponentOrElement | null, name); + if (found) { + return found; + } + } + return undefined; +} + +describe('ConfirmationFooter', () => { + const baseProps = { + locale: 'en', + cancelButtonName: 'cancel', + confirmButtonName: 'confirm', + }; + + it('renders the confirm button when acknowledgement is not required', () => { + const footer = ConfirmationFooter({ ...baseProps }); + + const confirm = findButton(footer, 'confirm'); + expect(confirm).toBeDefined(); + expect(confirm?.children).toBe(translate('confirmation.confirmButton')); + expect( + findButton(footer, MaliciousAcknowledgementFormNames.Review), + ).toBeUndefined(); + }); + + it('disables the confirm button when confirmDisabled is true', () => { + const footer = ConfirmationFooter({ ...baseProps, confirmDisabled: true }); + + expect(findButton(footer, 'confirm')?.disabled).toBe(true); + }); + + it('renders the review-alerts button when acknowledgement is required', () => { + const footer = ConfirmationFooter({ + ...baseProps, + requiresAcknowledgement: true, + }); + + const review = findButton(footer, MaliciousAcknowledgementFormNames.Review); + expect(review).toBeDefined(); + expect(review?.children).toBe(translate('confirmation.reviewAlertsButton')); + expect(findButton(footer, 'confirm')).toBeUndefined(); + }); + + it('always renders the cancel button', () => { + const footer = ConfirmationFooter({ + ...baseProps, + requiresAcknowledgement: true, + }); + + expect(findButton(footer, 'cancel')?.children).toBe( + translate('confirmation.cancelButton'), + ); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.tsx new file mode 100644 index 00000000..f319bf2d --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.tsx @@ -0,0 +1,56 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { Button, Footer } from '@metamask/snaps-sdk/jsx'; + +import type { Locale } from '../../../utils'; +import { i18n } from '../../../utils'; +import { MaliciousAcknowledgementFormNames } from '../views/MaliciousAcknowledgement/constants'; + +type ConfirmationFooterProps = { + locale: string; + cancelButtonName: string; + confirmButtonName: string; + confirmDisabled?: boolean; + // When true, the primary button becomes "Review alerts" and routes the user + // through the malicious acknowledgement screen instead of confirming directly. + requiresAcknowledgement?: boolean; +}; + +/** + * Shared confirmation footer (cancel + primary button). + * + * Centralizes the malicious-acknowledgement behavior: a malicious scan result + * swaps the primary button from "Confirm" to "Review alerts" rather than + * disabling it, so the user keeps a "proceed anyway" path behind friction. + * + * @param props - The footer props. + * @param props.locale - The active locale. + * @param props.cancelButtonName - Event name for the cancel button. + * @param props.confirmButtonName - Event name for the confirm button. + * @param props.confirmDisabled - Whether the confirm button is disabled. + * @param props.requiresAcknowledgement - Whether to show "Review alerts" instead of "Confirm". + * @returns The footer. + */ +export const ConfirmationFooter = ({ + locale, + cancelButtonName, + confirmButtonName, + confirmDisabled = false, + requiresAcknowledgement = false, +}: ConfirmationFooterProps): ComponentOrElement => { + const t = i18n(locale as Locale); + + return ( +
+ + {requiresAcknowledgement ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts index 2f03a431..176ce6c0 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/index.ts @@ -3,3 +3,4 @@ export * from './AssetIcon'; export * from './Asset'; export * from './TransactionAlert'; export * from './TransactionValidationAlert'; +export * from './ConfirmationFooter'; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 8c71aeca..c608b34d 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -1,10 +1,9 @@ -import type { ComponentOrElement, DialogResult } from '@metamask/snaps-sdk'; -import type { Json } from '@metamask/utils'; +import type { DialogResult } from '@metamask/snaps-sdk'; import { - ConfirmationInterfaceKey, - type ContextWithPrices, FetchStatus, + type ConfirmationInterfaceKey, + type ContextWithPrices, } from './api'; import { formatFeeData, @@ -29,32 +28,15 @@ import { updateInterfaceIfExists, } from '../../utils'; import { STELLAR_IMAGE } from '../images/icon'; -import type { ConfirmSendTransactionProps } from './views/ConfirmSendTransaction/ConfirmSendTransaction'; -import { ConfirmSendTransaction } from './views/ConfirmSendTransaction/ConfirmSendTransaction'; -import { - ConfirmSignAuthEntry, - type ConfirmSignAuthEntryProps, -} from './views/ConfirmSignAuthEntry/ConfirmSignAuthEntry'; -import type { ConfirmSignChangeTrustOptInProps } from './views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn'; -import { ConfirmSignChangeTrustOptIn } from './views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn'; -import type { ConfirmSignChangeTrustOptOutProps } from './views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut'; -import { ConfirmSignChangeTrustOptOut } from './views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut'; -import { - ConfirmSignMessage, - type ConfirmSignMessageProps, -} from './views/ConfirmSignMessage/ConfirmSignMessage'; import { - ConfirmSignTransaction, - type ConfirmSignTransactionProps, -} from './views/ConfirmSignTransaction/ConfirmSignTransaction'; + renderConfirmationView, + type ConfirmationViewProps, +} from './views/render'; import { ConfirmationContextRefresherKey, RefreshConfirmationContextHandler, } from '../../handlers/cronjob/refreshConfirmationContext'; -/** Serializable props bag stored on the interface and merged into each view. */ -type ConfirmationViewProps = Record; - type ConfirmationRenderOptions = { loadPrice?: boolean; scanTxn?: boolean; @@ -200,6 +182,9 @@ export class ConfirmationUXController { params.transactionValidationRequest !== undefined; const defaultContext = { + // Persisted so shared event handlers (malicious acknowledgement screen) + // can re-render the correct confirmation view. + interfaceKey, // if pricing is disabled, mark as fetched immediately tokenPricesFetchStatus: enablePricing ? FetchStatus.Fetching @@ -246,7 +231,7 @@ export class ConfirmationUXController { // 2. Initial render with loading skeleton (always show loading if pricing enabled) const id = await createInterface( - this.#renderConfirmationView(interfaceKey, context), + renderConfirmationView(interfaceKey, context), {}, ); const dialogPromise = showDialog(id); @@ -254,7 +239,7 @@ export class ConfirmationUXController { // 3. Update interface context after initial render (silently ignores if dismissed) const updated = await updateInterfaceIfExists( id, - this.#renderConfirmationView(interfaceKey, context), + renderConfirmationView(interfaceKey, context), context, ); @@ -315,52 +300,8 @@ export class ConfirmationUXController { const { interfaceId, updatedContext, interfaceKey } = params; await updateInterfaceIfExists( interfaceId, - this.#renderConfirmationView(interfaceKey, updatedContext), + renderConfirmationView(interfaceKey, updatedContext), updatedContext, ); } - - #renderConfirmationView( - interfaceKey: ConfirmationInterfaceKey, - context: ConfirmationViewProps, - ): ComponentOrElement { - switch (interfaceKey) { - case ConfirmationInterfaceKey.ChangeTrustlineOptIn: - return ( - - ); - case ConfirmationInterfaceKey.ChangeTrustlineOptOut: - return ( - - ); - case ConfirmationInterfaceKey.SignTransaction: - return ( - - ); - case ConfirmationInterfaceKey.SignMessage: - return ; - case ConfirmationInterfaceKey.SignAuthEntry: - return ( - - ); - case ConfirmationInterfaceKey.ConfirmSendTransaction: - return ( - - ); - default: { - const exhaustive: never = interfaceKey; - throw new Error(`Unsupported interface key: ${String(exhaustive)}`); - } - } - } } diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts index df7d12f0..74d0c053 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts @@ -4,9 +4,30 @@ import { FetchStatus } from './api'; import { isConfirmDisabledByScan, isConfirmDisabledByTransactionValidation, + requiresMaliciousAcknowledgement, } from './utils'; import { TransactionScanValidationType } from '../../services/transaction-scan'; +const maliciousScan = { + status: 'SUCCESS' as const, + estimatedChanges: { assets: [] }, + validation: { + type: TransactionScanValidationType.Malicious, + reason: 'known_attacker', + description: null, + }, + error: null, +}; + +const warningScan = { + ...maliciousScan, + validation: { + type: TransactionScanValidationType.Warning, + reason: 'suspicious_request', + description: null, + }, +}; + const preferences: GetPreferencesResult = { locale: 'en', currency: 'usd', @@ -25,71 +46,48 @@ describe('confirmation utils', () => { describe('isConfirmDisabledByScan', () => { it('disables confirm while scan is fetching', () => { expect( - isConfirmDisabledByScan({ - preferences, - scan: null, - scanFetchStatus: FetchStatus.Fetching, - }), + isConfirmDisabledByScan({ scanFetchStatus: FetchStatus.Fetching }), ).toBe(true); }); - it('disables confirm for malicious validation alerts', () => { + it('does not disable confirm once the scan has fetched', () => { expect( - isConfirmDisabledByScan({ - preferences, - scan: { - status: 'SUCCESS', - estimatedChanges: { assets: [] }, - validation: { - type: TransactionScanValidationType.Malicious, - reason: 'known_attacker', - description: null, - }, - error: null, - }, - scanFetchStatus: FetchStatus.Fetched, - }), + isConfirmDisabledByScan({ scanFetchStatus: FetchStatus.Fetched }), + ).toBe(false); + }); + + it('does not disable confirm for a malicious result (friction is added via acknowledgement instead)', () => { + expect( + isConfirmDisabledByScan({ scanFetchStatus: FetchStatus.Error }), + ).toBe(false); + }); + }); + + describe('requiresMaliciousAcknowledgement', () => { + it('requires acknowledgement for a malicious result when security alerts are enabled', () => { + expect( + requiresMaliciousAcknowledgement({ preferences, scan: maliciousScan }), ).toBe(true); }); - it('does not disable confirm for simulation errors', () => { + it('does not require acknowledgement when security alerts are disabled', () => { expect( - isConfirmDisabledByScan({ - preferences, - scan: { - status: 'ERROR', - estimatedChanges: { assets: [] }, - validation: null, - error: { - type: 'simulation', - code: 'insufficient_balance', - message: 'insufficient_balance', - }, - }, - scanFetchStatus: FetchStatus.Fetched, + requiresMaliciousAcknowledgement({ + preferences: { ...preferences, useSecurityAlerts: false }, + scan: maliciousScan, }), ).toBe(false); }); - it('does not disable confirm for malicious validation when security alerts are disabled', () => { + it('does not require acknowledgement for warning-level results', () => { expect( - isConfirmDisabledByScan({ - preferences: { - ...preferences, - useSecurityAlerts: false, - }, - scan: { - status: 'SUCCESS', - estimatedChanges: { assets: [] }, - validation: { - type: TransactionScanValidationType.Malicious, - reason: 'known_attacker', - description: null, - }, - error: null, - }, - scanFetchStatus: FetchStatus.Fetched, - }), + requiresMaliciousAcknowledgement({ preferences, scan: warningScan }), + ).toBe(false); + }); + + it('does not require acknowledgement when there is no scan result', () => { + expect( + requiresMaliciousAcknowledgement({ preferences, scan: null }), ).toBe(false); }); }); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts index 520a8abb..f6dce525 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/utils.ts @@ -171,22 +171,42 @@ export function formatFeeData( /** * Determines whether a transaction confirmation must be temporarily blocked by scan state. * - * @param params - Scan and preference state. - * @param params.preferences - User preferences controlling scan behavior. - * @param params.scan - Latest transaction scan result. + * @param params - Scan state. * @param params.scanFetchStatus - Latest transaction scan fetch status. * @returns True when the confirm action should be disabled. */ export function isConfirmDisabledByScan(params: { + scanFetchStatus: FetchStatus; +}): boolean { + // We only block while the scan is still running. A malicious result no longer + // disables confirm: per product/Blockaid, the user must always retain a + // "proceed anyway" path, gated behind the malicious acknowledgement screen + // (see {@link requiresMaliciousAcknowledgement}). + return params.scanFetchStatus === FetchStatus.Fetching; +} + +/** + * Determines whether a malicious scan result requires explicit user + * acknowledgement before the transaction can be confirmed. + * + * When true, the confirmation footer swaps its primary button to "Review alerts" + * and routes the user through the acknowledgement screen instead of confirming + * directly. Warning-level results intentionally do not require this (reduced + * friction): they show the banner only. + * + * @param params - Scan and preference state. + * @param params.preferences - User preferences controlling scan behavior. + * @param params.scan - Latest transaction scan result. + * @returns True when the user must acknowledge a malicious result to proceed. + */ +export function requiresMaliciousAcknowledgement(params: { preferences: GetPreferencesResult; scan?: TransactionScanResult | null; - scanFetchStatus: FetchStatus; }): boolean { - const { preferences, scan, scanFetchStatus } = params; + const { preferences, scan } = params; return ( - scanFetchStatus === FetchStatus.Fetching || - (preferences.useSecurityAlerts && - scan?.validation?.type === TransactionScanValidationType.Malicious) + preferences.useSecurityAlerts && + scan?.validation?.type === TransactionScanValidationType.Malicious ); } diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx index a6317db5..adc33ef3 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSendTransaction/ConfirmSendTransaction.tsx @@ -2,9 +2,7 @@ import type { ComponentOrElement } from '@metamask/snaps-sdk'; import { Address, Box, - Button, Container, - Footer, Heading, Icon, Image, @@ -29,6 +27,7 @@ import type { import { FetchStatus } from '../../api'; import { Asset, + ConfirmationFooter, FeeRow, TransactionAlert, TransactionValidationAlert, @@ -42,6 +41,7 @@ import { hasEnabledTransactionScan, isConfirmDisabledByScan, isConfirmDisabledByTransactionValidation, + requiresMaliciousAcknowledgement, } from '../../utils'; export type ConfirmSendTransactionProps = ConfirmationBaseProps & @@ -75,11 +75,8 @@ export const ConfirmSendTransaction = ({ const { address } = account; const { assetId, symbol } = assetMetadata; const shouldDisableConfirmButton = - isConfirmDisabledByScan({ - preferences, - scan, - scanFetchStatus, - }) || isConfirmDisabledByTransactionValidation(transactionsFetchStatus); + isConfirmDisabledByScan({ scanFetchStatus }) || + isConfirmDisabledByTransactionValidation(transactionsFetchStatus); const parsedAsset = parseCaipAssetType(assetId); let assetLink: string | undefined; if (!isSlip44Id(assetId)) { @@ -197,17 +194,16 @@ export const ConfirmSendTransaction = ({ />
-
- - -
+
); }; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx index 75d18d74..5377893e 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx @@ -2,9 +2,7 @@ import type { ComponentOrElement } from '@metamask/snaps-sdk'; import { Address, Box, - Button, Container, - Footer, Heading, Icon, Image, @@ -29,6 +27,7 @@ import { FetchStatus } from '../../api'; import { Asset, AssetIcon, + ConfirmationFooter, FeeRow, TransactionAlert, TransactionValidationAlert, @@ -40,6 +39,7 @@ import { isConfirmDisabledByScan, isConfirmDisabledByTransactionValidation, getNetworkName, + requiresMaliciousAcknowledgement, } from '../../utils'; export type ConfirmSignChangeTrustOptInProps = ConfirmationBaseProps & @@ -67,11 +67,8 @@ export const ConfirmSignChangeTrustOptIn = ({ const t = i18n(locale); const { address } = account; const shouldDisableConfirmButton = - isConfirmDisabledByScan({ - preferences, - scan, - scanFetchStatus, - }) || isConfirmDisabledByTransactionValidation(transactionsFetchStatus); + isConfirmDisabledByScan({ scanFetchStatus }) || + isConfirmDisabledByTransactionValidation(transactionsFetchStatus); return ( @@ -169,17 +166,16 @@ export const ConfirmSignChangeTrustOptIn = ({ /> -
- - -
+
); }; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx index bf101ccc..f0e647b5 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx @@ -2,9 +2,7 @@ import type { ComponentOrElement } from '@metamask/snaps-sdk'; import { Address, Box, - Button, Container, - Footer, Heading, Icon, Image, @@ -29,6 +27,7 @@ import { FetchStatus } from '../../api'; import { Asset, AssetIcon, + ConfirmationFooter, FeeRow, TransactionAlert, TransactionValidationAlert, @@ -40,6 +39,7 @@ import { isConfirmDisabledByScan, isConfirmDisabledByTransactionValidation, getNetworkName, + requiresMaliciousAcknowledgement, } from '../../utils'; export type ConfirmSignChangeTrustOptOutProps = ConfirmationBaseProps & @@ -67,11 +67,8 @@ export const ConfirmSignChangeTrustOptOut = ({ const t = i18n(locale); const { address } = account; const shouldDisableConfirmButton = - isConfirmDisabledByScan({ - preferences, - scan, - scanFetchStatus, - }) || isConfirmDisabledByTransactionValidation(transactionsFetchStatus); + isConfirmDisabledByScan({ scanFetchStatus }) || + isConfirmDisabledByTransactionValidation(transactionsFetchStatus); return ( @@ -169,17 +166,16 @@ export const ConfirmSignChangeTrustOptOut = ({ /> -
- - -
+
); }; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx index b75ba77e..c8da004f 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignTransaction/ConfirmSignTransaction.tsx @@ -2,9 +2,7 @@ import type { ComponentOrElement } from '@metamask/snaps-sdk'; import { Address, Box, - Button, Container, - Footer, Heading, Icon, Image, @@ -26,6 +24,7 @@ import { STELLAR_IMAGE } from '../../../images/icon'; import type { ConfirmationBaseProps, FeeData } from '../../api'; import { FetchStatus } from '../../api'; import { Asset } from '../../components/Asset'; +import { ConfirmationFooter } from '../../components/ConfirmationFooter'; import { FeeRow } from '../../components/Fee'; import { TransactionAlert } from '../../components/TransactionAlert'; import { @@ -33,6 +32,7 @@ import { getNetworkName, hasEnabledTransactionScan, isConfirmDisabledByScan, + requiresMaliciousAcknowledgement, resolveAssetDisplay, } from '../../utils'; @@ -178,8 +178,6 @@ export const ConfirmSignTransaction = ({ const priceLoading = tokenPricesFetchStatus === FetchStatus.Fetching; const feePrice = tokenPrices?.[feeData.assetId] ?? null; const shouldDisableConfirmButton = isConfirmDisabledByScan({ - preferences, - scan, scanFetchStatus, }); @@ -308,17 +306,16 @@ export const ConfirmSignTransaction = ({ ))} -
- - -
+ ); }; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/MaliciousAcknowledgementScreen.test.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/MaliciousAcknowledgementScreen.test.tsx new file mode 100644 index 00000000..5287af42 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/MaliciousAcknowledgementScreen.test.tsx @@ -0,0 +1,101 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; + +import { MaliciousAcknowledgementFormNames } from './constants'; +import { MaliciousAcknowledgementScreen } from './MaliciousAcknowledgementScreen'; + +type Element = { + type?: string; + props?: Record; +}; + +/** + * Recursively finds the first element matching a predicate. + * + * @param node - The element to search. + * @param match - Predicate over an element. + * @returns The matching element props, or undefined. + */ +function find( + node: ComponentOrElement | null, + match: (element: Element) => boolean, +): Record | undefined { + if (typeof node !== 'object' || node === null) { + return undefined; + } + const element = node as Element; + if (match(element)) { + return element.props; + } + const children = element.props?.children; + const list = Array.isArray(children) ? children : [children]; + for (const child of list) { + const found = find(child as ComponentOrElement | null, match); + if (found) { + return found; + } + } + return undefined; +} + +const byNamed = (type: string, name: string) => (element: Element) => + element.type === type && element.props?.name === name; + +const findButton = (node: ComponentOrElement | null, name: string) => + find(node, byNamed('Button', name)); + +const isBanner = (element: Element) => element.type === 'Banner'; + +describe('MaliciousAcknowledgementScreen', () => { + it('renders a danger banner with the malicious warning copy', () => { + const screen = MaliciousAcknowledgementScreen({ locale: 'en' }); + + const banner = find(screen, isBanner); + expect(banner).toMatchObject({ + severity: 'danger', + title: 'Malicious request', + }); + }); + + it('disables the proceed button until the risk is acknowledged', () => { + const screen = MaliciousAcknowledgementScreen({ + locale: 'en', + acknowledged: false, + }); + + expect( + findButton(screen, MaliciousAcknowledgementFormNames.Proceed)?.disabled, + ).toBe(true); + }); + + it('enables the proceed button once the risk is acknowledged', () => { + const screen = MaliciousAcknowledgementScreen({ + locale: 'en', + acknowledged: true, + }); + + expect( + findButton(screen, MaliciousAcknowledgementFormNames.Proceed)?.disabled, + ).toBe(false); + }); + + it('reflects the acknowledgement state on the checkbox', () => { + const screen = MaliciousAcknowledgementScreen({ + locale: 'en', + acknowledged: true, + }); + + const checkbox = find( + screen, + byNamed('Checkbox', MaliciousAcknowledgementFormNames.Acknowledge), + ); + expect(checkbox?.checked).toBe(true); + }); + + it('renders a back button', () => { + const screen = MaliciousAcknowledgementScreen({ locale: 'en' }); + + expect( + findButton(screen, MaliciousAcknowledgementFormNames.Back), + ).toBeDefined(); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/MaliciousAcknowledgementScreen.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/MaliciousAcknowledgementScreen.tsx new file mode 100644 index 00000000..1e31bb5a --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/MaliciousAcknowledgementScreen.tsx @@ -0,0 +1,68 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import { + Banner, + Box, + Button, + Checkbox, + Container, + Footer, + Heading, + Text as SnapText, +} from '@metamask/snaps-sdk/jsx'; + +import { MaliciousAcknowledgementFormNames } from './constants'; +import type { Locale } from '../../../../utils'; +import { i18n } from '../../../../utils'; +import type { ConfirmationBaseProps } from '../../api'; + +export type MaliciousAcknowledgementScreenProps = { + locale: ConfirmationBaseProps['locale']; + acknowledged?: boolean; +}; + +/** + * Friction screen shown when the user chooses to review a malicious-scan alert. + * + * The user cannot be outright blocked, so this screen forces an explicit + * acknowledgement: "Confirm" stays disabled until the risk checkbox is checked. + * + * @param props - The screen props. + * @param props.locale - The active locale. + * @param props.acknowledged - Whether the risk checkbox is currently checked. + * @returns The acknowledgement screen. + */ +export const MaliciousAcknowledgementScreen = ({ + locale, + acknowledged = false, +}: MaliciousAcknowledgementScreenProps): ComponentOrElement => { + const t = i18n(locale as Locale); + + return ( + + + + {t('confirmation.maliciousAck.title')} + + + {t('confirmation.maliciousAck.description')} + + + +
+ + +
+
+ ); +}; diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/constants.ts b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/constants.ts new file mode 100644 index 00000000..949b9bc5 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/constants.ts @@ -0,0 +1,12 @@ +/** + * Shared form element names for the malicious acknowledgement screen. + * + * The screen is reused across every scanned confirmation flow, so these names + * live in one place and are handled by a single set of event handlers. + */ +export enum MaliciousAcknowledgementFormNames { + Review = 'malicious-acknowledgement-review', + Acknowledge = 'malicious-acknowledgement-checkbox', + Proceed = 'malicious-acknowledgement-proceed', + Back = 'malicious-acknowledgement-back', +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/events.test.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/events.test.tsx new file mode 100644 index 00000000..eadd9d50 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/events.test.tsx @@ -0,0 +1,140 @@ +import type { UserInputEvent } from '@metamask/snaps-sdk'; +import { UserInputEventType } from '@metamask/snaps-sdk'; + +import { MaliciousAcknowledgementFormNames } from './constants'; +import { createEventHandlers } from './events'; +import { resolveInterface, updateInterfaceIfExists } from '../../../../utils'; +import { ConfirmationInterfaceKey } from '../../api'; +import { renderConfirmationView } from '../render'; + +jest.mock('../render', () => ({ + renderConfirmationView: jest.fn(() => 'RENDERED'), +})); + +jest.mock('../../../../utils', () => ({ + ...jest.requireActual('../../../../utils'), + resolveInterface: jest.fn(), + updateInterfaceIfExists: jest.fn(), +})); + +const INTERFACE_ID = 'interface-id'; + +const baseContext = { + interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, + locale: 'en', + acknowledgementScreen: false, + acknowledged: false, +}; + +const buttonEvent = (name: string): UserInputEvent => + ({ type: UserInputEventType.ButtonClickEvent, name }) as UserInputEvent; + +const checkboxEvent = (name: string, value: boolean): UserInputEvent => + ({ + type: UserInputEventType.InputChangeEvent, + name, + value, + }) as unknown as UserInputEvent; + +describe('malicious acknowledgement events', () => { + const handlers = createEventHandlers(); + + beforeEach(() => { + jest.clearAllMocks(); + jest + .mocked(renderConfirmationView) + .mockReturnValue( + 'RENDERED' as unknown as ReturnType, + ); + }); + + it('opens the acknowledgement screen on "Review alerts"', async () => { + const name = MaliciousAcknowledgementFormNames.Review; + await handlers[name]?.({ + id: INTERFACE_ID, + event: buttonEvent(name), + context: baseContext, + }); + + const expectedContext = { + ...baseContext, + acknowledgementScreen: true, + acknowledged: false, + }; + expect(renderConfirmationView).toHaveBeenCalledWith( + ConfirmationInterfaceKey.ConfirmSendTransaction, + expectedContext, + ); + expect(updateInterfaceIfExists).toHaveBeenCalledWith( + INTERFACE_ID, + 'RENDERED', + expectedContext, + ); + expect(resolveInterface).not.toHaveBeenCalled(); + }); + + it('tracks the acknowledgement checkbox value', async () => { + const name = MaliciousAcknowledgementFormNames.Acknowledge; + await handlers[name]?.({ + id: INTERFACE_ID, + event: checkboxEvent(name, true), + context: { ...baseContext, acknowledgementScreen: true }, + }); + + expect(updateInterfaceIfExists).toHaveBeenCalledWith( + INTERFACE_ID, + 'RENDERED', + expect.objectContaining({ acknowledged: true }), + ); + }); + + it('resolves the interface on "Confirm"', async () => { + const name = MaliciousAcknowledgementFormNames.Proceed; + await handlers[name]?.({ + id: INTERFACE_ID, + event: buttonEvent(name), + context: { + ...baseContext, + acknowledgementScreen: true, + acknowledged: true, + }, + }); + + expect(resolveInterface).toHaveBeenCalledWith(INTERFACE_ID, true); + expect(updateInterfaceIfExists).not.toHaveBeenCalled(); + }); + + it('returns to the confirmation view on "Go back"', async () => { + const name = MaliciousAcknowledgementFormNames.Back; + await handlers[name]?.({ + id: INTERFACE_ID, + event: buttonEvent(name), + context: { + ...baseContext, + acknowledgementScreen: true, + acknowledged: true, + }, + }); + + expect(updateInterfaceIfExists).toHaveBeenCalledWith( + INTERFACE_ID, + 'RENDERED', + expect.objectContaining({ + acknowledgementScreen: false, + acknowledged: false, + }), + ); + expect(resolveInterface).not.toHaveBeenCalled(); + }); + + it('does nothing when the interface context is missing', async () => { + const name = MaliciousAcknowledgementFormNames.Review; + await handlers[name]?.({ + id: INTERFACE_ID, + event: buttonEvent(name), + context: null, + }); + + expect(updateInterfaceIfExists).not.toHaveBeenCalled(); + }); +}); diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/events.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/events.tsx new file mode 100644 index 00000000..1b07d652 --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/MaliciousAcknowledgement/events.tsx @@ -0,0 +1,109 @@ +import type { InputChangeEvent } from '@metamask/snaps-sdk'; +import type { Json } from '@metamask/utils'; + +import { MaliciousAcknowledgementFormNames } from './constants'; +import type { + UserInputUiEventHandler, + UserInputUiEventHandlerContext, +} from '../../../../handlers/user-input/api'; +import { resolveInterface, updateInterfaceIfExists } from '../../../../utils'; +import type { ConfirmationInterfaceKey } from '../../api'; +import { renderConfirmationView } from '../render'; + +/** + * Re-renders the interface with a patched context. + * + * @param id - The interface id. + * @param context - The current interface context. + * @param patch - The context fields to override. + */ +async function reRender( + id: string, + context: Record, + patch: Record, +): Promise { + const nextContext = { ...context, ...patch }; + const interfaceKey = context.interfaceKey as ConfirmationInterfaceKey; + await updateInterfaceIfExists( + id, + renderConfirmationView(interfaceKey, nextContext), + nextContext, + ); +} + +/** + * Opens the malicious acknowledgement screen when the user clicks "Review alerts". + * + * @param options - The user input handler context. + */ +async function onReviewClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id, context } = options; + if (!context) { + return; + } + await reRender(id, context, { + acknowledgementScreen: true, + acknowledged: false, + }); +} + +/** + * Tracks the risk-acknowledgement checkbox so the "Confirm" button can enable. + * + * @param options - The user input handler context. + */ +async function onAcknowledgeChange( + options: UserInputUiEventHandlerContext, +): Promise { + const { id, event, context } = options; + if (!context) { + return; + } + const acknowledged = Boolean((event as InputChangeEvent).value); + await reRender(id, context, { acknowledged }); +} + +/** + * Confirms the transaction after the user acknowledged the malicious-scan risk. + * + * @param options - The user input handler context. + */ +async function onProceedClick( + options: UserInputUiEventHandlerContext, +): Promise { + await resolveInterface(options.id, true); +} + +/** + * Returns from the acknowledgement screen to the confirmation view. + * + * @param options - The user input handler context. + */ +async function onBackClick( + options: UserInputUiEventHandlerContext, +): Promise { + const { id, context } = options; + if (!context) { + return; + } + await reRender(id, context, { + acknowledgementScreen: false, + acknowledged: false, + }); +} + +/** + * Create the shared malicious-acknowledgement event handlers. + * + * @returns Object containing event handlers keyed by form element name. + */ +export function createEventHandlers(): Record { + return { + [MaliciousAcknowledgementFormNames.Review]: onReviewClick, + [MaliciousAcknowledgementFormNames.Acknowledge]: onAcknowledgeChange, + [MaliciousAcknowledgementFormNames.Proceed]: onProceedClick, + [MaliciousAcknowledgementFormNames.Back]: onBackClick, + }; +} diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/render.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/render.tsx new file mode 100644 index 00000000..cfc5b8ab --- /dev/null +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/views/render.tsx @@ -0,0 +1,86 @@ +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import type { Json } from '@metamask/utils'; + +import type { ConfirmationBaseProps } from '../api'; +import { ConfirmationInterfaceKey } from '../api'; +import type { ConfirmSendTransactionProps } from './ConfirmSendTransaction/ConfirmSendTransaction'; +import { ConfirmSendTransaction } from './ConfirmSendTransaction/ConfirmSendTransaction'; +import type { ConfirmSignAuthEntryProps } from './ConfirmSignAuthEntry/ConfirmSignAuthEntry'; +import { ConfirmSignAuthEntry } from './ConfirmSignAuthEntry/ConfirmSignAuthEntry'; +import type { ConfirmSignChangeTrustOptInProps } from './ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn'; +import { ConfirmSignChangeTrustOptIn } from './ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn'; +import type { ConfirmSignChangeTrustOptOutProps } from './ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut'; +import { ConfirmSignChangeTrustOptOut } from './ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut'; +import type { ConfirmSignMessageProps } from './ConfirmSignMessage/ConfirmSignMessage'; +import { ConfirmSignMessage } from './ConfirmSignMessage/ConfirmSignMessage'; +import type { ConfirmSignTransactionProps } from './ConfirmSignTransaction/ConfirmSignTransaction'; +import { ConfirmSignTransaction } from './ConfirmSignTransaction/ConfirmSignTransaction'; +import { MaliciousAcknowledgementScreen } from './MaliciousAcknowledgement/MaliciousAcknowledgementScreen'; + +/** Serializable props bag stored on the interface and merged into each view. */ +export type ConfirmationViewProps = Record; + +/** + * Renders the confirmation view for an interface key and context. + * + * Shared by {@link ConfirmationUXController} and the malicious acknowledgement + * event handlers so both render through the same logic. When the context marks + * the acknowledgement screen as active, it takes over regardless of the key. + * + * @param interfaceKey - The confirmation flow to render. + * @param context - The serialized interface context (view props + flags). + * @returns The component to render. + */ +export function renderConfirmationView( + interfaceKey: ConfirmationInterfaceKey, + context: ConfirmationViewProps, +): ComponentOrElement { + const baseContext = context as ConfirmationBaseProps; + if (baseContext.acknowledgementScreen) { + return ( + + ); + } + + switch (interfaceKey) { + case ConfirmationInterfaceKey.ChangeTrustlineOptIn: + return ( + + ); + case ConfirmationInterfaceKey.ChangeTrustlineOptOut: + return ( + + ); + case ConfirmationInterfaceKey.SignTransaction: + return ( + + ); + case ConfirmationInterfaceKey.SignMessage: + return ; + case ConfirmationInterfaceKey.SignAuthEntry: + return ( + + ); + case ConfirmationInterfaceKey.ConfirmSendTransaction: + return ( + + ); + default: { + const exhaustive: never = interfaceKey; + throw new Error(`Unsupported interface key: ${String(exhaustive)}`); + } + } +} From 093e30ad0cf5d73a1407a6545cf31766aeab51c6 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Thu, 11 Jun 2026 17:58:27 +0200 Subject: [PATCH 291/384] fix: fix icon --- merged-packages/stellar-wallet-snap/images/icon.svg | 2 +- merged-packages/stellar-wallet-snap/snap.manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/images/icon.svg b/merged-packages/stellar-wallet-snap/images/icon.svg index 02afb7b7..2165da62 100644 --- a/merged-packages/stellar-wallet-snap/images/icon.svg +++ b/merged-packages/stellar-wallet-snap/images/icon.svg @@ -1 +1 @@ -Asset 1 \ No newline at end of file +Stellar diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 1da4ec15..7c4d2204 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "eXMcfbZrI4vWl1+r062FKFoJHjJAJiv+NeXihkFaeZs=", + "shasum": "3s2twOPRGFWNbsKVksY8xLHJCZFHFDIh28DmHrNo9ow=", "location": { "npm": { "filePath": "dist/bundle.js", From 82df7661bef9b47a2a3f6e8579e0b0f9454c19d0 Mon Sep 17 00:00:00 2001 From: Amine Harty Date: Thu, 11 Jun 2026 18:21:08 +0200 Subject: [PATCH 292/384] fix: prioritize blocking state over ack swap and guard proceed handler --- .../stellar-wallet-snap/snap.manifest.json | 4 ++-- .../components/ConfirmationFooter.test.tsx | 13 +++++++++++++ .../components/ConfirmationFooter.tsx | 7 ++++++- .../src/ui/confirmation/utils.test.ts | 2 +- .../MaliciousAcknowledgement/events.test.tsx | 15 +++++++++++++++ .../views/MaliciousAcknowledgement/events.tsx | 10 +++++++++- 6 files changed, 46 insertions(+), 5 deletions(-) diff --git a/merged-packages/stellar-wallet-snap/snap.manifest.json b/merged-packages/stellar-wallet-snap/snap.manifest.json index 7c4d2204..536c7f9e 100644 --- a/merged-packages/stellar-wallet-snap/snap.manifest.json +++ b/merged-packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-stellar-wallet.git" }, "source": { - "shasum": "3s2twOPRGFWNbsKVksY8xLHJCZFHFDIh28DmHrNo9ow=", + "shasum": "m95xLPIUv2tfzW7Fb307iI6au4R0lrmAZePokYBRacg=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -50,6 +50,6 @@ "scopes": ["stellar:pubnet"] } }, - "platformVersion": "11.1.1", + "platformVersion": "10.3.0", "manifestVersion": "0.1" } diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.test.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.test.tsx index ae6ee153..47995beb 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.test.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.test.tsx @@ -76,6 +76,19 @@ describe('ConfirmationFooter', () => { expect(findButton(footer, 'confirm')).toBeUndefined(); }); + it('falls back to the disabled confirm button when blocked, even if acknowledgement is required', () => { + const footer = ConfirmationFooter({ + ...baseProps, + requiresAcknowledgement: true, + confirmDisabled: true, + }); + + expect( + findButton(footer, MaliciousAcknowledgementFormNames.Review), + ).toBeUndefined(); + expect(findButton(footer, 'confirm')?.disabled).toBe(true); + }); + it('always renders the cancel button', () => { const footer = ConfirmationFooter({ ...baseProps, diff --git a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.tsx b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.tsx index f319bf2d..7fd24199 100644 --- a/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.tsx +++ b/merged-packages/stellar-wallet-snap/src/ui/confirmation/components/ConfirmationFooter.tsx @@ -22,6 +22,11 @@ type ConfirmationFooterProps = { * swaps the primary button from "Confirm" to "Review alerts" rather than * disabling it, so the user keeps a "proceed anyway" path behind friction. * + * A blocking state (`confirmDisabled`, e.g. failed background re-validation) + * takes priority over the acknowledgement swap: we fall back to the disabled + * "Confirm" button so the user can never enter the acknowledgement flow for a + * transaction that is no longer valid. + * * @param props - The footer props. * @param props.locale - The active locale. * @param props.cancelButtonName - Event name for the cancel button. @@ -42,7 +47,7 @@ export const ConfirmationFooter = ({ return (