From c76ffa450360d625718aee4407975443b9d2e14c Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Thu, 7 May 2026 15:22:53 +0200 Subject: [PATCH] feat: account activation status --- packages/snap/src/context.ts | 10 ++ .../src/handlers/clientRequest/api.test.ts | 83 +++++++++++++++++ .../snap/src/handlers/clientRequest/api.ts | 38 ++++++++ .../getStellarAccountActivationStatus.test.ts | 91 +++++++++++++++++++ .../getStellarAccountActivationStatus.ts | 67 ++++++++++++++ .../snap/src/handlers/clientRequest/index.ts | 1 + 6 files changed, 290 insertions(+) create mode 100644 packages/snap/src/handlers/clientRequest/getStellarAccountActivationStatus.test.ts create mode 100644 packages/snap/src/handlers/clientRequest/getStellarAccountActivationStatus.ts diff --git a/packages/snap/src/context.ts b/packages/snap/src/context.ts index 0eb91d99..df856a1f 100644 --- a/packages/snap/src/context.ts +++ b/packages/snap/src/context.ts @@ -8,6 +8,7 @@ import { ChangeTrustOptHandler, ClientRequestHandler, ClientRequestMethod, + GetStellarAccountActivationStatusHandler, } from './handlers/clientRequest'; import type { ICronjobRequestHandler } from './handlers/cronjob/api'; import { BackgroundEventMethod } from './handlers/cronjob/api'; @@ -206,11 +207,20 @@ const changeTrustOptHandler = new ChangeTrustOptHandler({ confirmationUIController, }); +const getStellarAccountActivationStatusHandler = + new GetStellarAccountActivationStatusHandler({ + logger, + accountService, + networkService, + }); + const clientRequestMethodHandlers: Record< ClientRequestMethod, IClientRequestHandler > = { [ClientRequestMethod.ChangeTrustOpt]: changeTrustOptHandler, + [ClientRequestMethod.GetStellarAccountActivationStatus]: + getStellarAccountActivationStatusHandler, }; const clientRequestHandler = new ClientRequestHandler({ diff --git a/packages/snap/src/handlers/clientRequest/api.test.ts b/packages/snap/src/handlers/clientRequest/api.test.ts index 2a6e8227..12d93618 100644 --- a/packages/snap/src/handlers/clientRequest/api.test.ts +++ b/packages/snap/src/handlers/clientRequest/api.test.ts @@ -3,6 +3,8 @@ import { assert, StructError } from '@metamask/superstruct'; import { ChangeTrustOptJsonRpcRequestStruct, ChangeTrustOptJsonRpcResponseStruct, + GetStellarAccountActivationStatusJsonRpcRequestStruct, + GetStellarAccountActivationStatusJsonRpcResponseStruct, JsonRpcRequestWithAccountStruct, } from './api'; @@ -219,3 +221,84 @@ describe('ChangeTrustOptJsonRpcRequestStruct', () => { ); }); }); + +describe('GetStellarAccountActivationStatusJsonRpcRequestStruct', () => { + it('accepts a valid getStellarAccountActivationStatus JSON-RPC request', () => { + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'getStellarAccountActivationStatus', + params: { + accountId, + scope, + }, + }; + + expect(() => + assert(request, GetStellarAccountActivationStatusJsonRpcRequestStruct), + ).not.toThrow(); + }); + + it.each([ + { + jsonrpc: '2.0' as const, + id: 1, + method: 'wrongMethod', + params: { + accountId, + scope, + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: 'getStellarAccountActivationStatus', + params: { + accountId, + scope: 'stellar:unknown', + }, + }, + { + jsonrpc: '2.0' as const, + id: 1, + method: 'getStellarAccountActivationStatus', + params: { + accountId: 'invalid-account-id', + scope, + }, + }, + ])( + 'rejects an invalid getStellarAccountActivationStatus JSON-RPC request', + (request) => { + expect(() => + assert(request, GetStellarAccountActivationStatusJsonRpcRequestStruct), + ).toThrow(StructError); + }, + ); +}); + +describe('GetStellarAccountActivationStatusJsonRpcResponseStruct', () => { + it.each([{ activated: true }, { activated: false }])( + 'accepts a valid getStellarAccountActivationStatus JSON-RPC response', + (response) => { + expect(() => + assert( + response, + GetStellarAccountActivationStatusJsonRpcResponseStruct, + ), + ).not.toThrow(); + }, + ); + + it.each([{ activated: 'true' }, {}, { status: true }])( + 'rejects an invalid getStellarAccountActivationStatus JSON-RPC response', + (response) => { + expect(() => + assert( + response, + GetStellarAccountActivationStatusJsonRpcResponseStruct, + ), + ).toThrow(StructError); + }, + ); +}); diff --git a/packages/snap/src/handlers/clientRequest/api.ts b/packages/snap/src/handlers/clientRequest/api.ts index a56ed83e..3ef7ce9f 100644 --- a/packages/snap/src/handlers/clientRequest/api.ts +++ b/packages/snap/src/handlers/clientRequest/api.ts @@ -28,6 +28,7 @@ import { export enum ClientRequestMethod { /** -------------------------------- Stellar Specific -------------------------------- */ ChangeTrustOpt = 'changeTrustOpt', + GetStellarAccountActivationStatus = 'getStellarAccountActivationStatus', } /** @@ -109,6 +110,29 @@ export const ChangeTrustOptJsonRpcResponseStruct = object({ transactionId: optional(base64(string())), }); +const GetStellarAccountActivationStatusParamsStruct = object({ + accountId: UuidStruct, + scope: KnownCaip2ChainIdStruct, +}); + +/** + * Validation struct for the getStellarAccountActivationStatus JSON-RPC request. + */ +export const GetStellarAccountActivationStatusJsonRpcRequestStruct = assign( + JsonRpcRequestStruct, + object({ + method: literal(ClientRequestMethod.GetStellarAccountActivationStatus), + params: GetStellarAccountActivationStatusParamsStruct, + }), +); + +/** + * Validation struct for the getStellarAccountActivationStatus JSON-RPC response. + */ +export const GetStellarAccountActivationStatusJsonRpcResponseStruct = object({ + activated: boolean(), +}); + /** * A JSON-RPC request with an account resolve parameter. */ @@ -130,3 +154,17 @@ export type ChangeTrustOptJsonRpcRequest = Infer< export type ChangeTrustOptJsonRpcResponse = Infer< typeof ChangeTrustOptJsonRpcResponseStruct >; + +/** + * Type for the getStellarAccountActivationStatus JSON-RPC request. + */ +export type GetStellarAccountActivationStatusJsonRpcRequest = Infer< + typeof GetStellarAccountActivationStatusJsonRpcRequestStruct +>; + +/** + * Type for the getStellarAccountActivationStatus JSON-RPC response. + */ +export type GetStellarAccountActivationStatusJsonRpcResponse = Infer< + typeof GetStellarAccountActivationStatusJsonRpcResponseStruct +>; diff --git a/packages/snap/src/handlers/clientRequest/getStellarAccountActivationStatus.test.ts b/packages/snap/src/handlers/clientRequest/getStellarAccountActivationStatus.test.ts new file mode 100644 index 00000000..837ba449 --- /dev/null +++ b/packages/snap/src/handlers/clientRequest/getStellarAccountActivationStatus.test.ts @@ -0,0 +1,91 @@ +import { + ClientRequestMethod, + type GetStellarAccountActivationStatusJsonRpcRequest, +} from './api'; +import { GetStellarAccountActivationStatusHandler } from './getStellarAccountActivationStatus'; +import { KnownCaip2ChainId } from '../../api'; +import { AccountService } from '../../services/account'; +import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; +import { NetworkService } from '../../services/network'; +import { mockOnChainAccountService } from '../../services/on-chain-account/__mocks__/onChainAccount.fixtures'; +import { getTestWallet } from '../../services/wallet/__mocks__/wallet.fixtures'; +import { logger } from '../../utils/logger'; + +jest.mock('../../utils/logger'); + +describe('GetStellarAccountActivationStatusHandler', () => { + const accountId = '11111111-1111-4111-8111-111111111111'; + const scope = KnownCaip2ChainId.Mainnet; + + function buildRequest(): GetStellarAccountActivationStatusJsonRpcRequest { + return { + jsonrpc: '2.0', + id: 1, + method: ClientRequestMethod.GetStellarAccountActivationStatus, + params: { accountId, scope }, + }; + } + + it('returns activated true when Horizon has the account', async () => { + const wallet = getTestWallet(); + const account = generateStellarKeyringAccount( + accountId, + wallet.address, + 'entropy-source-1', + 0, + ); + + const resolveAccountSpy = jest + .spyOn(AccountService.prototype, 'resolveAccount') + .mockResolvedValue({ account }); + const getAccountOrNullSpy = jest + .spyOn(NetworkService.prototype, 'getAccountOrNull') + .mockResolvedValue({} as never); + + const { accountService } = mockOnChainAccountService(); + const networkService = new NetworkService({ logger }); + const handler = new GetStellarAccountActivationStatusHandler({ + logger, + accountService, + networkService, + }); + + expect(await handler.handle(buildRequest())).toStrictEqual({ + activated: true, + }); + + expect(resolveAccountSpy).toHaveBeenCalledWith({ accountId }); + expect(getAccountOrNullSpy).toHaveBeenCalledWith(account.address, scope); + }); + + it('returns activated false when Horizon has no account', async () => { + const wallet = getTestWallet(); + const account = generateStellarKeyringAccount( + accountId, + wallet.address, + 'entropy-source-1', + 0, + ); + + jest + .spyOn(AccountService.prototype, 'resolveAccount') + .mockResolvedValue({ account }); + const getAccountOrNullSpy = jest + .spyOn(NetworkService.prototype, 'getAccountOrNull') + .mockResolvedValue(null); + + const { accountService } = mockOnChainAccountService(); + const networkService = new NetworkService({ logger }); + const handler = new GetStellarAccountActivationStatusHandler({ + logger, + accountService, + networkService, + }); + + expect(await handler.handle(buildRequest())).toStrictEqual({ + activated: false, + }); + + expect(getAccountOrNullSpy).toHaveBeenCalledWith(account.address, scope); + }); +}); diff --git a/packages/snap/src/handlers/clientRequest/getStellarAccountActivationStatus.ts b/packages/snap/src/handlers/clientRequest/getStellarAccountActivationStatus.ts new file mode 100644 index 00000000..bbc10a21 --- /dev/null +++ b/packages/snap/src/handlers/clientRequest/getStellarAccountActivationStatus.ts @@ -0,0 +1,67 @@ +import type { + GetStellarAccountActivationStatusJsonRpcRequest, + GetStellarAccountActivationStatusJsonRpcResponse, +} from './api'; +import { + GetStellarAccountActivationStatusJsonRpcRequestStruct, + GetStellarAccountActivationStatusJsonRpcResponseStruct, +} from './api'; +import type { AccountService } from '../../services/account'; +import type { NetworkService } from '../../services/network'; +import type { ILogger } from '../../utils/logger'; +import { createPrefixedLogger } from '../../utils/logger'; +import { BaseHandler } from '../base'; + +export class GetStellarAccountActivationStatusHandler extends BaseHandler< + GetStellarAccountActivationStatusJsonRpcRequest, + GetStellarAccountActivationStatusJsonRpcResponse +> { + readonly #accountService: AccountService; + + readonly #networkService: NetworkService; + + constructor({ + logger, + accountService, + networkService, + }: { + logger: ILogger; + accountService: AccountService; + networkService: NetworkService; + }) { + const prefixedLogger = createPrefixedLogger( + logger, + '[GetStellarAccountActivationStatusHandler]', + ); + super({ + logger: prefixedLogger, + requestStruct: GetStellarAccountActivationStatusJsonRpcRequestStruct, + responseStruct: GetStellarAccountActivationStatusJsonRpcResponseStruct, + }); + this.#accountService = accountService; + this.#networkService = networkService; + } + + /** + * Returns whether the account exists on Horizon for the requested network scope. + * + * @param request - JSON-RPC request with `accountId` and `scope`. + * @returns `{ activated: true }` when Horizon has the account; `false` when missing / not funded on-ledger. + */ + protected async handleRequest( + request: GetStellarAccountActivationStatusJsonRpcRequest, + ): Promise { + const { accountId, scope } = request.params; + + const { account } = await this.#accountService.resolveAccount({ + accountId, + }); + + const onChain = await this.#networkService.getAccountOrNull( + account.address, + scope, + ); + + return { activated: onChain !== null }; + } +} diff --git a/packages/snap/src/handlers/clientRequest/index.ts b/packages/snap/src/handlers/clientRequest/index.ts index 2f949a4c..82350818 100644 --- a/packages/snap/src/handlers/clientRequest/index.ts +++ b/packages/snap/src/handlers/clientRequest/index.ts @@ -1,4 +1,5 @@ export * from './changeTrustOpt'; +export * from './getStellarAccountActivationStatus'; export * from './clientRequest'; export * from './api'; export type { IClientRequestHandler } from './base';