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 @@
+
\ 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 @@
+
\ 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