Skip to content

Commit 844ddf0

Browse files
✨ (solana-signer) [DSDK-1054]: Add delayed signing feature (#1358)
2 parents 8f570e9 + fc8c132 commit 844ddf0

30 files changed

Lines changed: 3155 additions & 107 deletions

.changeset/itchy-teams-join.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 delayed transaction signing feature

.changeset/little-beans-spend.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+
deduplicate repeated state logs, log terminal states, include intermediateValue in debug output

apps/docs/pages/docs/references/signers/solana.mdx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,15 @@ npm install @ledgerhq/device-signer-kit-solana
3939
To initialise a Solana signer instance, you need a Ledger Device Management Kit instance and the ID of the session of the connected device. Use the `SignerSolanaBuilder` along with the [Context Module](https://github.com/LedgerHQ/device-sdk-ts/tree/develop/packages/signer/context-module) by default developed by Ledger:
4040

4141
```typescript
42-
const signerSolana = new SignerSolanaBuilder({ dmk, sessionId }).build();
42+
const signerSolana = new SignerSolanaBuilder({
43+
dmk,
44+
sessionId,
45+
solanaRPCURL: "https://api.mainnet-beta.solana.com/",
46+
}).build();
4347
```
4448

49+
- **solanaRPCURL** _(optional)_ — Default Solana RPC endpoint for transaction inspection (SPL token resolution) and fetching a fresh `recentBlockhash` during delayed signing. Override per call with `SolanaTransactionOptionalConfig.solanaRPCURL`. In browser environments, use a CORS-enabled RPC URL.
50+
4551
## 🔹 Use Cases
4652

4753
The `SignerSolanaBuilder.build()` method will return a `SignerSolana` instance that exposes 4 dedicated methods, each of which calls an independent use case. Each use case will return an object that contains an observable and a method called `cancel`.
@@ -119,6 +125,9 @@ const { observable, cancel } = signerSolana.signTransaction(
119125
- **transactionOptions** `SolanaTransactionOptionalConfig`
120126
Provides additional context for transaction signing.
121127

128+
- **solanaRPCURL** `string` _(optional)_
129+
Overrides the RPC URL from `SignerSolanaBuilder` for this call (inspection and delayed signing).
130+
122131
- **transactionResolutionContext** `object`
123132
Lets you explicitly pass `tokenAddress` and ATA details, bypassing extraction from the transaction itself.
124133

@@ -134,13 +143,8 @@ const { observable, cancel } = signerSolana.signTransaction(
134143
- **tokenInternalId** `string`
135144
Ledger internal token ID
136145

137-
- **solanaRPCURL** `string`
138-
RPC endpoint used for address-lookup table resolution in versioned (v0) transactions.
139-
Defaults to `https://api.mainnet-beta.solana.com/`. Override this to use a private RPC
140-
or to avoid CORS issues in browser environments.
141-
142-
- **skipOpenApp** `boolean`
143-
If `true`, skips opening the Solana app on the device.
146+
- **skipOpenApp** `boolean`
147+
If `true`, skips opening the Solana app on the device.
144148

145149
---
146150

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

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
import React, { useCallback, useMemo, useState } from "react";
2+
import React, { useCallback, useMemo, useRef, useState } from "react";
33
import {
44
base64StringToBuffer,
55
isBase64String,
@@ -42,6 +42,7 @@ type SignTransactionInput = {
4242
derivationPath: string;
4343
transaction: string;
4444
skipOpenApp: boolean;
45+
delayed: boolean;
4546
templateId: string;
4647
tokenAddress: string;
4748
tokenInternalId: string;
@@ -53,6 +54,7 @@ const MAIN_KEYS: (keyof SignTransactionInput)[] = [
5354
"derivationPath",
5455
"transaction",
5556
"skipOpenApp",
57+
"delayed",
5658
];
5759

5860
const CONTEXT_KEYS: (keyof SignTransactionInput)[] = [
@@ -70,6 +72,9 @@ const SignTransactionForm: React.FC<{
7072
}> = ({ initialValues, onChange, disabled }) => {
7173
const [expanded, setExpanded] = useState(false);
7274

75+
const initialValuesRef = useRef(initialValues);
76+
initialValuesRef.current = initialValues;
77+
7378
const mainValues = Object.fromEntries(
7479
MAIN_KEYS.map((k) => [k, initialValues[k]]),
7580
) as Pick<SignTransactionInput, (typeof MAIN_KEYS)[number]>;
@@ -79,13 +84,15 @@ const SignTransactionForm: React.FC<{
7984
) as Pick<SignTransactionInput, (typeof CONTEXT_KEYS)[number]>;
8085

8186
const handleMainChange = useCallback(
82-
(vals: typeof mainValues) => onChange({ ...initialValues, ...vals }),
83-
[initialValues, onChange],
87+
(vals: typeof mainValues) =>
88+
onChange({ ...initialValuesRef.current, ...vals }),
89+
[onChange],
8490
);
8591

8692
const handleContextChange = useCallback(
87-
(vals: typeof contextValues) => onChange({ ...initialValues, ...vals }),
88-
[initialValues, onChange],
93+
(vals: typeof contextValues) =>
94+
onChange({ ...initialValuesRef.current, ...vals }),
95+
[onChange],
8996
);
9097

9198
return (
@@ -137,6 +144,7 @@ export const SignerSolanaView: React.FC<{ sessionId: string }> = ({
137144
dmk,
138145
sessionId,
139146
originToken: "Solana",
147+
solanaRPCURL: "https://solana.coin.ledger.com",
140148
}).build();
141149
const solanaTools = new SolanaToolsBuilder({
142150
dmk,
@@ -186,6 +194,7 @@ export const SignerSolanaView: React.FC<{ sessionId: string }> = ({
186194
executeDeviceAction: ({
187195
derivationPath,
188196
transaction,
197+
delayed,
189198
templateId,
190199
tokenAddress,
191200
tokenInternalId,
@@ -203,25 +212,23 @@ export const SignerSolanaView: React.FC<{ sessionId: string }> = ({
203212
const hasResolutionContext =
204213
templateId || tokenAddress || tokenInternalId || createATA;
205214

206-
return signer.signTransaction(
207-
derivationPath,
208-
serializedTransaction,
209-
hasResolutionContext
210-
? {
211-
transactionResolutionContext: {
212-
templateId: templateId || undefined,
213-
tokenAddress: tokenAddress || undefined,
214-
tokenInternalId: tokenInternalId || undefined,
215-
createATA,
216-
},
217-
}
218-
: undefined,
219-
);
215+
return signer.signTransaction(derivationPath, serializedTransaction, {
216+
...(hasResolutionContext && {
217+
transactionResolutionContext: {
218+
templateId: templateId || undefined,
219+
tokenAddress: tokenAddress || undefined,
220+
tokenInternalId: tokenInternalId || undefined,
221+
createATA,
222+
},
223+
}),
224+
delayed,
225+
});
220226
},
221227
initialValues: {
222228
derivationPath: DEFAULT_DERIVATION_PATH,
223229
transaction: "",
224230
skipOpenApp: false,
231+
delayed: false,
225232
templateId: "",
226233
tokenAddress: "",
227234
tokenInternalId: "",

packages/device-management-kit/src/api/device-action/xstate-utils/XStateDeviceAction.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,13 +185,16 @@ export abstract class XStateDeviceAction<
185185
}
186186

187187
// Log internal state on each state transition
188-
if (this.logger && status === "active") {
188+
if (this.logger && (status === "active" || status === "done")) {
189189
const stateValue =
190190
typeof snapshot.value === "string"
191191
? snapshot.value
192192
: JSON.stringify(snapshot.value);
193193
this.logger.debug(`[XStateDeviceAction] State: ${stateValue}`, {
194-
data: { internalState: context._internalState },
194+
data: {
195+
internalState: context._internalState,
196+
intermediateValue: context.intermediateValue,
197+
},
195198
});
196199
}
197200

packages/signer/signer-solana/README.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,15 @@ npm install @ledgerhq/device-signer-kit-solana
3939
To initialise a Solana signer instance, you need a Ledger Device Management Kit instance and the ID of the session of the connected device. Use the `SignerSolanaBuilder` along with the [Context Module](https://github.com/LedgerHQ/device-sdk-ts/tree/develop/packages/signer/context-module) by default developed by Ledger:
4040

4141
```typescript
42-
const signerSolana = new SignerSolanaBuilder({ dmk, sessionId }).build();
42+
const signerSolana = new SignerSolanaBuilder({
43+
dmk,
44+
sessionId,
45+
solanaRPCURL: "https://api.mainnet-beta.solana.com/",
46+
}).build();
4347
```
4448

49+
- **solanaRPCURL** _(optional)_ — Default Solana RPC endpoint for transaction inspection (SPL token resolution) and fetching a fresh `recentBlockhash` during delayed signing. You can override it per `signTransaction` call via `SolanaTransactionOptionalConfig.solanaRPCURL`. In browser environments, use a CORS-enabled RPC URL.
50+
4551
## 🔹 Use Cases
4652

4753
The `SignerSolanaBuilder.build()` method will return a `SignerSolana` instance that exposes 4 dedicated methods, each of which calls an independent use case. Each use case will return an object that contains an observable and a method called `cancel`.
@@ -119,6 +125,9 @@ const { observable, cancel } = signerSolana.signTransaction(
119125
- **transactionOptions** `SolanaTransactionOptionalConfig`
120126
Provides additional context for transaction signing.
121127

128+
- **solanaRPCURL** `string` _(optional)_
129+
Overrides the RPC URL from `SignerSolanaBuilder` for this call (inspection and delayed signing).
130+
122131
- **transactionResolutionContext** `object`
123132
Lets you explicitly pass `tokenAddress` and ATA details, bypassing extraction from the transaction itself.
124133

@@ -134,13 +143,8 @@ const { observable, cancel } = signerSolana.signTransaction(
134143
- **tokenInternalId** `string`
135144
Ledger internal token ID
136145

137-
- **solanaRPCURL** `string`
138-
RPC endpoint used for address-lookup table resolution in versioned (v0) transactions.
139-
Defaults to `https://api.mainnet-beta.solana.com/`. Override this to use a private RPC
140-
or to avoid CORS issues in browser environments.
141-
142-
- **skipOpenApp** `boolean`
143-
If `true`, skips opening the Solana app on the device.
146+
- **skipOpenApp** `boolean`
147+
If `true`, skips opening the Solana app on the device.
144148

145149
---
146150

packages/signer/signer-solana/src/api/SignerSolanaBuilder.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type SignerSolanaBuilderConstructorArgs = {
1313
dmk: DeviceManagementKit;
1414
sessionId: DeviceSessionId;
1515
originToken?: string;
16+
solanaRPCURL?: string;
1617
};
1718

1819
/**
@@ -29,15 +30,18 @@ export class SignerSolanaBuilder {
2930
private _sessionId: DeviceSessionId;
3031
private _customContextModule: ContextModule | undefined;
3132
private _originToken: string | undefined;
33+
private readonly _solanaRPCURL: string | undefined;
3234

3335
constructor({
3436
dmk,
3537
sessionId,
3638
originToken,
39+
solanaRPCURL,
3740
}: SignerSolanaBuilderConstructorArgs) {
3841
this._dmk = dmk;
3942
this._sessionId = sessionId;
4043
this._originToken = originToken;
44+
this._solanaRPCURL = solanaRPCURL;
4145
}
4246

4347
/**
@@ -60,6 +64,7 @@ export class SignerSolanaBuilder {
6064
return new DefaultSignerSolana({
6165
dmk: this._dmk,
6266
sessionId: this._sessionId,
67+
solanaRPCURL: this._solanaRPCURL,
6368
contextModule:
6469
this._customContextModule ??
6570
new ContextModuleBuilder({
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import {
2+
type DeviceActionState,
3+
type ExecuteDeviceActionReturnType,
4+
type OpenAppDAError,
5+
type OpenAppDARequiredInteraction,
6+
type SendCommandInAppDAError,
7+
type UserInteractionRequired,
8+
} from "@ledgerhq/device-management-kit";
9+
10+
import { type Signature } from "@api/model/Signature";
11+
import { type UserInputType } from "@api/model/TransactionResolutionContext";
12+
import { type SolanaAppErrorCodes } from "@internal/app-binder/command/utils/SolanaApplicationErrors";
13+
import { type BlockhashService } from "@internal/app-binder/services/BlockhashService";
14+
15+
export const delayedSignDAStateSteps = Object.freeze({
16+
ZERO_BLOCKHASH: "signer.sol.steps.zeroBlockhash",
17+
PREVIEW_TRANSACTION: "signer.sol.steps.previewTransaction",
18+
FETCH_BLOCKHASH: "signer.sol.steps.fetchBlockhash",
19+
PATCH_TRANSACTION: "signer.sol.steps.patchTransaction",
20+
DELAYED_SIGN: "signer.sol.steps.delayedSign",
21+
FALLBACK_TO_NON_DELAYED_SIGN: "signer.sol.steps.fallbackToNonDelayedSign",
22+
} as const);
23+
24+
export type DelayedSignDAStateStep =
25+
(typeof delayedSignDAStateSteps)[keyof typeof delayedSignDAStateSteps];
26+
27+
export type DelayedSignDAOutput = Signature;
28+
29+
export type DelayedSignDAInput = {
30+
readonly derivationPath: string;
31+
readonly transaction: Uint8Array;
32+
readonly rpcUrl?: string;
33+
readonly fetchBlockhash?: () => Promise<Uint8Array>;
34+
readonly userInputType?: UserInputType;
35+
readonly blockhashService?: BlockhashService;
36+
};
37+
38+
export type DelayedSignDAError =
39+
| OpenAppDAError
40+
| SendCommandInAppDAError<SolanaAppErrorCodes>;
41+
42+
type DelayedSignDARequiredInteraction =
43+
| UserInteractionRequired
44+
| OpenAppDARequiredInteraction;
45+
46+
export type DelayedSignDAIntermediateValue = {
47+
requiredUserInteraction: DelayedSignDARequiredInteraction;
48+
step: DelayedSignDAStateStep;
49+
};
50+
51+
export type DelayedSignDAState = DeviceActionState<
52+
DelayedSignDAOutput,
53+
DelayedSignDAError,
54+
DelayedSignDAIntermediateValue
55+
>;
56+
57+
export type DelayedSignDAInternalState = {
58+
readonly error: DelayedSignDAError | null;
59+
readonly signature: Signature | null;
60+
readonly zeroedTransaction: Uint8Array | null;
61+
readonly freshBlockhash: Uint8Array | null;
62+
readonly patchedTransaction: Uint8Array | null;
63+
readonly previewFallback: boolean;
64+
};
65+
66+
export type DelayedSignDAReturnType = ExecuteDeviceActionReturnType<
67+
DelayedSignDAOutput,
68+
DelayedSignDAError,
69+
DelayedSignDAIntermediateValue
70+
>;

packages/signer/signer-solana/src/api/app-binder/SignTransactionDeviceActionTypes.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,24 @@ import { type Signature } from "@api/model/Signature";
1414
import { type SolanaTransactionOptionalConfig } from "@api/model/SolanaTransactionOptionalConfig";
1515
import { type Transaction } from "@api/model/Transaction";
1616
import { type SolanaAppErrorCodes } from "@internal/app-binder/command/utils/SolanaApplicationErrors";
17+
import { type BlockhashService } from "@internal/app-binder/services/BlockhashService";
1718
import { type TxInspectorResult } from "@internal/app-binder/services/TransactionInspector";
1819

20+
import { type DelayedSignDAStateStep } from "./DelayedSignTransactionDeviceActionTypes";
21+
1922
export const signTransactionDAStateSteps = Object.freeze({
2023
OPEN_APP: "signer.sol.steps.openApp",
2124
GET_APP_CONFIG: "signer.sol.steps.getAppConfig",
2225
INSPECT_TRANSACTION: "signer.sol.steps.inspectTransaction",
2326
BUILD_TRANSACTION_CONTEXT: "signer.sol.steps.buildTransactionContext",
2427
PROVIDE_TRANSACTION_CONTEXT: "signer.sol.steps.provideTransactionContext",
2528
SIGN_TRANSACTION: "signer.sol.steps.signTransaction",
29+
DELAYED_SIGN: "signer.sol.steps.delayedSign",
2630
} as const);
2731

2832
export type SignTransactionDAStateStep =
29-
(typeof signTransactionDAStateSteps)[keyof typeof signTransactionDAStateSteps];
33+
| (typeof signTransactionDAStateSteps)[keyof typeof signTransactionDAStateSteps]
34+
| DelayedSignDAStateStep;
3035

3136
export type SignTransactionDAOutput = Signature;
3237

@@ -35,6 +40,8 @@ export type SignTransactionDAInput = {
3540
readonly transaction: Transaction;
3641
readonly contextModule: ContextModule;
3742
readonly transactionOptions?: SolanaTransactionOptionalConfig;
43+
readonly solanaRPCURL?: string;
44+
readonly blockhashService?: BlockhashService;
3845
};
3946

4047
export type SignTransactionDAError =

packages/signer/signer-solana/src/api/model/SolanaTransactionOptionalConfig.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { type TransactionResolutionContext } from "./TransactionResolutionContex
22

33
export type SolanaTransactionOptionalConfig = {
44
transactionResolutionContext?: TransactionResolutionContext;
5+
/** When set, overrides the signer default RPC URL for this sign only. */
56
solanaRPCURL?: string;
67
skipOpenApp?: boolean;
8+
delayed?: boolean;
9+
fetchBlockhash?: () => Promise<Uint8Array>;
710
};

0 commit comments

Comments
 (0)