diff --git a/.changeset/large-stars-relate.md b/.changeset/large-stars-relate.md new file mode 100644 index 0000000000..6c9efdbde5 --- /dev/null +++ b/.changeset/large-stars-relate.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-solana": minor +--- + +Add transaction check diff --git a/.changeset/swift-otters-roam.md b/.changeset/swift-otters-roam.md new file mode 100644 index 0000000000..19ace5803a --- /dev/null +++ b/.changeset/swift-otters-roam.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-management-kit": minor +--- + +Extend `ApplicationChecker` with app-name aware constraints: track the current running app from the device session state and expose `excludeApp`, `excludeApps`, and `excludeDeviceModels` builder methods. diff --git a/packages/device-management-kit/src/api/utils/ApplicationChecker.ts b/packages/device-management-kit/src/api/utils/ApplicationChecker.ts index 7047591e37..e9fafcc029 100644 --- a/packages/device-management-kit/src/api/utils/ApplicationChecker.ts +++ b/packages/device-management-kit/src/api/utils/ApplicationChecker.ts @@ -12,6 +12,7 @@ export class ApplicationChecker { private isCompatible: boolean; private version: string; private modelId: DeviceModelId; + private readonly appName: string | undefined; constructor( deviceState: DeviceSessionState, @@ -19,6 +20,8 @@ export class ApplicationChecker { resolver: ApplicationResolver, ) { this.modelId = deviceState.deviceModelId; + this.appName = + "currentApp" in deviceState ? deviceState.currentApp.name : undefined; const resolved = resolver.resolve(deviceState, appConfig); this.isCompatible = resolved.isCompatible; this.version = resolved.version; @@ -39,6 +42,21 @@ export class ApplicationChecker { return this; } + excludeDeviceModels(...modelIds: DeviceModelId[]): ApplicationChecker { + for (const id of modelIds) this.excludeDeviceModel(id); + return this; + } + + excludeApp(name: string): ApplicationChecker { + if (this.appName === name) this.isCompatible = false; + return this; + } + + excludeApps(...names: string[]): ApplicationChecker { + for (const name of names) this.excludeApp(name); + return this; + } + check(): boolean { return this.isCompatible; } diff --git a/packages/signer/context-module/src/DefaultContextModule.ts b/packages/signer/context-module/src/DefaultContextModule.ts index 95d670e04b..573efb4d22 100644 --- a/packages/signer/context-module/src/DefaultContextModule.ts +++ b/packages/signer/context-module/src/DefaultContextModule.ts @@ -121,6 +121,9 @@ export class DefaultContextModule implements ContextModule { this._container.get( ownerInfoTypes.OwnerInfoContextLoader, ), + this._container.get( + transactionCheckTypes.TransactionCheckLoader, + ), ]; case ContextModuleChainID.Concordium: return [ diff --git a/packages/signer/context-module/src/di.ts b/packages/signer/context-module/src/di.ts index deda639436..64370047f7 100644 --- a/packages/signer/context-module/src/di.ts +++ b/packages/signer/context-module/src/di.ts @@ -22,6 +22,7 @@ import { uniswapModuleFactory } from "@/modules/ethereum/uniswap/di/uniswapModul import { nanoPkiModuleFactory } from "@/modules/multichain/pki/di/pkiModuleFactory"; import { reporterModuleFactory } from "@/modules/multichain/reporter/di/reporterModuleFactory"; import { ethereumTransactionCheckModuleFactory } from "@/modules/multichain/transaction-check/di/ethereumTransactionCheckModuleFactory"; +import { solanaTransactionCheckModuleFactory } from "@/modules/multichain/transaction-check/di/solanaTransactionCheckModuleFactory"; import { lifiModuleFactory } from "@/modules/solana/lifi/di/lifiModuleFactory"; import { ownerInfoModuleFactory } from "@/modules/solana/owner-info/di/ownerInfoModuleFactory"; import { tokenModuleFactory as solanaTokenModuleFactory } from "@/modules/solana/token/di/tokenModuleFactory"; @@ -71,6 +72,7 @@ export const makeContainer = ({ config }: MakeContainerArgs) => { ownerInfoModuleFactory(), solanaTokenModuleFactory(), lifiModuleFactory(), + solanaTransactionCheckModuleFactory(), ); break; case ContextModuleChainID.Concordium: diff --git a/packages/signer/context-module/src/index.ts b/packages/signer/context-module/src/index.ts index b6b39e02c4..afa53a393c 100644 --- a/packages/signer/context-module/src/index.ts +++ b/packages/signer/context-module/src/index.ts @@ -72,8 +72,10 @@ export * from "./modules/multichain/reporter/domain/BlindSigningReporter"; export * from "./modules/multichain/reporter/domain/DefaultBlindSigningReporter"; export * from "./modules/multichain/reporter/model/BlindSigningEvent"; export * from "./modules/multichain/reporter/model/BlindSigningModelId"; +export * from "./modules/multichain/transaction-check/utils/constants"; export * from "./modules/solana/model/SolanaClearSignContext"; export * from "./modules/solana/model/SolanaContextTypes"; +export * from "./modules/solana/model/SolanaTransactionScanChainId"; export * from "./modules/solana/owner-info/data/HttpOwnerInfoDataSource"; export * from "./modules/solana/owner-info/data/OwnerInfoDataSource"; export * from "./modules/solana/owner-info/domain/OwnerInfoContextLoader"; diff --git a/packages/signer/context-module/src/modules/multichain/transaction-check/di/solanaTransactionCheckModuleFactory.ts b/packages/signer/context-module/src/modules/multichain/transaction-check/di/solanaTransactionCheckModuleFactory.ts new file mode 100644 index 0000000000..04572e59ab --- /dev/null +++ b/packages/signer/context-module/src/modules/multichain/transaction-check/di/solanaTransactionCheckModuleFactory.ts @@ -0,0 +1,15 @@ +import { ContainerModule } from "inversify"; + +import { HttpTransactionCheckDataSource } from "@/modules/multichain/transaction-check/data/HttpTransactionCheckDataSource"; +import { transactionCheckTypes } from "@/modules/multichain/transaction-check/di/transactionCheckTypes"; +import { SolanaTransactionCheckLoader } from "@/modules/multichain/transaction-check/loaders/SolanaTransactionCheckLoader"; + +export const solanaTransactionCheckModuleFactory = () => + new ContainerModule(({ bind }) => { + bind(transactionCheckTypes.TransactionCheckDataSource).to( + HttpTransactionCheckDataSource, + ); + bind(transactionCheckTypes.TransactionCheckLoader).to( + SolanaTransactionCheckLoader, + ); + }); diff --git a/packages/signer/context-module/src/modules/multichain/transaction-check/loaders/SolanaTransactionCheckLoader.ts b/packages/signer/context-module/src/modules/multichain/transaction-check/loaders/SolanaTransactionCheckLoader.ts new file mode 100644 index 0000000000..8e734e578d --- /dev/null +++ b/packages/signer/context-module/src/modules/multichain/transaction-check/loaders/SolanaTransactionCheckLoader.ts @@ -0,0 +1,115 @@ +import { + DeviceModelId, + LoggerPublisherService, +} from "@ledgerhq/device-management-kit"; +import { inject, injectable } from "inversify"; +import { Codec, exactly, number, oneOf, string } from "purify-ts"; + +import { configTypes } from "@/config/di/configTypes"; +import { pkiTypes } from "@/modules/multichain/pki/di/pkiTypes"; +import { type PkiCertificateLoader } from "@/modules/multichain/pki/domain/PkiCertificateLoader"; +import { KeyUsage } from "@/modules/multichain/pki/model/KeyUsage"; +import { type TransactionCheckDataSource } from "@/modules/multichain/transaction-check/data/TransactionCheckDataSource"; +import { transactionCheckTypes } from "@/modules/multichain/transaction-check/di/transactionCheckTypes"; +import { type TransactionCheckLoader } from "@/modules/multichain/transaction-check/loaders/TransactionCheckLoader"; +import { TransactionCheckPaths } from "@/modules/multichain/transaction-check/utils/constants"; +import { + ClearSignContext, + ClearSignContextType, +} from "@/shared/model/ClearSignContext"; + +export type SolanaTransactionCheckRequest = { + from: string; + rawTx: string; + chain: number; +}; + +export type SolanaTransactionCheckContextInput = { + deviceModelId: DeviceModelId; + transactionCheck: SolanaTransactionCheckRequest; +}; + +const SUPPORTED_TYPES: ClearSignContextType[] = [ + ClearSignContextType.SOLANA_TRANSACTION_CHECK, +]; + +const solanaTransactionCheckInputCodec = Codec.interface({ + deviceModelId: oneOf([ + exactly(DeviceModelId.NANO_X), + exactly(DeviceModelId.NANO_SP), + exactly(DeviceModelId.STAX), + exactly(DeviceModelId.FLEX), + ]), + transactionCheck: Codec.interface({ + from: string, + rawTx: string, + chain: number, + }), +}); + +@injectable() +export class SolanaTransactionCheckLoader + implements TransactionCheckLoader +{ + private readonly logger: LoggerPublisherService; + + constructor( + @inject(transactionCheckTypes.TransactionCheckDataSource) + private readonly transactionCheckDataSource: TransactionCheckDataSource, + @inject(pkiTypes.PkiCertificateLoader) + private readonly certificateLoader: PkiCertificateLoader, + @inject(configTypes.ContextModuleLoggerFactory) + loggerFactory: (tag: string) => LoggerPublisherService, + ) { + this.logger = loggerFactory("SolanaTransactionCheckLoader"); + } + + canHandle( + input: unknown, + expectedType: ClearSignContextType[], + ): input is SolanaTransactionCheckContextInput { + if (!SUPPORTED_TYPES.every((type) => expectedType.includes(type))) + return false; + return solanaTransactionCheckInputCodec.decode(input).caseOf({ + Left: () => false, + Right: ({ transactionCheck: { from, rawTx } }) => + from.length > 0 && rawTx.length > 0, + }); + } + + async load( + ctx: SolanaTransactionCheckContextInput, + ): Promise { + const { from, rawTx, chain } = ctx.transactionCheck; + + const txCheck = await this.transactionCheckDataSource.check({ + path: TransactionCheckPaths.SOLANA_TRANSACTION, + body: { tx: { from, raw: rawTx }, chain }, + }); + + const context = await txCheck.caseOf>({ + Left: (error) => + Promise.resolve({ + type: ClearSignContextType.ERROR, + error, + }), + Right: async (data) => { + const certificate = await this.certificateLoader.loadCertificate({ + keyId: data.publicKeyId, + keyUsage: KeyUsage.TxSimulationSigner, + targetDevice: ctx.deviceModelId, + }); + + return { + type: ClearSignContextType.SOLANA_TRANSACTION_CHECK, + payload: { descriptor: data.descriptor }, + certificate, + }; + }, + }); + + const result = [context]; + this.logger.debug("load result", { data: { result } }); + return result; + } +} diff --git a/packages/signer/context-module/src/modules/multichain/transaction-check/loaders/TransactionCheckLoader.ts b/packages/signer/context-module/src/modules/multichain/transaction-check/loaders/TransactionCheckLoader.ts index ce06ad10c3..f1ab091cce 100644 --- a/packages/signer/context-module/src/modules/multichain/transaction-check/loaders/TransactionCheckLoader.ts +++ b/packages/signer/context-module/src/modules/multichain/transaction-check/loaders/TransactionCheckLoader.ts @@ -2,8 +2,12 @@ import { type DeviceModelId } from "@ledgerhq/device-management-kit"; import { type ContextLoader } from "@/shared/domain/ContextLoader"; +/** + * Common requirement across all transaction-check loaders: a target device + * model is needed to fetch the right PKI certificate. Other fields (signer + * address, raw tx, chain id) are shaped per chain. + */ export type TransactionCheckInput = { - from: string; deviceModelId: DeviceModelId; }; diff --git a/packages/signer/context-module/src/modules/multichain/transaction-check/utils/constants.ts b/packages/signer/context-module/src/modules/multichain/transaction-check/utils/constants.ts index c30d441a02..650fd5131b 100644 --- a/packages/signer/context-module/src/modules/multichain/transaction-check/utils/constants.ts +++ b/packages/signer/context-module/src/modules/multichain/transaction-check/utils/constants.ts @@ -1,4 +1,5 @@ export enum TransactionCheckPaths { ETHEREUM_TRANSACTION = "/ethereum/scan/tx", ETHEREUM_TYPED_DATA = "/ethereum/scan/eip-712", + SOLANA_TRANSACTION = "/solana/scan/tx", } diff --git a/packages/signer/context-module/src/modules/solana/model/SolanaClearSignContext.ts b/packages/signer/context-module/src/modules/solana/model/SolanaClearSignContext.ts index 5f5c3d49e2..a0db12c523 100644 --- a/packages/signer/context-module/src/modules/solana/model/SolanaClearSignContext.ts +++ b/packages/signer/context-module/src/modules/solana/model/SolanaClearSignContext.ts @@ -2,6 +2,7 @@ import { type PkiCertificate } from "@/modules/multichain/pki/model/PkiCertifica import { type SolanaLifiPayload, type SolanaTokenData, + type SolanaTransactionCheckPayload, } from "@/modules/solana/model/SolanaPayloads"; import { type ClearSignContext, @@ -14,6 +15,7 @@ export type { SolanaLifiInstructionMeta, SolanaLifiPayload, SolanaTokenData, + SolanaTransactionCheckPayload, SolanaTransactionDescriptor, } from "@/modules/solana/model/SolanaPayloads"; @@ -34,12 +36,17 @@ export type SolanaPayloadOverrides = { payload: Uint8Array; certificate?: PkiCertificate; }; + [ClearSignContextType.SOLANA_TRANSACTION_CHECK]: { + payload: SolanaTransactionCheckPayload; + certificate?: PkiCertificate; + }; }; export const SolanaContextType = { TOKEN: ClearSignContextType.SOLANA_TOKEN, LIFI: ClearSignContextType.SOLANA_LIFI, TRUSTED_NAME: ClearSignContextType.SOLANA_TRUSTED_NAME, + TRANSACTION_CHECK: ClearSignContextType.SOLANA_TRANSACTION_CHECK, } as const; /** @@ -55,7 +62,7 @@ export type SolanaClearSignContextSuccess = ClearSignContextSuccess; /** - * Set of all Solana-relevant ClearSignContextType values. + * Set of all Solana-relevant success ClearSignContextType values. * Used by the type guard below to filter out non-Solana contexts at runtime. */ export const SOLANA_CLEAR_SIGN_CONTEXT_SUCCESS_TYPES = diff --git a/packages/signer/context-module/src/modules/solana/model/SolanaContextTypes.ts b/packages/signer/context-module/src/modules/solana/model/SolanaContextTypes.ts index 072ed08225..e902bbe101 100644 --- a/packages/signer/context-module/src/modules/solana/model/SolanaContextTypes.ts +++ b/packages/signer/context-module/src/modules/solana/model/SolanaContextTypes.ts @@ -39,7 +39,8 @@ export type SolanaTransactionDescriptorList = Record< export type SolanaContextSuccessType = | ClearSignContextType.SOLANA_TOKEN - | ClearSignContextType.SOLANA_LIFI; + | ClearSignContextType.SOLANA_LIFI + | ClearSignContextType.SOLANA_TRANSACTION_CHECK; export type SolanaContextSuccess< T extends SolanaContextSuccessType = SolanaContextSuccessType, @@ -47,11 +48,7 @@ export type SolanaContextSuccess< export type SolanaContextError = ClearSignContextError; -export type SolanaContext = - | ClearSignContextSuccess< - ClearSignContextType.SOLANA_TOKEN | ClearSignContextType.SOLANA_LIFI - > - | ClearSignContextError; +export type SolanaContext = SolanaContextSuccess | SolanaContextError; export type SolanaTokenContextSuccess = ClearSignContextSuccess; @@ -59,6 +56,9 @@ export type SolanaTokenContextSuccess = export type SolanaLifiContextSuccess = ClearSignContextSuccess; +export type SolanaTransactionCheckContextSuccess = + ClearSignContextSuccess; + export type SolanaTokenContextResult = | SolanaTokenContextSuccess | SolanaContextError; @@ -67,4 +67,11 @@ export type SolanaLifiContextResult = | SolanaLifiContextSuccess | SolanaContextError; -export type LoaderResult = SolanaTokenContextResult | SolanaLifiContextResult; +export type SolanaTransactionCheckContextResult = + | SolanaTransactionCheckContextSuccess + | SolanaContextError; + +export type LoaderResult = + | SolanaTokenContextResult + | SolanaLifiContextResult + | SolanaTransactionCheckContextResult; diff --git a/packages/signer/context-module/src/modules/solana/model/SolanaPayloads.ts b/packages/signer/context-module/src/modules/solana/model/SolanaPayloads.ts index fc5d0c7a44..01fb1cb89e 100644 --- a/packages/signer/context-module/src/modules/solana/model/SolanaPayloads.ts +++ b/packages/signer/context-module/src/modules/solana/model/SolanaPayloads.ts @@ -24,3 +24,7 @@ export type SolanaLifiPayload = { descriptors: Record; instructions: SolanaLifiInstructionMeta[]; }; + +export type SolanaTransactionCheckPayload = { + descriptor: string; +}; diff --git a/packages/signer/context-module/src/modules/solana/model/SolanaTransactionScanChainId.ts b/packages/signer/context-module/src/modules/solana/model/SolanaTransactionScanChainId.ts new file mode 100644 index 0000000000..4a597c1768 --- /dev/null +++ b/packages/signer/context-module/src/modules/solana/model/SolanaTransactionScanChainId.ts @@ -0,0 +1,9 @@ +/** + * Web3Checks scan chain identifiers for Solana clusters. + * Mirrors the backend contract for the `chain` field on Solana scan requests. + */ +export enum SolanaTransactionScanChainId { + MAINNET = 1, + DEVNET = 2, + TESTNET = 3, +} diff --git a/packages/signer/context-module/src/shared/model/ClearSignContext.ts b/packages/signer/context-module/src/shared/model/ClearSignContext.ts index ece3282684..1a8deecfd4 100644 --- a/packages/signer/context-module/src/shared/model/ClearSignContext.ts +++ b/packages/signer/context-module/src/shared/model/ClearSignContext.ts @@ -23,6 +23,7 @@ export enum ClearSignContextType { SOLANA_TOKEN = "solanaToken", SOLANA_LIFI = "solanaLifi", SOLANA_TRUSTED_NAME = "solanaTrustedName", + SOLANA_TRANSACTION_CHECK = "solanaTransactionCheck", } export type ClearSignContextSuccessType = Exclude< diff --git a/packages/signer/signer-solana/src/api/app-binder/SignTransactionDeviceActionTypes.ts b/packages/signer/signer-solana/src/api/app-binder/SignTransactionDeviceActionTypes.ts index d7e2b71b08..67b61f796c 100644 --- a/packages/signer/signer-solana/src/api/app-binder/SignTransactionDeviceActionTypes.ts +++ b/packages/signer/signer-solana/src/api/app-binder/SignTransactionDeviceActionTypes.ts @@ -24,7 +24,10 @@ import { type DelayedSignDAStateStep } from "./DelayedSignTransactionDeviceActio export const signTransactionDAStateSteps = Object.freeze({ OPEN_APP: "signer.sol.steps.openApp", GET_APP_CONFIG: "signer.sol.steps.getAppConfig", + WEB3_CHECKS_OPT_IN: "signer.sol.steps.web3ChecksOptIn", + WEB3_CHECKS_OPT_IN_RESULT: "signer.sol.steps.web3ChecksOptInResult", INSPECT_TRANSACTION: "signer.sol.steps.inspectTransaction", + GET_PUB_KEY: "signer.sol.steps.getPubKey", BUILD_TRANSACTION_CONTEXT: "signer.sol.steps.buildTransactionContext", PROVIDE_TRANSACTION_CONTEXT: "signer.sol.steps.provideTransactionContext", SIGN_TRANSACTION: "signer.sol.steps.signTransaction", @@ -54,10 +57,19 @@ type SignTransactionDARequiredInteraction = | UserInteractionRequired | OpenAppDARequiredInteraction; -export type SignTransactionDAIntermediateValue = { - requiredUserInteraction: SignTransactionDARequiredInteraction; - step: SignTransactionDAStateStep; -}; +export type SignTransactionDAIntermediateValue = + | { + requiredUserInteraction: SignTransactionDARequiredInteraction; + step: Exclude< + SignTransactionDAStateStep, + typeof signTransactionDAStateSteps.WEB3_CHECKS_OPT_IN_RESULT + >; + } + | { + requiredUserInteraction: UserInteractionRequired.None; + step: typeof signTransactionDAStateSteps.WEB3_CHECKS_OPT_IN_RESULT; + result: boolean; + }; export type SignTransactionDAState = DeviceActionState< SignTransactionDAOutput, @@ -71,6 +83,7 @@ export type SignTransactionDAInternalState = { readonly appConfig: AppConfiguration | null; readonly solanaTransactionContext: SolanaTransactionContextResultSuccess | null; readonly inspectorResult: TxInspectorResult | null; + readonly signerAddress: string | null; }; export type SignTransactionDAReturnType = ExecuteDeviceActionReturnType< diff --git a/packages/signer/signer-solana/src/api/model/AppConfiguration.ts b/packages/signer/signer-solana/src/api/model/AppConfiguration.ts index 29820f505c..a31712f0c6 100644 --- a/packages/signer/signer-solana/src/api/model/AppConfiguration.ts +++ b/packages/signer/signer-solana/src/api/model/AppConfiguration.ts @@ -4,4 +4,6 @@ export type AppConfiguration = { blindSigningEnabled: boolean; pubKeyDisplayMode: PublicKeyDisplayMode; version: string; + web3ChecksEnabled?: boolean; + web3ChecksOptIn?: boolean; }; diff --git a/packages/signer/signer-solana/src/internal/app-binder/SolanaApplicationResolver.test.ts b/packages/signer/signer-solana/src/internal/app-binder/SolanaApplicationResolver.test.ts index 64de5c094c..3892aa48ff 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/SolanaApplicationResolver.test.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/SolanaApplicationResolver.test.ts @@ -1,93 +1,75 @@ import { type AppConfig, DeviceModelId, - type DeviceSessionState, DeviceSessionStateType, DeviceStatus, } from "@ledgerhq/device-management-kit"; import { SolanaApplicationResolver } from "./SolanaApplicationResolver"; -describe("SolanaApplicationResolver", () => { - const resolver = new SolanaApplicationResolver(); +function createAppConfig(version: string): AppConfig { + return { + version, + blindSigningEnabled: false, + }; +} - function makeAppConfig(version: string): AppConfig { - return { version, blindSigningEnabled: false }; - } +function createReadyState( + appName: string, + appVersion: string, + modelId: DeviceModelId = DeviceModelId.FLEX, +) { + return { + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: DeviceStatus.CONNECTED, + installedApps: [], + currentApp: { name: appName, version: appVersion }, + deviceModelId: modelId, + isSecureConnectionAllowed: false, + }; +} - function makeReadyState( - appName: string, - appVersion: string, - modelId: DeviceModelId = DeviceModelId.FLEX, - ) { - return { - sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, - deviceStatus: DeviceStatus.CONNECTED, - installedApps: [], - currentApp: { name: appName, version: appVersion }, - deviceModelId: modelId, - isSecureConnectionAllowed: false, - }; - } - - it("should be incompatible when device session is not ready", () => { - const state = { - sessionStateType: DeviceSessionStateType.Connected, - deviceStatus: DeviceStatus.CONNECTED, - installedApps: [], - currentApp: { name: "Solana", version: "1.4.0" }, - deviceModelId: DeviceModelId.FLEX, - isSecureConnectionAllowed: false, - }; - expect(resolver.resolve(state, makeAppConfig("1.4.0"))).toStrictEqual({ - isCompatible: false, - version: "0.0.1", - }); - }); +function createConnectedState() { + return { + sessionStateType: DeviceSessionStateType.Connected, + deviceStatus: DeviceStatus.CONNECTED, + installedApps: [], + currentApp: { name: "Solana", version: "1.0.0" }, + deviceModelId: DeviceModelId.FLEX, + isSecureConnectionAllowed: false, + }; +} - it("should be incompatible when no app is open", () => { - const state = { - sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, - deviceStatus: DeviceStatus.CONNECTED, - installedApps: [], - currentApp: undefined, - deviceModelId: DeviceModelId.FLEX, - isSecureConnectionAllowed: false, - } as unknown as DeviceSessionState; - expect(resolver.resolve(state, makeAppConfig("1.4.0"))).toStrictEqual({ - isCompatible: false, - version: "0.0.1", - }); - }); +describe("SolanaApplicationResolver", () => { + const resolver = new SolanaApplicationResolver(); - it("should be incompatible when a different app is open", () => { - const state = makeReadyState("Bitcoin", "2.1.0"); - expect(resolver.resolve(state, makeAppConfig("1.4.0"))).toStrictEqual({ + it("should resolve as incompatible when device is Connected", () => { + const state = createConnectedState(); + const config = createAppConfig("1.0.0"); + const result = resolver.resolve(state, config); + expect(result).toStrictEqual({ isCompatible: false, version: "0.0.1", }); }); - it("should be compatible and use the version from appConfig when Solana is open", () => { - // appConfig.version comes from GetAppConfiguration executed live on the device, - // so it is the authoritative version source even for direct Solana signing. - const state = makeReadyState("Solana", "1.4.0"); - expect(resolver.resolve(state, makeAppConfig("1.4.0"))).toStrictEqual({ + it("should resolve as compatible with appConfig.version when app is Solana", () => { + const state = createReadyState("Solana", "1.4.0"); + const config = createAppConfig("1.0.0"); + const result = resolver.resolve(state, config); + expect(result).toStrictEqual({ isCompatible: true, - version: "1.4.0", + version: "1.0.0", }); }); - it("should be compatible and use Solana version from appConfig when Exchange is orchestrating", () => { - // During a swap, Exchange opens the Solana app on-device without going through - // OpenAppDeviceAction, so deviceState.currentApp stays as "Exchange". - // GetAppConfiguration is proxied by Exchange to Solana, so appConfig.version - // reflects the actual Solana version, not the Exchange app version. - const state = makeReadyState("Exchange", "4.4.2"); - const solanaAppConfig = makeAppConfig("1.9.5"); - expect(resolver.resolve(state, solanaAppConfig)).toStrictEqual({ - isCompatible: true, - version: "1.9.5", + it("should resolve as incompatible when app is not Solana", () => { + const state = createReadyState("Ethereum", "1.0.0"); + const config = createAppConfig("1.0.0"); + const result = resolver.resolve(state, config); + expect(result).toStrictEqual({ + isCompatible: false, + version: "0.0.1", }); }); }); diff --git a/packages/signer/signer-solana/src/internal/app-binder/SolanaApplicationResolver.ts b/packages/signer/signer-solana/src/internal/app-binder/SolanaApplicationResolver.ts index 1ac48f53e5..b4cb961ee1 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/SolanaApplicationResolver.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/SolanaApplicationResolver.ts @@ -1,6 +1,7 @@ import { type AppConfig, type ApplicationResolver, + DeviceModelId, type DeviceSessionState, DeviceSessionStateType, type ResolvedApp, @@ -11,8 +12,29 @@ import { APP_NAME } from "./constants"; const DEFAULT_VERSION = "0.0.1"; export const SOLANA_MIN_SPL_VERSION = "1.9.2"; export const SOLANA_MIN_DELAYED_SIGNING_VERSION = "1.14.0"; +export const SOLANA_MIN_WEB3_CHECKS_VERSION = "1.15.0"; -export const SOLANA_APP_SPL_MIN_VERSION = "1.9.2"; +export const SOLANA_FEATURES = { + spl: { + minVersion: SOLANA_MIN_SPL_VERSION, + excludedModels: [DeviceModelId.NANO_S], + excludedApps: [] as string[], + }, + web3Checks: { + minVersion: SOLANA_MIN_WEB3_CHECKS_VERSION, + excludedModels: [ + DeviceModelId.NANO_S, + DeviceModelId.NANO_SP, + DeviceModelId.NANO_X, + ], + excludedApps: ["Exchange"], + }, + delayedSigning: { + minVersion: SOLANA_MIN_DELAYED_SIGNING_VERSION, + excludedModels: [] as DeviceModelId[], + excludedApps: [] as string[], + }, +} as const; export class SolanaApplicationResolver implements ApplicationResolver { resolve(deviceState: DeviceSessionState, appConfig: AppConfig): ResolvedApp { diff --git a/packages/signer/signer-solana/src/internal/app-binder/command/GetAppConfigurationCommand.test.ts b/packages/signer/signer-solana/src/internal/app-binder/command/GetAppConfigurationCommand.test.ts index ebec6b4376..1d8811cb99 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/command/GetAppConfigurationCommand.test.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/command/GetAppConfigurationCommand.test.ts @@ -59,6 +59,8 @@ describe("GetAppConfigurationCommand", () => { blindSigningEnabled: true, pubKeyDisplayMode: PublicKeyDisplayMode.LONG, version: "2.5.10", + web3ChecksEnabled: false, + web3ChecksOptIn: false, }, }), ); @@ -74,6 +76,27 @@ describe("GetAppConfigurationCommand", () => { blindSigningEnabled: true, pubKeyDisplayMode: PublicKeyDisplayMode.SHORT, version: "2.5.10", + web3ChecksEnabled: false, + web3ChecksOptIn: false, + }, + }), + ); + }); + + it("should parse extended feature flags from a 6th byte", () => { + const response = new ApduResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: new Uint8Array([0x01, 0x00, 0x02, 0x05, 0x0a, 0x30]), + }); + const parsed = command.parseResponse(response); + expect(parsed).toStrictEqual( + CommandResultFactory({ + data: { + blindSigningEnabled: true, + pubKeyDisplayMode: PublicKeyDisplayMode.LONG, + version: "2.5.10", + web3ChecksEnabled: true, + web3ChecksOptIn: true, }, }), ); diff --git a/packages/signer/signer-solana/src/internal/app-binder/command/GetAppConfigurationCommand.ts b/packages/signer/signer-solana/src/internal/app-binder/command/GetAppConfigurationCommand.ts index 47e70621a5..2ec5f03c05 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/command/GetAppConfigurationCommand.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/command/GetAppConfigurationCommand.ts @@ -1,7 +1,6 @@ import { type Apdu, ApduBuilder, - ApduParser, type ApduResponse, type Command, type CommandResult, @@ -20,6 +19,21 @@ import { type SolanaAppErrorCodes, } from "./utils/SolanaApplicationErrors"; +const APDU_CLA = 0xe0; +const APDU_INS_GET_APP_CONFIGURATION = 0x04; + +const RESPONSE_OFFSET_BLIND_SIGNING = 0; +const RESPONSE_OFFSET_PUB_KEY_DISPLAY_MODE = 1; +const RESPONSE_OFFSET_VERSION_MAJOR = 2; +const RESPONSE_OFFSET_VERSION_MINOR = 3; +const RESPONSE_OFFSET_VERSION_PATCH = 4; +const RESPONSE_OFFSET_FEATURE_FLAGS = 5; +const RESPONSE_BASE_LENGTH = 5; +const RESPONSE_LENGTH_WITH_FEATURE_FLAGS = 6; + +const FEATURE_FLAG_WEB3_CHECKS_ENABLED = 0x10; +const FEATURE_FLAG_WEB3_CHECKS_OPT_IN = 0x20; + type GetAppConfigurationCommandArgs = void; export class GetAppConfigurationCommand @@ -44,8 +58,8 @@ export class GetAppConfigurationCommand getApdu(): Apdu { return new ApduBuilder({ - cla: 0xe0, - ins: 0x04, + cla: APDU_CLA, + ins: APDU_INS_GET_APP_CONFIGURATION, p1: 0x00, p2: 0x00, }).build(); @@ -57,25 +71,34 @@ export class GetAppConfigurationCommand return Maybe.fromNullable( this.errorHelper.getError(response), ).orDefaultLazy(() => { - const parser = new ApduParser(response); - const buffer = parser.extractFieldByLength(5); - if ( - !buffer || - buffer.length !== 5 || - buffer.some((element) => element === undefined) - ) { + const { data } = response; + if (data.length < RESPONSE_BASE_LENGTH) { return CommandResultFactory({ error: new InvalidStatusWordError("Invalid response"), }); } + const blindSigningEnabled = Boolean(data[RESPONSE_OFFSET_BLIND_SIGNING]); + const pubKeyDisplayMode = + data[RESPONSE_OFFSET_PUB_KEY_DISPLAY_MODE] === 0 + ? PublicKeyDisplayMode.LONG + : PublicKeyDisplayMode.SHORT; + const version = `${data[RESPONSE_OFFSET_VERSION_MAJOR]}.${data[RESPONSE_OFFSET_VERSION_MINOR]}.${data[RESPONSE_OFFSET_VERSION_PATCH]}`; + + let web3ChecksEnabled = false; + let web3ChecksOptIn = false; + if (data.length >= RESPONSE_LENGTH_WITH_FEATURE_FLAGS) { + const featureFlags = data[RESPONSE_OFFSET_FEATURE_FLAGS]!; + web3ChecksEnabled = !!(featureFlags & FEATURE_FLAG_WEB3_CHECKS_ENABLED); + web3ChecksOptIn = !!(featureFlags & FEATURE_FLAG_WEB3_CHECKS_OPT_IN); + } + const config: AppConfiguration = { - blindSigningEnabled: Boolean(buffer[0]), - pubKeyDisplayMode: - buffer[1] === 0 - ? PublicKeyDisplayMode.LONG - : PublicKeyDisplayMode.SHORT, - version: `${buffer[2]}.${buffer[3]}.${buffer[4]}`, + blindSigningEnabled, + pubKeyDisplayMode, + version, + web3ChecksEnabled, + web3ChecksOptIn, }; return CommandResultFactory({ diff --git a/packages/signer/signer-solana/src/internal/app-binder/command/ProvideWeb3CheckCommand.test.ts b/packages/signer/signer-solana/src/internal/app-binder/command/ProvideWeb3CheckCommand.test.ts new file mode 100644 index 0000000000..0b58ff4c47 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/command/ProvideWeb3CheckCommand.test.ts @@ -0,0 +1,104 @@ +import { isSuccessCommandResult } from "@ledgerhq/device-management-kit"; + +import { + P2_EXTEND, + P2_MORE, + ProvideWeb3CheckCommand, + TRANSACTION_CHECK_CLA, + TRANSACTION_CHECK_INS, + TRANSACTION_CHECK_P1_PROVIDE, +} from "@internal/app-binder/command/ProvideWeb3CheckCommand"; + +describe("ProvideWeb3CheckCommand", () => { + describe("name", () => { + it("should be 'provideWeb3Check'", () => { + const command = new ProvideWeb3CheckCommand({ + payload: new Uint8Array([0xaa]), + isFirstChunk: true, + hasMore: false, + }); + expect(command.name).toBe("provideWeb3Check"); + }); + }); + + describe("getApdu", () => { + const payload = new Uint8Array([0xaa, 0xbb, 0xcc]); + + it("single chunk: P2 = 0x00 (no EXTEND, no MORE)", () => { + const command = new ProvideWeb3CheckCommand({ + payload, + isFirstChunk: true, + hasMore: false, + }); + const raw = command.getApdu().getRawApdu(); + + expect(raw[0]).toBe(TRANSACTION_CHECK_CLA); + expect(raw[1]).toBe(TRANSACTION_CHECK_INS); + expect(raw[2]).toBe(TRANSACTION_CHECK_P1_PROVIDE); + expect(raw[3]).toBe(0x00); + expect(raw[4]).toBe(payload.length); + expect(raw.slice(5)).toStrictEqual(payload); + }); + + it("first of many: P2 = P2_MORE", () => { + const command = new ProvideWeb3CheckCommand({ + payload, + isFirstChunk: true, + hasMore: true, + }); + const raw = command.getApdu().getRawApdu(); + + expect(raw[3]).toBe(P2_MORE); + }); + + it("middle chunk: P2 = P2_MORE | P2_EXTEND", () => { + const command = new ProvideWeb3CheckCommand({ + payload, + isFirstChunk: false, + hasMore: true, + }); + const raw = command.getApdu().getRawApdu(); + + expect(raw[3]).toBe(P2_MORE | P2_EXTEND); + }); + + it("last chunk: P2 = P2_EXTEND", () => { + const command = new ProvideWeb3CheckCommand({ + payload, + isFirstChunk: false, + hasMore: false, + }); + const raw = command.getApdu().getRawApdu(); + + expect(raw[3]).toBe(P2_EXTEND); + }); + }); + + describe("parseResponse", () => { + it("should return success on 0x9000", () => { + const command = new ProvideWeb3CheckCommand({ + payload: new Uint8Array([0xaa]), + isFirstChunk: true, + hasMore: false, + }); + const result = command.parseResponse({ + statusCode: Uint8Array.from([0x90, 0x00]), + data: new Uint8Array(), + }); + expect(isSuccessCommandResult(result)).toBe(true); + }); + + it("should return error on non-success status", () => { + const command = new ProvideWeb3CheckCommand({ + payload: new Uint8Array([0xaa]), + isFirstChunk: true, + hasMore: false, + }); + const result = command.parseResponse({ + statusCode: Uint8Array.from([0x6c, 0xb0]), + data: new Uint8Array(), + }); + expect(isSuccessCommandResult(result)).toBe(false); + }); + }); +}); diff --git a/packages/signer/signer-solana/src/internal/app-binder/command/ProvideWeb3CheckCommand.ts b/packages/signer/signer-solana/src/internal/app-binder/command/ProvideWeb3CheckCommand.ts new file mode 100644 index 0000000000..6ad662a372 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/command/ProvideWeb3CheckCommand.ts @@ -0,0 +1,74 @@ +import { + type Apdu, + ApduBuilder, + type ApduBuilderArgs, + type ApduResponse, + type Command, + type CommandResult, + CommandResultFactory, +} from "@ledgerhq/device-management-kit"; +import { CommandErrorHelper } from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; + +import { + SOLANA_APP_ERRORS, + SolanaAppCommandErrorFactory, + type SolanaAppErrorCodes, +} from "./utils/SolanaApplicationErrors"; + +export const TRANSACTION_CHECK_CLA = 0xe0; +export const TRANSACTION_CHECK_INS = 0x23; +export const TRANSACTION_CHECK_P1_PROVIDE = 0x00; +export const P2_EXTEND = 0x01; +export const P2_MORE = 0x02; + +export type ProvideWeb3CheckCommandArgs = { + readonly payload: Uint8Array; + readonly isFirstChunk: boolean; + readonly hasMore: boolean; +}; + +/** + * Sends a chunk of the Web3Checks transaction-check descriptor to the Solana app. + * P2 uses the standard EXTEND/MORE chunking protocol: + * - Single chunk: P2 = 0x00 + * - First of many: P2 = 0x02 (MORE) + * - Middle: P2 = 0x03 (MORE | EXTEND) + * - Last: P2 = 0x01 (EXTEND) + */ +export class ProvideWeb3CheckCommand + implements Command +{ + readonly name = "provideWeb3Check"; + private readonly errorHelper = new CommandErrorHelper< + void, + SolanaAppErrorCodes + >(SOLANA_APP_ERRORS, SolanaAppCommandErrorFactory); + + constructor(private readonly args: ProvideWeb3CheckCommandArgs) {} + + getApdu(): Apdu { + let p2 = 0x00; + if (!this.args.isFirstChunk) p2 |= P2_EXTEND; + if (this.args.hasMore) p2 |= P2_MORE; + + const apduBuilderArgs: ApduBuilderArgs = { + cla: TRANSACTION_CHECK_CLA, + ins: TRANSACTION_CHECK_INS, + p1: TRANSACTION_CHECK_P1_PROVIDE, + p2, + }; + + return new ApduBuilder(apduBuilderArgs) + .addBufferToData(this.args.payload) + .build(); + } + + parseResponse( + response: ApduResponse, + ): CommandResult { + return Maybe.fromNullable(this.errorHelper.getError(response)).orDefault( + CommandResultFactory({ data: undefined }), + ); + } +} diff --git a/packages/signer/signer-solana/src/internal/app-binder/command/Web3CheckOptInCommand.test.ts b/packages/signer/signer-solana/src/internal/app-binder/command/Web3CheckOptInCommand.test.ts new file mode 100644 index 0000000000..d13b48a5a8 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/command/Web3CheckOptInCommand.test.ts @@ -0,0 +1,76 @@ +import { isSuccessCommandResult } from "@ledgerhq/device-management-kit"; + +import { + TRANSACTION_CHECK_CLA, + TRANSACTION_CHECK_INS, +} from "@internal/app-binder/command/ProvideWeb3CheckCommand"; +import { + TRANSACTION_CHECK_P1_OPT_IN, + Web3CheckOptInCommand, +} from "@internal/app-binder/command/Web3CheckOptInCommand"; + +describe("Web3CheckOptInCommand", () => { + describe("name", () => { + it("should be 'web3CheckOptIn'", () => { + const command = new Web3CheckOptInCommand(); + expect(command.name).toBe("web3CheckOptIn"); + }); + }); + + describe("getApdu", () => { + it("should return the raw APDU with correct header and dummy payload", () => { + const command = new Web3CheckOptInCommand(); + const apdu = command.getApdu(); + const raw = apdu.getRawApdu(); + + expect(raw[0]).toBe(TRANSACTION_CHECK_CLA); + expect(raw[1]).toBe(TRANSACTION_CHECK_INS); + expect(raw[2]).toBe(TRANSACTION_CHECK_P1_OPT_IN); + expect(raw[3]).toBe(0x00); // P2 + expect(raw[4]).toBe(0x01); // Lc (1 byte payload) + expect(raw[5]).toBe(0x00); // dummy byte + expect(raw.length).toBe(6); + }); + }); + + describe("parseResponse", () => { + it("should return enabled: true", () => { + const response = { + statusCode: Uint8Array.from([0x90, 0x00]), + data: new Uint8Array([0x01]), + }; + const result = new Web3CheckOptInCommand().parseResponse(response); + if (isSuccessCommandResult(result)) { + expect(result.data).toStrictEqual({ enabled: true }); + } else { + assert.fail("Expected a success"); + } + }); + + it("should return enabled: false", () => { + const response = { + statusCode: Uint8Array.from([0x90, 0x00]), + data: new Uint8Array([0x00]), + }; + const result = new Web3CheckOptInCommand().parseResponse(response); + if (isSuccessCommandResult(result)) { + expect(result.data).toStrictEqual({ enabled: false }); + } else { + assert.fail("Expected a success"); + } + }); + + it("should return enabled: false if missing byte", () => { + const response = { + statusCode: Uint8Array.from([0x90, 0x00]), + data: new Uint8Array([]), + }; + const result = new Web3CheckOptInCommand().parseResponse(response); + if (isSuccessCommandResult(result)) { + expect(result.data).toStrictEqual({ enabled: false }); + } else { + assert.fail("Expected a success"); + } + }); + }); +}); diff --git a/packages/signer/signer-solana/src/internal/app-binder/command/Web3CheckOptInCommand.ts b/packages/signer/signer-solana/src/internal/app-binder/command/Web3CheckOptInCommand.ts new file mode 100644 index 0000000000..3648585cf5 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/command/Web3CheckOptInCommand.ts @@ -0,0 +1,68 @@ +import { + type Apdu, + ApduBuilder, + type ApduBuilderArgs, + ApduParser, + type ApduResponse, + type Command, + type CommandResult, + CommandResultFactory, +} from "@ledgerhq/device-management-kit"; +import { CommandErrorHelper } from "@ledgerhq/signer-utils"; +import { Maybe } from "purify-ts"; + +import { + SOLANA_APP_ERRORS, + SolanaAppCommandErrorFactory, + type SolanaAppErrorCodes, +} from "./utils/SolanaApplicationErrors"; +import { + TRANSACTION_CHECK_CLA, + TRANSACTION_CHECK_INS, +} from "./ProvideWeb3CheckCommand"; + +export const TRANSACTION_CHECK_P1_OPT_IN = 0x01; + +export type Web3CheckOptInCommandResponse = { + enabled: boolean; +}; + +/** + * Triggers Web3Checks opt-in on the Solana app. + */ +export class Web3CheckOptInCommand + implements Command +{ + readonly name = "web3CheckOptIn"; + private readonly errorHelper = new CommandErrorHelper< + Web3CheckOptInCommandResponse, + SolanaAppErrorCodes + >(SOLANA_APP_ERRORS, SolanaAppCommandErrorFactory); + + getApdu(): Apdu { + const apduBuilderArgs: ApduBuilderArgs = { + cla: TRANSACTION_CHECK_CLA, + ins: TRANSACTION_CHECK_INS, + p1: TRANSACTION_CHECK_P1_OPT_IN, + p2: 0x00, + }; + + return new ApduBuilder(apduBuilderArgs) + .addBufferToData(new Uint8Array([0x00])) + .build(); + } + + parseResponse( + response: ApduResponse, + ): CommandResult { + return Maybe.fromNullable( + this.errorHelper.getError(response), + ).orDefaultLazy(() => { + const enabled = new ApduParser(response).extract8BitUInt(); + if (enabled === undefined) { + return CommandResultFactory({ data: { enabled: false } }); + } + return CommandResultFactory({ data: { enabled: enabled !== 0 } }); + }); + } +} diff --git a/packages/signer/signer-solana/src/internal/app-binder/device-action/SignTransactionDeviceAction.test.ts b/packages/signer/signer-solana/src/internal/app-binder/device-action/SignTransactionDeviceAction.test.ts index 6a88213f20..d4afb2acda 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/device-action/SignTransactionDeviceAction.test.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/device-action/SignTransactionDeviceAction.test.ts @@ -24,8 +24,8 @@ import { SolanaAppCommandError } from "@internal/app-binder/command/utils/Solana import { testDeviceActionStates } from "@internal/app-binder/device-action/__test-utils__/testDeviceActionStates"; import { SolanaTransactionTypes } from "@internal/app-binder/services/TransactionInspector"; import { - SOLANA_APP_SPL_MIN_VERSION, SOLANA_MIN_DELAYED_SIGNING_VERSION, + SOLANA_MIN_SPL_VERSION, } from "@internal/app-binder/SolanaApplicationResolver"; import { type SolanaBuildContextResult } from "@internal/app-binder/task/BuildTransactionContextTask"; @@ -36,13 +36,13 @@ import { SignTransactionDeviceAction } from "./SignTransactionDeviceAction"; const defaultDerivation = "44'/501'/0'/0'"; const exampleTx = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); -function makeAppConfig(version: string): AppConfiguration { - return { - version, - blindSigningEnabled: false, - pubKeyDisplayMode: PublicKeyDisplayMode.SHORT, - }; -} +const defaultAppConfig: AppConfiguration = { + blindSigningEnabled: true, + pubKeyDisplayMode: PublicKeyDisplayMode.LONG, + version: "2.5.10", + web3ChecksEnabled: false, + web3ChecksOptIn: false, +}; const contextModuleStub: ContextModule = { getContexts: vi.fn(), @@ -50,6 +50,8 @@ const contextModuleStub: ContextModule = { let apiMock: ReturnType; let getAppConfigMock: ReturnType; +let web3CheckOptInMock: ReturnType; +let getPubKeyMock: ReturnType; let buildContextMock: ReturnType; let provideContextMock: ReturnType; let signMock: ReturnType; @@ -58,6 +60,8 @@ let inspectTransactionMock: ReturnType; function extractDeps() { return { getAppConfig: getAppConfigMock, + web3CheckOptIn: web3CheckOptInMock, + getPubKey: getPubKeyMock, buildContext: buildContextMock, provideContext: provideContextMock, signTransaction: signMock, @@ -69,6 +73,14 @@ describe("SignTransactionDeviceAction (Solana)", () => { beforeEach(() => { apiMock = makeDeviceActionInternalApiMock(); getAppConfigMock = vi.fn(); + web3CheckOptInMock = vi + .fn() + .mockResolvedValue(CommandResultFactory({ data: { enabled: true } })); + getPubKeyMock = vi.fn().mockResolvedValue( + CommandResultFactory({ + data: "So1anaSignerPubKey111111111111111111111111111", + }), + ); buildContextMock = vi.fn(); provideContextMock = vi.fn(); signMock = vi.fn(); @@ -90,7 +102,7 @@ describe("SignTransactionDeviceAction (Solana)", () => { }); getAppConfigMock.mockResolvedValue( - CommandResultFactory({ data: makeAppConfig("1.10.0") }), + CommandResultFactory({ data: { version: "1.10.0" } }), ); inspectTransactionMock.mockImplementation( async (arg: { rpcUrl?: string }) => { @@ -167,7 +179,7 @@ describe("SignTransactionDeviceAction (Solana)", () => { }); getAppConfigMock.mockResolvedValue( - CommandResultFactory({ data: makeAppConfig("1.10.0") }), + CommandResultFactory({ data: defaultAppConfig }), ); inspectTransactionMock.mockResolvedValue({ transactionType: SolanaTransactionTypes.SPL, @@ -219,6 +231,14 @@ describe("SignTransactionDeviceAction (Solana)", () => { }, status: DeviceActionStatus.Pending, }, + // getPubKey + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.GET_PUB_KEY, + }, + status: DeviceActionStatus.Pending, + }, // buildContext { intermediateValue: { @@ -271,7 +291,7 @@ describe("SignTransactionDeviceAction (Solana)", () => { }); getAppConfigMock.mockResolvedValue( - CommandResultFactory({ data: makeAppConfig("1.10.0") }), + CommandResultFactory({ data: defaultAppConfig }), ); // InspectTransaction fails, machine transitions to SignTransaction @@ -348,7 +368,7 @@ describe("SignTransactionDeviceAction (Solana)", () => { }); getAppConfigMock.mockResolvedValue( - CommandResultFactory({ data: makeAppConfig("1.10.0") }), + CommandResultFactory({ data: defaultAppConfig }), ); inspectTransactionMock.mockResolvedValue({ transactionType: SolanaTransactionTypes.SPL, @@ -387,6 +407,14 @@ describe("SignTransactionDeviceAction (Solana)", () => { }, status: DeviceActionStatus.Pending, }, + // getPubKey + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.GET_PUB_KEY, + }, + status: DeviceActionStatus.Pending, + }, // buildContext (fails, but we still saw the pending step) { intermediateValue: { @@ -431,7 +459,7 @@ describe("SignTransactionDeviceAction (Solana)", () => { }); getAppConfigMock.mockResolvedValue( - CommandResultFactory({ data: makeAppConfig("1.10.0") }), + CommandResultFactory({ data: defaultAppConfig }), ); inspectTransactionMock.mockResolvedValue({ transactionType: SolanaTransactionTypes.SPL, @@ -483,6 +511,14 @@ describe("SignTransactionDeviceAction (Solana)", () => { }, status: DeviceActionStatus.Pending, }, + // getPubKey + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.GET_PUB_KEY, + }, + status: DeviceActionStatus.Pending, + }, // buildContext { intermediateValue: { @@ -538,7 +574,7 @@ describe("SignTransactionDeviceAction (Solana)", () => { }); getAppConfigMock.mockResolvedValue( - CommandResultFactory({ data: makeAppConfig("1.10.0") }), + CommandResultFactory({ data: defaultAppConfig }), ); inspectTransactionMock.mockResolvedValue({ transactionType: SolanaTransactionTypes.SPL, @@ -590,6 +626,14 @@ describe("SignTransactionDeviceAction (Solana)", () => { }, status: DeviceActionStatus.Pending, }, + // getPubKey + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.GET_PUB_KEY, + }, + status: DeviceActionStatus.Pending, + }, // buildContext { intermediateValue: { @@ -642,7 +686,7 @@ describe("SignTransactionDeviceAction (Solana)", () => { }); getAppConfigMock.mockResolvedValue( - CommandResultFactory({ data: makeAppConfig("1.10.0") }), + CommandResultFactory({ data: defaultAppConfig }), ); inspectTransactionMock.mockResolvedValue({ transactionType: SolanaTransactionTypes.SWAP, @@ -698,6 +742,13 @@ describe("SignTransactionDeviceAction (Solana)", () => { }, status: DeviceActionStatus.Pending, }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.GET_PUB_KEY, + }, + status: DeviceActionStatus.Pending, + }, { intermediateValue: { requiredUserInteraction: UserInteractionRequired.None, @@ -753,7 +804,7 @@ describe("SignTransactionDeviceAction (Solana)", () => { }); getAppConfigMock.mockResolvedValue( - CommandResultFactory({ data: makeAppConfig("1.10.0") }), + CommandResultFactory({ data: defaultAppConfig }), ); inspectTransactionMock.mockResolvedValue({ transactionType: SolanaTransactionTypes.SPL, @@ -793,6 +844,14 @@ describe("SignTransactionDeviceAction (Solana)", () => { }, status: DeviceActionStatus.Pending, }, + // getPubKey + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.GET_PUB_KEY, + }, + status: DeviceActionStatus.Pending, + }, // buildContext (throws) { intermediateValue: { @@ -837,7 +896,7 @@ describe("SignTransactionDeviceAction (Solana)", () => { }); getAppConfigMock.mockResolvedValue( - CommandResultFactory({ data: makeAppConfig("1.10.0") }), + CommandResultFactory({ data: { version: "1.10.0" } }), ); inspectTransactionMock.mockRejectedValue( new InvalidStatusWordError("inspErr"), @@ -911,7 +970,7 @@ describe("SignTransactionDeviceAction (Solana)", () => { }); getAppConfigMock.mockResolvedValue( - CommandResultFactory({ data: makeAppConfig(belowDelayedVersion) }), + CommandResultFactory({ data: { version: "1.10.0" } }), ); inspectTransactionMock.mockRejectedValue( new InvalidStatusWordError("inspErr"), @@ -970,7 +1029,7 @@ describe("SignTransactionDeviceAction (Solana)", () => { }); }); - it(`app version strictly below ${SOLANA_APP_SPL_MIN_VERSION} skips SPL pipeline and signs directly`, () => + it(`app version strictly below ${SOLANA_MIN_SPL_VERSION} skips SPL pipeline and signs directly`, () => new Promise((resolve, reject) => { apiMock.getDeviceSessionState.mockReturnValue({ sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, @@ -982,7 +1041,9 @@ describe("SignTransactionDeviceAction (Solana)", () => { }); getAppConfigMock.mockResolvedValue( - CommandResultFactory({ data: makeAppConfig("1.9.1") }), + CommandResultFactory({ + data: { ...defaultAppConfig, version: "1.9.1" }, + }), ); const sig = new Uint8Array([0xa1, 0xb2]); @@ -1043,7 +1104,7 @@ describe("SignTransactionDeviceAction (Solana)", () => { }); getAppConfigMock.mockResolvedValue( - CommandResultFactory({ data: makeAppConfig("1.10.0") }), + CommandResultFactory({ data: { version: "1.10.0" } }), ); const sig = new Uint8Array([0xc3, 0xd4]); @@ -1104,7 +1165,7 @@ describe("SignTransactionDeviceAction (Solana)", () => { }); getAppConfigMock.mockResolvedValue( - CommandResultFactory({ data: makeAppConfig("1.10.0") }), + CommandResultFactory({ data: { version: "1.10.0" } }), ); inspectTransactionMock.mockRejectedValue( new InvalidStatusWordError("inspErr"), @@ -1161,6 +1222,454 @@ describe("SignTransactionDeviceAction (Solana)", () => { onError: reject, }); })); + + it("non-SPL web3checks-supported: inspects then routes to getPubKey -> build -> provide -> sign", () => + new Promise((resolve, reject) => { + apiMock.getDeviceSessionState.mockReturnValue({ + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: DeviceStatus.CONNECTED, + installedApps: [], + currentApp: { name: "Solana", version: "2.0.0" }, + deviceModelId: DeviceModelId.FLEX, + isSecureConnectionAllowed: true, + }); + + getAppConfigMock.mockResolvedValue( + CommandResultFactory({ + data: { + ...defaultAppConfig, + version: "2.0.0", + web3ChecksEnabled: true, + web3ChecksOptIn: true, + }, + }), + ); + + inspectTransactionMock.mockResolvedValue({ + transactionType: SolanaTransactionTypes.STANDARD, + }); + + buildContextMock.mockResolvedValue({ + loadersResults: [], + }); + provideContextMock.mockResolvedValue(Nothing); + + const sig = new Uint8Array([0xaa, 0xbb]); + signMock.mockResolvedValue(CommandResultFactory({ data: Just(sig) })); + + const action = new SignTransactionDeviceAction({ + input: { + derivationPath: defaultDerivation, + transaction: exampleTx, + transactionOptions: { + skipOpenApp: true, + }, + contextModule: contextModuleStub, + }, + }); + vi.spyOn(action, "extractDependencies").mockReturnValue(extractDeps()); + + const expected = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.GET_APP_CONFIG, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.INSPECT_TRANSACTION, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.GET_PUB_KEY, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.BUILD_TRANSACTION_CONTEXT, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.PROVIDE_TRANSACTION_CONTEXT, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + step: signTransactionDAStateSteps.SIGN_TRANSACTION, + }, + status: DeviceActionStatus.Pending, + }, + { output: sig, status: DeviceActionStatus.Completed }, + ] as DeviceActionState< + Uint8Array, + SignTransactionDAError, + SignTransactionDAIntermediateValue + >[]; + + testDeviceActionStates(action, expected, apiMock, { + onDone: () => { + expect(inspectTransactionMock).toHaveBeenCalledTimes(1); + expect(getPubKeyMock).toHaveBeenCalledTimes(1); + expect(buildContextMock).toHaveBeenCalledTimes(1); + expect(provideContextMock).toHaveBeenCalledTimes(1); + resolve(); + }, + onError: reject, + }); + })); + + it("non-SPL on unsupported device (NANO_X): skips GetPubKey/BuildContext and signs directly", () => + new Promise((resolve, reject) => { + apiMock.getDeviceSessionState.mockReturnValue({ + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: DeviceStatus.CONNECTED, + installedApps: [], + currentApp: { name: "Solana", version: "2.0.0" }, + deviceModelId: DeviceModelId.NANO_X, + isSecureConnectionAllowed: true, + }); + + getAppConfigMock.mockResolvedValue( + CommandResultFactory({ + data: { + ...defaultAppConfig, + version: "2.0.0", + web3ChecksEnabled: true, + web3ChecksOptIn: true, + }, + }), + ); + + inspectTransactionMock.mockResolvedValue({ + transactionType: SolanaTransactionTypes.STANDARD, + }); + + const sig = new Uint8Array([0xaa, 0xbb]); + signMock.mockResolvedValue(CommandResultFactory({ data: Just(sig) })); + + const action = new SignTransactionDeviceAction({ + input: { + derivationPath: defaultDerivation, + transaction: exampleTx, + transactionOptions: { + skipOpenApp: true, + }, + contextModule: contextModuleStub, + }, + }); + vi.spyOn(action, "extractDependencies").mockReturnValue(extractDeps()); + + const expected = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.GET_APP_CONFIG, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.INSPECT_TRANSACTION, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + step: signTransactionDAStateSteps.SIGN_TRANSACTION, + }, + status: DeviceActionStatus.Pending, + }, + { output: sig, status: DeviceActionStatus.Completed }, + ] as DeviceActionState< + Uint8Array, + SignTransactionDAError, + SignTransactionDAIntermediateValue + >[]; + + testDeviceActionStates(action, expected, apiMock, { + onDone: () => { + expect(inspectTransactionMock).toHaveBeenCalledTimes(1); + expect(getPubKeyMock).not.toHaveBeenCalled(); + expect(buildContextMock).not.toHaveBeenCalled(); + expect(provideContextMock).not.toHaveBeenCalled(); + resolve(); + }, + onError: reject, + }); + })); + + it("Flex: Web3Checks opt-in runs before inspect when app requires it", () => + new Promise((resolve, reject) => { + apiMock.getDeviceSessionState.mockReturnValue({ + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: DeviceStatus.CONNECTED, + installedApps: [], + currentApp: { name: "Solana", version: "2.0.0" }, + deviceModelId: DeviceModelId.FLEX, + isSecureConnectionAllowed: true, + }); + + getAppConfigMock.mockResolvedValue( + CommandResultFactory({ + data: { + ...defaultAppConfig, + version: "2.0.0", + web3ChecksEnabled: false, + web3ChecksOptIn: false, + }, + }), + ); + + web3CheckOptInMock.mockResolvedValue( + CommandResultFactory({ data: { enabled: true } }), + ); + + inspectTransactionMock.mockResolvedValue({ + transactionType: SolanaTransactionTypes.STANDARD, + data: {}, + }); + + buildContextMock.mockResolvedValue({ + loadersResults: [], + }); + provideContextMock.mockResolvedValue(Nothing); + + const sig = new Uint8Array([0x01, 0x02]); + signMock.mockResolvedValue(CommandResultFactory({ data: Just(sig) })); + + const action = new SignTransactionDeviceAction({ + input: { + derivationPath: defaultDerivation, + transaction: exampleTx, + transactionOptions: { skipOpenApp: true }, + contextModule: contextModuleStub, + }, + }); + vi.spyOn(action, "extractDependencies").mockReturnValue(extractDeps()); + + const expected = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.GET_APP_CONFIG, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.Web3ChecksOptIn, + step: signTransactionDAStateSteps.WEB3_CHECKS_OPT_IN, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.WEB3_CHECKS_OPT_IN_RESULT, + result: true, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.INSPECT_TRANSACTION, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.GET_PUB_KEY, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.BUILD_TRANSACTION_CONTEXT, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.PROVIDE_TRANSACTION_CONTEXT, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + step: signTransactionDAStateSteps.SIGN_TRANSACTION, + }, + status: DeviceActionStatus.Pending, + }, + { output: sig, status: DeviceActionStatus.Completed }, + ] as DeviceActionState< + Uint8Array, + SignTransactionDAError, + SignTransactionDAIntermediateValue + >[]; + + testDeviceActionStates< + Uint8Array, + SignTransactionDAInput, + SignTransactionDAError, + SignTransactionDAIntermediateValue + >(action, expected, apiMock, { + onDone: () => { + expect(web3CheckOptInMock).toHaveBeenCalledTimes(1); + resolve(); + }, + onError: reject, + }); + })); + + it("Flex: Web3Checks opt-in command error proceeds without web3checks", () => + new Promise((resolve, reject) => { + apiMock.getDeviceSessionState.mockReturnValue({ + sessionStateType: DeviceSessionStateType.ReadyWithoutSecureChannel, + deviceStatus: DeviceStatus.CONNECTED, + installedApps: [], + currentApp: { name: "Solana", version: "2.0.0" }, + deviceModelId: DeviceModelId.FLEX, + isSecureConnectionAllowed: true, + }); + + getAppConfigMock.mockResolvedValue( + CommandResultFactory({ + data: { + ...defaultAppConfig, + version: "2.0.0", + web3ChecksEnabled: false, + web3ChecksOptIn: false, + }, + }), + ); + + web3CheckOptInMock.mockResolvedValue( + CommandResultFactory({ + error: new InvalidStatusWordError("user declined opt-in"), + }), + ); + + inspectTransactionMock.mockResolvedValue({ + transactionType: SolanaTransactionTypes.STANDARD, + data: {}, + }); + + buildContextMock.mockResolvedValue({ + loadersResults: [], + }); + provideContextMock.mockResolvedValue(Nothing); + + const sig = new Uint8Array([0x01, 0x02]); + signMock.mockResolvedValue(CommandResultFactory({ data: Just(sig) })); + + const action = new SignTransactionDeviceAction({ + input: { + derivationPath: defaultDerivation, + transaction: exampleTx, + transactionOptions: { skipOpenApp: true }, + contextModule: contextModuleStub, + }, + }); + vi.spyOn(action, "extractDependencies").mockReturnValue(extractDeps()); + + const expected = [ + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.GET_APP_CONFIG, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.Web3ChecksOptIn, + step: signTransactionDAStateSteps.WEB3_CHECKS_OPT_IN, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.WEB3_CHECKS_OPT_IN_RESULT, + result: false, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.INSPECT_TRANSACTION, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.GET_PUB_KEY, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.BUILD_TRANSACTION_CONTEXT, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.PROVIDE_TRANSACTION_CONTEXT, + }, + status: DeviceActionStatus.Pending, + }, + { + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.SignTransaction, + step: signTransactionDAStateSteps.SIGN_TRANSACTION, + }, + status: DeviceActionStatus.Pending, + }, + { output: sig, status: DeviceActionStatus.Completed }, + ] as DeviceActionState< + Uint8Array, + SignTransactionDAError, + SignTransactionDAIntermediateValue + >[]; + + testDeviceActionStates< + Uint8Array, + SignTransactionDAInput, + SignTransactionDAError, + SignTransactionDAIntermediateValue + >(action, expected, apiMock, { + onDone: () => { + expect(web3CheckOptInMock).toHaveBeenCalledTimes(1); + resolve(); + }, + onError: reject, + }); + })); }); describe("SignTransactionDeviceAction – child machine integration", () => { @@ -1214,9 +1723,7 @@ describe("SignTransactionDeviceAction – child machine integration", () => { getAppConfigMock = vi .fn() - .mockResolvedValue( - CommandResultFactory({ data: makeAppConfig("1.14.0") }), - ); + .mockResolvedValue(CommandResultFactory({ data: { version: "1.14.0" } })); buildContextMock = vi.fn(); provideContextMock = vi.fn(); signMock = vi.fn(); diff --git a/packages/signer/signer-solana/src/internal/app-binder/device-action/SignTransactionDeviceAction.ts b/packages/signer/signer-solana/src/internal/app-binder/device-action/SignTransactionDeviceAction.ts index f781d42b23..539f29c880 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/device-action/SignTransactionDeviceAction.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/device-action/SignTransactionDeviceAction.ts @@ -3,7 +3,6 @@ import { type CommandErrorResult, type CommandResult, type DeviceActionStateMachine, - DeviceModelId, type InternalApi, isSuccessCommandResult, OpenAppDeviceAction, @@ -13,7 +12,7 @@ import { XStateDeviceAction, } from "@ledgerhq/device-management-kit"; import { Left, type Maybe, Right } from "purify-ts"; -import { assign, fromPromise, setup } from "xstate"; +import { and, assign, fromPromise, setup } from "xstate"; import { type SignTransactionDAError, @@ -31,8 +30,16 @@ import { type UserInputType, } from "@api/model/TransactionResolutionContext"; import { GetAppConfigurationCommand } from "@internal/app-binder/command/GetAppConfigurationCommand"; +import { + GetPubKeyCommand, + type GetPubKeyCommandResponse, +} from "@internal/app-binder/command/GetPubKeyCommand"; import { SignTransactionCommand } from "@internal/app-binder/command/SignTransactionCommand"; import { type SolanaAppErrorCodes } from "@internal/app-binder/command/utils/SolanaApplicationErrors"; +import { + Web3CheckOptInCommand, + type Web3CheckOptInCommandResponse, +} from "@internal/app-binder/command/Web3CheckOptInCommand"; import { APP_NAME } from "@internal/app-binder/constants"; import { SolanaTransactionTypes, @@ -40,8 +47,7 @@ import { } from "@internal/app-binder/services/TransactionInspector"; import { type TxInspectorResult } from "@internal/app-binder/services/TransactionInspector"; import { - SOLANA_APP_SPL_MIN_VERSION, - SOLANA_MIN_DELAYED_SIGNING_VERSION, + SOLANA_FEATURES, SolanaApplicationResolver, } from "@internal/app-binder/SolanaApplicationResolver"; import { @@ -59,7 +65,7 @@ import { DelayedSignTransactionDeviceAction } from "./DelayedSignTransactionDevi /** * Per-sign `transactionOptions.solanaRPCURL` overrides the builder default - * (`input.solanaRPCURL`). + * (`input.solanaRPCURL`) for inspection and delayed signing. */ function resolveSolanaRpcUrl( input: SignTransactionDAInput, @@ -71,6 +77,15 @@ export type MachineDependencies = { readonly getAppConfig: () => Promise< CommandResult >; + readonly web3CheckOptIn: () => Promise< + CommandResult + >; + readonly getPubKey: (arg0: { + input: { + derivationPath: string; + checkOnDevice: boolean; + }; + }) => Promise>; readonly buildContext: (arg0: { input: BuildTransactionContextTaskArgs; }) => Promise; @@ -117,11 +132,34 @@ export class SignTransactionDeviceAction extends XStateDeviceAction< const { signTransaction, getAppConfig, + web3CheckOptIn, + getPubKey, buildContext, provideContext, inspectTransaction, } = this.extractDependencies(internalApi); + const logger = this.getLoggerFactory(internalApi)( + "SignTransactionDeviceAction", + ); + + const isSupported = ( + feature: keyof typeof SOLANA_FEATURES, + appConfig: AppConfiguration, + ): boolean => { + const { minVersion, excludedModels, excludedApps } = + SOLANA_FEATURES[feature]; + return new ApplicationChecker( + internalApi.getDeviceSessionState(), + appConfig, + new SolanaApplicationResolver(), + ) + .withMinVersionInclusive(minVersion) + .excludeDeviceModels(...excludedModels) + .excludeApps(...excludedApps) + .check(); + }; + return setup({ types: { input: {} as types["input"], @@ -133,6 +171,8 @@ export class SignTransactionDeviceAction extends XStateDeviceAction< input: { appName: APP_NAME }, }).makeStateMachine(internalApi), getAppConfig: fromPromise(getAppConfig), + web3CheckOptIn: fromPromise(web3CheckOptIn), + getPubKey: fromPromise(getPubKey), inspectTransaction: fromPromise( ({ input, @@ -163,37 +203,29 @@ export class SignTransactionDeviceAction extends XStateDeviceAction< noInternalError: ({ context }) => context._internalState.error === null, skipOpenApp: ({ context }) => context.input.transactionOptions?.skipOpenApp || false, + isSPLSupported: ({ context }) => + isSupported("spl", context._internalState.appConfig!), isDelayedRequested: ({ context }) => context.input.transactionOptions?.delayed === true, - isSPLSupported: ({ context }) => - new ApplicationChecker( - internalApi.getDeviceSessionState(), - context._internalState.appConfig!, - new SolanaApplicationResolver(), - ) - .excludeDeviceModel(DeviceModelId.NANO_S) - .withMinVersionInclusive(SOLANA_APP_SPL_MIN_VERSION) - .check(), - shouldBuildContext: ({ context }) => - context._internalState.inspectorResult?.transactionType === - SolanaTransactionTypes.SPL || - context._internalState.inspectorResult?.transactionType === - SolanaTransactionTypes.SWAP, isDelayedWithConfigAndSupported: ({ context }) => context.input.transactionOptions?.delayed === true && !!( resolveSolanaRpcUrl(context.input) || context.input.transactionOptions?.fetchBlockhash ) && - new ApplicationChecker( - internalApi.getDeviceSessionState(), - context._internalState.appConfig!, - new SolanaApplicationResolver(), - ) - .withMinVersionInclusive(SOLANA_MIN_DELAYED_SIGNING_VERSION) - .check(), + isSupported("delayedSigning", context._internalState.appConfig!), + shouldBuildContext: ({ context }) => + context._internalState.inspectorResult?.transactionType === + SolanaTransactionTypes.SPL || + context._internalState.inspectorResult?.transactionType === + SolanaTransactionTypes.SWAP, + isWeb3ChecksSupported: ({ context }) => + isSupported("web3Checks", context._internalState.appConfig!), hasSignature: ({ context }) => context._internalState.signature !== null, + shouldOptIn: ({ context }) => + !context._internalState.appConfig!.web3ChecksEnabled && + !context._internalState.appConfig!.web3ChecksOptIn, }, actions: { assignErrorFromEvent: assign({ @@ -211,9 +243,9 @@ export class SignTransactionDeviceAction extends XStateDeviceAction< resolveSolanaRpcUrl(context.input) || context.input.transactionOptions?.fetchBlockhash ); - this.logger?.warn( + logger.warn( hasConfig - ? `delayed signing requires Solana app version >= ${SOLANA_MIN_DELAYED_SIGNING_VERSION}; falling back to standard signing` + ? `delayed signing requires Solana app version >= ${SOLANA_FEATURES.delayedSigning.minVersion}; falling back to standard signing` : "delayed signing requires a Solana RPC URL or a fetchBlockhash callback; falling back to standard signing", ); }, @@ -234,6 +266,7 @@ export class SignTransactionDeviceAction extends XStateDeviceAction< appConfig: null, solanaTransactionContext: null, inspectorResult: null, + signerAddress: null, }, }), states: { @@ -312,6 +345,79 @@ export class SignTransactionDeviceAction extends XStateDeviceAction< }, }, GetAppConfigResultCheck: { + always: [ + { + target: "Web3ChecksOptIn", + guard: and([ + "noInternalError", + "isWeb3ChecksSupported", + "shouldOptIn", + ]), + }, + { target: "CheckSPLSupported", guard: "noInternalError" }, + { target: "Error" }, + ], + }, + Web3ChecksOptIn: { + entry: assign({ + intermediateValue: () => ({ + requiredUserInteraction: UserInteractionRequired.Web3ChecksOptIn, + step: signTransactionDAStateSteps.WEB3_CHECKS_OPT_IN, + }), + }), + invoke: { + id: "web3CheckOptIn", + src: "web3CheckOptIn", + onDone: { + target: "Web3ChecksOptInResult", + actions: [ + ({ event }) => { + if (!isSuccessCommandResult(event.output)) { + logger.warn( + "[Web3ChecksOptIn] opt-in command returned error, proceeding without web3checks", + { data: { error: event.output } }, + ); + } + }, + assign({ + _internalState: ({ event, context }) => { + if (isSuccessCommandResult(event.output)) { + return { + ...context._internalState, + appConfig: { + ...context._internalState.appConfig!, + web3ChecksEnabled: event.output.data.enabled, + }, + }; + } + return context._internalState; + }, + }), + ], + }, + onError: { + target: "Error", + actions: "assignErrorFromEvent", + }, + }, + }, + Web3ChecksOptInResult: { + entry: assign(({ context }) => ({ + intermediateValue: { + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.WEB3_CHECKS_OPT_IN_RESULT, + result: context._internalState.appConfig!.web3ChecksEnabled!, + }, + })), + // Zero-delay self-transition: ensures the entry assign above is + // visible to onSnapshot observers before the machine moves on. + after: { + 0: { + target: "PostWeb3ChecksOptIn", + }, + }, + }, + PostWeb3ChecksOptIn: { always: [ { target: "CheckSPLSupported", guard: "noInternalError" }, { target: "Error" }, @@ -320,7 +426,7 @@ export class SignTransactionDeviceAction extends XStateDeviceAction< CheckSPLSupported: { always: [ { target: "InspectTransaction", guard: "isSPLSupported" }, - { target: "CheckDelayed" }, + { target: "CheckBuildNeeded" }, ], }, InspectTransaction: { @@ -356,10 +462,52 @@ export class SignTransactionDeviceAction extends XStateDeviceAction< }, AfterInspect: { always: [ - { target: "BuildContext", guard: "shouldBuildContext" }, + { target: "GetPubKey", guard: "shouldBuildContext" }, + { target: "CheckBuildNeeded" }, + ], + }, + CheckBuildNeeded: { + always: [ + { + target: "GetPubKey", + guard: "isWeb3ChecksSupported", + }, { target: "CheckDelayed" }, ], }, + GetPubKey: { + entry: assign({ + intermediateValue: () => ({ + requiredUserInteraction: UserInteractionRequired.None, + step: signTransactionDAStateSteps.GET_PUB_KEY, + }), + }), + invoke: { + id: "getPubKey", + src: "getPubKey", + input: ({ context }) => ({ + derivationPath: context.input.derivationPath, + checkOnDevice: false, + }), + onDone: { + target: "BuildContext", + actions: assign({ + _internalState: ({ event, context }) => { + if (isSuccessCommandResult(event.output)) { + return { + ...context._internalState, + signerAddress: event.output.data, + }; + } + return context._internalState; + }, + }), + }, + onError: { + target: "BuildContext", + }, + }, + }, BuildContext: { entry: assign({ intermediateValue: () => ({ @@ -376,6 +524,8 @@ export class SignTransactionDeviceAction extends XStateDeviceAction< return { contextModule: context.input.contextModule, loggerFactory: this.getLoggerFactory(internalApi), + transactionBytes: context.input.transaction, + signerAddress: context._internalState.signerAddress, options: { tokenAddress: inspectorData?.tokenAddress, createATA: inspectorData?.createATA, @@ -475,13 +625,14 @@ export class SignTransactionDeviceAction extends XStateDeviceAction< onSnapshot: { actions: [ assign({ - intermediateValue: ({ event }) => ({ - requiredUserInteraction: - event.snapshot.context.intermediateValue - .requiredUserInteraction, - step: event.snapshot.context.intermediateValue - .step as SignTransactionDAStateStep, - }), + intermediateValue: ({ event }) => + ({ + requiredUserInteraction: + event.snapshot.context.intermediateValue + .requiredUserInteraction, + step: event.snapshot.context.intermediateValue + .step as SignTransactionDAStateStep, + }) as SignTransactionDAIntermediateValue, }), ({ event }) => { const stateValue = @@ -615,6 +766,22 @@ export class SignTransactionDeviceAction extends XStateDeviceAction< const getAppConfig = async () => internalApi.sendCommand(new GetAppConfigurationCommand()); + const web3CheckOptIn = async () => + internalApi.sendCommand(new Web3CheckOptInCommand()); + + const getPubKey = async (arg0: { + input: { + derivationPath: string; + checkOnDevice: boolean; + }; + }) => + internalApi.sendCommand( + new GetPubKeyCommand({ + derivationPath: arg0.input.derivationPath, + checkOnDevice: arg0.input.checkOnDevice, + }), + ); + const buildContext = async (arg0: { input: BuildTransactionContextTaskArgs; }) => new BuildTransactionContextTask(internalApi, arg0.input).run(); @@ -659,6 +826,8 @@ export class SignTransactionDeviceAction extends XStateDeviceAction< return { getAppConfig, + web3CheckOptIn, + getPubKey, buildContext, provideContext, signTransaction, diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/BuildTransactionContextTask.test.ts b/packages/signer/signer-solana/src/internal/app-binder/task/BuildTransactionContextTask.test.ts index 8c4d82ef10..6883c26ccd 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/task/BuildTransactionContextTask.test.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/task/BuildTransactionContextTask.test.ts @@ -1,12 +1,11 @@ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { ClearSignContextType, type ContextModule, + SolanaTransactionScanChainId, } from "@ledgerhq/context-module"; import { - CommandResultStatus, + CommandResultFactory, DeviceModelId, type InternalApi, } from "@ledgerhq/device-management-kit"; @@ -31,29 +30,29 @@ const contextModuleMock: ContextModule = { getContexts: vi.fn(), } as unknown as ContextModule; +const trustedNamePayload = new Uint8Array([1, 2, 3]); +const trustedNameCert = { + payload: new Uint8Array([0xaa, 0xbb]), + keyUsageNumber: 1, +}; + const defaultArgs = { contextModule: contextModuleMock, loggerFactory: mockLoggerFactory, + transactionBytes: new Uint8Array([0xde, 0xad, 0xbe, 0xef]), + signerAddress: null, options: { tokenAddress: "someAddress", createATA: undefined, }, }; -const trustedNamePayload = new Uint8Array([1, 2, 3]); -const trustedNameCert = { - payload: new Uint8Array([0xaa, 0xbb]), - keyUsageNumber: 1, +const trustedNameSuccessContext = { + type: ClearSignContextType.SOLANA_TRUSTED_NAME as const, + payload: trustedNamePayload, + certificate: trustedNameCert, }; -const solanaContextsPayload = [ - { - type: ClearSignContextType.SOLANA_TRUSTED_NAME, - payload: trustedNamePayload as unknown as string, - certificate: trustedNameCert, - }, -]; - let apiMock: InternalApi; describe("BuildTransactionContextTask", () => { @@ -64,25 +63,35 @@ describe("BuildTransactionContextTask", () => { getDeviceSessionState: vi .fn() .mockReturnValue({ deviceModelId: DeviceModelId.NANO_X }), - sendCommand: vi.fn().mockResolvedValue({ - status: CommandResultStatus.Success, - data: { challenge: "someChallenge" }, - }), + sendCommand: vi + .fn() + .mockResolvedValue( + CommandResultFactory({ data: { challenge: "someChallenge" } }), + ), } as unknown as InternalApi; }); - it("returns context successfully when challenge command succeeds", async () => { - (contextModuleMock.getContexts as any).mockResolvedValue( - solanaContextsPayload, - ); + it("requests the challenge from the device", async () => { + (contextModuleMock.getContexts as any).mockResolvedValue([ + trustedNameSuccessContext, + ]); const task = new BuildTransactionContextTask(apiMock, defaultArgs); - const result = await task.run(); + await task.run(); - // challenge is fetched expect(apiMock.sendCommand).toHaveBeenCalledWith( expect.any(GetChallengeCommand), ); + }); + + // TODO-WEB3CHECK: flip this back once transaction-check is ready + it.skip("calls contextModule.getContexts with all Solana context types (including transaction-check)", async () => { + (contextModuleMock.getContexts as any).mockResolvedValue([ + trustedNameSuccessContext, + ]); + + const task = new BuildTransactionContextTask(apiMock, defaultArgs); + await task.run(); expect(contextModuleMock.getContexts).toHaveBeenCalledWith( { @@ -92,15 +101,50 @@ describe("BuildTransactionContextTask", () => { createATA: undefined, tokenInternalId: undefined, templateId: undefined, + transactionCheck: undefined, }, [ ClearSignContextType.SOLANA_TOKEN, ClearSignContextType.SOLANA_LIFI, ClearSignContextType.SOLANA_TRUSTED_NAME, + ClearSignContextType.SOLANA_TRANSACTION_CHECK, ], ); + }); + + it("derives transactionCheck from signerAddress and transactionBytes when address is provided", async () => { + (contextModuleMock.getContexts as any).mockResolvedValue([ + trustedNameSuccessContext, + ]); + + const argsWithSigner = { + ...defaultArgs, + signerAddress: "So1anaSignerPubKey111111111111111111111111111", + }; + + const task = new BuildTransactionContextTask(apiMock, argsWithSigner); + await task.run(); + + expect(contextModuleMock.getContexts).toHaveBeenCalledWith( + expect.objectContaining({ + transactionCheck: { + from: "So1anaSignerPubKey111111111111111111111111111", + rawTx: expect.any(String), + chain: SolanaTransactionScanChainId.MAINNET, + }, + }), + expect.any(Array), + ); + }); + + it("returns trustedName cert + tlvDescriptor when SOLANA_TRUSTED_NAME context is present", async () => { + (contextModuleMock.getContexts as any).mockResolvedValue([ + trustedNameSuccessContext, + ]); + + const task = new BuildTransactionContextTask(apiMock, defaultArgs); + const result = await task.run(); - // matches SolanaBuildContextResult shape expect(result).toEqual({ tlvDescriptor: trustedNamePayload, trustedNamePKICertificate: trustedNameCert, @@ -109,13 +153,52 @@ describe("BuildTransactionContextTask", () => { }); }); - it("throws if challenge command fails", async () => { - (apiMock.sendCommand as any).mockResolvedValue({ - status: CommandResultStatus.Error, - data: {}, - }); - (contextModuleMock.getContexts as any).mockResolvedValue( - solanaContextsPayload, + it("includes SOLANA_TRANSACTION_CHECK results in loadersResults", async () => { + const txCheckContext = { + type: ClearSignContextType.SOLANA_TRANSACTION_CHECK as const, + payload: { descriptor: "aabbccdd" }, + certificate: { payload: new Uint8Array([0x99]), keyUsageNumber: 14 }, + }; + (contextModuleMock.getContexts as any).mockResolvedValue([ + trustedNameSuccessContext, + txCheckContext, + ]); + + const task = new BuildTransactionContextTask(apiMock, defaultArgs); + const result = await task.run(); + + expect(result.loadersResults).toEqual([txCheckContext]); + expect(result.contextErrorCount).toBe(0); + }); + + it("includes SOLANA_TOKEN and SOLANA_LIFI results in loadersResults", async () => { + const tokenContext = { + type: ClearSignContextType.SOLANA_TOKEN as const, + payload: { solanaTokenDescriptor: { data: "aa", signature: "bb" } }, + certificate: undefined, + }; + const lifiContext = { + type: ClearSignContextType.SOLANA_LIFI as const, + payload: { descriptors: {}, instructions: [] }, + certificate: undefined, + }; + (contextModuleMock.getContexts as any).mockResolvedValue([ + trustedNameSuccessContext, + tokenContext, + lifiContext, + ]); + + const task = new BuildTransactionContextTask(apiMock, defaultArgs); + const result = await task.run(); + + expect(result.loadersResults).toEqual([tokenContext, lifiContext]); + }); + + it("throws when challenge command fails", async () => { + (apiMock.sendCommand as any).mockResolvedValue( + CommandResultFactory({ + error: { _tag: "SomeError", errorCode: 0x6a80, message: "bad" } as any, + }), ); const task = new BuildTransactionContextTask(apiMock, defaultArgs); @@ -125,8 +208,26 @@ describe("BuildTransactionContextTask", () => { ); }); - it("returns empty result when getContexts returns only errors and owner info is not required", async () => { - const error = new Error("Solana context failure"); + it("counts ERROR contexts and surfaces them via contextErrorCount and loadersResults", async () => { + const error = new Error("token loader failure"); + (contextModuleMock.getContexts as any).mockResolvedValue([ + trustedNameSuccessContext, + { type: ClearSignContextType.ERROR, error }, + ]); + + const task = new BuildTransactionContextTask(apiMock, defaultArgs); + const result = await task.run(); + + expect(result.trustedNamePKICertificate).toEqual(trustedNameCert); + expect(result.tlvDescriptor).toEqual(trustedNamePayload); + expect(result.contextErrorCount).toBe(1); + expect(result.loadersResults).toEqual([ + { type: ClearSignContextType.ERROR, error }, + ]); + }); + + it("returns empty trusted-name fields when owner info is not required and only errors are returned", async () => { + const error = new Error("solana context failure"); const argsWithoutOwnerInfo = { ...defaultArgs, options: { tokenAddress: undefined, createATA: undefined }, @@ -140,19 +241,18 @@ describe("BuildTransactionContextTask", () => { expect(result.trustedNamePKICertificate).toBeUndefined(); expect(result.tlvDescriptor).toBeUndefined(); - expect(result.loadersResults).toHaveLength(1); - expect(result.loadersResults[0]).toEqual({ - type: ClearSignContextType.ERROR, - error, - }); expect(result.contextErrorCount).toBe(1); + expect(result.loadersResults).toEqual([ + { type: ClearSignContextType.ERROR, error }, + ]); }); - it("throws when owner info was required but only errors were returned", async () => { - const error = new Error("PKI cert load failure"); - // defaultArgs has tokenAddress: "someAddress", so owner info IS required + it("throws when owner info is required but no SOLANA_TRUSTED_NAME context was returned", async () => { (contextModuleMock.getContexts as any).mockResolvedValue([ - { type: ClearSignContextType.ERROR, error }, + { + type: ClearSignContextType.ERROR, + error: new Error("PKI cert load failure"), + }, ]); const task = new BuildTransactionContextTask(apiMock, defaultArgs); @@ -162,8 +262,7 @@ describe("BuildTransactionContextTask", () => { ); }); - it("throws when owner info was required but getContexts returns empty array", async () => { - // (getOwnerInfo succeeded but tlvDescriptor is undefined) — no error, but no TRUSTED_NAME either. + it("throws when owner info is required but contextModule returns an empty array", async () => { (contextModuleMock.getContexts as any).mockResolvedValue([]); const task = new BuildTransactionContextTask(apiMock, defaultArgs); @@ -172,28 +271,4 @@ describe("BuildTransactionContextTask", () => { "[SignerSolana] BuildTransactionContextTask: owner info was required but could not be resolved", ); }); - - it("reports contextErrorCount when some contexts are errors alongside successes", async () => { - const error = new Error("token loader failure"); - (contextModuleMock.getContexts as any).mockResolvedValue([ - { - type: ClearSignContextType.SOLANA_TRUSTED_NAME, - payload: trustedNamePayload as unknown as string, - certificate: trustedNameCert, - }, - { type: ClearSignContextType.ERROR, error }, - ]); - - const task = new BuildTransactionContextTask(apiMock, defaultArgs); - const result = await task.run(); - - expect(result.trustedNamePKICertificate).toEqual(trustedNameCert); - expect(result.tlvDescriptor).toEqual(trustedNamePayload); - expect(result.contextErrorCount).toBe(1); - expect(result.loadersResults).toHaveLength(1); - expect(result.loadersResults[0]).toEqual({ - type: ClearSignContextType.ERROR, - error, - }); - }); }); diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/BuildTransactionContextTask.ts b/packages/signer/signer-solana/src/internal/app-binder/task/BuildTransactionContextTask.ts index 1971293507..cf7a05bbe5 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/task/BuildTransactionContextTask.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/task/BuildTransactionContextTask.ts @@ -4,6 +4,7 @@ import { type ContextModule, type LoaderResult, type SolanaTransactionContextResultSuccess, + SolanaTransactionScanChainId, } from "@ledgerhq/context-module"; import { type InternalApi, @@ -13,12 +14,15 @@ import { import { type TransactionResolutionContext } from "@api/model/TransactionResolutionContext"; import { GetChallengeCommand } from "@internal/app-binder/command/GetChallengeCommand"; +import { DefaultBs58Encoder } from "@internal/app-binder/services/bs58Encoder"; export type { SolanaTransactionContextResultSuccess as SolanaBuildContextResult }; export type BuildTransactionContextTaskArgs = { readonly contextModule: ContextModule; readonly options: TransactionResolutionContext; + readonly transactionBytes: Uint8Array; + readonly signerAddress: string | null; readonly loggerFactory: (tag: string) => LoggerPublisherService; }; @@ -45,27 +49,39 @@ export class BuildTransactionContextTask { throw new Error("Failed to get challenge from device"); } - const contextModuleGetContextArgs = { + const transactionCheck = this.args.signerAddress + ? { + from: this.args.signerAddress, + rawTx: DefaultBs58Encoder.encode(this.args.transactionBytes), + chain: SolanaTransactionScanChainId.MAINNET, + } + : undefined; + + const contextModuleGetSolanaContextArgs = { deviceModelId: deviceState.deviceModelId, tokenAddress: options.tokenAddress, challenge, createATA: options.createATA, tokenInternalId: options.tokenInternalId, templateId: options.templateId, + transactionCheck, }; // get Solana context this._logger.debug("[run] Calling contextModule.getContexts for Solana", { data: { - args: contextModuleGetContextArgs, + args: contextModuleGetSolanaContextArgs, }, }); const contexts = await contextModule.getContexts( - contextModuleGetContextArgs, + contextModuleGetSolanaContextArgs, [ ClearSignContextType.SOLANA_TOKEN, ClearSignContextType.SOLANA_LIFI, ClearSignContextType.SOLANA_TRUSTED_NAME, + // !! TODO-WEB3CHECK FLIP THIS BACK ONCE TRANSACTION CHECK IS READY, + // TO BE KEEPT OFF FOR NOW + //ClearSignContextType.SOLANA_TRANSACTION_CHECK, ], ); @@ -98,7 +114,9 @@ export class BuildTransactionContextTask { (contextResponseItem): contextResponseItem is LoaderResult => contextResponseItem.type === ClearSignContextType.ERROR || contextResponseItem.type === ClearSignContextType.SOLANA_TOKEN || - contextResponseItem.type === ClearSignContextType.SOLANA_LIFI, + contextResponseItem.type === ClearSignContextType.SOLANA_LIFI || + contextResponseItem.type === + ClearSignContextType.SOLANA_TRANSACTION_CHECK, ); return { diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/ProvideTransactionContextTask.test.ts b/packages/signer/signer-solana/src/internal/app-binder/task/ProvideTransactionContextTask.test.ts index c4937d1f30..916fea6bb2 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/task/ProvideTransactionContextTask.test.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/task/ProvideTransactionContextTask.test.ts @@ -1,39 +1,18 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/require-await */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ import { ClearSignContextType } from "@ledgerhq/context-module"; import { CommandResultFactory, LoadCertificateCommand, } from "@ledgerhq/device-management-kit"; -import { - ASSOCIATED_TOKEN_PROGRAM_ID, - createAssociatedTokenAccountInstruction, - createTransferInstruction, - getAssociatedTokenAddressSync, - TOKEN_PROGRAM_ID, -} from "@solana/spl-token"; -import { - Keypair, - PublicKey, - SystemProgram, - Transaction, - TransactionInstruction, - TransactionMessage, - VersionedTransaction, -} from "@solana/web3.js"; -import bs58 from "bs58"; -import { Buffer } from "buffer"; import { Nothing } from "purify-ts"; import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; -import { ProvideInstructionDescriptorCommand } from "@internal/app-binder/command/ProvideInstructionDescriptorCommand"; import { ProvideTLVDescriptorCommand } from "@internal/app-binder/command/ProvideTLVDescriptorCommand"; import { ProvideTLVTransactionInstructionDescriptorCommand } from "@internal/app-binder/command/ProvideTLVTransactionInstructionDescriptorCommand"; -import { DefaultSolanaMessageNormaliser } from "@internal/app-binder/services/utils/DefaultSolanaMessageNormaliser"; +import { ProvideWeb3CheckCommand } from "@internal/app-binder/command/ProvideWeb3CheckCommand"; + +import { ProvideSolanaTransactionContextTask } from "./ProvideTransactionContextTask"; const mockLoggerFactory = () => ({ debug: vi.fn(), @@ -43,54 +22,8 @@ const mockLoggerFactory = () => ({ subscribers: [], }); -import { ProvideSolanaTransactionContextTask } from "./ProvideTransactionContextTask"; - -const DUMMY_BLOCKHASH = bs58.encode(new Uint8Array(32).fill(0xaa)); - -function makeSignedRawLegacy( - ixs: TransactionInstruction[], - signers: Keypair[], - feePayer?: Keypair, -) { - const payer = feePayer ?? signers[0] ?? Keypair.generate(); - const tx = new Transaction(); - tx.recentBlockhash = DUMMY_BLOCKHASH; - tx.feePayer = payer.publicKey; - tx.add(...ixs); - const seen = new Set(); - const uniq = [payer, ...signers].filter((kp) => { - const k = kp.publicKey.toBase58(); - if (seen.has(k)) return false; - seen.add(k); - return true; - }); - tx.sign(...uniq); - return { raw: tx.serialize(), payer }; -} - -function makeSignedRawV0( - ixs: TransactionInstruction[], - signers: Keypair[], - feePayer?: Keypair, -) { - const payer = feePayer ?? signers[0] ?? Keypair.generate(); - const messageV0 = new TransactionMessage({ - payerKey: payer.publicKey, - recentBlockhash: DUMMY_BLOCKHASH, - instructions: ixs, - }).compileToV0Message(); // no ALTs -> offline-safe - - const vtx = new VersionedTransaction(messageV0); - vtx.sign([payer, ...signers]); - return { raw: vtx.serialize(), payer }; -} - -const makeKey = (base58: string) => ({ toBase58: () => base58 }); - const buildNormaliser = (message: any) => - ({ - normaliseMessage: vi.fn(async () => message), - }) as const; + ({ normaliseMessage: vi.fn(async () => message) }) as const; describe("ProvideSolanaTransactionContextTask", () => { let api: { sendCommand: Mock }; @@ -103,1419 +36,281 @@ describe("ProvideSolanaTransactionContextTask", () => { const tlvDescriptor = new Uint8Array([0xaa, 0xbb, 0xcc]); const tokenCert = { - payload: new Uint8Array([0xf0, 0xca, 0xcc, 0x1a]), + payload: new Uint8Array([0x01, 0x02]), keyUsageNumber: 2, } as const; - const tokenDescriptor = { data: "f0cacc1a", signature: "01020304", } as const; - const swapCert = { - payload: new Uint8Array([0x01, 0x02, 0x03]), - keyUsageNumber: 13, + const txCheckCert = { + payload: new Uint8Array([0xde, 0xad]), + keyUsageNumber: 14, } as const; - const SIG = "f0cacc1a"; - beforeEach(() => { vi.resetAllMocks(); - api = { - sendCommand: vi.fn(), - }; + api = { sendCommand: vi.fn() }; }); - // basic context - describe("basic context", () => { - it("sends PKI certificate then TLV descriptor and returns Nothing (no loaders results)", async () => { - // given - api.sendCommand - .mockResolvedValueOnce(success) // LoadCertificateCommand (trusted name PKI) - .mockResolvedValueOnce(success); // ProvideTLVDescriptorCommand - - const args = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults: [], - transactionBytes: new Uint8Array([0xf0]), // unused in this path - loggerFactory: mockLoggerFactory, - }; - - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - args as any, - ); - - // when - const result = await task.run(); - - // then - expect(api.sendCommand).toHaveBeenCalledTimes(2); - - const first = api.sendCommand.mock.calls[0]![0]!; - expect(first).toBeInstanceOf(LoadCertificateCommand); - expect(first.args.certificate).toStrictEqual(baseCert.payload); - expect(first.args.keyUsage).toBe(baseCert.keyUsageNumber); - - const second = api.sendCommand.mock.calls[1]![0]!; - expect(second).toBeInstanceOf(ProvideTLVDescriptorCommand); - expect(second.args.payload).toStrictEqual(tlvDescriptor); - - expect(result).toStrictEqual(Nothing); - }); - - it("propagates a rejection thrown by InternalApi.sendCommand", async () => { - // given - api.sendCommand.mockRejectedValueOnce(new Error("oupsy")); - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults: [], - transactionBytes: new Uint8Array([0xca]), - loggerFactory: mockLoggerFactory, - }; - - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, - ); - - await expect(task.run()).rejects.toThrow("oupsy"); - expect(api.sendCommand).toHaveBeenCalledTimes(1); - }); - - it("ignores ClearSignContextType.ERROR entries (no extra APDUs beyond base context)", async () => { - // given: include an ERROR loader which should be ignored + describe("base context (trusted-name)", () => { + it("sends PKI certificate then TLV descriptor when both are provided", async () => { api.sendCommand - .mockResolvedValueOnce(success) // PKI - .mockResolvedValueOnce(success); // TLV - - const loadersResults = [ - { type: ClearSignContextType.ERROR, error: { message: "err" } as any }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0x1a]), - loggerFactory: mockLoggerFactory, - }; + .mockResolvedValueOnce(success) + .mockResolvedValueOnce(success); const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, + api as any, + { + trustedNamePKICertificate: baseCert, + tlvDescriptor, + loadersResults: [], + transactionBytes: new Uint8Array([0xf0]), + loggerFactory: mockLoggerFactory, + } as any, ); const result = await task.run(); expect(result).toStrictEqual(Nothing); expect(api.sendCommand).toHaveBeenCalledTimes(2); - expect(api.sendCommand.mock.calls[0]![0]!).toBeInstanceOf( - LoadCertificateCommand, - ); - expect(api.sendCommand.mock.calls[1]![0]!).toBeInstanceOf( - ProvideTLVDescriptorCommand, - ); - }); - }); - - // basic context + token metadata - describe("basic context + token", () => { - it("when token metadata present, sends token certificate then TLV transaction-instruction descriptor", async () => { - // given - api.sendCommand - .mockResolvedValueOnce(success) // base PKI certificate - .mockResolvedValueOnce(success) // TLV descriptor - .mockResolvedValueOnce(success) // token metadata certificate - .mockResolvedValueOnce(success); // token descriptor via TLVTransactionInstructionDescriptor - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_TOKEN, - payload: { - solanaTokenDescriptor: tokenDescriptor, - }, - certificate: tokenCert, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0x1a]), // unused in this path - loggerFactory: mockLoggerFactory, - }; - - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, - ); - - // when - const result = await task.run(); - - // then - expect(api.sendCommand).toHaveBeenCalledTimes(4); - - const third = api.sendCommand.mock.calls[2]![0]!; - expect(third).toBeInstanceOf(LoadCertificateCommand); - expect(third.args.certificate).toStrictEqual(tokenCert.payload); - expect(third.args.keyUsage).toBe(tokenCert.keyUsageNumber); - - const fourth = api.sendCommand.mock.calls[3]![0]!; - expect(fourth).toBeInstanceOf( - ProvideTLVTransactionInstructionDescriptorCommand, - ); - expect(fourth.args.dataHex).toBe(tokenDescriptor.data); - expect(fourth.args.signatureHex).toBe(tokenDescriptor.signature); + const certCmd = api.sendCommand.mock.calls[0]![0]!; + expect(certCmd).toBeInstanceOf(LoadCertificateCommand); + expect(certCmd.args.certificate).toStrictEqual(baseCert.payload); + expect(certCmd.args.keyUsage).toBe(baseCert.keyUsageNumber); - expect(result).toStrictEqual(Nothing); + const tlvCmd = api.sendCommand.mock.calls[1]![0]!; + expect(tlvCmd).toBeInstanceOf(ProvideTLVDescriptorCommand); + expect(tlvCmd.args.payload).toStrictEqual(tlvDescriptor); }); - it("does not send token commands if token payload is missing", async () => { - // given - api.sendCommand - .mockResolvedValueOnce(success) // PKI - .mockResolvedValueOnce(success); // TLV - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_TOKEN, - payload: undefined, - certificate: tokenCert, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0xf0]), - loggerFactory: mockLoggerFactory, - }; - + it("skips base context APDUs when trustedNamePKICertificate is missing", async () => { const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, - ); - - // when - const result = await task.run(); - - // then - expect(api.sendCommand).toHaveBeenCalledTimes(2); - expect(result).toStrictEqual(Nothing); - }); - - it("does not send token commands if token certificate is missing", async () => { - // given - api.sendCommand - .mockResolvedValueOnce(success) // PKI - .mockResolvedValueOnce(success); // TLV - - const loadersResults = [ + api as any, { - type: ClearSignContextType.SOLANA_TOKEN, - payload: { solanaTokenDescriptor: tokenDescriptor }, - certificate: undefined, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0xca]), - loggerFactory: mockLoggerFactory, - }; - - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, + trustedNamePKICertificate: undefined, + tlvDescriptor: undefined, + loadersResults: [], + transactionBytes: new Uint8Array([0xf0]), + loggerFactory: mockLoggerFactory, + } as any, ); - // when const result = await task.run(); - // then - expect(api.sendCommand).toHaveBeenCalledTimes(2); expect(result).toStrictEqual(Nothing); + expect(api.sendCommand).not.toHaveBeenCalled(); }); - it("throws a mapped error when sending token certificate returns a CommandErrorResult", async () => { - // given - const errorResult = CommandResultFactory({ - error: { _tag: "SomeError", errorCode: 0x6a80, message: "bad" }, - }); - - api.sendCommand - .mockResolvedValueOnce(success) // base PKI - .mockResolvedValueOnce(success) // TLV - .mockResolvedValueOnce(errorResult); // token certificate -> error - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_TOKEN, - payload: { solanaTokenDescriptor: tokenDescriptor }, - certificate: tokenCert, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0xcc]), - loggerFactory: mockLoggerFactory, - }; + it("propagates errors thrown by sendCommand", async () => { + api.sendCommand.mockRejectedValueOnce(new Error("transport fail")); const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, - ); - - // when + then - await expect(task.run()).rejects.toThrow( - "[SignerSolana] ProvideSolanaTransactionContextTask: Failed to send tokenMetadataCertificate to device, latest firmware version required", - ); - - // ensure the TLVTransactionInstructionDescriptor was NOT attempted - expect(api.sendCommand).toHaveBeenCalledTimes(3); - const third = api.sendCommand.mock.calls[2]![0]!; - expect(third).toBeInstanceOf(LoadCertificateCommand); - }); - - it("does not send swap APDUs when SOLANA_LIFI context is missing (token present)", async () => { - // given: base + token succeed, but no LIFI in loadersResults - api.sendCommand - .mockResolvedValueOnce(success) // PKI - .mockResolvedValueOnce(success) // TLV - .mockResolvedValueOnce(success) // token cert - .mockResolvedValueOnce(success); // token TLVTransactionInstructionDescriptor - - const loadersResults = [ + api as any, { - type: ClearSignContextType.SOLANA_TOKEN, - payload: { solanaTokenDescriptor: tokenDescriptor }, - certificate: tokenCert, - }, - // no SOLANA_LIFI entry - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0x1a]), - normaliser: { normaliseMessage: vi.fn() } as any, - loggerFactory: mockLoggerFactory, - }; - - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, + trustedNamePKICertificate: baseCert, + tlvDescriptor, + loadersResults: [], + transactionBytes: new Uint8Array([0xca]), + loggerFactory: mockLoggerFactory, + } as any, ); - const result = await task.run(); - - expect(result).toStrictEqual(Nothing); - // 2 base + 2 token only - expect(api.sendCommand).toHaveBeenCalledTimes(4); - expect(api.sendCommand.mock.calls[0]![0]!).toBeInstanceOf( - LoadCertificateCommand, - ); - expect(api.sendCommand.mock.calls[1]![0]!).toBeInstanceOf( - ProvideTLVDescriptorCommand, - ); - expect(api.sendCommand.mock.calls[2]![0]!).toBeInstanceOf( - LoadCertificateCommand, - ); - const tokenCmd = api.sendCommand.mock.calls[3]![0]!; - expect(tokenCmd).toBeInstanceOf( - ProvideTLVTransactionInstructionDescriptorCommand, - ); + await expect(task.run()).rejects.toThrow("transport fail"); }); }); - // basic context + token + lifi (swap) - describe("basic context + token + lifi", () => { - it("sends swap template certificate then one APDU per matched instruction (skipping unmatched) after base + token are sent", async () => { - // given - api.sendCommand - .mockResolvedValueOnce(success) // base PKI - .mockResolvedValueOnce(success) // TLV - .mockResolvedValueOnce(success) // token cert - .mockResolvedValueOnce(success) // token TLVTransactionInstructionDescriptor - .mockResolvedValueOnce(success) // swap template cert - .mockResolvedValue(success); // swap APDUs - - const message = { - compiledInstructions: [ - { programIdIndex: 0, data: new Uint8Array([0x01]) }, - { programIdIndex: 1, data: new Uint8Array([0x02]) }, - { programIdIndex: 2, data: new Uint8Array([0x03]) }, - ], - allKeys: [makeKey("A_PID"), makeKey("B_PID"), makeKey("C_PID")], - }; - const normaliser = buildNormaliser(message); - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_TOKEN, - payload: { solanaTokenDescriptor: tokenDescriptor }, - certificate: tokenCert, - }, - { - type: ClearSignContextType.SOLANA_LIFI, - payload: { - descriptors: { - "A_PID:1": { data: SIG, signature: SIG }, - // B missing -> skipped - "C_PID:3": { data: SIG, signature: SIG }, - }, - instructions: [ - { program_id: "A_PID", discriminator_hex: "1" }, - { program_id: "C_PID", discriminator_hex: "3" }, - ], - }, - certificate: swapCert, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0xf0]), - normaliser: normaliser as any, - loggerFactory: mockLoggerFactory, - }; - + describe("loaders dispatch", () => { + it("returns Nothing when loadersResults is empty", async () => { const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, + api as any, + { + trustedNamePKICertificate: undefined, + tlvDescriptor: undefined, + loadersResults: [], + transactionBytes: new Uint8Array([0xf0]), + loggerFactory: mockLoggerFactory, + } as any, ); - // when const result = await task.run(); - // then expect(result).toStrictEqual(Nothing); - // 2 base + 2 token + 1 swap cert + 2 swap descriptors - expect(api.sendCommand).toHaveBeenCalledTimes(7); - - // swap cert at index 4 - const certCmd = api.sendCommand.mock.calls[4]![0]!; - expect(certCmd).toBeInstanceOf(LoadCertificateCommand); - expect(certCmd.args.certificate).toStrictEqual(swapCert.payload); - expect(certCmd.args.keyUsage).toBe(swapCert.keyUsageNumber); - - // swap descriptor calls start at index 5 - const c0 = api.sendCommand.mock.calls[5]![0]!; - expect(c0).toBeInstanceOf(ProvideInstructionDescriptorCommand); - expect(c0.args.dataHex).toBe(SIG); - expect(c0.args.signatureHex).toBe(SIG); - - const c1 = api.sendCommand.mock.calls[6]![0]!; - expect(c1).toBeInstanceOf(ProvideInstructionDescriptorCommand); - expect(c1.args.dataHex).toBe(SIG); - expect(c1.args.signatureHex).toBe(SIG); - - expect((normaliser as any).normaliseMessage).toHaveBeenCalledOnce(); + expect(api.sendCommand).not.toHaveBeenCalled(); }); - it("throws when sending swap template certificate returns a CommandErrorResult", async () => { - const errorResult = CommandResultFactory({ - error: { _tag: "SomeError", errorCode: 0x6a80, message: "bad" }, - }); - - api.sendCommand - .mockResolvedValueOnce(success) // base PKI - .mockResolvedValueOnce(success) // TLV - .mockResolvedValueOnce(success) // token cert - .mockResolvedValueOnce(success) // token TLVTransactionInstructionDescriptor - .mockResolvedValueOnce(errorResult); // swap template cert -> error - - const message = { - compiledInstructions: [ - { programIdIndex: 0, data: new Uint8Array([0x01]) }, - ], - allKeys: [makeKey("A_PID")], - }; - const normaliser = buildNormaliser(message); - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_TOKEN, - payload: { solanaTokenDescriptor: tokenDescriptor }, - certificate: tokenCert, - }, - { - type: ClearSignContextType.SOLANA_LIFI, - payload: { - descriptors: { - "A_PID:1": { data: SIG, signature: SIG }, - }, - instructions: [{ program_id: "A_PID", discriminator_hex: "1" }], - }, - certificate: swapCert, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0xf0]), - normaliser: normaliser as any, - loggerFactory: mockLoggerFactory, - }; - + it("skips ERROR loader results without sending APDUs", async () => { const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, - ); - - await expect(task.run()).rejects.toThrow( - "[SignerSolana] ProvideSolanaTransactionContextTask: Failed to send swapTemplateCertificate to device", - ); - - // 2 base + 2 token + 1 swap cert (failed) - expect(api.sendCommand).toHaveBeenCalledTimes(5); - }); - - it("sends no swap APDU when signature is an empty string", async () => { - // given - api.sendCommand - .mockResolvedValueOnce(success) // base PKI - .mockResolvedValueOnce(success) // TLV - .mockResolvedValueOnce(success) // token cert - .mockResolvedValueOnce(success); // token TLVTransactionInstructionDescriptor - - const message = { - compiledInstructions: [ - { programIdIndex: 0, data: new Uint8Array([0x01]) }, - ], - allKeys: [makeKey("ONLY_PID")], - }; - const normaliser = buildNormaliser(message); - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_TOKEN, - payload: { solanaTokenDescriptor: tokenDescriptor }, - certificate: tokenCert, - }, + api as any, { - type: ClearSignContextType.SOLANA_LIFI, - payload: { - descriptors: { - "ONLY_PID:": { - data: SIG, - signature: "", - }, + trustedNamePKICertificate: undefined, + tlvDescriptor: undefined, + loadersResults: [ + { + type: ClearSignContextType.ERROR, + error: new Error("loader failed"), }, - instructions: [{ program_id: "ONLY_PID" }], - }, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0xca]), - normaliser: normaliser as any, - loggerFactory: mockLoggerFactory, - }; - - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, + ], + transactionBytes: new Uint8Array([0xf0]), + loggerFactory: mockLoggerFactory, + } as any, ); const result = await task.run(); expect(result).toStrictEqual(Nothing); - // 2 base + 2 token (no swap APDUs since signature is empty) - expect(api.sendCommand).toHaveBeenCalledTimes(4); + expect(api.sendCommand).not.toHaveBeenCalled(); }); - it("sends no swap APDU when programId is missing for an instruction", async () => { - // given + it("dispatches SOLANA_TOKEN result to the token handler (cert + descriptor APDUs)", async () => { api.sendCommand .mockResolvedValueOnce(success) // base PKI - .mockResolvedValueOnce(success) // TLV - .mockResolvedValueOnce(success) // token cert - .mockResolvedValueOnce(success); // token TLVTransactionInstructionDescriptor - - const message = { - compiledInstructions: [{ programIdIndex: 5, data: new Uint8Array() }], // out-of-range - allKeys: [makeKey("X")], - }; - const normaliser = buildNormaliser(message); - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_TOKEN, - payload: { solanaTokenDescriptor: tokenDescriptor }, - certificate: tokenCert, - }, - { - type: ClearSignContextType.SOLANA_LIFI, - payload: { descriptors: {}, instructions: [] }, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0xcc]), - normaliser: normaliser as any, - loggerFactory: mockLoggerFactory, - }; - - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, - ); - - const result = await task.run(); - - expect(result).toStrictEqual(Nothing); - // 2 base + 2 token (no swap APDUs since programId is missing) - expect(api.sendCommand).toHaveBeenCalledTimes(4); - }); - - it("propagates a rejection thrown by InternalApi.sendCommand on the second swap APDU (after base + token succeed)", async () => { - // given - api.sendCommand - .mockResolvedValueOnce(success) // base PKI - .mockResolvedValueOnce(success) // TLV + .mockResolvedValueOnce(success) // TLV descriptor .mockResolvedValueOnce(success) // token cert - .mockResolvedValueOnce(success) // token TLVTransactionInstructionDescriptor - .mockResolvedValueOnce(success) // 1st swap ok - .mockRejectedValueOnce(new Error("err")); // 2nd swap fails - - const message = { - compiledInstructions: [ - { programIdIndex: 0, data: new Uint8Array([0x01]) }, // descriptor A - { programIdIndex: 1, data: new Uint8Array([0x02]) }, // no match -> skipped - { programIdIndex: 2, data: new Uint8Array([0x03]) }, // descriptor C -> rejects - ], - allKeys: [makeKey("A_PID"), makeKey("B_PID"), makeKey("C_PID")], - }; - const normaliser = buildNormaliser(message); - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_TOKEN, - payload: { solanaTokenDescriptor: tokenDescriptor }, - certificate: tokenCert, - }, - { - type: ClearSignContextType.SOLANA_LIFI, - payload: { - descriptors: { - "A_PID:1": { data: SIG, signature: SIG }, - // B missing -> skipped - "C_PID:3": { data: SIG, signature: SIG }, - }, - instructions: [ - { program_id: "A_PID", discriminator_hex: "1" }, - { program_id: "C_PID", discriminator_hex: "3" }, - ], - }, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0x1a]), - normaliser: normaliser as any, - loggerFactory: mockLoggerFactory, - }; + .mockResolvedValueOnce(success); // token descriptor const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, - ); - - await expect(task.run()).rejects.toThrow("err"); - // 2 base + 2 token + 2 swap (failed on 2nd) - expect(api.sendCommand).toHaveBeenCalledTimes(6); - - const c0 = api.sendCommand.mock.calls[4]![0]!; - expect(c0).toBeInstanceOf(ProvideInstructionDescriptorCommand); - expect(c0.args.dataHex).toBe(SIG); - expect(c0.args.signatureHex).toBe(SIG); - - const c1 = api.sendCommand.mock.calls[5]![0]!; - expect(c1).toBeInstanceOf(ProvideInstructionDescriptorCommand); - expect(c1.args.dataHex).toBe(SIG); - expect(c1.args.signatureHex).toBe(SIG); - }); - - it("uses the pre-resolved signature field from the descriptor", async () => { - // given - api.sendCommand - .mockResolvedValueOnce(success) // base PKI - .mockResolvedValueOnce(success) // TLV - .mockResolvedValueOnce(success) // token cert - .mockResolvedValueOnce(success) // token TLVTransactionInstructionDescriptor - .mockResolvedValue(success); - - const message = { - compiledInstructions: [ - { programIdIndex: 0, data: new Uint8Array([0x01]) }, - ], - allKeys: [makeKey("SIG_PID")], - }; - const normaliser = buildNormaliser(message); - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_TOKEN, - payload: { solanaTokenDescriptor: tokenDescriptor }, - certificate: tokenCert, - }, + api as any, { - type: ClearSignContextType.SOLANA_LIFI, - payload: { - descriptors: { - "SIG_PID:1": { - data: SIG, - signature: SIG, - }, + trustedNamePKICertificate: baseCert, + tlvDescriptor, + loadersResults: [ + { + type: ClearSignContextType.SOLANA_TOKEN as const, + payload: { solanaTokenDescriptor: tokenDescriptor }, + certificate: tokenCert, }, - instructions: [{ program_id: "SIG_PID", discriminator_hex: "1" }], - }, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0xf0]), - normaliser: normaliser as any, - loggerFactory: mockLoggerFactory, - }; - - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, + ], + transactionBytes: new Uint8Array([0x1a]), + loggerFactory: mockLoggerFactory, + } as any, ); - const result = await task.run(); + await task.run(); - expect(result).toStrictEqual(Nothing); - // 2 base + 2 token + 1 swap - expect(api.sendCommand).toHaveBeenCalledTimes(5); - - const c0 = api.sendCommand.mock.calls[4]![0]!; - expect(c0).toBeInstanceOf(ProvideInstructionDescriptorCommand); - expect(c0.args.dataHex).toBe(SIG); - expect(c0.args.signatureHex).toBe(SIG); - }); - - it("parses a real *legacy* tx via DefaultSolanaMessageNormaliser and sends matched descriptors (skipping unmatched) after base + token", async () => { - // given - api.sendCommand - .mockResolvedValueOnce(success) // base PKI - .mockResolvedValueOnce(success) // TLV - .mockResolvedValueOnce(success) // token cert - .mockResolvedValueOnce(success) // token TLVTransactionInstructionDescriptor - .mockResolvedValue(success); - - const payer = Keypair.generate(); - const dest1 = Keypair.generate().publicKey; - const ix1 = SystemProgram.transfer({ - fromPubkey: payer.publicKey, - toPubkey: dest1, - lamports: 1234, - }); - - const owner = Keypair.generate(); - const srcToken = Keypair.generate().publicKey; - const dstToken = Keypair.generate().publicKey; - const ix2 = createTransferInstruction( - srcToken, - dstToken, - owner.publicKey, - 42n, - [], - TOKEN_PROGRAM_ID, - ); - - const MEMO_PROGRAM_ID = new PublicKey( - "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", - ); - const ix3 = new TransactionInstruction({ - programId: MEMO_PROGRAM_ID, - keys: [], - data: Buffer.from("hi"), - }); - - const { raw } = makeSignedRawLegacy( - [ix1, ix2, ix3], - [payer, owner], - payer, - ); - - const SYSTEM_PID = SystemProgram.programId.toBase58(); - const MEMO_PID = MEMO_PROGRAM_ID.toBase58(); - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_TOKEN, - payload: { solanaTokenDescriptor: tokenDescriptor }, - certificate: tokenCert, - }, - { - type: ClearSignContextType.SOLANA_LIFI, - payload: { - descriptors: { - [`${SYSTEM_PID}:`]: { - data: SIG, - signature: SIG, - }, - // Tokenkeg missing -> skipped - [`${MEMO_PID}:`]: { - data: SIG, - signature: SIG, - }, - }, - instructions: [ - { program_id: SYSTEM_PID }, - { program_id: MEMO_PID }, - ], - }, - }, - ]; + expect(api.sendCommand).toHaveBeenCalledTimes(4); - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: raw, - normaliser: new DefaultSolanaMessageNormaliser(), - loggerFactory: mockLoggerFactory, - }; + const tokenCertCmd = api.sendCommand.mock.calls[2]![0]!; + expect(tokenCertCmd).toBeInstanceOf(LoadCertificateCommand); + expect(tokenCertCmd.args.certificate).toStrictEqual(tokenCert.payload); - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, + const tokenDescCmd = api.sendCommand.mock.calls[3]![0]!; + expect(tokenDescCmd).toBeInstanceOf( + ProvideTLVTransactionInstructionDescriptorCommand, ); - - const result = await task.run(); - - expect(result).toStrictEqual(Nothing); - // 2 base + 2 token + 2 swap descriptors - expect(api.sendCommand).toHaveBeenCalledTimes(6); - - const c0 = api.sendCommand.mock.calls[4]![0]!; - expect(c0).toBeInstanceOf(ProvideInstructionDescriptorCommand); - expect(c0.args.dataHex).toBe(SIG); - expect(c0.args.signatureHex).toBe(SIG); - - const c1 = api.sendCommand.mock.calls[5]![0]!; - expect(c1).toBeInstanceOf(ProvideInstructionDescriptorCommand); - expect(c1.args.dataHex).toBe(SIG); - expect(c1.args.signatureHex).toBe(SIG); + expect(tokenDescCmd.args.dataHex).toBe(tokenDescriptor.data); + expect(tokenDescCmd.args.signatureHex).toBe(tokenDescriptor.signature); }); - it("parses a real *v0* tx via DefaultSolanaMessageNormaliser (no ALTs) and sends matched descriptors (skipping unmatched) after base + token", async () => { - // given + it("dispatches SOLANA_TRANSACTION_CHECK result to the web3-check handler", async () => { api.sendCommand - .mockResolvedValueOnce(success) // base PKI - .mockResolvedValueOnce(success) // TLV - .mockResolvedValueOnce(success) // token cert - .mockResolvedValueOnce(success) // token TLVTransactionInstructionDescriptor - .mockResolvedValue(success); - - const payer = Keypair.generate(); - const sysDest = Keypair.generate().publicKey; - const sysIx = SystemProgram.transfer({ - fromPubkey: payer.publicKey, - toPubkey: sysDest, - lamports: 5_678, - }); - - const owner = Keypair.generate().publicKey; - const mint = Keypair.generate().publicKey; - const ata = getAssociatedTokenAddressSync( - mint, - owner, - true, - TOKEN_PROGRAM_ID, - ASSOCIATED_TOKEN_PROGRAM_ID, - ); - const ataIx = createAssociatedTokenAccountInstruction( - payer.publicKey, - ata, - owner, - mint, - TOKEN_PROGRAM_ID, - ASSOCIATED_TOKEN_PROGRAM_ID, - ); - - const MEMO_PROGRAM_ID = new PublicKey( - "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr", - ); - const memoIx = new TransactionInstruction({ - programId: MEMO_PROGRAM_ID, - keys: [], - data: Buffer.from("hello"), - }); - - // IMPORTANT: sign only with the payer (no PublicKey in signers array) - const { raw } = makeSignedRawV0([sysIx, ataIx, memoIx], [], payer); - - const SYSTEM_PID = SystemProgram.programId.toBase58(); - const ATA_PID = ASSOCIATED_TOKEN_PROGRAM_ID.toBase58(); - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_TOKEN, - payload: { solanaTokenDescriptor: tokenDescriptor }, - certificate: tokenCert, - }, - { - type: ClearSignContextType.SOLANA_LIFI, - payload: { - descriptors: { - [`${SYSTEM_PID}:`]: { - data: SIG, - signature: SIG, - }, - [`${ATA_PID}:`]: { - data: SIG, - signature: SIG, - }, - // Memo intentionally missing -> skipped - }, - instructions: [{ program_id: SYSTEM_PID }, { program_id: ATA_PID }], - }, - }, - ]; - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: raw, - normaliser: new DefaultSolanaMessageNormaliser(), - loggerFactory: mockLoggerFactory, - }; + .mockResolvedValueOnce(success) // tx-check cert + .mockResolvedValueOnce(success); // descriptor chunk const task = new ProvideSolanaTransactionContextTask( api as any, - context as any, - ); - - const res = await task.run(); - - expect(res).toEqual(Nothing); - // 2 base + 2 token + 2 swap descriptors - expect(api.sendCommand).toHaveBeenCalledTimes(6); - - const c0 = api.sendCommand.mock.calls[4]![0]!; - expect(c0).toBeInstanceOf(ProvideInstructionDescriptorCommand); - expect(c0.args.dataHex).toBe(SIG); - expect(c0.args.signatureHex).toBe(SIG); - - const c1 = api.sendCommand.mock.calls[5]![0]!; - expect(c1).toBeInstanceOf(ProvideInstructionDescriptorCommand); - expect(c1.args.dataHex).toBe(SIG); - expect(c1.args.signatureHex).toBe(SIG); - }); - - it("parses a real *v0* tx via DefaultSolanaMessageNormaliser: System, createATA, token transfer (sends matched, skips unmatched) after base + token", async () => { - // given - api.sendCommand - .mockResolvedValueOnce(success) // base PKI - .mockResolvedValueOnce(success) // TLV - .mockResolvedValueOnce(success) // token cert - .mockResolvedValueOnce(success) // token TLVTransactionInstructionDescriptor - .mockResolvedValue(success); // swap APDUs - - const payer = Keypair.generate(); - - const sysDest = Keypair.generate().publicKey; - const sysIx = SystemProgram.transfer({ - fromPubkey: payer.publicKey, - toPubkey: sysDest, - lamports: 7_777, - }); - - const tokenOwner = Keypair.generate(); // owner of the source SPL token account (signer) - const mint = Keypair.generate().publicKey; - - const recipientOwner = Keypair.generate().publicKey; // unfunded account (no ATA yet) - const recipientATA = getAssociatedTokenAddressSync( - mint, - recipientOwner, - true, - TOKEN_PROGRAM_ID, - ASSOCIATED_TOKEN_PROGRAM_ID, - ); - - const createAtaIx = createAssociatedTokenAccountInstruction( - payer.publicKey, // funder - recipientATA, // ata to be created - recipientOwner, // owner of the ATA - mint, - TOKEN_PROGRAM_ID, - ASSOCIATED_TOKEN_PROGRAM_ID, - ); - - const srcTokenAcc = Keypair.generate().publicKey; // pretend this is an existing token account - const transferIx = createTransferInstruction( - srcTokenAcc, - recipientATA, - tokenOwner.publicKey, // authority of srcTokenAcc (we will sign with tokenOwner) - 9n, - [], - TOKEN_PROGRAM_ID, - ); - - // sign v0 with payer + tokenOwner (NO PublicKey objects in the signers array) - const { raw } = makeSignedRawV0( - [sysIx, createAtaIx, transferIx], - [tokenOwner], - payer, - ); - - const SYSTEM_PID = SystemProgram.programId.toBase58(); - const ATA_PID = ASSOCIATED_TOKEN_PROGRAM_ID.toBase58(); - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_TOKEN, - payload: { solanaTokenDescriptor: tokenDescriptor }, - certificate: tokenCert, - }, { - type: ClearSignContextType.SOLANA_LIFI, - payload: { - descriptors: { - [`${SYSTEM_PID}:`]: { - data: SIG, - signature: SIG, - }, - [`${ATA_PID}:`]: { - data: SIG, - signature: SIG, - }, - // Token Program intentionally missing -> skipped + trustedNamePKICertificate: undefined, + tlvDescriptor: undefined, + loadersResults: [ + { + type: ClearSignContextType.SOLANA_TRANSACTION_CHECK as const, + payload: { descriptor: "aabbccdd" }, + certificate: txCheckCert, }, - instructions: [{ program_id: SYSTEM_PID }, { program_id: ATA_PID }], - }, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: raw, - normaliser: new DefaultSolanaMessageNormaliser(), - loggerFactory: mockLoggerFactory, - }; - - // when - const task = new ProvideSolanaTransactionContextTask( - api as any, - context as any, + ], + transactionBytes: new Uint8Array([0xf0]), + loggerFactory: mockLoggerFactory, + } as any, ); - const res = await task.run(); + await task.run(); - // then - expect(res).toEqual(Nothing); - // 2 base + 2 token + 2 swap descriptors - expect(api.sendCommand).toHaveBeenCalledTimes(6); + expect(api.sendCommand).toHaveBeenCalledTimes(2); - // swap calls start at index 4 - const c0 = api.sendCommand.mock.calls[4]![0]!; - expect(c0).toBeInstanceOf(ProvideInstructionDescriptorCommand); - expect(c0.args.dataHex).toBe(SIG); - expect(c0.args.signatureHex).toBe(SIG); + const certCmd = api.sendCommand.mock.calls[0]![0]!; + expect(certCmd).toBeInstanceOf(LoadCertificateCommand); + expect(certCmd.args.certificate).toStrictEqual(txCheckCert.payload); + expect(certCmd.args.keyUsage).toBe(txCheckCert.keyUsageNumber); - const c1 = api.sendCommand.mock.calls[5]![0]!; - expect(c1).toBeInstanceOf(ProvideInstructionDescriptorCommand); - expect(c1.args.dataHex).toBe(SIG); - expect(c1.args.signatureHex).toBe(SIG); + const web3Cmd = api.sendCommand.mock.calls[1]![0]!; + expect(web3Cmd).toBeInstanceOf(ProvideWeb3CheckCommand); }); - it("selects the correct descriptor when a program_id has multiple discriminator candidates", async () => { + it("dispatches multiple loader results in order", async () => { api.sendCommand .mockResolvedValueOnce(success) // base PKI - .mockResolvedValueOnce(success) // TLV + .mockResolvedValueOnce(success) // TLV descriptor .mockResolvedValueOnce(success) // token cert .mockResolvedValueOnce(success) // token descriptor - .mockResolvedValue(success); // swap APDUs - - const message = { - compiledInstructions: [ - { programIdIndex: 0, data: new Uint8Array([0xbb, 0xcc, 0x00]) }, - { programIdIndex: 0, data: new Uint8Array([0xaa, 0xff, 0x00]) }, - ], - allKeys: [makeKey("MULTI")], - }; - const normaliser = buildNormaliser(message); + .mockResolvedValueOnce(success) // tx-check cert + .mockResolvedValueOnce(success); // tx-check descriptor - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_TOKEN, - payload: { solanaTokenDescriptor: tokenDescriptor }, - certificate: tokenCert, - }, + const task = new ProvideSolanaTransactionContextTask( + api as any, { - type: ClearSignContextType.SOLANA_LIFI, - payload: { - descriptors: { - "MULTI:aaff": { - data: "data_aa", - signature: SIG, - }, - "MULTI:bbcc": { - data: "data_bb", - signature: SIG, - }, + trustedNamePKICertificate: baseCert, + tlvDescriptor, + loadersResults: [ + { + type: ClearSignContextType.SOLANA_TOKEN as const, + payload: { solanaTokenDescriptor: tokenDescriptor }, + certificate: tokenCert, }, - instructions: [ - { program_id: "MULTI", discriminator_hex: "aaff" }, - { program_id: "MULTI", discriminator_hex: "bbcc" }, - ], - }, - certificate: swapCert, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0xf0]), - normaliser: normaliser as any, - loggerFactory: mockLoggerFactory, - }; - - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, - ); - - const result = await task.run(); - - expect(result).toStrictEqual(Nothing); - // 2 base + 2 token + 1 swap cert + 2 swap descriptors - expect(api.sendCommand).toHaveBeenCalledTimes(7); - - const c0 = api.sendCommand.mock.calls[5]![0]!; - expect(c0).toBeInstanceOf(ProvideInstructionDescriptorCommand); - expect(c0.args.dataHex).toBe("data_bb"); - - const c1 = api.sendCommand.mock.calls[6]![0]!; - expect(c1).toBeInstanceOf(ProvideInstructionDescriptorCommand); - expect(c1.args.dataHex).toBe("data_aa"); - }); - }); - - describe("edge cases", () => { - it("skips base context when trustedNamePKICertificate is missing", async () => { - api.sendCommand.mockResolvedValue(success); - - const context = { - trustedNamePKICertificate: undefined, - tlvDescriptor: undefined, - loadersResults: [], - transactionBytes: new Uint8Array([0xf0]), - loggerFactory: mockLoggerFactory, - }; - - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, - ); - - const result = await task.run(); - - expect(result).toStrictEqual(Nothing); - expect(api.sendCommand).toHaveBeenCalledTimes(0); - }); - - it("skips unknown loader types via the default case", async () => { - api.sendCommand - .mockResolvedValueOnce(success) - .mockResolvedValueOnce(success); - - const loadersResults = [{ type: "SOME_FUTURE_TYPE" as any, payload: {} }]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0xf0]), - loggerFactory: mockLoggerFactory, - }; - - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, + { + type: ClearSignContextType.SOLANA_TRANSACTION_CHECK as const, + payload: { descriptor: "aabbccdd" }, + certificate: txCheckCert, + }, + ], + transactionBytes: new Uint8Array([0xca]), + loggerFactory: mockLoggerFactory, + normaliser: buildNormaliser({}) as any, + } as any, ); - const result = await task.run(); + await task.run(); - expect(result).toStrictEqual(Nothing); - // only 2 base context commands, unknown loader type was skipped - expect(api.sendCommand).toHaveBeenCalledTimes(2); + expect(api.sendCommand).toHaveBeenCalledTimes(6); }); - it("skips swap flow entirely when lifiDescriptors is falsy", async () => { + it("ignores ERROR entries while still dispatching subsequent success entries", async () => { api.sendCommand - .mockResolvedValueOnce(success) - .mockResolvedValueOnce(success); - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_LIFI, - payload: { - descriptors: undefined as any, - instructions: [], - }, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0xf0]), - normaliser: { normaliseMessage: vi.fn() } as any, - loggerFactory: mockLoggerFactory, - }; + .mockResolvedValueOnce(success) // tx-check cert + .mockResolvedValueOnce(success); // tx-check descriptor const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, - ); - - const result = await task.run(); - - expect(result).toStrictEqual(Nothing); - // only 2 base context commands, swap was skipped - expect(api.sendCommand).toHaveBeenCalledTimes(2); - expect(context.normaliser.normaliseMessage).not.toHaveBeenCalled(); - }); - - it("skips certificate loading but still processes instructions when swapTemplateCertificate is missing", async () => { - api.sendCommand - .mockResolvedValueOnce(success) - .mockResolvedValueOnce(success) - .mockResolvedValue(success); - - const message = { - compiledInstructions: [ - { programIdIndex: 0, data: new Uint8Array([0x01]) }, - ], - allKeys: [makeKey("P1")], - }; - const normaliser = buildNormaliser(message); - - const loadersResults = [ + api as any, { - type: ClearSignContextType.SOLANA_LIFI, - payload: { - descriptors: { - "P1:1": { data: SIG, signature: SIG }, + trustedNamePKICertificate: undefined, + tlvDescriptor: undefined, + loadersResults: [ + { + type: ClearSignContextType.ERROR, + error: new Error("first loader failed"), }, - instructions: [{ program_id: "P1", discriminator_hex: "1" }], - }, - certificate: undefined, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0xf0]), - normaliser: normaliser as any, - loggerFactory: mockLoggerFactory, - }; - - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, - ); - - const result = await task.run(); - - expect(result).toStrictEqual(Nothing); - // 2 base + 1 swap descriptor (no swap cert) - expect(api.sendCommand).toHaveBeenCalledTimes(3); - - const swapCmd = api.sendCommand.mock.calls[2]![0]!; - expect(swapCmd).toBeInstanceOf(ProvideInstructionDescriptorCommand); - expect(swapCmd.args.dataHex).toBe(SIG); - }); - - it("returns undefined when discriminator matches but descriptor key is not in the map", async () => { - api.sendCommand - .mockResolvedValueOnce(success) - .mockResolvedValueOnce(success); - - const message = { - compiledInstructions: [ - { programIdIndex: 0, data: new Uint8Array([0x01]) }, - ], - allKeys: [makeKey("PROG")], - }; - const normaliser = buildNormaliser(message); - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_LIFI, - payload: { - descriptors: { - // key "PROG:1" deliberately absent despite the instruction meta listing it + { + type: ClearSignContextType.SOLANA_TRANSACTION_CHECK as const, + payload: { descriptor: "aabbccdd" }, + certificate: txCheckCert, }, - instructions: [{ program_id: "PROG", discriminator_hex: "1" }], - }, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0xf0]), - normaliser: normaliser as any, - loggerFactory: mockLoggerFactory, - }; - - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, + ], + transactionBytes: new Uint8Array([0xf0]), + loggerFactory: mockLoggerFactory, + } as any, ); - const result = await task.run(); + await task.run(); - expect(result).toStrictEqual(Nothing); - // 2 base only, no swap APDU because descriptor map entry is missing expect(api.sendCommand).toHaveBeenCalledTimes(2); - }); - - it("skips instruction when its data is shorter than the discriminator", async () => { - api.sendCommand - .mockResolvedValueOnce(success) - .mockResolvedValueOnce(success); - - const message = { - compiledInstructions: [ - { programIdIndex: 0, data: new Uint8Array([0x2a]) }, // 1 byte, but disc is 4 bytes - ], - allKeys: [makeKey("SHORT")], - }; - const normaliser = buildNormaliser(message); - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_LIFI, - payload: { - descriptors: { - "SHORT:2aade37a": { - data: SIG, - signature: SIG, - }, - }, - instructions: [ - { program_id: "SHORT", discriminator_hex: "2aade37a" }, - ], - }, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0xf0]), - normaliser: normaliser as any, - loggerFactory: mockLoggerFactory, - }; - - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, + expect(api.sendCommand.mock.calls[0]![0]).toBeInstanceOf( + LoadCertificateCommand, ); - - const result = await task.run(); - - expect(result).toStrictEqual(Nothing); - // 2 base only, no swap APDU because instruction data is too short for the discriminator - expect(api.sendCommand).toHaveBeenCalledTimes(2); - }); - - it("skips instruction when discriminator bytes do not match the instruction data prefix", async () => { - api.sendCommand - .mockResolvedValueOnce(success) - .mockResolvedValueOnce(success); - - const message = { - compiledInstructions: [ - { programIdIndex: 0, data: new Uint8Array([0xff, 0xee, 0xdd, 0xcc]) }, - ], - allKeys: [makeKey("MISMATCH")], - }; - const normaliser = buildNormaliser(message); - - const loadersResults = [ - { - type: ClearSignContextType.SOLANA_LIFI, - payload: { - descriptors: { - "MISMATCH:aabbccdd": { - data: SIG, - signature: SIG, - }, - }, - instructions: [ - { program_id: "MISMATCH", discriminator_hex: "aabbccdd" }, - ], - }, - }, - ]; - - const context = { - trustedNamePKICertificate: baseCert, - tlvDescriptor, - loadersResults, - transactionBytes: new Uint8Array([0xf0]), - normaliser: normaliser as any, - loggerFactory: mockLoggerFactory, - }; - - const task = new ProvideSolanaTransactionContextTask( - api as unknown as any, - context as any, + expect(api.sendCommand.mock.calls[1]![0]).toBeInstanceOf( + ProvideWeb3CheckCommand, ); - - const result = await task.run(); - - expect(result).toStrictEqual(Nothing); - // 2 base only, no swap APDU because discriminator 0xaabbccdd != data prefix 0xffeeddcc - expect(api.sendCommand).toHaveBeenCalledTimes(2); }); }); }); diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/ProvideTransactionContextTask.ts b/packages/signer/signer-solana/src/internal/app-binder/task/ProvideTransactionContextTask.ts index bceec12bf5..6270b86221 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/task/ProvideTransactionContextTask.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/task/ProvideTransactionContextTask.ts @@ -1,29 +1,20 @@ -import { - ClearSignContextType, - type SolanaLifiContextSuccess, - type SolanaLifiInstructionMeta, - type SolanaTokenContextSuccess, - type SolanaTransactionDescriptor, - type SolanaTransactionDescriptorList, -} from "@ledgerhq/context-module"; +import { ClearSignContextType } from "@ledgerhq/context-module"; import { type CommandErrorResult, type InternalApi, - isSuccessCommandResult, LoadCertificateCommand, type LoggerPublisherService, } from "@ledgerhq/device-management-kit"; import { type Maybe, Nothing } from "purify-ts"; -import { ProvideInstructionDescriptorCommand } from "@internal/app-binder/command/ProvideInstructionDescriptorCommand"; import { ProvideTLVDescriptorCommand } from "@internal/app-binder/command/ProvideTLVDescriptorCommand"; -import { ProvideTLVTransactionInstructionDescriptorCommand } from "@internal/app-binder/command/ProvideTLVTransactionInstructionDescriptorCommand"; import { type SolanaAppErrorCodes } from "@internal/app-binder/command/utils/SolanaApplicationErrors"; import { DefaultSolanaMessageNormaliser, type SolanaMessageNormaliser, } from "@internal/app-binder/services/utils/DefaultSolanaMessageNormaliser"; +import { dispatchProvideContext } from "./context-providers/provideContextRegistry"; import { type SolanaBuildContextResult } from "./BuildTransactionContextTask"; export type ProvideSolanaTransactionContextTaskArgs = @@ -76,254 +67,24 @@ export class ProvideSolanaTransactionContextTask { this._logger.debug("[run] Providing optional Solana context from loaders", { data: { loadersResults }, }); - for (const loaderResult of loadersResults) { - switch (loaderResult.type) { - // always resolve SOLANA_TOKEN first - case ClearSignContextType.SOLANA_TOKEN: { - const tokenMetadataResult = loadersResults.find( - (res): res is SolanaTokenContextSuccess => - res.type === ClearSignContextType.SOLANA_TOKEN, - ); - this._logger.debug( - `[run] Providing ${ClearSignContextType.SOLANA_TOKEN}`, - { data: { args: { tokenMetadataResult } } }, - ); - if (tokenMetadataResult) { - await this.provideTokenMetadataContext(tokenMetadataResult); - } - break; - } - - case ClearSignContextType.SOLANA_LIFI: { - const lifiDescriptorListResult = loadersResults.find( - (res): res is SolanaLifiContextSuccess => - res.type === ClearSignContextType.SOLANA_LIFI, - ); - this._logger.debug( - `[run] Providing ${ClearSignContextType.SOLANA_LIFI}`, - { data: { args: { lifiDescriptorListResult, transactionBytes } } }, - ); - if (lifiDescriptorListResult) { - await this.provideSwapContext( - lifiDescriptorListResult, - transactionBytes, - ); - } - break; - } - - case ClearSignContextType.ERROR: { - this._logger.debug(`[run] Loader result of type ERROR, skipping`); - break; - } - - default: { - const _exhaustiveCheck: never = loaderResult; - void _exhaustiveCheck; - break; - } - } - } - - return Nothing; - } - - private async provideTokenMetadataContext( - tokenMetadataResult: SolanaTokenContextSuccess, - ): Promise { - const { - payload: tokenMetadataPayload, - certificate: tokenMetadataCertificate, - } = tokenMetadataResult; - - if (tokenMetadataPayload && tokenMetadataCertificate) { - // send token metadata certificate - const tokenMetadataCertificateToDeviceResult = await this.api.sendCommand( - new LoadCertificateCommand({ - certificate: tokenMetadataCertificate.payload, - keyUsage: tokenMetadataCertificate.keyUsageNumber, - }), - ); - if (!isSuccessCommandResult(tokenMetadataCertificateToDeviceResult)) { - // IMPORTANT, TO BE MAPPED TO LatestFirmwareVersionRequired("LatestFirmwareVersionRequired") ERROR - throw new Error( - "[SignerSolana] ProvideSolanaTransactionContextTask: Failed to send tokenMetadataCertificate to device, latest firmware version required", - ); - } - - // send token metadata signed descriptor - await this.api.sendCommand( - new ProvideTLVTransactionInstructionDescriptorCommand({ - dataHex: tokenMetadataPayload.solanaTokenDescriptor.data, - signatureHex: tokenMetadataPayload.solanaTokenDescriptor.signature, - }), - ); - } - } - - private async provideSwapContext( - lifiDescriptorListResult: SolanaLifiContextSuccess, - transactionBytes: Uint8Array, - ): Promise { - const { descriptors: lifiDescriptors, instructions: instructionsMeta } = - lifiDescriptorListResult.payload; - const { certificate: swapTemplateCertificate } = lifiDescriptorListResult; - - if (lifiDescriptors) { - if (swapTemplateCertificate) { - const swapCertResult = await this.api.sendCommand( - new LoadCertificateCommand({ - certificate: swapTemplateCertificate.payload, - keyUsage: swapTemplateCertificate.keyUsageNumber, - }), - ); - if (!isSuccessCommandResult(swapCertResult)) { - throw new Error( - "[SignerSolana] ProvideSolanaTransactionContextTask: Failed to send swapTemplateCertificate to device", - ); - } - } - const message = await this._normaliser.normaliseMessage(transactionBytes); - this._logger.debug( - "[provideSwapContext] Matching transaction instructions to descriptors", - { - data: { - compiledInstructionsCount: message.compiledInstructions.length, - descriptorKeys: Object.keys(lifiDescriptors), - instructionsMetaCount: instructionsMeta.length, - }, - }, - ); - - for (const [ - index, - instruction, - ] of message.compiledInstructions.entries()) { - const programId = message.allKeys[instruction.programIdIndex]; - const programIdStr = programId?.toBase58(); - - const descriptor = this.findMatchingDescriptor( - programIdStr, - instruction.data, - instructionsMeta, - lifiDescriptors, - ); - const sigHex = descriptor?.signature; - - this._logger.debug( - `[provideSwapContext] Instruction ${index}: ${descriptor ? "matched" : "no match"}`, - { - data: { - index, - programId: programIdStr, - hasDescriptor: !!descriptor, - hasSignature: !!sigHex, - signatureHex: sigHex ?? null, - }, - }, - ); - - if (descriptor && sigHex) { - await this.api.sendCommand( - new ProvideInstructionDescriptorCommand({ - dataHex: descriptor.data, - signatureHex: sigHex, - }), - ); - } - } - } - } - - /** - * Find the matching descriptor for a compiled transaction instruction by: - * 1. Filtering instruction metadata by program_id - * 2. Comparing instruction data bytes against the discriminator_hex - * 3. Looking up the descriptor using the composite key (program_id:discriminator_hex) - */ - private findMatchingDescriptor( - programIdStr: string | undefined, - instructionData: Uint8Array, - instructionsMeta: SolanaLifiInstructionMeta[], - descriptors: SolanaTransactionDescriptorList, - ): SolanaTransactionDescriptor | undefined { - if (!programIdStr) return undefined; - - const candidates = instructionsMeta.filter( - (meta) => meta.program_id === programIdStr, - ); - - if (candidates.length === 0) { - this._logger.debug( - "[findMatchingDescriptor] No instruction metadata found for program", - { data: { programId: programIdStr } }, - ); - return undefined; - } - - for (const candidate of candidates) { - const discriminatorHex = candidate.discriminator_hex ?? ""; - - if (this.matchesDiscriminator(instructionData, discriminatorHex)) { - const key = `${programIdStr}:${discriminatorHex}`; - const descriptor = descriptors[key]; - - this._logger.debug("[findMatchingDescriptor] Discriminator matched", { - data: { - programId: programIdStr, - discriminatorHex, - key, - found: !!descriptor, - }, - }); + const deps = { + api: this.api, + logger: this._logger, + normaliser: this._normaliser, + transactionBytes, + }; - if (descriptor) return descriptor; + for (const loaderResult of loadersResults) { + if (loaderResult.type === ClearSignContextType.ERROR) { + this._logger.debug("[run] Loader result of type ERROR, skipping"); + continue; } - } - - this._logger.debug( - "[findMatchingDescriptor] No matching discriminator found", - { - data: { - programId: programIdStr, - instructionDataLength: instructionData.length, - candidateDiscriminators: candidates.map( - (c) => c.discriminator_hex ?? "", - ), - }, - }, - ); - return undefined; - } - - /** - * Check if instruction data starts with the expected discriminator bytes. - * An empty discriminatorHex means no discriminator constraint (always matches). - */ - private matchesDiscriminator( - instructionData: Uint8Array, - discriminatorHex: string, - ): boolean { - if (discriminatorHex === "") return true; - - const padded = - discriminatorHex.length % 2 !== 0 - ? "0" + discriminatorHex - : discriminatorHex; - const discriminatorBytes = new Uint8Array(padded.length / 2); - for (let i = 0; i < padded.length; i += 2) { - const byteStr = padded.substring(i, i + 2); - const parsed = parseInt(byteStr, 16); - if (Number.isNaN(parsed)) { - return false; - } - discriminatorBytes[i / 2] = parsed; + this._logger.debug(`[run] Providing ${loaderResult.type}`); + await dispatchProvideContext(loaderResult, deps); } - if (instructionData.length < discriminatorBytes.length) return false; - - return discriminatorBytes.every((byte, i) => instructionData[i] === byte); + return Nothing; } } diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/loadCertificate.ts b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/loadCertificate.ts new file mode 100644 index 0000000000..26e3587d37 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/loadCertificate.ts @@ -0,0 +1,22 @@ +import { type PkiCertificate } from "@ledgerhq/context-module"; +import { + type InternalApi, + isSuccessCommandResult, + LoadCertificateCommand, +} from "@ledgerhq/device-management-kit"; + +export async function loadCertificate( + api: InternalApi, + certificate: PkiCertificate, + errorMessage: string, +): Promise { + const result = await api.sendCommand( + new LoadCertificateCommand({ + certificate: certificate.payload, + keyUsage: certificate.keyUsageNumber, + }), + ); + if (!isSuccessCommandResult(result)) { + throw new Error(errorMessage); + } +} diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideContextRegistry.test.ts b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideContextRegistry.test.ts new file mode 100644 index 0000000000..8fb7fe3140 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideContextRegistry.test.ts @@ -0,0 +1,81 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ClearSignContextType } from "@ledgerhq/context-module"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { type ProvideContextDeps } from "./provideContextTypes"; + +vi.mock("./provideTokenContext", () => ({ + provideTokenContext: vi.fn(async () => undefined), +})); +vi.mock("./provideLifiContext", () => ({ + provideLifiContext: vi.fn(async () => undefined), +})); +vi.mock("./provideTransactionCheckContext", () => ({ + provideTransactionCheckContext: vi.fn(async () => undefined), +})); + +import { dispatchProvideContext } from "./provideContextRegistry"; +import { provideLifiContext } from "./provideLifiContext"; +import { provideTokenContext } from "./provideTokenContext"; +import { provideTransactionCheckContext } from "./provideTransactionCheckContext"; + +describe("dispatchProvideContext", () => { + let deps: ProvideContextDeps; + + beforeEach(() => { + vi.clearAllMocks(); + deps = { + api: {} as any, + logger: {} as any, + normaliser: {} as any, + transactionBytes: new Uint8Array(), + }; + }); + + it("routes SOLANA_TOKEN to provideTokenContext", async () => { + const result = { type: ClearSignContextType.SOLANA_TOKEN } as any; + + await dispatchProvideContext(result, deps); + + expect(provideTokenContext).toHaveBeenCalledTimes(1); + expect(provideTokenContext).toHaveBeenCalledWith(result, deps); + expect(provideLifiContext).not.toHaveBeenCalled(); + expect(provideTransactionCheckContext).not.toHaveBeenCalled(); + }); + + it("routes SOLANA_LIFI to provideLifiContext", async () => { + const result = { type: ClearSignContextType.SOLANA_LIFI } as any; + + await dispatchProvideContext(result, deps); + + expect(provideLifiContext).toHaveBeenCalledTimes(1); + expect(provideLifiContext).toHaveBeenCalledWith(result, deps); + expect(provideTokenContext).not.toHaveBeenCalled(); + expect(provideTransactionCheckContext).not.toHaveBeenCalled(); + }); + + it("routes SOLANA_TRANSACTION_CHECK to provideTransactionCheckContext", async () => { + const result = { + type: ClearSignContextType.SOLANA_TRANSACTION_CHECK, + } as any; + + await dispatchProvideContext(result, deps); + + expect(provideTransactionCheckContext).toHaveBeenCalledTimes(1); + expect(provideTransactionCheckContext).toHaveBeenCalledWith(result, deps); + expect(provideTokenContext).not.toHaveBeenCalled(); + expect(provideLifiContext).not.toHaveBeenCalled(); + }); + + it("propagates rejection from the underlying handler", async () => { + const boom = new Error("boom"); + (provideTokenContext as any).mockRejectedValueOnce(boom); + + await expect( + dispatchProvideContext( + { type: ClearSignContextType.SOLANA_TOKEN } as any, + deps, + ), + ).rejects.toBe(boom); + }); +}); diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideContextRegistry.ts b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideContextRegistry.ts new file mode 100644 index 0000000000..365ddd6d3e --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideContextRegistry.ts @@ -0,0 +1,41 @@ +import { + ClearSignContextType, + type SolanaContextSuccess, + type SolanaContextSuccessType, +} from "@ledgerhq/context-module"; + +import { + type ProvideContextDeps, + type ProvideContextHandler, +} from "./provideContextTypes"; +import { provideLifiContext } from "./provideLifiContext"; +import { provideTokenContext } from "./provideTokenContext"; +import { provideTransactionCheckContext } from "./provideTransactionCheckContext"; + +/** + * Per-type provide pipeline: maps each success context type to its device-provision handler. + * Adding a new {@link SolanaContextSuccessType} without a handler here is a compile error. + */ +const PROVIDE_CONTEXT_REGISTRY: { + [K in SolanaContextSuccessType]: ProvideContextHandler; +} = { + [ClearSignContextType.SOLANA_TOKEN]: provideTokenContext, + [ClearSignContextType.SOLANA_LIFI]: provideLifiContext, + [ClearSignContextType.SOLANA_TRANSACTION_CHECK]: + provideTransactionCheckContext, +}; + +type DiscriminatedSolanaContextSuccess = { + [K in SolanaContextSuccessType]: SolanaContextSuccess; +}[SolanaContextSuccessType]; + +export function dispatchProvideContext( + result: DiscriminatedSolanaContextSuccess, + deps: ProvideContextDeps, +): Promise { + const handler = PROVIDE_CONTEXT_REGISTRY[result.type] as ( + result: DiscriminatedSolanaContextSuccess, + deps: ProvideContextDeps, + ) => Promise; + return handler(result, deps); +} diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideContextTypes.ts b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideContextTypes.ts new file mode 100644 index 0000000000..6e004e3e10 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideContextTypes.ts @@ -0,0 +1,22 @@ +import { + type SolanaContextSuccess, + type SolanaContextSuccessType, +} from "@ledgerhq/context-module"; +import { + type InternalApi, + type LoggerPublisherService, +} from "@ledgerhq/device-management-kit"; + +import { type SolanaMessageNormaliser } from "@internal/app-binder/services/utils/DefaultSolanaMessageNormaliser"; + +export type ProvideContextDeps = { + readonly api: InternalApi; + readonly logger: LoggerPublisherService; + readonly normaliser: SolanaMessageNormaliser; + readonly transactionBytes: Uint8Array; +}; + +export type ProvideContextHandler = ( + result: SolanaContextSuccess, + deps: ProvideContextDeps, +) => Promise; diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideLifiContext.test.ts b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideLifiContext.test.ts new file mode 100644 index 0000000000..199adc9141 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideLifiContext.test.ts @@ -0,0 +1,306 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ClearSignContextType } from "@ledgerhq/context-module"; +import { + CommandResultFactory, + LoadCertificateCommand, +} from "@ledgerhq/device-management-kit"; +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; + +import { ProvideInstructionDescriptorCommand } from "@internal/app-binder/command/ProvideInstructionDescriptorCommand"; + +import { type ProvideContextDeps } from "./provideContextTypes"; +import { provideLifiContext } from "./provideLifiContext"; + +const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + subscribers: [], +}; + +const makeKey = (base58: string) => ({ toBase58: () => base58 }); + +const buildNormaliser = (message: any) => + ({ normaliseMessage: vi.fn(async () => message) }) as const; + +const SIG = "f0cacc1a"; + +const swapCert = { + payload: new Uint8Array([0x01, 0x02, 0x03]), + keyUsageNumber: 13, +} as const; + +describe("provideLifiContext", () => { + let api: { sendCommand: Mock }; + const success = CommandResultFactory({ data: undefined }); + + beforeEach(() => { + vi.resetAllMocks(); + api = { sendCommand: vi.fn() }; + }); + + function makeDeps(normaliser: any): ProvideContextDeps { + return { + api: api as any, + logger: mockLogger as any, + normaliser, + transactionBytes: new Uint8Array([0xf0]), + }; + } + + it("sends swap certificate then matched instruction descriptors", async () => { + api.sendCommand + .mockResolvedValueOnce(success) // swap cert + .mockResolvedValue(success); // descriptors + + const message = { + compiledInstructions: [ + { programIdIndex: 0, data: new Uint8Array([0x01]) }, + { programIdIndex: 1, data: new Uint8Array([0x02]) }, + { programIdIndex: 2, data: new Uint8Array([0x03]) }, + ], + allKeys: [makeKey("A_PID"), makeKey("B_PID"), makeKey("C_PID")], + }; + const normaliser = buildNormaliser(message); + + const result = { + type: ClearSignContextType.SOLANA_LIFI as const, + payload: { + descriptors: { + "A_PID:1": { data: SIG, signature: SIG }, + "C_PID:3": { data: SIG, signature: SIG }, + }, + instructions: [ + { program_id: "A_PID", discriminator_hex: "1" }, + { program_id: "C_PID", discriminator_hex: "3" }, + ], + }, + certificate: swapCert, + }; + + await provideLifiContext(result as any, makeDeps(normaliser)); + + // 1 cert + 2 matched descriptors (B skipped) + expect(api.sendCommand).toHaveBeenCalledTimes(3); + + expect(api.sendCommand.mock.calls[0]![0]).toBeInstanceOf( + LoadCertificateCommand, + ); + expect(api.sendCommand.mock.calls[1]![0]).toBeInstanceOf( + ProvideInstructionDescriptorCommand, + ); + expect(api.sendCommand.mock.calls[2]![0]).toBeInstanceOf( + ProvideInstructionDescriptorCommand, + ); + }); + + it("throws when swap certificate load fails", async () => { + const errorResult = CommandResultFactory({ + error: { _tag: "Err", errorCode: 0x6a80, message: "bad" }, + }); + api.sendCommand.mockResolvedValueOnce(errorResult); + + const message = { + compiledInstructions: [ + { programIdIndex: 0, data: new Uint8Array([0x01]) }, + ], + allKeys: [makeKey("A")], + }; + + const result = { + type: ClearSignContextType.SOLANA_LIFI as const, + payload: { + descriptors: { "A:1": { data: SIG, signature: SIG } }, + instructions: [{ program_id: "A", discriminator_hex: "1" }], + }, + certificate: swapCert, + }; + + await expect( + provideLifiContext(result as any, makeDeps(buildNormaliser(message))), + ).rejects.toThrow("Failed to send swapTemplateCertificate to device"); + }); + + it("skips entirely when descriptors is falsy", async () => { + const normaliser = buildNormaliser({}); + + const result = { + type: ClearSignContextType.SOLANA_LIFI as const, + payload: { descriptors: undefined as any, instructions: [] }, + }; + + await provideLifiContext(result as any, makeDeps(normaliser)); + + expect(api.sendCommand).not.toHaveBeenCalled(); + expect(normaliser.normaliseMessage).not.toHaveBeenCalled(); + }); + + it("skips certificate but still processes instructions when certificate is absent", async () => { + api.sendCommand.mockResolvedValue(success); + + const message = { + compiledInstructions: [ + { programIdIndex: 0, data: new Uint8Array([0x01]) }, + ], + allKeys: [makeKey("P1")], + }; + + const result = { + type: ClearSignContextType.SOLANA_LIFI as const, + payload: { + descriptors: { "P1:1": { data: SIG, signature: SIG } }, + instructions: [{ program_id: "P1", discriminator_hex: "1" }], + }, + certificate: undefined, + }; + + await provideLifiContext(result as any, makeDeps(buildNormaliser(message))); + + // no cert, 1 descriptor + expect(api.sendCommand).toHaveBeenCalledTimes(1); + expect(api.sendCommand.mock.calls[0]![0]).toBeInstanceOf( + ProvideInstructionDescriptorCommand, + ); + }); + + it("sends no APDU when signature is empty", async () => { + const message = { + compiledInstructions: [ + { programIdIndex: 0, data: new Uint8Array([0x01]) }, + ], + allKeys: [makeKey("PID")], + }; + + const result = { + type: ClearSignContextType.SOLANA_LIFI as const, + payload: { + descriptors: { "PID:": { data: SIG, signature: "" } }, + instructions: [{ program_id: "PID" }], + }, + }; + + await provideLifiContext(result as any, makeDeps(buildNormaliser(message))); + + expect(api.sendCommand).not.toHaveBeenCalled(); + }); + + it("sends no APDU when programId is out of range", async () => { + const message = { + compiledInstructions: [{ programIdIndex: 5, data: new Uint8Array() }], + allKeys: [makeKey("X")], + }; + + const result = { + type: ClearSignContextType.SOLANA_LIFI as const, + payload: { descriptors: {}, instructions: [] }, + }; + + await provideLifiContext(result as any, makeDeps(buildNormaliser(message))); + + expect(api.sendCommand).not.toHaveBeenCalled(); + }); + + it("skips instruction when data is shorter than discriminator", async () => { + const message = { + compiledInstructions: [ + { programIdIndex: 0, data: new Uint8Array([0x2a]) }, + ], + allKeys: [makeKey("SHORT")], + }; + + const result = { + type: ClearSignContextType.SOLANA_LIFI as const, + payload: { + descriptors: { "SHORT:2aade37a": { data: SIG, signature: SIG } }, + instructions: [{ program_id: "SHORT", discriminator_hex: "2aade37a" }], + }, + }; + + await provideLifiContext(result as any, makeDeps(buildNormaliser(message))); + + expect(api.sendCommand).not.toHaveBeenCalled(); + }); + + it("skips instruction when discriminator does not match data prefix", async () => { + const message = { + compiledInstructions: [ + { programIdIndex: 0, data: new Uint8Array([0xff, 0xee, 0xdd, 0xcc]) }, + ], + allKeys: [makeKey("MM")], + }; + + const result = { + type: ClearSignContextType.SOLANA_LIFI as const, + payload: { + descriptors: { "MM:aabbccdd": { data: SIG, signature: SIG } }, + instructions: [{ program_id: "MM", discriminator_hex: "aabbccdd" }], + }, + }; + + await provideLifiContext(result as any, makeDeps(buildNormaliser(message))); + + expect(api.sendCommand).not.toHaveBeenCalled(); + }); + + it("selects the correct descriptor among multiple discriminator candidates", async () => { + api.sendCommand.mockResolvedValue(success); + + const message = { + compiledInstructions: [ + { programIdIndex: 0, data: new Uint8Array([0xbb, 0xcc, 0x00]) }, + { programIdIndex: 0, data: new Uint8Array([0xaa, 0xff, 0x00]) }, + ], + allKeys: [makeKey("MULTI")], + }; + + const result = { + type: ClearSignContextType.SOLANA_LIFI as const, + payload: { + descriptors: { + "MULTI:aaff": { data: "data_aa", signature: SIG }, + "MULTI:bbcc": { data: "data_bb", signature: SIG }, + }, + instructions: [ + { program_id: "MULTI", discriminator_hex: "aaff" }, + { program_id: "MULTI", discriminator_hex: "bbcc" }, + ], + }, + certificate: swapCert, + }; + + await provideLifiContext(result as any, makeDeps(buildNormaliser(message))); + + // 1 cert + 2 descriptors + expect(api.sendCommand).toHaveBeenCalledTimes(3); + + const c0 = api.sendCommand.mock.calls[1]![0]!; + expect(c0).toBeInstanceOf(ProvideInstructionDescriptorCommand); + expect(c0.args.dataHex).toBe("data_bb"); + + const c1 = api.sendCommand.mock.calls[2]![0]!; + expect(c1).toBeInstanceOf(ProvideInstructionDescriptorCommand); + expect(c1.args.dataHex).toBe("data_aa"); + }); + + it("returns undefined when discriminator matches but descriptor key is absent", async () => { + const message = { + compiledInstructions: [ + { programIdIndex: 0, data: new Uint8Array([0x01]) }, + ], + allKeys: [makeKey("PROG")], + }; + + const result = { + type: ClearSignContextType.SOLANA_LIFI as const, + payload: { + descriptors: {}, + instructions: [{ program_id: "PROG", discriminator_hex: "1" }], + }, + }; + + await provideLifiContext(result as any, makeDeps(buildNormaliser(message))); + + expect(api.sendCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideLifiContext.ts b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideLifiContext.ts new file mode 100644 index 0000000000..6e93337ea1 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideLifiContext.ts @@ -0,0 +1,162 @@ +import { + type ClearSignContextType, + type SolanaLifiContextSuccess, + type SolanaLifiInstructionMeta, + type SolanaTransactionDescriptor, + type SolanaTransactionDescriptorList, +} from "@ledgerhq/context-module"; +import { type LoggerPublisherService } from "@ledgerhq/device-management-kit"; + +import { ProvideInstructionDescriptorCommand } from "@internal/app-binder/command/ProvideInstructionDescriptorCommand"; + +import { loadCertificate } from "./loadCertificate"; +import { type ProvideContextHandler } from "./provideContextTypes"; + +const HEX_RADIX = 16; + +export const provideLifiContext: ProvideContextHandler< + ClearSignContextType.SOLANA_LIFI +> = async ( + result: SolanaLifiContextSuccess, + { api, logger, normaliser, transactionBytes }, +) => { + const { descriptors: lifiDescriptors, instructions: instructionsMeta } = + result.payload; + const { certificate: swapTemplateCertificate } = result; + + if (!lifiDescriptors) return; + + if (swapTemplateCertificate) { + await loadCertificate( + api, + swapTemplateCertificate, + "[SignerSolana] provideLifiContext: Failed to send swapTemplateCertificate to device", + ); + } + + const message = await normaliser.normaliseMessage(transactionBytes); + + logger.debug( + "[provideLifiContext] Matching transaction instructions to descriptors", + { + data: { + compiledInstructionsCount: message.compiledInstructions.length, + descriptorKeys: Object.keys(lifiDescriptors), + instructionsMetaCount: instructionsMeta.length, + }, + }, + ); + + for (const [index, instruction] of message.compiledInstructions.entries()) { + const programId = message.allKeys[instruction.programIdIndex]; + const programIdStr = programId?.toBase58(); + + const descriptor = findMatchingDescriptor( + programIdStr, + instruction.data, + instructionsMeta, + lifiDescriptors, + logger, + ); + const sigHex = descriptor?.signature; + + logger.debug( + `[provideLifiContext] Instruction ${index}: ${descriptor ? "matched" : "no match"}`, + { + data: { + index, + programId: programIdStr, + hasDescriptor: !!descriptor, + hasSignature: !!sigHex, + signatureHex: sigHex ?? null, + }, + }, + ); + + if (descriptor && sigHex) { + await api.sendCommand( + new ProvideInstructionDescriptorCommand({ + dataHex: descriptor.data, + signatureHex: sigHex, + }), + ); + } + } +}; + +function findMatchingDescriptor( + programIdStr: string | undefined, + instructionData: Uint8Array, + instructionsMeta: SolanaLifiInstructionMeta[], + descriptors: SolanaTransactionDescriptorList, + logger: LoggerPublisherService, +): SolanaTransactionDescriptor | undefined { + if (!programIdStr) return undefined; + + const candidates = instructionsMeta.filter( + (meta) => meta.program_id === programIdStr, + ); + + if (candidates.length === 0) { + logger.debug( + "[findMatchingDescriptor] No instruction metadata found for program", + { data: { programId: programIdStr } }, + ); + return undefined; + } + + for (const candidate of candidates) { + const discriminatorHex = candidate.discriminator_hex ?? ""; + + if (matchesDiscriminator(instructionData, discriminatorHex)) { + const key = `${programIdStr}:${discriminatorHex}`; + const descriptor = descriptors[key]; + + logger.debug("[findMatchingDescriptor] Discriminator matched", { + data: { + programId: programIdStr, + discriminatorHex, + key, + found: !!descriptor, + }, + }); + + if (descriptor) return descriptor; + } + } + + logger.debug("[findMatchingDescriptor] No matching discriminator found", { + data: { + programId: programIdStr, + instructionDataLength: instructionData.length, + candidateDiscriminators: candidates.map((c) => c.discriminator_hex ?? ""), + }, + }); + + return undefined; +} + +function matchesDiscriminator( + instructionData: Uint8Array, + discriminatorHex: string, +): boolean { + if (discriminatorHex === "") return true; + + const padded = + discriminatorHex.length % 2 === 0 + ? discriminatorHex + : "0" + discriminatorHex; + const discriminatorBytes = new Uint8Array(padded.length / 2); + for (let i = 0; i < padded.length; i += 2) { + const byteStr = padded.substring(i, i + 2); + const parsed = Number.parseInt(byteStr, HEX_RADIX); + if (Number.isNaN(parsed)) { + return false; + } + discriminatorBytes[i / 2] = parsed; + } + + if (instructionData.length < discriminatorBytes.length) return false; + + return discriminatorBytes.every((byte, i) => instructionData[i] === byte); +} diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideTokenContext.test.ts b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideTokenContext.test.ts new file mode 100644 index 0000000000..03c9f53bdf --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideTokenContext.test.ts @@ -0,0 +1,122 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ClearSignContextType } from "@ledgerhq/context-module"; +import { + CommandResultFactory, + LoadCertificateCommand, +} from "@ledgerhq/device-management-kit"; +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; + +import { ProvideTLVTransactionInstructionDescriptorCommand } from "@internal/app-binder/command/ProvideTLVTransactionInstructionDescriptorCommand"; + +import { type ProvideContextDeps } from "./provideContextTypes"; +import { provideTokenContext } from "./provideTokenContext"; + +const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + subscribers: [], +}; + +describe("provideTokenContext", () => { + let api: { sendCommand: Mock }; + let deps: ProvideContextDeps; + const success = CommandResultFactory({ data: undefined }); + + const tokenCert = { + payload: new Uint8Array([0xf0, 0xca, 0xcc, 0x1a]), + keyUsageNumber: 2, + } as const; + + const tokenDescriptor = { + data: "f0cacc1a", + signature: "01020304", + } as const; + + beforeEach(() => { + vi.resetAllMocks(); + api = { sendCommand: vi.fn() }; + deps = { + api: api as any, + logger: mockLogger as any, + normaliser: {} as any, + transactionBytes: new Uint8Array([0xf0]), + }; + }); + + // test 1: sends certificate then descriptor + it("sends certificate then TLV transaction-instruction descriptor", async () => { + api.sendCommand + .mockResolvedValueOnce(success) + .mockResolvedValueOnce(success); + + const result = { + type: ClearSignContextType.SOLANA_TOKEN as const, + payload: { solanaTokenDescriptor: tokenDescriptor }, + certificate: tokenCert, + }; + + await provideTokenContext(result, deps); + + expect(api.sendCommand).toHaveBeenCalledTimes(2); + + const certCmd = api.sendCommand.mock.calls[0]![0]!; + expect(certCmd).toBeInstanceOf(LoadCertificateCommand); + expect(certCmd.args.certificate).toStrictEqual(tokenCert.payload); + expect(certCmd.args.keyUsage).toBe(tokenCert.keyUsageNumber); + + const descCmd = api.sendCommand.mock.calls[1]![0]!; + expect(descCmd).toBeInstanceOf( + ProvideTLVTransactionInstructionDescriptorCommand, + ); + expect(descCmd.args.dataHex).toBe(tokenDescriptor.data); + expect(descCmd.args.signatureHex).toBe(tokenDescriptor.signature); + }); + + // test 2: no commands when payload is missing + it("does not send commands if payload is missing", async () => { + const result = { + type: ClearSignContextType.SOLANA_TOKEN as const, + payload: undefined, + certificate: tokenCert, + }; + + await provideTokenContext(result as any, deps); + + expect(api.sendCommand).not.toHaveBeenCalled(); + }); + + // test 3: no commands when certificate is missing + it("does not send commands if certificate is missing", async () => { + const result = { + type: ClearSignContextType.SOLANA_TOKEN as const, + payload: { solanaTokenDescriptor: tokenDescriptor }, + certificate: undefined, + }; + + await provideTokenContext(result as any, deps); + + expect(api.sendCommand).not.toHaveBeenCalled(); + }); + + // test 4: throws when cert load fails + it("throws when certificate load returns error", async () => { + const errorResult = CommandResultFactory({ + error: { _tag: "SomeError", errorCode: 0x6a80, message: "bad" }, + }); + api.sendCommand.mockResolvedValueOnce(errorResult); + + const result = { + type: ClearSignContextType.SOLANA_TOKEN as const, + payload: { solanaTokenDescriptor: tokenDescriptor }, + certificate: tokenCert, + }; + + await expect(provideTokenContext(result, deps)).rejects.toThrow( + "Failed to send tokenMetadataCertificate to device", + ); + + expect(api.sendCommand).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideTokenContext.ts b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideTokenContext.ts new file mode 100644 index 0000000000..1b6195f975 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideTokenContext.ts @@ -0,0 +1,33 @@ +import { + type ClearSignContextType, + type SolanaTokenContextSuccess, +} from "@ledgerhq/context-module"; +import { ProvideTLVTransactionInstructionDescriptorCommand } from "@internal/app-binder/command/ProvideTLVTransactionInstructionDescriptorCommand"; + +import { loadCertificate } from "./loadCertificate"; +import { type ProvideContextHandler } from "./provideContextTypes"; + +export const provideTokenContext: ProvideContextHandler< + ClearSignContextType.SOLANA_TOKEN +> = async (result: SolanaTokenContextSuccess, { api, logger }) => { + const { + payload: tokenMetadataPayload, + certificate: tokenMetadataCertificate, + } = result; + + if (!tokenMetadataPayload || !tokenMetadataCertificate) return; + + await loadCertificate( + api, + tokenMetadataCertificate, + "[SignerSolana] provideTokenContext: Failed to send tokenMetadataCertificate to device, latest firmware version required", + ); + + logger.debug("[provideTokenContext] Sending token descriptor"); + await api.sendCommand( + new ProvideTLVTransactionInstructionDescriptorCommand({ + dataHex: tokenMetadataPayload.solanaTokenDescriptor.data, + signatureHex: tokenMetadataPayload.solanaTokenDescriptor.signature, + }), + ); +}; diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideTransactionCheckContext.test.ts b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideTransactionCheckContext.test.ts new file mode 100644 index 0000000000..19a5eb9f02 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideTransactionCheckContext.test.ts @@ -0,0 +1,159 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { ClearSignContextType } from "@ledgerhq/context-module"; +import { + APDU_MAX_PAYLOAD, + CommandResultFactory, + LoadCertificateCommand, +} from "@ledgerhq/device-management-kit"; +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; + +import { ProvideWeb3CheckCommand } from "@internal/app-binder/command/ProvideWeb3CheckCommand"; + +import { type ProvideContextDeps } from "./provideContextTypes"; +import { provideTransactionCheckContext } from "./provideTransactionCheckContext"; + +const mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + subscribers: [], +}; + +describe("provideTransactionCheckContext", () => { + let api: { sendCommand: Mock }; + let deps: ProvideContextDeps; + const success = CommandResultFactory({ data: undefined }); + + const txCheckCert = { + payload: new Uint8Array([0xde, 0xad]), + keyUsageNumber: 14, + } as const; + + beforeEach(() => { + vi.resetAllMocks(); + api = { sendCommand: vi.fn() }; + deps = { + api: api as any, + logger: mockLogger as any, + normaliser: {} as any, + transactionBytes: new Uint8Array([0xf0]), + }; + }); + + it("sends certificate then ProvideWeb3CheckCommand", async () => { + api.sendCommand + .mockResolvedValueOnce(success) + .mockResolvedValueOnce(success); + + const result = { + type: ClearSignContextType.SOLANA_TRANSACTION_CHECK as const, + payload: { descriptor: "aabbccdd" }, + certificate: txCheckCert, + }; + + await provideTransactionCheckContext(result, deps); + + expect(api.sendCommand).toHaveBeenCalledTimes(2); + + const certCmd = api.sendCommand.mock.calls[0]![0]!; + expect(certCmd).toBeInstanceOf(LoadCertificateCommand); + expect(certCmd.args.certificate).toStrictEqual(txCheckCert.payload); + expect(certCmd.args.keyUsage).toBe(txCheckCert.keyUsageNumber); + + const web3Cmd = api.sendCommand.mock.calls[1]![0]!; + expect(web3Cmd).toBeInstanceOf(ProvideWeb3CheckCommand); + }); + + it("throws when certificate load fails", async () => { + const errorResult = CommandResultFactory({ + error: { _tag: "SomeError", errorCode: 0x6a80, message: "bad" }, + }); + api.sendCommand.mockResolvedValueOnce(errorResult); + + const result = { + type: ClearSignContextType.SOLANA_TRANSACTION_CHECK as const, + payload: { descriptor: "aabbccdd" }, + certificate: txCheckCert, + }; + + await expect(provideTransactionCheckContext(result, deps)).rejects.toThrow( + "Failed to send transaction-check certificate to device", + ); + }); + + it("sends descriptor without certificate when certificate is absent", async () => { + api.sendCommand.mockResolvedValueOnce(success); + + const result = { + type: ClearSignContextType.SOLANA_TRANSACTION_CHECK as const, + payload: { descriptor: "aabbccdd" }, + certificate: undefined, + }; + + await provideTransactionCheckContext(result as any, deps); + + expect(api.sendCommand).toHaveBeenCalledTimes(1); + + const web3Cmd = api.sendCommand.mock.calls[0]![0]!; + expect(web3Cmd).toBeInstanceOf(ProvideWeb3CheckCommand); + }); + + it("chunks large descriptors across multiple APDU calls", async () => { + api.sendCommand.mockResolvedValue(success); + + const largeDescriptorHex = "aa" + .repeat(APDU_MAX_PAYLOAD + 10) + .padEnd((APDU_MAX_PAYLOAD + 10) * 2, "bb"); + const result = { + type: ClearSignContextType.SOLANA_TRANSACTION_CHECK as const, + payload: { descriptor: largeDescriptorHex }, + certificate: undefined, + }; + + await provideTransactionCheckContext(result as any, deps); + + expect(api.sendCommand.mock.calls.length).toBeGreaterThanOrEqual(2); + + const allCmds = api.sendCommand.mock.calls.map( + (c: any[]) => c[0] as ProvideWeb3CheckCommand, + ); + expect(allCmds.every((cmd) => cmd instanceof ProvideWeb3CheckCommand)).toBe( + true, + ); + }); + + it("throws when descriptor sending fails", async () => { + api.sendCommand.mockResolvedValueOnce( + CommandResultFactory({ + error: { _tag: "SomeError", errorCode: 0x6a80, message: "bad" }, + }), + ); + + const result = { + type: ClearSignContextType.SOLANA_TRANSACTION_CHECK as const, + payload: { descriptor: "aabbccdd" }, + certificate: undefined, + }; + + await expect( + provideTransactionCheckContext(result as any, deps), + ).rejects.toThrow("Failed to send transaction-check descriptor to device"); + }); + + it("warns and returns when descriptor is unparseable", async () => { + const result = { + type: ClearSignContextType.SOLANA_TRANSACTION_CHECK as const, + payload: { descriptor: "" }, + certificate: undefined, + }; + + await provideTransactionCheckContext(result as any, deps); + + expect(api.sendCommand).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("descriptor could not be parsed"), + ); + }); +}); diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideTransactionCheckContext.ts b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideTransactionCheckContext.ts new file mode 100644 index 0000000000..ce419d1f97 --- /dev/null +++ b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideTransactionCheckContext.ts @@ -0,0 +1,52 @@ +import { + type ClearSignContextType, + type SolanaTransactionCheckContextSuccess, +} from "@ledgerhq/context-module"; +import { + hexaStringToBuffer, + isSuccessCommandResult, +} from "@ledgerhq/device-management-kit"; + +import { ProvideWeb3CheckCommand } from "@internal/app-binder/command/ProvideWeb3CheckCommand"; +import { SendCommandInChunksTask } from "@internal/app-binder/task/SendCommandInChunksTask"; + +import { loadCertificate } from "./loadCertificate"; +import { type ProvideContextHandler } from "./provideContextTypes"; + +export const provideTransactionCheckContext: ProvideContextHandler< + ClearSignContextType.SOLANA_TRANSACTION_CHECK +> = async (result: SolanaTransactionCheckContextSuccess, { api, logger }) => { + const { payload, certificate } = result; + + if (certificate) { + await loadCertificate( + api, + certificate, + "[SignerSolana] provideTransactionCheckContext: Failed to send transaction-check certificate to device", + ); + } + + const descriptorBytes = hexaStringToBuffer(payload.descriptor); + if (!descriptorBytes || descriptorBytes.length === 0) { + logger.warn( + "[provideTransactionCheckContext] descriptor could not be parsed, skipping", + ); + return; + } + + const chunkResult = await new SendCommandInChunksTask(api, { + data: descriptorBytes, + commandFactory: (args) => + new ProvideWeb3CheckCommand({ + payload: args.chunkedData, + isFirstChunk: !args.extend, + hasMore: args.more, + }), + }).run(); + + if (!isSuccessCommandResult(chunkResult)) { + throw new Error( + "[SignerSolana] provideTransactionCheckContext: Failed to send transaction-check descriptor to device", + ); + } +}; diff --git a/packages/signer/signer-solana/src/internal/use-cases/app-configuration/GetAppConfiguration.test.ts b/packages/signer/signer-solana/src/internal/use-cases/app-configuration/GetAppConfiguration.test.ts index 13e0a4c9b4..42c213011b 100644 --- a/packages/signer/signer-solana/src/internal/use-cases/app-configuration/GetAppConfiguration.test.ts +++ b/packages/signer/signer-solana/src/internal/use-cases/app-configuration/GetAppConfiguration.test.ts @@ -10,6 +10,8 @@ describe("GetAppConfigurationUseCase", () => { blindSigningEnabled: false, pubKeyDisplayMode: PublicKeyDisplayMode.LONG, version: "1.0.0", + web3ChecksEnabled: false, + web3ChecksOptIn: false, }; const appBinderMock = { getAppConfiguration: getAppConfigurationMock,