Skip to content

Commit 70c9dea

Browse files
✨ (solana-signer): Add solana swap transaction signer
1 parent e7cdf7e commit 70c9dea

File tree

20 files changed

+2010
-2
lines changed

20 files changed

+2010
-2
lines changed

.changeset/calm-beds-fly.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ledgerhq/device-management-kit": patch
3+
---
4+
5+
Add bufferToBase64String util

.changeset/slimy-walls-talk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ledgerhq/device-signer-kit-solana": minor
3+
---
4+
5+
Add swap transaction signer

apps/sample/src/components/SignerSolanaView/index.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ import {
2323
type SignTransactionDAOutput,
2424
SolanaToolsBuilder,
2525
} from "@ledgerhq/device-signer-kit-solana";
26+
import {
27+
type SwapTransactionSignerDAError,
28+
type SwapTransactionSignerDAIntermediateValue,
29+
type SwapTransactionSignerDAOutput,
30+
} from "@ledgerhq/device-signer-kit-solana/api/app-binder/SwapTransactionSignerDeviceActionTypes.js";
2631

2732
import { DeviceActionsList } from "@/components/DeviceActionsView/DeviceActionsList";
2833
import { type DeviceActionProps } from "@/components/DeviceActionsView/DeviceActionTester";
@@ -82,7 +87,7 @@ export const SignerSolanaView: React.FC<{ sessionId: string }> = ({
8287
GetAddressDAIntermediateValue
8388
>,
8489
{
85-
title: "Sign Transaction",
90+
title: "Sign transaction",
8691
description:
8792
"Perform all the actions necessary to sign a Solana transaction with the device",
8893
executeDeviceAction: ({ derivationPath, transaction }) => {
@@ -146,7 +151,7 @@ export const SignerSolanaView: React.FC<{ sessionId: string }> = ({
146151
GetAppConfigurationDAIntermediateValue
147152
>,
148153
{
149-
title: "Generate Transaction",
154+
title: "Generate transaction",
150155
description:
151156
"Perform all the actions necessary to generate a transaction to test the Solana signer",
152157
executeDeviceAction: ({ derivationPath }) => {
@@ -166,6 +171,32 @@ export const SignerSolanaView: React.FC<{ sessionId: string }> = ({
166171
GenerateTransactionDAError,
167172
GenerateTransactionDAIntermediateValue
168173
>,
174+
{
175+
title: "Swap transaction signer",
176+
description:
177+
"Perform all the actions necessary to swap a transaction signer",
178+
executeDeviceAction: ({ derivationPath, serialisedTransaction }) => {
179+
return solanaTools.swapTransactionSigner(
180+
derivationPath,
181+
serialisedTransaction,
182+
);
183+
},
184+
initialValues: {
185+
derivationPath: DEFAULT_DERIVATION_PATH,
186+
serialisedTransaction: "",
187+
skipOpenApp: false,
188+
},
189+
deviceModelId,
190+
} satisfies DeviceActionProps<
191+
SwapTransactionSignerDAOutput,
192+
{
193+
derivationPath: string;
194+
serialisedTransaction: string;
195+
skipOpenApp: boolean;
196+
},
197+
SwapTransactionSignerDAError,
198+
SwapTransactionSignerDAIntermediateValue
199+
>,
169200
],
170201
[deviceModelId, solanaTools, signer],
171202
);

packages/device-management-kit/src/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export { TransportConnectedDevice } from "@api/transport/model/TransportConnecte
119119
export { connectedDeviceStubBuilder } from "@api/transport/model/TransportConnectedDevice.stub";
120120
export * from "@api/types";
121121
export { base64StringToBuffer, isBase64String } from "@api/utils/Base64String";
122+
export { bufferToBase64String } from "@api/utils/BufferToBase64String";
122123
export {
123124
bufferToHexaString,
124125
hexaStringToBuffer,
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
2+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
3+
/* eslint-disable @typescript-eslint/no-explicit-any */
4+
import { bufferToBase64String } from "./BufferToBase64String";
5+
6+
describe("bufferToBase64String", () => {
7+
const originalBtoa = (globalThis as any).btoa;
8+
const originalBuffer = (globalThis as any).Buffer;
9+
10+
beforeEach(() => {
11+
vi.restoreAllMocks();
12+
(globalThis as any).btoa = originalBtoa;
13+
(globalThis as any).Buffer = originalBuffer;
14+
});
15+
16+
afterAll(() => {
17+
(globalThis as any).btoa = originalBtoa;
18+
(globalThis as any).Buffer = originalBuffer;
19+
});
20+
21+
it("should encode an empty buffer to an empty base64 string when btoa is available", () => {
22+
// GIVEN
23+
(globalThis as any).btoa = vi.fn((input: string) => {
24+
expect(input).toBe("");
25+
return "";
26+
});
27+
28+
const bytes = new Uint8Array();
29+
30+
// WHEN
31+
const result = bufferToBase64String(bytes);
32+
33+
// THEN
34+
expect(result).toBe("");
35+
expect(globalThis.btoa).toHaveBeenCalledTimes(1);
36+
});
37+
38+
it("should encode a buffer to base64 using btoa when available", () => {
39+
// GIVEN
40+
const text = "first testing str";
41+
const bytes = Uint8Array.from(text.split("").map((c) => c.charCodeAt(0)));
42+
43+
(globalThis as any).btoa = vi.fn((input: string) => {
44+
expect(input).toBe(text);
45+
return "Zmlyc3QgdGVzdGluZyBzdHI=";
46+
});
47+
48+
// WHEN
49+
const result = bufferToBase64String(bytes);
50+
51+
// THEN
52+
expect(result).toBe("Zmlyc3QgdGVzdGluZyBzdHI=");
53+
expect(globalThis.btoa).toHaveBeenCalledTimes(1);
54+
});
55+
56+
it("should encode a buffer to base64 using Buffer when btoa is not available", () => {
57+
// GIVEN
58+
(globalThis as any).btoa = undefined;
59+
60+
const text = "testing str";
61+
const expectedBase64 = Buffer.from(text, "binary").toString("base64");
62+
const bytes = Uint8Array.from(text.split("").map((c) => c.charCodeAt(0)));
63+
64+
const bufferFromSpy = vi.spyOn(Buffer, "from");
65+
66+
// WHEN
67+
const result = bufferToBase64String(bytes);
68+
69+
// THEN
70+
expect(result).toBe(expectedBase64);
71+
expect(bufferFromSpy).toHaveBeenCalledTimes(1);
72+
});
73+
74+
it("should throw an error when no Base64 encoder is available", () => {
75+
// GIVEN
76+
(globalThis as any).btoa = undefined;
77+
(globalThis as any).Buffer = undefined;
78+
79+
const bytes = Uint8Array.from([0x01, 0x02, 0x03]);
80+
81+
// WHEN / THEN
82+
expect(() => bufferToBase64String(bytes)).toThrowError(
83+
"No Base64 encoder available in this environment.",
84+
);
85+
});
86+
87+
it("should throw if an undefined byte is encountered (defensive check)", () => {
88+
// GIVEN
89+
const bytes = {
90+
length: 3,
91+
0: 0x66,
92+
1: undefined,
93+
2: 0x6f,
94+
} as unknown as Uint8Array;
95+
96+
(globalThis as any).btoa = vi.fn();
97+
98+
// WHEN / THEN
99+
expect(() => bufferToBase64String(bytes)).toThrowError(
100+
"Unexpected undefined byte in array.",
101+
);
102+
});
103+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export function bufferToBase64String(bytes: Uint8Array): string {
2+
const g = globalThis as typeof globalThis & {
3+
Buffer?: typeof Buffer;
4+
btoa?: (data: string) => string;
5+
};
6+
7+
if (typeof g.btoa === "function") {
8+
// convert bytes to a binary string for btoa
9+
let binary = "";
10+
for (let i = 0; i < bytes.length; i++) {
11+
const byte = bytes[i];
12+
if (byte === undefined) {
13+
throw new Error("Unexpected undefined byte in array.");
14+
}
15+
binary += String.fromCharCode(byte);
16+
}
17+
return g.btoa(binary);
18+
}
19+
20+
const Buf = g.Buffer;
21+
if (typeof Buf !== "undefined") {
22+
return Buf.from(bytes).toString("base64");
23+
}
24+
25+
throw new Error("No Base64 encoder available in this environment.");
26+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { type ContextModule } from "@ledgerhq/context-module";
2+
import {
3+
type DeviceActionState,
4+
type ExecuteDeviceActionReturnType,
5+
type OpenAppDAError,
6+
type OpenAppDARequiredInteraction,
7+
type SendCommandInAppDAError,
8+
type UserInteractionRequired,
9+
} from "@ledgerhq/device-management-kit";
10+
11+
import { type PublicKey } from "@api/model/PublicKey";
12+
import { type SolanaAppErrorCodes } from "@internal/app-binder/command/utils/SolanaApplicationErrors";
13+
14+
export type SwapTransactionSignerDAOutput = string;
15+
16+
export type SwapTransactionSignerDAInput = {
17+
readonly derivationPath: string;
18+
readonly serialisedTransaction: string;
19+
readonly skipOpenApp: boolean;
20+
readonly contextModule: ContextModule;
21+
};
22+
23+
export type SwapTransactionSignerDAError =
24+
| OpenAppDAError
25+
| SendCommandInAppDAError<SolanaAppErrorCodes>;
26+
27+
type SwapTransactionSignerDARequiredInteraction =
28+
| UserInteractionRequired
29+
| OpenAppDARequiredInteraction;
30+
31+
export type SwapTransactionSignerDAIntermediateValue = {
32+
requiredUserInteraction: SwapTransactionSignerDARequiredInteraction;
33+
};
34+
35+
export type SwapTransactionSignerDAState = DeviceActionState<
36+
SwapTransactionSignerDAOutput,
37+
SwapTransactionSignerDAError,
38+
SwapTransactionSignerDAIntermediateValue
39+
>;
40+
41+
export type SwapTransactionSignerDAInternalState = {
42+
readonly error: SwapTransactionSignerDAError | null;
43+
readonly publicKey: PublicKey | null;
44+
readonly serialisedTransaction: string | null;
45+
};
46+
47+
export type SwapTransactionSignerDAReturnType = ExecuteDeviceActionReturnType<
48+
SwapTransactionSignerDAOutput,
49+
SwapTransactionSignerDAError,
50+
SwapTransactionSignerDAIntermediateValue
51+
>;

packages/signer/signer-solana/src/internal/DefaultSolanaTools.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,21 @@ describe("DefaultSolanaTools", () => {
6060
solanaTools.generateTransaction("derivationPath");
6161
expect(dmk.executeDeviceAction).toHaveBeenCalled();
6262
});
63+
64+
it("should call swapTransaction", () => {
65+
const dmk = {
66+
executeDeviceAction: vi.fn(),
67+
} as unknown as DeviceManagementKit;
68+
const sessionId = {} as DeviceSessionId;
69+
const solanaTools = new DefaultSolanaTools({
70+
dmk,
71+
sessionId,
72+
contextModule: contextModuleStub,
73+
});
74+
solanaTools.swapTransactionSigner(
75+
"derivationPath",
76+
"serialisedTransaction",
77+
);
78+
expect(dmk.executeDeviceAction).toHaveBeenCalled();
79+
});
6380
});

packages/signer/signer-solana/src/internal/DefaultSolanaTools.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ import { type Container } from "inversify";
88
import { type GenerateTransactionDAReturnType } from "@api/app-binder/GenerateTransactionDeviceActionTypes";
99
import { type GetAddressDAReturnType } from "@api/app-binder/GetAddressDeviceActionTypes";
1010
import { type GetAppConfigurationDAReturnType } from "@api/app-binder/GetAppConfigurationDeviceActionTypes";
11+
import { type SwapTransactionSignerDAReturnType } from "@api/app-binder/SwapTransactionSignerDeviceActionTypes";
1112
import { type AddressOptions } from "@api/model/AddressOption";
1213
import { type SolanaTools } from "@api/SolanaTools";
1314

1415
import { type GetAddressUseCase } from "./use-cases/address/GetAddressUseCase";
1516
import { type GetAppConfigurationUseCase } from "./use-cases/app-configuration/GetAppConfigurationUseCase";
1617
import { useCasesTypes } from "./use-cases/di/useCasesTypes";
1718
import { type GenerateTransactionUseCase } from "./use-cases/generateTransaction/GenerateTransactionUseCase";
19+
import { type SwapTransactionSignerUseCase } from "./use-cases/swap-transaction-signer/SwapTransactionSignerUseCase";
1820
import { makeContainer } from "./di";
1921

2022
export type DefaultSolanaToolsConstructorArgs = {
@@ -40,6 +42,17 @@ export class DefaultSolanaTools implements SolanaTools {
4042
.execute(derivationPath);
4143
}
4244

45+
swapTransactionSigner(
46+
derivationPath: string,
47+
serialisedTransaction: string,
48+
): SwapTransactionSignerDAReturnType {
49+
return this._container
50+
.get<SwapTransactionSignerUseCase>(
51+
useCasesTypes.SwapTransactionSignerUseCase,
52+
)
53+
.execute(derivationPath, serialisedTransaction);
54+
}
55+
4356
getAddress(
4457
derivationPath: string,
4558
options?: AddressOptions,

0 commit comments

Comments
 (0)