Skip to content

Commit 8a809c1

Browse files
✨ (context-module): Add solana tx checks loaders
1 parent 631bb8c commit 8a809c1

65 files changed

Lines changed: 5104 additions & 8 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/plain-houses-itch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ledgerhq/context-module": minor
3+
---
4+
5+
Add Solana clear-signing context loaders (ALT resolution, instruction info, enum variant, token info, token account state, trusted name) and shared certificate/result utilities.

packages/signer/context-module/src/DefaultContextModule.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,15 @@ import { type BlindSigningReportParams } from "@/modules/multichain/reporter/dat
2424
import { reporterTypes } from "@/modules/multichain/reporter/di/reporterTypes";
2525
import { type BlindSigningReporter } from "@/modules/multichain/reporter/domain/BlindSigningReporter";
2626
import { transactionCheckTypes } from "@/modules/multichain/transaction-check/di/transactionCheckTypes";
27+
import { altResolutionTypes } from "@/modules/solana/alt-resolution/di/altResolutionTypes";
28+
import { enumVariantTypes } from "@/modules/solana/enum-variant/di/enumVariantTypes";
29+
import { instructionInfoTypes } from "@/modules/solana/instruction-info/di/instructionInfoTypes";
2730
import { lifiTypes } from "@/modules/solana/lifi/di/lifiTypes";
2831
import { ownerInfoTypes } from "@/modules/solana/owner-info/di/ownerInfoTypes";
2932
import { tokenTypes as solanaTokenTypes } from "@/modules/solana/token/di/tokenTypes";
33+
import { tokenAccountStateTypes } from "@/modules/solana/token-account-state/di/tokenAccountStateTypes";
34+
import { tokenInfoTypes } from "@/modules/solana/token-info/di/tokenInfoTypes";
35+
import { solanaTrustedNameTypes } from "@/modules/solana/trusted-name/di/trustedNameTypes";
3036
import { type ContextFieldLoader } from "@/shared/domain/ContextFieldLoader";
3137
import { type ContextLoader } from "@/shared/domain/ContextLoader";
3238
import { ContextModuleChainID } from "@/shared/domain/ContextModuleChainID";
@@ -124,6 +130,24 @@ export class DefaultContextModule implements ContextModule {
124130
this._container.get<ContextLoader>(
125131
transactionCheckTypes.TransactionCheckLoader,
126132
),
133+
this._container.get<ContextLoader>(
134+
instructionInfoTypes.InstructionInfoContextLoader,
135+
),
136+
this._container.get<ContextLoader>(
137+
enumVariantTypes.EnumVariantContextLoader,
138+
),
139+
this._container.get<ContextLoader>(
140+
tokenInfoTypes.TokenInfoContextLoader,
141+
),
142+
this._container.get<ContextLoader>(
143+
tokenAccountStateTypes.TokenAccountStateContextLoader,
144+
),
145+
this._container.get<ContextLoader>(
146+
altResolutionTypes.AltResolutionContextLoader,
147+
),
148+
this._container.get<ContextLoader>(
149+
solanaTrustedNameTypes.SolanaTrustedNameContextLoader,
150+
),
127151
];
128152
case ContextModuleChainID.Concordium:
129153
return [

packages/signer/context-module/src/di.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,15 @@ import { nanoPkiModuleFactory } from "@/modules/multichain/pki/di/pkiModuleFacto
2323
import { reporterModuleFactory } from "@/modules/multichain/reporter/di/reporterModuleFactory";
2424
import { ethereumTransactionCheckModuleFactory } from "@/modules/multichain/transaction-check/di/ethereumTransactionCheckModuleFactory";
2525
import { solanaTransactionCheckModuleFactory } from "@/modules/multichain/transaction-check/di/solanaTransactionCheckModuleFactory";
26+
import { altResolutionModuleFactory } from "@/modules/solana/alt-resolution/di/altResolutionModuleFactory";
27+
import { enumVariantModuleFactory } from "@/modules/solana/enum-variant/di/enumVariantModuleFactory";
28+
import { instructionInfoModuleFactory } from "@/modules/solana/instruction-info/di/instructionInfoModuleFactory";
2629
import { lifiModuleFactory } from "@/modules/solana/lifi/di/lifiModuleFactory";
2730
import { ownerInfoModuleFactory } from "@/modules/solana/owner-info/di/ownerInfoModuleFactory";
2831
import { tokenModuleFactory as solanaTokenModuleFactory } from "@/modules/solana/token/di/tokenModuleFactory";
32+
import { tokenAccountStateModuleFactory } from "@/modules/solana/token-account-state/di/tokenAccountStateModuleFactory";
33+
import { tokenInfoModuleFactory } from "@/modules/solana/token-info/di/tokenInfoModuleFactory";
34+
import { solanaTrustedNameModuleFactory } from "@/modules/solana/trusted-name/di/trustedNameModuleFactory";
2935
import { ContextModuleChainID } from "@/shared/domain/ContextModuleChainID";
3036
import { networkModuleFactory } from "@/shared/network/di/networkModuleFactory";
3137

@@ -73,6 +79,12 @@ export const makeContainer = ({ config }: MakeContainerArgs) => {
7379
solanaTokenModuleFactory(),
7480
lifiModuleFactory(),
7581
solanaTransactionCheckModuleFactory(),
82+
instructionInfoModuleFactory(),
83+
enumVariantModuleFactory(),
84+
tokenInfoModuleFactory(),
85+
tokenAccountStateModuleFactory(),
86+
altResolutionModuleFactory(),
87+
solanaTrustedNameModuleFactory(),
7688
);
7789
break;
7890
case ContextModuleChainID.Concordium:

packages/signer/context-module/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,29 @@ export * from "./modules/multichain/reporter/domain/DefaultBlindSigningReporter"
7373
export * from "./modules/multichain/reporter/model/BlindSigningEvent";
7474
export * from "./modules/multichain/reporter/model/BlindSigningModelId";
7575
export * from "./modules/multichain/transaction-check/utils/constants";
76+
export * from "./modules/solana/alt-resolution/data/AltResolutionDataSource";
77+
export * from "./modules/solana/alt-resolution/data/HttpAltResolutionDataSource";
78+
export * from "./modules/solana/alt-resolution/domain/AltResolutionContextLoader";
79+
export * from "./modules/solana/enum-variant/domain/EnumVariantContextLoader";
80+
export * from "./modules/solana/instruction-info/data/HttpInstructionInfoDataSource";
81+
export * from "./modules/solana/instruction-info/data/InstructionInfoDataSource";
82+
export * from "./modules/solana/instruction-info/domain/InstructionInfoContextLoader";
7683
export * from "./modules/solana/model/SolanaClearSignContext";
7784
export * from "./modules/solana/model/SolanaContextTypes";
7885
export * from "./modules/solana/model/SolanaTransactionScanChainId";
7986
export * from "./modules/solana/owner-info/data/HttpOwnerInfoDataSource";
8087
export * from "./modules/solana/owner-info/data/OwnerInfoDataSource";
8188
export * from "./modules/solana/owner-info/domain/OwnerInfoContextLoader";
8289
export * from "./modules/solana/owner-info/domain/solanaContextTypes";
90+
export * from "./modules/solana/token-account-state/data/HttpTokenAccountStateDataSource";
91+
export * from "./modules/solana/token-account-state/data/TokenAccountStateDataSource";
92+
export * from "./modules/solana/token-account-state/domain/TokenAccountStateContextLoader";
93+
export * from "./modules/solana/token-info/data/HttpTokenInfoDataSource";
94+
export * from "./modules/solana/token-info/data/TokenInfoDataSource";
95+
export * from "./modules/solana/token-info/domain/TokenInfoContextLoader";
96+
export * from "./modules/solana/trusted-name/data/HttpTrustedNameDataSource";
97+
export * from "./modules/solana/trusted-name/data/TrustedNameDataSource";
98+
export * from "./modules/solana/trusted-name/domain/TrustedNameContextLoader";
8399
export * from "./shared/domain/ContextFieldLoader";
84100
export * from "./shared/domain/ContextLoader";
85101
export * from "./shared/domain/ContextModuleChainID";

packages/signer/context-module/src/modules/multichain/transaction-check/loaders/SolanaTransactionCheckLoader.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import {
2-
DeviceModelId,
2+
type DeviceModelId,
33
LoggerPublisherService,
44
} from "@ledgerhq/device-management-kit";
55
import { inject, injectable } from "inversify";
6-
import { Codec, exactly, number, oneOf, string } from "purify-ts";
6+
import { Codec, number, string } from "purify-ts";
77

88
import { configTypes } from "@/config/di/configTypes";
99
import { pkiTypes } from "@/modules/multichain/pki/di/pkiTypes";
@@ -21,6 +21,7 @@ import {
2121
type Bs58Encoder,
2222
DefaultBs58Encoder,
2323
} from "@/shared/utils/bs58Encoder";
24+
import { deviceModelIdCodec } from "@/shared/utils/deviceModelIdCodec";
2425
import { uint8ArrayCodec } from "@/shared/utils/uint8ArrayCodec";
2526

2627
export type SolanaTransactionCheckRequest = {
@@ -46,12 +47,7 @@ const SHORTVEC_DATA_MASK = 0x7f;
4647
const SHORTVEC_DATA_BITS = 7;
4748

4849
const solanaTransactionCheckInputCodec = Codec.interface({
49-
deviceModelId: oneOf([
50-
exactly(DeviceModelId.NANO_X),
51-
exactly(DeviceModelId.NANO_SP),
52-
exactly(DeviceModelId.STAX),
53-
exactly(DeviceModelId.FLEX),
54-
]),
50+
deviceModelId: deviceModelIdCodec,
5551
transactionCheck: Codec.interface({
5652
from: string,
5753
transactionBytes: uint8ArrayCodec,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { type Either } from "purify-ts";
2+
3+
export type GetAltResolutionParams = {
4+
altAddress: string;
5+
entryIndex: number;
6+
challenge: string;
7+
};
8+
9+
export type AltResolutionResult = {
10+
altAddress: string;
11+
entryIndex: number;
12+
// Raw signed TLV bytes the device receives verbatim through
13+
// `PROVIDE ALT RESOLUTION` (0x28).
14+
descriptor: Uint8Array;
15+
keyId: string;
16+
keyUsage: string;
17+
};
18+
19+
export interface AltResolutionDataSource {
20+
getAltResolution(
21+
params: GetAltResolutionParams,
22+
): Promise<Either<Error, AltResolutionResult>>;
23+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { type DmkNetworkClient } from "@ledgerhq/device-management-kit";
3+
import { Left, Right } from "purify-ts";
4+
5+
import { type ContextModuleServiceConfig } from "@/config/model/ContextModuleConfig";
6+
7+
import { type AltResolutionDataSource } from "./AltResolutionDataSource";
8+
import { HttpAltResolutionDataSource } from "./HttpAltResolutionDataSource";
9+
10+
describe("HttpAltResolutionDataSource", () => {
11+
let datasource: AltResolutionDataSource;
12+
let httpMock: { get: ReturnType<typeof vi.fn> };
13+
const altAddress = "AltAddress1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
14+
const entryIndex = 3;
15+
const challenge = "cafebabe";
16+
const config: ContextModuleServiceConfig = {
17+
metadataServiceDomain: { url: "https://nft.api.ledger.com" },
18+
} as ContextModuleServiceConfig;
19+
20+
beforeEach(() => {
21+
vi.clearAllMocks();
22+
httpMock = { get: vi.fn() };
23+
datasource = new HttpAltResolutionDataSource(
24+
config,
25+
httpMock as unknown as DmkNetworkClient,
26+
);
27+
});
28+
29+
it("calls the metadata endpoint with the right path and challenge", async () => {
30+
httpMock.get.mockResolvedValue({
31+
signedDescriptor: "01020304",
32+
keyId: "alt_key",
33+
keyUsage: "coin_meta",
34+
});
35+
36+
await datasource.getAltResolution({ altAddress, entryIndex, challenge });
37+
38+
expect(httpMock.get).toHaveBeenCalledWith(
39+
`https://nft.api.ledger.com/v2/solana/alt-resolution/${altAddress}/${entryIndex}`,
40+
{ params: { challenge } },
41+
);
42+
});
43+
44+
it("decodes signedDescriptor hex into Uint8Array", async () => {
45+
httpMock.get.mockResolvedValue({
46+
signedDescriptor: "abcd",
47+
keyId: "k",
48+
keyUsage: "u",
49+
});
50+
51+
const result = await datasource.getAltResolution({
52+
altAddress,
53+
entryIndex,
54+
challenge,
55+
});
56+
57+
expect(result).toEqual(
58+
Right({
59+
altAddress,
60+
entryIndex,
61+
descriptor: new Uint8Array([0xab, 0xcd]),
62+
keyId: "k",
63+
keyUsage: "u",
64+
}),
65+
);
66+
});
67+
68+
it.each([-1, 256, 1.5, Number.NaN])(
69+
"rejects entryIndex out of u8 range: %s",
70+
async (badIndex) => {
71+
const result = await datasource.getAltResolution({
72+
altAddress,
73+
entryIndex: badIndex,
74+
challenge,
75+
});
76+
expect(result.isLeft()).toBe(true);
77+
expect((result.extract() as Error).message).toMatch(/entryIndex/);
78+
},
79+
);
80+
81+
it("returns Left on malformed response", async () => {
82+
httpMock.get.mockResolvedValue({ keyId: "k" } as any);
83+
84+
const result = await datasource.getAltResolution({
85+
altAddress,
86+
entryIndex,
87+
challenge,
88+
});
89+
90+
expect(result.isLeft()).toBe(true);
91+
expect((result.extract() as Error).message).toMatch(
92+
new RegExp(
93+
String.raw`\[ContextModule\] HttpAltResolutionDataSource: malformed response for \(${altAddress}, ${entryIndex}\):`,
94+
),
95+
);
96+
});
97+
98+
it("returns Left when HTTP client throws", async () => {
99+
httpMock.get.mockRejectedValue(new Error("net"));
100+
101+
const result = await datasource.getAltResolution({
102+
altAddress,
103+
entryIndex,
104+
challenge,
105+
});
106+
107+
expect(result).toEqual(
108+
Left(
109+
new Error(
110+
"[ContextModule] HttpAltResolutionDataSource: Failed to fetch ALT resolution: net",
111+
),
112+
),
113+
);
114+
});
115+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { DmkNetworkClient } from "@ledgerhq/device-management-kit";
2+
import { inject, injectable } from "inversify";
3+
import { Either, Left, Right } from "purify-ts";
4+
5+
import { configTypes } from "@/config/di/configTypes";
6+
import { type ContextModuleServiceConfig } from "@/config/model/ContextModuleConfig";
7+
import { networkTypes } from "@/shared/network/di/networkTypes";
8+
import { HexStringUtils } from "@/shared/utils/HexStringUtils";
9+
import {
10+
type SignedDescriptorDto,
11+
signedDescriptorDtoCodec,
12+
} from "@/shared/utils/signedDescriptorDto";
13+
import { u8Codec } from "@/shared/utils/u8Codec";
14+
15+
import {
16+
type AltResolutionDataSource,
17+
type AltResolutionResult,
18+
type GetAltResolutionParams,
19+
} from "./AltResolutionDataSource";
20+
21+
type AltResolutionResponseDto = {
22+
descriptorType?: string;
23+
descriptorVersion?: string;
24+
alt_address?: string;
25+
entry_index?: number;
26+
resolved_address?: string;
27+
signedDescriptor: string;
28+
keyId: string;
29+
keyUsage: string;
30+
};
31+
32+
@injectable()
33+
export class HttpAltResolutionDataSource implements AltResolutionDataSource {
34+
constructor(
35+
@inject(configTypes.Config)
36+
private readonly config: ContextModuleServiceConfig,
37+
@inject(networkTypes.NetworkClient)
38+
private readonly http: DmkNetworkClient,
39+
) {}
40+
41+
public async getAltResolution({
42+
altAddress,
43+
entryIndex,
44+
challenge,
45+
}: GetAltResolutionParams): Promise<Either<Error, AltResolutionResult>> {
46+
const entryIndexResult = u8Codec.decode(entryIndex);
47+
if (entryIndexResult.isLeft()) {
48+
return Left(
49+
new Error(
50+
`[ContextModule] HttpAltResolutionDataSource: invalid entryIndex (got ${entryIndex}): ${entryIndexResult.extract() as string}`,
51+
),
52+
);
53+
}
54+
55+
let dto: AltResolutionResponseDto;
56+
try {
57+
dto = (await this.http.get(
58+
`${this.config.metadataServiceDomain.url}/v2/solana/alt-resolution/${altAddress}/${entryIndex}`,
59+
{ params: { challenge } },
60+
)) as AltResolutionResponseDto;
61+
} catch (error) {
62+
const reason = error instanceof Error ? error.message : String(error);
63+
return Left(
64+
new Error(
65+
`[ContextModule] HttpAltResolutionDataSource: Failed to fetch ALT resolution: ${reason}`,
66+
),
67+
);
68+
}
69+
70+
const decoded = signedDescriptorDtoCodec.decode(dto);
71+
if (decoded.isLeft()) {
72+
return Left(
73+
new Error(
74+
`[ContextModule] HttpAltResolutionDataSource: malformed response for (${altAddress}, ${entryIndex}): ${decoded.extract() as string}`,
75+
),
76+
);
77+
}
78+
const validated = decoded.extract() as SignedDescriptorDto;
79+
80+
let descriptor: Uint8Array;
81+
try {
82+
descriptor = HexStringUtils.hexToBytes(validated.signedDescriptor);
83+
} catch (error) {
84+
return Left(
85+
new Error(
86+
`[ContextModule] HttpAltResolutionDataSource: invalid hex in signedDescriptor for (${altAddress}, ${entryIndex}): ${(error as Error).message}`,
87+
),
88+
);
89+
}
90+
91+
return Right({
92+
altAddress,
93+
entryIndex,
94+
descriptor,
95+
keyId: validated.keyId,
96+
keyUsage: validated.keyUsage,
97+
});
98+
}
99+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { ContainerModule } from "inversify";
2+
3+
import { HttpAltResolutionDataSource } from "@/modules/solana/alt-resolution/data/HttpAltResolutionDataSource";
4+
import { altResolutionTypes } from "@/modules/solana/alt-resolution/di/altResolutionTypes";
5+
import { AltResolutionContextLoader } from "@/modules/solana/alt-resolution/domain/AltResolutionContextLoader";
6+
7+
export const altResolutionModuleFactory = () =>
8+
new ContainerModule(({ bind }) => {
9+
bind(altResolutionTypes.AltResolutionDataSource).to(
10+
HttpAltResolutionDataSource,
11+
);
12+
bind(altResolutionTypes.AltResolutionContextLoader).to(
13+
AltResolutionContextLoader,
14+
);
15+
});

0 commit comments

Comments
 (0)