Skip to content

Commit b9e0d53

Browse files
✨ (ledger-button-core) [LBD-538]: Create Solana provider module in core (#460)
2 parents a4f9673 + eee1f4a commit b9e0d53

15 files changed

Lines changed: 361 additions & 0 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ledgerhq/ledger-wallet-provider-core": minor
3+
---
4+
5+
Add minimal Solana provider module scaffold (DI, cluster utils, RPC datasource stub)

packages/ledger-button-core/src/api/model/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,5 @@ export * from "./signing/SignPersonalMessageParams.js";
3030
export * from "./signing/SignRawTransactionParams.js";
3131
export * from "./signing/SignTransactionParams.js";
3232
export * from "./signing/SignTypedMessageParams.js";
33+
export * from "./solana/SolanaTypes.js";
3334
export * from "./UserInteractionNeeded.js";
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export type SolanaCluster = "devnet" | "testnet" | "mainnet-beta";
2+
3+
export type SolanaJSONRPCRequest = {
4+
readonly jsonrpc: string;
5+
readonly id: number;
6+
readonly method: string;
7+
readonly params: readonly unknown[] | object;
8+
};
9+
10+
export type SolanaJsonRpcResponseSuccess = {
11+
id: number;
12+
jsonrpc: string;
13+
result: string | object;
14+
};
15+
16+
export type SolanaJsonRpcResponseError = {
17+
id: number;
18+
jsonrpc: string;
19+
error: {
20+
code: number;
21+
message: string;
22+
};
23+
};
24+
25+
export type SolanaJsonRpcResponse =
26+
| SolanaJsonRpcResponseSuccess
27+
| SolanaJsonRpcResponseError;
28+
29+
export const CommonSolanaErrorCode = {
30+
MethodNotFound: -32601,
31+
} as const;

packages/ledger-button-core/src/internal/di.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { modalModuleFactory } from "./modal/modalModule.js";
2020
import { networkModuleFactory } from "./network/networkModule.js";
2121
import { pendingTransactionModuleFactory } from "./pending-transaction/pendingTransactionModule.js";
2222
import { platformModuleFactory } from "./platform/platformModule.js";
23+
import { solanaProviderModuleFactory } from "./solana-provider/solanaProviderModule.js";
2324
import { storageModuleFactory } from "./storage/storageModule.js";
2425
import { transactionModuleFactory } from "./transaction/transactionModule.js";
2526
import { transactionHistoryModuleFactory } from "./transaction-history/di/transactionHistoryModule.js";
@@ -37,6 +38,7 @@ export function createContainer({
3738
account: false,
3839
device: false,
3940
web3Provider: false,
41+
solanaProvider: false,
4042
balance: false,
4143
dAppConfig: false,
4244
transactionHistory: false,
@@ -66,6 +68,7 @@ export function createContainer({
6668
stub: devConfig.stub.transactionHistory,
6769
}),
6870
evmProviderModuleFactory({ stub: devConfig.stub.web3Provider }),
71+
solanaProviderModuleFactory({ stub: devConfig.stub.solanaProvider }),
6972
ledgerSyncModuleFactory({ stub: devConfig.stub.base }),
7073
cryptographicModuleFactory({ stub: devConfig.stub.base }),
7174
cloudSyncModuleFactory({ stub: devConfig.stub.base }),

packages/ledger-button-core/src/internal/diTypes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type ContainerOptions = {
1717
account: boolean;
1818
device: boolean;
1919
web3Provider: boolean;
20+
solanaProvider: boolean;
2021
dAppConfig: boolean;
2122
transactionHistory: boolean;
2223
}>;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Thin UI contract consumed by the future Solana wallet provider.
3+
*
4+
* Defined in `ledger-button-core` so the provider can live alongside the rest
5+
* of the Solana stack without taking a hard dependency on the UI package.
6+
*/
7+
export type SolanaProviderUI = {
8+
readonly isModalOpen: boolean;
9+
navigationIntent(intent: string, params?: unknown): void;
10+
};
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { type Factory, inject, injectable } from "inversify";
2+
import { Either, Left, Right } from "purify-ts";
3+
4+
import type { JSONRPCRequest } from "../../../../api/model/eip/EIPTypes.js";
5+
import {
6+
SolanaJSONRPCRequest,
7+
SolanaJsonRpcResponse,
8+
} from "../../../../api/model/solana/SolanaTypes.js";
9+
import { backendModuleTypes } from "../../../backend/backendModuleTypes.js";
10+
import { type BackendService } from "../../../backend/BackendService.js";
11+
import { isJsonRpcResponse } from "../../../backend/types.js";
12+
import { contextModuleTypes } from "../../../context/contextModuleTypes.js";
13+
import { type ContextService } from "../../../context/ContextService.js";
14+
import { loggerModuleTypes } from "../../../logger/loggerModuleTypes.js";
15+
import { LoggerPublisher } from "../../../logger/service/LoggerPublisher.js";
16+
import {
17+
DEFAULT_SOLANA_CLUSTER,
18+
getClusterFromCurrencyId,
19+
} from "../../utils/clusterUtils.js";
20+
21+
@injectable()
22+
export class SolanaRemoteDatasource {
23+
private readonly logger: LoggerPublisher;
24+
25+
constructor(
26+
@inject(loggerModuleTypes.LoggerPublisher)
27+
private readonly loggerFactory: Factory<LoggerPublisher>,
28+
@inject(backendModuleTypes.BackendService)
29+
private readonly backendService: BackendService,
30+
@inject(contextModuleTypes.ContextService)
31+
private readonly contextService: ContextService,
32+
) {
33+
this.logger = this.loggerFactory("SolanaRemoteDatasource");
34+
}
35+
36+
async JSONRPCRequest(
37+
args: SolanaJSONRPCRequest,
38+
): Promise<Either<Error, SolanaJsonRpcResponse>> {
39+
try {
40+
const cluster = this.resolveCluster();
41+
const response = await this.backendService.broadcast({
42+
blockchain: {
43+
name: "solana",
44+
chainId: cluster,
45+
},
46+
rpc: args as unknown as JSONRPCRequest,
47+
});
48+
49+
if (response.isLeft()) {
50+
return Left(
51+
new Error("Error in Solana JSONRPCRequest", {
52+
cause: response.extract(),
53+
}),
54+
);
55+
}
56+
if (response.isRight() && isJsonRpcResponse(response.extract())) {
57+
return Right(response.extract() as SolanaJsonRpcResponse);
58+
}
59+
return Left(
60+
new Error("Error in Solana JSONRPCRequest", {
61+
cause: response.extract(),
62+
}),
63+
);
64+
} catch (error) {
65+
this.logger.error("Error in Solana JSONRPCRequest", { error });
66+
return Left(new Error("Error in Solana JSONRPCRequest", { cause: error }));
67+
}
68+
}
69+
70+
private resolveCluster(): string {
71+
const selectedAccount = this.contextService.getContext().selectedAccount;
72+
if (selectedAccount?.currencyId) {
73+
return getClusterFromCurrencyId(selectedAccount.currencyId);
74+
}
75+
return DEFAULT_SOLANA_CLUSTER;
76+
}
77+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { type Factory, inject, injectable } from "inversify";
2+
import { Right } from "purify-ts";
3+
4+
import {
5+
CommonSolanaErrorCode,
6+
SolanaJSONRPCRequest,
7+
} from "../../../../api/model/solana/SolanaTypes.js";
8+
import { backendModuleTypes } from "../../../backend/backendModuleTypes.js";
9+
import { type BackendService } from "../../../backend/BackendService.js";
10+
import { contextModuleTypes } from "../../../context/contextModuleTypes.js";
11+
import { type ContextService } from "../../../context/ContextService.js";
12+
import { loggerModuleTypes } from "../../../logger/loggerModuleTypes.js";
13+
import { LoggerPublisher } from "../../../logger/service/LoggerPublisher.js";
14+
import { SolanaRemoteDatasource } from "./SolanaRemoteDatasource.js";
15+
16+
@injectable()
17+
export class StubSolanaRemoteDatasource extends SolanaRemoteDatasource {
18+
constructor(
19+
@inject(loggerModuleTypes.LoggerPublisher)
20+
loggerFactory: Factory<LoggerPublisher>,
21+
@inject(backendModuleTypes.BackendService)
22+
backendService: BackendService,
23+
@inject(contextModuleTypes.ContextService)
24+
contextService: ContextService,
25+
) {
26+
super(loggerFactory, backendService, contextService);
27+
}
28+
29+
override async JSONRPCRequest(args: SolanaJSONRPCRequest) {
30+
return Promise.resolve(
31+
Right({
32+
jsonrpc: "2.0",
33+
id: args.id,
34+
result: undefined,
35+
error: {
36+
code: CommonSolanaErrorCode.MethodNotFound,
37+
message: `Method ${args.method} is not supported, { method: ${args.method}, params: ${JSON.stringify(args.params)} }`,
38+
},
39+
}),
40+
);
41+
}
42+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { SolanaRemoteDatasource } from "./rpc/datasource/SolanaRemoteDatasource.js";
4+
import { StubSolanaRemoteDatasource } from "./rpc/datasource/StubSolanaRemoteDatasource.js";
5+
import { createContainer } from "../di.js";
6+
import { solanaProviderModuleTypes } from "./solanaProviderModuleTypes.js";
7+
8+
describe("solanaProviderModule", () => {
9+
it("should resolve SolanaRemoteDatasource from the root container", () => {
10+
const container = createContainer({
11+
devConfig: {
12+
stub: {
13+
solanaProvider: false,
14+
},
15+
},
16+
});
17+
18+
expect(
19+
container.get<SolanaRemoteDatasource>(
20+
solanaProviderModuleTypes.SolanaRemoteDatasource,
21+
),
22+
).toBeInstanceOf(SolanaRemoteDatasource);
23+
});
24+
25+
it("should bind StubSolanaRemoteDatasource when solanaProvider stub is enabled", () => {
26+
const container = createContainer({
27+
devConfig: {
28+
stub: {
29+
solanaProvider: true,
30+
},
31+
},
32+
});
33+
34+
expect(
35+
container.get<SolanaRemoteDatasource>(
36+
solanaProviderModuleTypes.SolanaRemoteDatasource,
37+
),
38+
).toBeInstanceOf(StubSolanaRemoteDatasource);
39+
});
40+
41+
it("should return unsupported method error from stub datasource", async () => {
42+
const container = createContainer({
43+
devConfig: {
44+
stub: {
45+
solanaProvider: true,
46+
},
47+
},
48+
});
49+
50+
const datasource = container.get<SolanaRemoteDatasource>(
51+
solanaProviderModuleTypes.SolanaRemoteDatasource,
52+
);
53+
54+
const response = await datasource.JSONRPCRequest({
55+
jsonrpc: "2.0",
56+
id: 1,
57+
method: "getBalance",
58+
params: [],
59+
});
60+
61+
expect(response.isRight()).toBe(true);
62+
expect(response.extract()).toMatchObject({
63+
jsonrpc: "2.0",
64+
id: 1,
65+
error: {
66+
code: -32601,
67+
message: expect.stringContaining("getBalance"),
68+
},
69+
});
70+
});
71+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { ContainerModule } from "inversify";
2+
3+
import { SolanaRemoteDatasource } from "./rpc/datasource/SolanaRemoteDatasource.js";
4+
import { StubSolanaRemoteDatasource } from "./rpc/datasource/StubSolanaRemoteDatasource.js";
5+
import { solanaProviderModuleTypes } from "./solanaProviderModuleTypes.js";
6+
7+
type SolanaProviderModuleOptions = {
8+
stub?: boolean;
9+
};
10+
11+
export function solanaProviderModuleFactory({ stub }: SolanaProviderModuleOptions) {
12+
return new ContainerModule(({ bind, rebindSync }) => {
13+
bind(solanaProviderModuleTypes.SolanaRemoteDatasource).to(
14+
SolanaRemoteDatasource,
15+
);
16+
17+
if (stub) {
18+
rebindSync(solanaProviderModuleTypes.SolanaRemoteDatasource).to(
19+
StubSolanaRemoteDatasource,
20+
);
21+
}
22+
});
23+
}

0 commit comments

Comments
 (0)