Skip to content
5 changes: 5 additions & 0 deletions .changeset/large-stars-relate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/device-signer-kit-solana": minor
Comment thread
fAnselmi-Ledger marked this conversation as resolved.
---

Add transaction check
5 changes: 5 additions & 0 deletions .changeset/swift-otters-roam.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions packages/device-management-kit/src/api/utils/ApplicationChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@
private isCompatible: boolean;
private version: string;
private modelId: DeviceModelId;
private readonly appName: string | undefined;

constructor(
deviceState: DeviceSessionState,
appConfig: AppConfig,
resolver: ApplicationResolver,
) {
this.modelId = deviceState.deviceModelId;
this.appName =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[ASK] why appName can be undefined?

@fAnselmi-Ledger fAnselmi-Ledger May 13, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConnectedState variant has no currentApp, which is why the narrowing falls back to undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the state is connected but not ready yet we should not enter in this part of code no? because we have the same problem with other signers, wdyt?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree it's an issue across signers! Currently harmless here because SolanaApplicationResolver.resolve already returns isCompatible: false for Connected state, so the undefined fallback never affects the result.

"currentApp" in deviceState ? deviceState.currentApp.name : undefined;
const resolved = resolver.resolve(deviceState, appConfig);
this.isCompatible = resolved.isCompatible;
this.version = resolved.version;
Expand All @@ -39,6 +42,21 @@
return this;
}

excludeDeviceModels(...modelIds: DeviceModelId[]): ApplicationChecker {

Check warning on line 45 in packages/device-management-kit/src/api/utils/ApplicationChecker.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `this` type instead.

See more on https://sonarcloud.io/project/issues?id=LedgerHQ_device-sdk-ts&issues=AZ4XnNr2VmrTIp0-FXH_&open=AZ4XnNr2VmrTIp0-FXH_&pullRequest=1481
for (const id of modelIds) this.excludeDeviceModel(id);
return this;
}

excludeApp(name: string): ApplicationChecker {

Check warning on line 50 in packages/device-management-kit/src/api/utils/ApplicationChecker.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `this` type instead.

See more on https://sonarcloud.io/project/issues?id=LedgerHQ_device-sdk-ts&issues=AZ4XnNr2VmrTIp0-FXIA&open=AZ4XnNr2VmrTIp0-FXIA&pullRequest=1481
if (this.appName === name) this.isCompatible = false;
return this;
}

excludeApps(...names: string[]): ApplicationChecker {

Check warning on line 55 in packages/device-management-kit/src/api/utils/ApplicationChecker.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `this` type instead.

See more on https://sonarcloud.io/project/issues?id=LedgerHQ_device-sdk-ts&issues=AZ4XnNr2VmrTIp0-FXIB&open=AZ4XnNr2VmrTIp0-FXIB&pullRequest=1481
for (const name of names) this.excludeApp(name);
return this;
}

check(): boolean {
return this.isCompatible;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/signer/context-module/src/DefaultContextModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ export class DefaultContextModule implements ContextModule {
this._container.get<ContextLoader>(
ownerInfoTypes.OwnerInfoContextLoader,
),
this._container.get<ContextLoader>(
transactionCheckTypes.TransactionCheckLoader,
),
];
case ContextModuleChainID.Concordium:
return [
Expand Down
2 changes: 2 additions & 0 deletions packages/signer/context-module/src/di.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -71,6 +72,7 @@ export const makeContainer = ({ config }: MakeContainerArgs) => {
ownerInfoModuleFactory(),
solanaTokenModuleFactory(),
lifiModuleFactory(),
solanaTransactionCheckModuleFactory(),
);
break;
case ContextModuleChainID.Concordium:
Expand Down
2 changes: 2 additions & 0 deletions packages/signer/context-module/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
);
});
Original file line number Diff line number Diff line change
@@ -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<SolanaTransactionCheckContextInput>
{
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,
Comment thread
fAnselmi-Ledger marked this conversation as resolved.
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<ClearSignContext[]> {
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<Promise<ClearSignContext>>({
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
fAnselmi-Ledger marked this conversation as resolved.
deviceModelId: DeviceModelId;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export enum TransactionCheckPaths {
ETHEREUM_TRANSACTION = "/ethereum/scan/tx",
ETHEREUM_TYPED_DATA = "/ethereum/scan/eip-712",
SOLANA_TRANSACTION = "/solana/scan/tx",
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,6 +15,7 @@ export type {
SolanaLifiInstructionMeta,
SolanaLifiPayload,
SolanaTokenData,
SolanaTransactionCheckPayload,
SolanaTransactionDescriptor,
} from "@/modules/solana/model/SolanaPayloads";

Expand All @@ -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;

/**
Expand All @@ -55,7 +62,7 @@ export type SolanaClearSignContextSuccess =
ClearSignContextSuccess<SolanaClearSignContextSuccessType>;

/**
* 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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,26 +39,26 @@ 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,
> = ClearSignContextSuccess<T>;

export type SolanaContextError = ClearSignContextError;

export type SolanaContext =
| ClearSignContextSuccess<
ClearSignContextType.SOLANA_TOKEN | ClearSignContextType.SOLANA_LIFI
>
| ClearSignContextError;
export type SolanaContext = SolanaContextSuccess | SolanaContextError;

export type SolanaTokenContextSuccess =
ClearSignContextSuccess<ClearSignContextType.SOLANA_TOKEN>;

export type SolanaLifiContextSuccess =
ClearSignContextSuccess<ClearSignContextType.SOLANA_LIFI>;

export type SolanaTransactionCheckContextSuccess =
ClearSignContextSuccess<ClearSignContextType.SOLANA_TRANSACTION_CHECK>;

export type SolanaTokenContextResult =
| SolanaTokenContextSuccess
| SolanaContextError;
Expand All @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ export type SolanaLifiPayload = {
descriptors: Record<string, SolanaTransactionDescriptor>;
instructions: SolanaLifiInstructionMeta[];
};

export type SolanaTransactionCheckPayload = {
descriptor: string;
};
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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<
Expand Down
Loading
Loading