Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plain-houses-itch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/context-module": minor
---

Add Solana clear-signing context loaders (ALT resolution, instruction info, enum variant, token info, token account state, trusted name) and shared certificate/result utilities.
24 changes: 24 additions & 0 deletions packages/signer/context-module/src/DefaultContextModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,15 @@ import { type BlindSigningReportParams } from "@/modules/multichain/reporter/dat
import { reporterTypes } from "@/modules/multichain/reporter/di/reporterTypes";
import { type BlindSigningReporter } from "@/modules/multichain/reporter/domain/BlindSigningReporter";
import { transactionCheckTypes } from "@/modules/multichain/transaction-check/di/transactionCheckTypes";
import { altResolutionTypes } from "@/modules/solana/alt-resolution/di/altResolutionTypes";
import { enumVariantTypes } from "@/modules/solana/enum-variant/di/enumVariantTypes";
import { instructionInfoTypes } from "@/modules/solana/instruction-info/di/instructionInfoTypes";
import { lifiTypes } from "@/modules/solana/lifi/di/lifiTypes";
import { ownerInfoTypes } from "@/modules/solana/owner-info/di/ownerInfoTypes";
import { tokenTypes as solanaTokenTypes } from "@/modules/solana/token/di/tokenTypes";
import { tokenAccountStateTypes } from "@/modules/solana/token-account-state/di/tokenAccountStateTypes";
import { tokenInfoTypes } from "@/modules/solana/token-info/di/tokenInfoTypes";
import { solanaTrustedNameTypes } from "@/modules/solana/trusted-name/di/trustedNameTypes";
import { type ContextFieldLoader } from "@/shared/domain/ContextFieldLoader";
import { type ContextLoader } from "@/shared/domain/ContextLoader";
import { ContextModuleChainID } from "@/shared/domain/ContextModuleChainID";
Expand Down Expand Up @@ -124,6 +130,24 @@ export class DefaultContextModule implements ContextModule {
this._container.get<ContextLoader>(
transactionCheckTypes.TransactionCheckLoader,
),
this._container.get<ContextLoader>(
instructionInfoTypes.InstructionInfoContextLoader,
),
this._container.get<ContextLoader>(
enumVariantTypes.EnumVariantContextLoader,
),
this._container.get<ContextLoader>(
tokenInfoTypes.TokenInfoContextLoader,
),
this._container.get<ContextLoader>(
tokenAccountStateTypes.TokenAccountStateContextLoader,
),
this._container.get<ContextLoader>(
altResolutionTypes.AltResolutionContextLoader,
),
this._container.get<ContextLoader>(
solanaTrustedNameTypes.SolanaTrustedNameContextLoader,
),
];
case ContextModuleChainID.Concordium:
return [
Expand Down
12 changes: 12 additions & 0 deletions packages/signer/context-module/src/di.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,15 @@ import { nanoPkiModuleFactory } from "@/modules/multichain/pki/di/pkiModuleFacto
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 { altResolutionModuleFactory } from "@/modules/solana/alt-resolution/di/altResolutionModuleFactory";
import { enumVariantModuleFactory } from "@/modules/solana/enum-variant/di/enumVariantModuleFactory";
import { instructionInfoModuleFactory } from "@/modules/solana/instruction-info/di/instructionInfoModuleFactory";
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";
import { tokenAccountStateModuleFactory } from "@/modules/solana/token-account-state/di/tokenAccountStateModuleFactory";
import { tokenInfoModuleFactory } from "@/modules/solana/token-info/di/tokenInfoModuleFactory";
import { solanaTrustedNameModuleFactory } from "@/modules/solana/trusted-name/di/trustedNameModuleFactory";
import { ContextModuleChainID } from "@/shared/domain/ContextModuleChainID";
import { networkModuleFactory } from "@/shared/network/di/networkModuleFactory";

Expand Down Expand Up @@ -73,6 +79,12 @@ export const makeContainer = ({ config }: MakeContainerArgs) => {
solanaTokenModuleFactory(),
lifiModuleFactory(),
solanaTransactionCheckModuleFactory(),
instructionInfoModuleFactory(),
enumVariantModuleFactory(),
tokenInfoModuleFactory(),
tokenAccountStateModuleFactory(),
altResolutionModuleFactory(),
solanaTrustedNameModuleFactory(),
);
break;
case ContextModuleChainID.Concordium:
Expand Down
16 changes: 16 additions & 0 deletions packages/signer/context-module/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,29 @@ 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/alt-resolution/data/AltResolutionDataSource";
export * from "./modules/solana/alt-resolution/data/HttpAltResolutionDataSource";
export * from "./modules/solana/alt-resolution/domain/AltResolutionContextLoader";
export * from "./modules/solana/enum-variant/domain/EnumVariantContextLoader";
export * from "./modules/solana/instruction-info/data/HttpInstructionInfoDataSource";
export * from "./modules/solana/instruction-info/data/InstructionInfoDataSource";
export * from "./modules/solana/instruction-info/domain/InstructionInfoContextLoader";
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";
export * from "./modules/solana/owner-info/domain/solanaContextTypes";
export * from "./modules/solana/token-account-state/data/HttpTokenAccountStateDataSource";
export * from "./modules/solana/token-account-state/data/TokenAccountStateDataSource";
export * from "./modules/solana/token-account-state/domain/TokenAccountStateContextLoader";
export * from "./modules/solana/token-info/data/HttpTokenInfoDataSource";
export * from "./modules/solana/token-info/data/TokenInfoDataSource";
export * from "./modules/solana/token-info/domain/TokenInfoContextLoader";
export * from "./modules/solana/trusted-name/data/HttpTrustedNameDataSource";
export * from "./modules/solana/trusted-name/data/TrustedNameDataSource";
export * from "./modules/solana/trusted-name/domain/TrustedNameContextLoader";
export * from "./shared/domain/ContextFieldLoader";
export * from "./shared/domain/ContextLoader";
export * from "./shared/domain/ContextModuleChainID";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export enum KeyId {
LedgerRootV3 = "ledger_root_v3",
PluginSelectorKey = "plugin_selector_key",
NftMetadataKey = "nft_metadata_key",
TokenMetadataKey = "token_metadata_key",
PartnerMetadataKey = "partner_metadata_key",
Erc20MetadataKey = "erc20_metadata_key",
DomainMetadataKey = "domain_metadata_key",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {
DeviceModelId,
type DeviceModelId,
LoggerPublisherService,
} from "@ledgerhq/device-management-kit";
import { inject, injectable } from "inversify";
import { Codec, exactly, number, oneOf, string } from "purify-ts";
import { Codec, number, string } from "purify-ts";

import { configTypes } from "@/config/di/configTypes";
import { pkiTypes } from "@/modules/multichain/pki/di/pkiTypes";
Expand All @@ -21,6 +21,7 @@ import {
type Bs58Encoder,
DefaultBs58Encoder,
} from "@/shared/utils/bs58Encoder";
import { deviceModelIdCodec } from "@/shared/utils/deviceModelIdCodec";
import { uint8ArrayCodec } from "@/shared/utils/uint8ArrayCodec";

export type SolanaTransactionCheckRequest = {
Expand All @@ -46,12 +47,7 @@ const SHORTVEC_DATA_MASK = 0x7f;
const SHORTVEC_DATA_BITS = 7;

const solanaTransactionCheckInputCodec = Codec.interface({
deviceModelId: oneOf([
exactly(DeviceModelId.NANO_X),
exactly(DeviceModelId.NANO_SP),
exactly(DeviceModelId.STAX),
exactly(DeviceModelId.FLEX),
]),
deviceModelId: deviceModelIdCodec,
transactionCheck: Codec.interface({
from: string,
transactionBytes: uint8ArrayCodec,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { type Either } from "purify-ts";

export type GetAltResolutionParams = {
altAddress: string;
entryIndex: number;
challenge: string;
};

export type AltResolutionResult = {
altAddress: string;
entryIndex: number;
// Raw signed TLV bytes decoded from the backend response.
descriptor: Uint8Array;
keyId: string;
keyUsage: string;
};

export interface AltResolutionDataSource {
getAltResolution(
params: GetAltResolutionParams,
): Promise<Either<Error, AltResolutionResult>>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type DmkNetworkClient } from "@ledgerhq/device-management-kit";
import { Left, Right } from "purify-ts";

import { type ContextModuleServiceConfig } from "@/config/model/ContextModuleConfig";

import { type AltResolutionDataSource } from "./AltResolutionDataSource";
import { HttpAltResolutionDataSource } from "./HttpAltResolutionDataSource";

describe("HttpAltResolutionDataSource", () => {
let datasource: AltResolutionDataSource;
let httpMock: { get: ReturnType<typeof vi.fn> };
const altAddress = "AltAddress1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
const entryIndex = 3;
const challenge = "cafebabe";
const config: ContextModuleServiceConfig = {
metadataServiceDomain: { url: "https://nft.api.ledger.com" },
} as ContextModuleServiceConfig;

beforeEach(() => {
vi.clearAllMocks();
httpMock = { get: vi.fn() };
datasource = new HttpAltResolutionDataSource(
config,
httpMock as unknown as DmkNetworkClient,
);
});

it("calls the metadata endpoint with the right path and challenge", async () => {
httpMock.get.mockResolvedValue({
signedDescriptor: "01020304",
keyId: "alt_key",
keyUsage: "coin_meta",
});

await datasource.getAltResolution({ altAddress, entryIndex, challenge });

expect(httpMock.get).toHaveBeenCalledWith(
`https://nft.api.ledger.com/v2/solana/alt-resolution/${altAddress}/${entryIndex}`,
{ params: { challenge } },
);
});

it("decodes signedDescriptor hex into Uint8Array", async () => {
httpMock.get.mockResolvedValue({
signedDescriptor: "abcd",
keyId: "k",
keyUsage: "u",
});

const result = await datasource.getAltResolution({
altAddress,
entryIndex,
challenge,
});

expect(result).toEqual(
Right({
altAddress,
entryIndex,
descriptor: new Uint8Array([0xab, 0xcd]),
keyId: "k",
keyUsage: "u",
}),
);
});

it.each([-1, 256, 1.5, Number.NaN])(
"rejects entryIndex out of u8 range: %s",
async (badIndex) => {
const result = await datasource.getAltResolution({
altAddress,
entryIndex: badIndex,
challenge,
});
expect(result.isLeft()).toBe(true);
expect((result.extract() as Error).message).toMatch(/entryIndex/);
},
);

it("returns Left on malformed response", async () => {
httpMock.get.mockResolvedValue({ keyId: "k" } as any);

const result = await datasource.getAltResolution({
altAddress,
entryIndex,
challenge,
});

expect(result.isLeft()).toBe(true);
expect((result.extract() as Error).message).toMatch(
new RegExp(
String.raw`\[ContextModule\] HttpAltResolutionDataSource: malformed response for \(${altAddress}, ${entryIndex}\):`,
),
);
});

it("returns Left when HTTP client throws", async () => {
httpMock.get.mockRejectedValue(new Error("net"));

const result = await datasource.getAltResolution({
altAddress,
entryIndex,
challenge,
});

expect(result).toEqual(
Left(
new Error(
"[ContextModule] HttpAltResolutionDataSource: Failed to fetch ALT resolution: net",
),
),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { DmkNetworkClient } from "@ledgerhq/device-management-kit";
import { inject, injectable } from "inversify";
import { Either, Left, Right } from "purify-ts";

import { configTypes } from "@/config/di/configTypes";
import { type ContextModuleServiceConfig } from "@/config/model/ContextModuleConfig";
import { networkTypes } from "@/shared/network/di/networkTypes";
import { HexStringUtils } from "@/shared/utils/HexStringUtils";
import { signedDescriptorDtoCodec } from "@/shared/utils/signedDescriptorDto";
import { u8Codec } from "@/shared/utils/uIntCodec";

import {
type AltResolutionDataSource,
type AltResolutionResult,
type GetAltResolutionParams,
} from "./AltResolutionDataSource";

type AltResolutionResponseDto = {
descriptorType?: string;
descriptorVersion?: string;
alt_address?: string;
entry_index?: number;
resolved_address?: string;
signedDescriptor: string;
keyId: string;
keyUsage: string;
};

@injectable()
export class HttpAltResolutionDataSource implements AltResolutionDataSource {
constructor(
@inject(configTypes.Config)
private readonly config: ContextModuleServiceConfig,
@inject(networkTypes.NetworkClient)
private readonly http: DmkNetworkClient,
) {}

public async getAltResolution({
altAddress,
entryIndex,
challenge,
}: GetAltResolutionParams): Promise<Either<Error, AltResolutionResult>> {
const entryIndexResult = u8Codec.decode(entryIndex);
if (entryIndexResult.isLeft()) {
return Left(
new Error(
`[ContextModule] HttpAltResolutionDataSource: invalid entryIndex (got ${entryIndex}): ${entryIndexResult.leftOrDefault("")}`,
),
);
}

let dto: AltResolutionResponseDto;
try {
dto = (await this.http.get(
`${this.config.metadataServiceDomain.url}/v2/solana/alt-resolution/${altAddress}/${entryIndex}`,
{ params: { challenge } },
)) as AltResolutionResponseDto;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return Left(
new Error(
`[ContextModule] HttpAltResolutionDataSource: Failed to fetch ALT resolution: ${reason}`,
),
);
}

return signedDescriptorDtoCodec
.decode(dto)
.caseOf<Either<Error, AltResolutionResult>>({
Left: (error) =>
Left(
new Error(
`[ContextModule] HttpAltResolutionDataSource: malformed response for (${altAddress}, ${entryIndex}): ${error}`,
),
),
Right: (validated) => {
let descriptor: Uint8Array;
try {
descriptor = HexStringUtils.hexToBytes(validated.signedDescriptor);
} catch (error) {
return Left(
new Error(
`[ContextModule] HttpAltResolutionDataSource: invalid hex in signedDescriptor for (${altAddress}, ${entryIndex}): ${(error as Error).message}`,
),
);
}

return Right({
altAddress,
entryIndex,
descriptor,
keyId: validated.keyId,
keyUsage: validated.keyUsage,
});
},
});
}
}
Loading
Loading