From 9ff5a4006756a7b6429116b15f2ff0f7f4481b34 Mon Sep 17 00:00:00 2001 From: fAnselmi-Ledger Date: Mon, 14 Sep 2026 14:47:27 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20(signer-solana):=20Lock=20in=20?= =?UTF-8?q?substructure=20framing=20+=20warn=20on=20pinned=20skips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/dull-baths-poke.md | 5 + .../requirements/rules/altResolutionRule.ts | 10 +- .../ProvideInstructionSubstructureCommand.ts | 7 +- .../ProvideGenericClearSignContextTask.ts | 110 ++++++++++++++++-- .../provideInstructionInfoContext.test.ts | 33 ++++++ 5 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 .changeset/dull-baths-poke.md diff --git a/.changeset/dull-baths-poke.md b/.changeset/dull-baths-poke.md new file mode 100644 index 0000000000..7bc8b5a145 --- /dev/null +++ b/.changeset/dull-baths-poke.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/device-signer-kit-solana": patch +--- + +Lock in substructure framing + warn on pinned skips diff --git a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/rules/altResolutionRule.ts b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/rules/altResolutionRule.ts index 471c127659..5d33b7ebbb 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/rules/altResolutionRule.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/rules/altResolutionRule.ts @@ -47,10 +47,12 @@ import { resolvePortAccountIndex } from "@internal/app-binder/clear-sign/require * * Deliberately excluded, to keep device heap use down: read-only ALT accounts * that no port, token reference, display field, association or reset names. The - * only site that reads them is `collect_all_accounts`, and a slot missing there - * only weakens `condition_account_used_elsewhere` — it cannot cost merge - * compaction, it can only make the device show more screens, never fewer and - * never a wrong value. + * only site that reads them is `collect_all_accounts`, and `ACCOUNT_USED_ELSEWHERE` + * is the only predicate that consults it — and that predicate is not + * unresolvable (it is never three-valued, unlike a port left unresolved by a + * missing descriptor, see spec/device/tlv_structs.md#unevaluable-predicates, + * G-051), so a slot missing there can only make the device show more screens, + * never fewer and never a wrong value. It cannot cost merge compaction. */ export function applyAltResolutionRule( parsed: ParsedInstruction, diff --git a/packages/signer/signer-solana/src/internal/app-binder/command/ProvideInstructionSubstructureCommand.ts b/packages/signer/signer-solana/src/internal/app-binder/command/ProvideInstructionSubstructureCommand.ts index fdf0aa7310..401e6d4d25 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/command/ProvideInstructionSubstructureCommand.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/command/ProvideInstructionSubstructureCommand.ts @@ -48,8 +48,11 @@ export type ProvideInstructionSubstructureCommandArgs = { * HIDE_RULE / ACCOUNT_RESET) referenced by the current `INSTRUCTION_INFO`. * * The caller pre-builds the wire payload — a 1-byte substructure-type selector - * followed by the substructure TLV (no length prefix; the device recovers the - * total length from the chunk flags) — and splits it into ≤255-byte chunks. + * followed by the substructure TLV — and splits it into ≤255-byte chunks. The + * `SUBSTRUCT_TYPE ‖ uint32be length ‖ TLV` framing is absent on the wire (the + * device recovers the total length from the chunk flags) but the device still + * folds that framing into the running `SUBSTRUCTURES_HASH`, so each call must + * carry exactly one substructure — never two packed together. */ export class ProvideInstructionSubstructureCommand implements diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/ProvideGenericClearSignContextTask.ts b/packages/signer/signer-solana/src/internal/app-binder/task/ProvideGenericClearSignContextTask.ts index d2fd1959bd..f7dcf91cc5 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/task/ProvideGenericClearSignContextTask.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/task/ProvideGenericClearSignContextTask.ts @@ -221,6 +221,15 @@ export class ProvideGenericClearSignContextTask { }), ClearSignContextType.SOLANA_TOKEN_ACCOUNT_STATE, ); + if (contexts.length === 0) { + // No descriptor at all — not even a minimal no-mint/owner response + // (valid for path 2's ephemeral WSOL ATA case). The port this account + // backs stays unresolved and pins its instruction (G-051). + this.logger.warn( + "[run] TOKEN_ACCOUNT_STATE fetch returned no descriptor; instruction may be pinned", + { data: { tokenAccount } }, + ); + } for (const ctx of contexts) { if ( ctx.type === ClearSignContextType.SOLANA_TOKEN_ACCOUNT_STATE && @@ -259,15 +268,33 @@ export class ProvideGenericClearSignContextTask { }), ClearSignContextType.SOLANA_ALT_RESOLUTION, ); + if (altContexts.length === 0) { + // Unresolved ALT_RESOLUTION for a MINT_ASSOC entry pins the + // instruction (G-051): the mint stays undisplayed and the merge + // cannot compact it away. + this.logger.warn( + "[run] ALT_RESOLUTION fetch failed for a MINT_ASSOC ref; instruction may be pinned", + { data: { altAddress, entryIndex } }, + ); + continue; + } for (const altCtx of altContexts) { if ( altCtx.type !== ClearSignContextType.SOLANA_ALT_RESOLUTION || !isSolanaContextSuccess(altCtx) - ) + ) { continue; + } const resolvedAddress = (altCtx as SolanaAltResolutionContextSuccess) .payload.resolvedAddress; - if (resolvedAddress && !streamedMints.has(resolvedAddress)) { + if (!resolvedAddress) { + this.logger.warn( + "[run] ALT_RESOLUTION for a MINT_ASSOC ref resolved to no address; instruction may be pinned", + { data: { altAddress, entryIndex } }, + ); + continue; + } + if (!streamedMints.has(resolvedAddress)) { streamedMints.add(resolvedAddress); await this.fetchAndStreamTokenInfo(resolvedAddress, deviceModelId); } @@ -288,15 +315,33 @@ export class ProvideGenericClearSignContextTask { }), ClearSignContextType.SOLANA_ALT_RESOLUTION, ); + if (altContexts.length === 0) { + // Unresolved ALT_RESOLUTION for a TOKEN_AMOUNT.TOKEN ref pins the + // instruction (G-051): the amount's token cannot be displayed and + // the merge cannot compact it away. + this.logger.warn( + "[run] ALT_RESOLUTION fetch failed for a TOKEN_AMOUNT.TOKEN ref; instruction may be pinned", + { data: { altAddress, entryIndex } }, + ); + continue; + } for (const altCtx of altContexts) { if ( altCtx.type !== ClearSignContextType.SOLANA_ALT_RESOLUTION || !isSolanaContextSuccess(altCtx) - ) + ) { continue; + } const resolvedAddress = (altCtx as SolanaAltResolutionContextSuccess) .payload.resolvedAddress; - if (!resolvedAddress || streamedMints.has(resolvedAddress)) continue; + if (!resolvedAddress) { + this.logger.warn( + "[run] ALT_RESOLUTION for a TOKEN_AMOUNT.TOKEN ref resolved to no address; instruction may be pinned", + { data: { altAddress, entryIndex } }, + ); + continue; + } + if (streamedMints.has(resolvedAddress)) continue; // Optimistic: resolved address is a mint. const tokenInfoContexts = await this.args.contextModule.getContexts( @@ -322,11 +367,24 @@ export class ProvideGenericClearSignContextTask { }), ClearSignContextType.SOLANA_TOKEN_ACCOUNT_STATE, ); - if (!stateCtx || !isSolanaContextSuccess(stateCtx)) continue; + if (!stateCtx || !isSolanaContextSuccess(stateCtx)) { + this.logger.warn( + "[run] TOKEN_ACCOUNT_STATE fetch failed for a resolved TOKEN_AMOUNT.TOKEN ATA; instruction may be pinned", + { data: { tokenAccount: resolvedAddress } }, + ); + continue; + } const mint = (stateCtx as SolanaTokenAccountStateContextSuccess).payload .mint; - if (!mint || streamedMints.has(mint)) continue; + if (!mint) { + this.logger.warn( + "[run] TOKEN_ACCOUNT_STATE for a resolved TOKEN_AMOUNT.TOKEN ATA carried no mint; instruction may be pinned", + { data: { tokenAccount: resolvedAddress } }, + ); + continue; + } + if (streamedMints.has(mint)) continue; const mintTokenInfoContexts = await this.args.contextModule.getContexts( { deviceModelId, mints: [mint], network: this.network }, @@ -335,7 +393,13 @@ export class ProvideGenericClearSignContextTask { const mintTokenInfoCtx = mintTokenInfoContexts.find( (c) => c.type === ClearSignContextType.SOLANA_TOKEN_INFO, ); - if (!mintTokenInfoCtx) continue; + if (!mintTokenInfoCtx) { + this.logger.warn( + "[run] TOKEN_INFO fetch failed for a resolved TOKEN_AMOUNT.TOKEN mint; instruction may be pinned", + { data: { mint } }, + ); + continue; + } streamedTokenAccounts.add(resolvedAddress); await this.provideDescriptor(stateCtx); @@ -361,15 +425,32 @@ export class ProvideGenericClearSignContextTask { }), ClearSignContextType.SOLANA_ALT_RESOLUTION, ); + if (altContexts.length === 0) { + // Unresolved ALT_RESOLUTION for an owner/mint-map target pins the + // instruction (G-051): neither the IS_SIGNER hide nor the mint + // display can be established for it. + this.logger.warn( + "[run] ALT_RESOLUTION fetch failed for a TOKEN_ACCOUNT_STATE ref; instruction may be pinned", + { data: { altAddress, entryIndex } }, + ); + continue; + } for (const altCtx of altContexts) { if ( altCtx.type !== ClearSignContextType.SOLANA_ALT_RESOLUTION || !isSolanaContextSuccess(altCtx) - ) + ) { continue; + } const resolvedAddress = (altCtx as SolanaAltResolutionContextSuccess) .payload.resolvedAddress; - if (!resolvedAddress) continue; + if (!resolvedAddress) { + this.logger.warn( + "[run] ALT_RESOLUTION for a TOKEN_ACCOUNT_STATE ref resolved to no address; instruction may be pinned", + { data: { altAddress, entryIndex } }, + ); + continue; + } if (streamedTokenAccounts.has(resolvedAddress)) continue; const stateCtx = await this.fetchChallengeBoundDescriptorOnly( @@ -493,11 +574,22 @@ export class ProvideGenericClearSignContextTask { { deviceModelId, mints: [mint], network: this.network }, [ClearSignContextType.SOLANA_TOKEN_INFO], ); + let found = false; for (const ctx of contexts) { if (ctx.type === ClearSignContextType.SOLANA_TOKEN_INFO) { + found = true; await this.provideDescriptor(ctx); } } + if (!found) { + // No TOKEN_INFO descriptor at all (as opposed to a streamed-but-failed + // one, which provideDescriptor already logs): the mint pins the + // instruction it belongs to (G-051) instead of merging or hiding. + this.logger.warn( + "[run] TOKEN_INFO fetch returned no descriptor; instruction may be pinned", + { data: { mint } }, + ); + } } /** 0x0A — derivation path + serialized TX, chunked. No length prefix. */ diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideInstructionInfoContext.test.ts b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideInstructionInfoContext.test.ts index c02612ceca..0853fd5fff 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideInstructionInfoContext.test.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/task/context-providers/provideInstructionInfoContext.test.ts @@ -2,6 +2,7 @@ import { ClearSignContextType } from "@ledgerhq/context-module"; import { CommandResultFactory, + hexaStringToBuffer, isSuccessCommandResult, LoadCertificateCommand, } from "@ledgerhq/device-management-kit"; @@ -84,6 +85,38 @@ describe("provideInstructionInfoContext", () => { expect(sub1.args.payload).toStrictEqual(new Uint8Array([0x01, 0xee])); }); + it("sends exactly one ProvideInstructionSubstructureCommand per substructure, never batched", async () => { + api.sendCommand.mockResolvedValue(success); + const result = makeResult(); + result.payload.substructures = [ + { kind: 0x00, data: "aa" }, + { kind: 0x01, data: "bb" }, + { kind: 0x02, data: "cc" }, + ]; + + await provideInstructionInfoContext(result as any, deps); + + const substructureCalls = api.sendCommand.mock.calls + .map((call) => call[0]) + .filter( + (command) => command instanceof ProvideInstructionSubstructureCommand, + ); + + // One exchange per substructure — the device's SUBSTRUCTURES_HASH commits + // to each substructure framed individually, so two must never be packed + // into a single PROVIDE INSTRUCTION SUBSTRUCTURE (0x25) exchange. + expect(substructureCalls).toHaveLength(result.payload.substructures.length); + substructureCalls.forEach((command, index) => { + const substructure = result.payload.substructures[index]!; + expect((command as any).args.payload).toStrictEqual( + Uint8Array.of( + substructure.kind, + ...hexaStringToBuffer(substructure.data)!, + ), + ); + }); + }); + it("returns success without sending any command when payload is absent", async () => { const result = makeResult(); (result as any).payload = undefined; From ef4285c8ac44e20ae579ba8fd6f7c868321658ff Mon Sep 17 00:00:00 2001 From: fAnselmi-Ledger Date: Thu, 17 Sep 2026 14:35:27 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20(signer-solana):=20Resolve?= =?UTF-8?q?=20CAL=20trusted=20names=20through=20ALT-supplied=20display=20f?= =?UTF-8?q?ields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RequirementAccumulator.test.ts | 19 + .../requirements/RequirementAccumulator.ts | 17 + .../__tests__/fixtures/calBuilders.ts | 2 +- .../requirements/buildRequirements.test.ts | 4 +- .../clear-sign/requirements/fromCal.test.ts | 14 +- .../clear-sign/requirements/fromCal.ts | 6 +- .../clear-sign/requirements/model.ts | 9 + .../rules/trustedNameRule.test.ts | 58 +++ .../requirements/rules/trustedNameRule.ts | 20 +- .../ProvisionGenericClearSignDeviceAction.ts | 1 + .../task/BuildGenericClearSignContextTask.ts | 4 + ...ProvideGenericClearSignContextTask.test.ts | 407 +++++++++++++++++- .../ProvideGenericClearSignContextTask.ts | 380 ++++++++-------- 13 files changed, 748 insertions(+), 193 deletions(-) diff --git a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/RequirementAccumulator.test.ts b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/RequirementAccumulator.test.ts index 4814b5d2e6..53bc7b6ab2 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/RequirementAccumulator.test.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/RequirementAccumulator.test.ts @@ -27,6 +27,24 @@ describe("RequirementAccumulator", () => { expect(result.trustedNames).toEqual(["name"]); }); + it("trustedNameAltRefs is deduplicated but never stripped by the ALT priority dedup", () => { + const accumulator = new RequirementAccumulator(); + accumulator.addTrustedNameAltRef("ALT", 5); + accumulator.addTrustedNameAltRef("ALT", 5); + // The same entry also requested through a higher-priority ALT bucket: + // trustedNameAltRefs is a marker set, so it must survive build()'s + // cross-bucket strip untouched. + accumulator.addTokenAccountStateAltRef("ALT", 5); + + const result = accumulator.build(); + expect(result.trustedNameAltRefs).toEqual([ + { altAddress: "ALT", entryIndex: 5 }, + ]); + expect(result.tokenAccountStateAltRefs).toEqual([ + { altAddress: "ALT", entryIndex: 5 }, + ]); + }); + it("preserves first-seen insertion order", () => { const accumulator = new RequirementAccumulator(); accumulator.addTokenInfo("c"); @@ -137,6 +155,7 @@ describe("RequirementAccumulator", () => { tokenAccountStates: [], altResolutions: [], trustedNames: [], + trustedNameAltRefs: [], tokenAmountRefs: [], tokenAmountAltRefs: [], tokenAccountStateAltRefs: [], diff --git a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/RequirementAccumulator.ts b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/RequirementAccumulator.ts index ec93164f35..6f502dfd34 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/RequirementAccumulator.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/RequirementAccumulator.ts @@ -20,6 +20,7 @@ export class RequirementAccumulator { private readonly tokenAmountAltRefs = new OrderedSet(); private readonly mintAltRefs = new OrderedSet(); private readonly tokenAccountStateAltRefs = new OrderedSet(); + private readonly trustedNameAltRefs = new OrderedSet(); addInstructionInfo(programId: string, discriminator: string): void { this.instructionInfos.add(`${programId}:${discriminator}`, { @@ -59,6 +60,21 @@ export class RequirementAccumulator { this.trustedNames.add(address, address); } + /** + * Marks `(altAddress, entryIndex)` as a trusted-name target for an + * ALT-supplied slot. This is not a fifth ALT_RESOLUTION requester: the slot + * is already covered by `altResolutionRule`'s DISPLAY_FIELD pass (or by a + * higher-priority ALT bucket), so this set is consulted, not stripped, in + * the provide phase — once any of the other loops resolves the entry, its + * address gets a TRUSTED_NAME fetch too. + */ + addTrustedNameAltRef(altAddress: string, entryIndex: number): void { + this.trustedNameAltRefs.add(`${altAddress}:${entryIndex}`, { + altAddress, + entryIndex, + }); + } + addTokenAmountRef(address: string): void { this.tokenAmountRefs.add(address, address); } @@ -126,6 +142,7 @@ export class RequirementAccumulator { !tokenAmountKeys.has(altKey(k)), ), trustedNames: this.trustedNames.values(), + trustedNameAltRefs: this.trustedNameAltRefs.values(), tokenAmountRefs: this.tokenAmountRefs .values() .filter((address) => !tokenAccountKeys.has(address)), diff --git a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/__tests__/fixtures/calBuilders.ts b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/__tests__/fixtures/calBuilders.ts index fe2da142b1..2368473638 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/__tests__/fixtures/calBuilders.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/__tests__/fixtures/calBuilders.ts @@ -103,7 +103,7 @@ export function accountReset(opts: { return { account_index: opts.accountIndex, require_pre_balance_zero: opts.requirePreBalanceZero, - value_kind: opts.valueKind ?? "native", + value_kind: opts.valueKind ?? "NATIVE", token: opts.token, require_native_pre_balance_zero: opts.requireNativePreBalanceZero, }; diff --git a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/buildRequirements.test.ts b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/buildRequirements.test.ts index 9e12c4c609..884d76b23f 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/buildRequirements.test.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/buildRequirements.test.ts @@ -236,12 +236,12 @@ describe("buildRequirements", () => { accountResets: [ accountReset({ accountIndex: 0, - valueKind: "native", + valueKind: "NATIVE", requirePreBalanceZero: true, }), accountReset({ accountIndex: 0, - valueKind: "splToken", + valueKind: "SPL_TOKEN", requirePreBalanceZero: true, token: { kind: "DIRECT" }, }), diff --git a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/fromCal.test.ts b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/fromCal.test.ts index d7506f37c7..84bcd17b39 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/fromCal.test.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/fromCal.test.ts @@ -183,10 +183,10 @@ describe("fromCalValueFlowPort optional account strategy", () => { }); describe("fromCalAccountReset", () => { - it('maps value_kind "native" with no token', () => { + it('maps value_kind "NATIVE" with no token', () => { const out = fromCalAccountReset({ account_index: 2, - value_kind: "native", + value_kind: "NATIVE", }); expect(out.accountIndex).toBe(2); expect(out.valueKind).toBe(ValueKind.NATIVE); @@ -195,10 +195,10 @@ describe("fromCalAccountReset", () => { expect(out.requirePreBalanceZero).toBe(false); }); - it('maps value_kind "splToken" with a token reference', () => { + it('maps value_kind "SPL_TOKEN" with a token reference', () => { const out = fromCalAccountReset({ account_index: 1, - value_kind: "splToken", + value_kind: "SPL_TOKEN", token: { kind: "RESOLVE", account_index: 3 }, require_pre_balance_zero: true, }); @@ -211,7 +211,7 @@ describe("fromCalAccountReset", () => { it("maps requireNativePreBalanceZero", () => { const out = fromCalAccountReset({ account_index: 0, - value_kind: "native", + value_kind: "NATIVE", require_native_pre_balance_zero: true, }); expect(out.requireNativePreBalanceZero).toBe(true); @@ -229,9 +229,9 @@ describe("fromCalAccountReset", () => { ).toThrow(/unknown or missing ACCOUNT_RESET value_kind/); }); - it("rejects splToken without a token field as a decode error", () => { + it("rejects SPL_TOKEN without a token field as a decode error", () => { expect(() => - fromCalAccountReset({ account_index: 0, value_kind: "splToken" }), + fromCalAccountReset({ account_index: 0, value_kind: "SPL_TOKEN" }), ).toThrow(/missing the required TOKEN field/); }); }); diff --git a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/fromCal.ts b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/fromCal.ts index 634c8e84ef..687da92a0f 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/fromCal.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/fromCal.ts @@ -212,8 +212,8 @@ export function fromCalOwnerAssociation( } const VALUE_KIND_BY_NAME: Readonly> = { - splToken: ValueKind.SPL_TOKEN, - native: ValueKind.NATIVE, + SPL_TOKEN: ValueKind.SPL_TOKEN, + NATIVE: ValueKind.NATIVE, }; export function fromCalAccountReset( @@ -233,7 +233,7 @@ export function fromCalAccountReset( } if (valueKind === ValueKind.SPL_TOKEN && !reset.token) { decodeError( - "ACCOUNT_RESET with value_kind 'splToken' is missing the required TOKEN field", + "ACCOUNT_RESET with value_kind 'SPL_TOKEN' is missing the required TOKEN field", ); } return { diff --git a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/model.ts b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/model.ts index d411ebf9ca..886161ff57 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/model.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/model.ts @@ -93,6 +93,15 @@ export type DescriptorRequirements = { tokenAccountStates: string[]; altResolutions: AltEntryKey[]; trustedNames: string[]; + /** + * ALT-supplied slots targeted by a `PARAM_TRUSTED_NAME` / `PARAM_ACCOUNT` + * display field. A marker set, not an ALT_RESOLUTION requester: the slot's + * resolution is already requested by whichever of `altResolutions` / + * `tokenAmountAltRefs` / `tokenAccountStateAltRefs` / `mintAltRefs` covers + * it. Once the provide phase resolves the entry, it fetches a TRUSTED_NAME + * for the resulting address too. + */ + trustedNameAltRefs: AltEntryKey[]; /** * PARAM_TOKEN_AMOUNT.TOKEN refs (ACCOUNT_PATH, non-ALT, not in mintBindings). * Try TOKEN_INFO first at fetch time; fall back to TOKEN_ACCOUNT_STATE if it fails. diff --git a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/rules/trustedNameRule.test.ts b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/rules/trustedNameRule.test.ts index fad88a75d0..54692a0631 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/rules/trustedNameRule.test.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/rules/trustedNameRule.test.ts @@ -140,4 +140,62 @@ describe("applyTrustedNameRule", () => { ); expect(unresolved).toEqual([]); }); + + it("falls back to trustedNameAltRefs for an ALT-supplied slot", () => { + const parsed: ParsedInstruction = { + info: { + typePool: [], + rootType: 0, + mintAssociations: [], + ownerAssociations: [], + }, + valueFlowPorts: [], + accountResets: [], + displayFields: [ + { + paramType: PARAM_TYPE_TRUSTED_NAME, + value: { + source: ValueSource.ACCOUNT_PATH, + payload: Uint8Array.of(0), + }, + }, + ], + hideRules: [], + }; + const instruction: RequirementInstruction = { + programId: "P", + accounts: [ + { + address: undefined, + altRef: { altAddress: "ALT", entryIndex: 2 }, + isWritable: false, + isSigner: false, + }, + ], + data: new Uint8Array(), + }; + const accumulator = new RequirementAccumulator(); + applyTrustedNameRule(parsed, instruction, accumulator); + const result = accumulator.build(); + expect(result.trustedNames).toEqual([]); + expect(result.trustedNameAltRefs).toEqual([ + { altAddress: "ALT", entryIndex: 2 }, + ]); + }); + + it("does not record an ALT ref for a CONSTANT or ARGUMENT_PATH value", () => { + const result = run( + [ + { + paramType: PARAM_TYPE_TRUSTED_NAME, + value: { + source: ValueSource.ARGUMENT_PATH, + payload: new Uint8Array(), + }, + }, + ], + [], + ); + expect(result).toEqual([]); + }); }); diff --git a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/rules/trustedNameRule.ts b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/rules/trustedNameRule.ts index 00a1d0a851..21be01fa88 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/rules/trustedNameRule.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/clear-sign/requirements/rules/trustedNameRule.ts @@ -5,7 +5,10 @@ import { type ParsedInstruction, } from "@internal/app-binder/clear-sign/requirements/records"; import { type RequirementAccumulator } from "@internal/app-binder/clear-sign/requirements/RequirementAccumulator"; -import { resolvePubkeyValue } from "@internal/app-binder/clear-sign/requirements/valueResolution"; +import { + altRefForPubkeyValue, + resolvePubkeyValue, +} from "@internal/app-binder/clear-sign/requirements/valueResolution"; import { type Bs58Encoder, DefaultBs58Encoder, @@ -16,6 +19,12 @@ import { * that may have a CAL name. For `PARAM_ACCOUNT` this is best-effort: the device * shows the name if a descriptor is found and falls back to the base58 address * otherwise. + * + * A field targeting an ALT-supplied slot has no address yet at build time — + * `resolvePubkeyValue` misses — so it is recorded as a `trustedNameAltRef` + * instead: `altResolutionRule` already requests this slot's `ALT_RESOLUTION`, + * and the provide phase fetches the TRUSTED_NAME once that resolution comes + * back. */ export function applyTrustedNameRule( parsed: ParsedInstruction, @@ -32,6 +41,13 @@ export function applyTrustedNameRule( continue; } const target = resolvePubkeyValue(field.value, instruction, bs58Encoder); - if (target !== undefined) accumulator.addTrustedName(target); + if (target !== undefined) { + accumulator.addTrustedName(target); + continue; + } + const altRef = altRefForPubkeyValue(field.value, instruction); + if (altRef !== undefined) { + accumulator.addTrustedNameAltRef(altRef.altAddress, altRef.entryIndex); + } } } diff --git a/packages/signer/signer-solana/src/internal/app-binder/device-action/ProvisionGenericClearSignDeviceAction.ts b/packages/signer/signer-solana/src/internal/app-binder/device-action/ProvisionGenericClearSignDeviceAction.ts index 6e96227037..ed12394028 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/device-action/ProvisionGenericClearSignDeviceAction.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/device-action/ProvisionGenericClearSignDeviceAction.ts @@ -211,6 +211,7 @@ export class ProvisionGenericClearSignDeviceAction extends XStateDeviceAction< tokenAmountAltRefs: [], tokenAccountStateAltRefs: [], mintAltRefs: [], + trustedNameAltRefs: [], }, }), onDone: [ diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/BuildGenericClearSignContextTask.ts b/packages/signer/signer-solana/src/internal/app-binder/task/BuildGenericClearSignContextTask.ts index 2f28ae98a4..31c80a1d1b 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/task/BuildGenericClearSignContextTask.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/task/BuildGenericClearSignContextTask.ts @@ -42,6 +42,8 @@ export type ChallengeBoundRequirements = Pick< tokenAccountStateAltRefs: AltEntryKey[]; /** ALT-backed MINT entries from MINT_ASSOCIATIONS; require TOKEN_INFO via hold-and-conditionally-stream. */ mintAltRefs: AltEntryKey[]; + /** ALT-supplied slots targeted by a trusted-name display field; see {@link DescriptorRequirements.trustedNameAltRefs}. */ + trustedNameAltRefs: AltEntryKey[]; }; /** @@ -110,6 +112,7 @@ export class BuildGenericClearSignContextTask { tokenAmountAltRefs: [], tokenAccountStateAltRefs: [], mintAltRefs: [], + trustedNameAltRefs: [], }, unrecognizedProgramIds: [], staleDescriptor: false, @@ -316,6 +319,7 @@ export class BuildGenericClearSignContextTask { tokenAmountAltRefs: requirements.tokenAmountAltRefs, tokenAccountStateAltRefs: requirements.tokenAccountStateAltRefs, mintAltRefs: requirements.mintAltRefs, + trustedNameAltRefs: requirements.trustedNameAltRefs, }; this.logger.debug("[run] built clear-sign context", { diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/ProvideGenericClearSignContextTask.test.ts b/packages/signer/signer-solana/src/internal/app-binder/task/ProvideGenericClearSignContextTask.test.ts index 7c9d0ed229..be6410cabd 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/task/ProvideGenericClearSignContextTask.test.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/task/ProvideGenericClearSignContextTask.test.ts @@ -30,6 +30,7 @@ const NO_CHALLENGE_BOUND: ChallengeBoundRequirements = { tokenAmountAltRefs: [], tokenAccountStateAltRefs: [], mintAltRefs: [], + trustedNameAltRefs: [], }; function tokenInfoContext(): ClearSignContext { @@ -60,6 +61,12 @@ function makeTask( challengeBoundRequirements: ChallengeBoundRequirements = NO_CHALLENGE_BOUND, getContexts: Mock = vi.fn(async () => []), ) { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; const api = { sendCommand: vi.fn(async (cmd: unknown) => cmd instanceof GetChallengeCommand ? challenge : success, @@ -74,11 +81,10 @@ function makeTask( instructionInfoContexts, challengeBoundRequirements, contextModule, - loggerFactory: () => - ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }) as any, + loggerFactory: () => logger as any, normaliser: {} as any, }); - return { task, api, getContexts }; + return { task, api, getContexts, logger }; } describe("ProvideGenericClearSignContextTask", () => { @@ -137,6 +143,7 @@ describe("ProvideGenericClearSignContextTask", () => { tokenAmountAltRefs: [], tokenAccountStateAltRefs: [], mintAltRefs: [], + trustedNameAltRefs: [], }, // Empty fetch: assert the challenge + fetch protocol, not handler internals. vi.fn(async () => []), @@ -203,6 +210,7 @@ describe("ProvideGenericClearSignContextTask", () => { tokenAmountAltRefs: [], tokenAccountStateAltRefs: [], mintAltRefs: [], + trustedNameAltRefs: [], }, vi.fn(async () => []), ); @@ -524,6 +532,65 @@ describe("ProvideGenericClearSignContextTask", () => { ); }); + it("tokenAmountAltRefs: resolved address is directly a mint, streams TOKEN_INFO without an ATA fallback", async () => { + const getContexts = vi.fn( + async (_input: any, types: ClearSignContextType[]) => { + if (types[0] === ClearSignContextType.SOLANA_ALT_RESOLUTION) + return [altResolutionCtx("MINT_DIRECT")]; + if (types[0] === ClearSignContextType.SOLANA_TOKEN_INFO) + return [tokenInfoCtxFor("MINT_DIRECT")]; + return []; + }, + ); + const { task } = makeTask( + [], + [], + { + ...NO_CHALLENGE_BOUND, + tokenAmountAltRefs: [{ altAddress: "ALT2", entryIndex: 1 }], + }, + getContexts, + ); + + await task.run(); + + expect(getContexts).toHaveBeenCalledWith( + expect.objectContaining({ mints: ["MINT_DIRECT"] }), + [ClearSignContextType.SOLANA_TOKEN_INFO], + ); + // No ATA fallback: resolved directly as a mint. + const stateFetches = getContexts.mock.calls.filter( + (c) => c[1]?.[0] === ClearSignContextType.SOLANA_TOKEN_ACCOUNT_STATE, + ); + expect(stateFetches).toHaveLength(0); + }); + + it("tokenAmountAltRefs: skips a resolved address whose mint was already streamed", async () => { + const getContexts = vi.fn( + async (_input: any, types: ClearSignContextType[]) => + types[0] === ClearSignContextType.SOLANA_ALT_RESOLUTION + ? [altResolutionCtx("MINT_SEEN")] + : [], + ); + const { task } = makeTask( + [tokenInfoCtxFor("MINT_SEEN")], + [], + { + ...NO_CHALLENGE_BOUND, + tokenAmountAltRefs: [{ altAddress: "ALT2", entryIndex: 1 }], + }, + getContexts, + ); + + await task.run(); + + // Already streamed from the pool: no TOKEN_INFO/TOKEN_ACCOUNT_STATE probe. + const tokenInfoCalls = getContexts.mock.calls.filter( + (c) => c[1]?.[0] === ClearSignContextType.SOLANA_TOKEN_INFO, + ); + expect(tokenInfoCalls).toHaveLength(0); + }); + it("tokenAccountStateAltRefs: streams ALT_RESOLUTION, then TOKEN_ACCOUNT_STATE, then TOKEN_INFO for the attested mint", async () => { // The IS_SIGNER / RESOLVE case: the state payload is the point (it seeds the // device's owner and mint maps), so no TOKEN_INFO is probed on the resolved @@ -647,6 +714,340 @@ describe("ProvideGenericClearSignContextTask", () => { expect(mintFetches).toHaveLength(1); }); + it("trustedNameAltRefs: fetches TRUSTED_NAME for the address the plain altResolutions loop resolves", async () => { + const getContexts = vi.fn( + async (_input: any, types: ClearSignContextType[]) => { + if (types[0] === ClearSignContextType.SOLANA_ALT_RESOLUTION) + return [altResolutionCtx("RESOLVED_ADDR")]; + return []; + }, + ); + const { task } = makeTask( + [], + [], + { + ...NO_CHALLENGE_BOUND, + altResolutions: [{ altAddress: "ALT9", entryIndex: 7 }], + trustedNameAltRefs: [{ altAddress: "ALT9", entryIndex: 7 }], + }, + getContexts, + ); + + await task.run(); + + expect(getContexts).toHaveBeenCalledWith( + expect.objectContaining({ + requests: [ + expect.objectContaining({ + address: "RESOLVED_ADDR", + types: ["token", "smart_contract"], + sources: ["crypto_asset_list"], + }), + ], + }), + [ClearSignContextType.SOLANA_TRUSTED_NAME], + ); + }); + + it("trustedNameAltRefs: dedupes against a statically-named trustedNames address", async () => { + const getContexts = vi.fn( + async (_input: any, types: ClearSignContextType[]) => { + if (types[0] === ClearSignContextType.SOLANA_ALT_RESOLUTION) + return [altResolutionCtx("SAME")]; + return []; + }, + ); + const { task } = makeTask( + [], + [], + { + ...NO_CHALLENGE_BOUND, + trustedNames: ["SAME"], + altResolutions: [{ altAddress: "ALT10", entryIndex: 0 }], + trustedNameAltRefs: [{ altAddress: "ALT10", entryIndex: 0 }], + }, + getContexts, + ); + + await task.run(); + + const trustedNameCalls = getContexts.mock.calls.filter( + (c) => c[1]?.[0] === ClearSignContextType.SOLANA_TRUSTED_NAME, + ); + expect(trustedNameCalls).toHaveLength(1); + }); + + it("trustedNameAltRefs: an ALT ref that is not a trusted-name target fetches no TRUSTED_NAME", async () => { + const getContexts = vi.fn( + async (_input: any, types: ClearSignContextType[]) => { + if (types[0] === ClearSignContextType.SOLANA_ALT_RESOLUTION) + return [altResolutionCtx("UNRELATED")]; + return []; + }, + ); + const { task } = makeTask( + [], + [], + { + ...NO_CHALLENGE_BOUND, + altResolutions: [{ altAddress: "ALT11", entryIndex: 0 }], + }, + getContexts, + ); + + await task.run(); + + const trustedNameCalls = getContexts.mock.calls.filter( + (c) => c[1]?.[0] === ClearSignContextType.SOLANA_TRUSTED_NAME, + ); + expect(trustedNameCalls).toHaveLength(0); + }); + + // --- G-051 pinning warnings on unresolved descriptors --- + + it("tokenAccountStates: warns when TOKEN_ACCOUNT_STATE fetch returns no descriptor at all", async () => { + const getContexts = vi.fn(async () => []); + const { task, logger } = makeTask( + [], + [], + { ...NO_CHALLENGE_BOUND, tokenAccountStates: ["ATA1"] }, + getContexts, + ); + + await task.run(); + + expect(logger.warn).toHaveBeenCalledWith( + "[run] TOKEN_ACCOUNT_STATE fetch returned no descriptor; instruction may be pinned", + { data: { tokenAccount: "ATA1" } }, + ); + }); + + it("mintAltRefs: warns and skips when ALT_RESOLUTION fetch returns no descriptor", async () => { + const getContexts = vi.fn( + async (_input: any, _types: ClearSignContextType[]) => [], + ); + const { task, logger } = makeTask( + [], + [], + { + ...NO_CHALLENGE_BOUND, + mintAltRefs: [{ altAddress: "ALT1", entryIndex: 0 }], + }, + getContexts, + ); + + await task.run(); + + expect(logger.warn).toHaveBeenCalledWith( + "[run] ALT_RESOLUTION fetch failed for a MINT_ASSOC ref; instruction may be pinned", + { data: { altAddress: "ALT1", entryIndex: 0 } }, + ); + const tokenInfoCalls = getContexts.mock.calls.filter( + (c) => c[1]?.[0] === ClearSignContextType.SOLANA_TOKEN_INFO, + ); + expect(tokenInfoCalls).toHaveLength(0); + }); + + it("mintAltRefs: warns and skips when ALT_RESOLUTION resolves to no address", async () => { + const getContexts = vi.fn( + async (_input: any, types: ClearSignContextType[]) => { + if (types[0] === ClearSignContextType.SOLANA_ALT_RESOLUTION) + return [altResolutionCtx(undefined as unknown as string)]; + return []; + }, + ); + const { task, logger } = makeTask( + [], + [], + { + ...NO_CHALLENGE_BOUND, + mintAltRefs: [{ altAddress: "ALT1", entryIndex: 0 }], + }, + getContexts, + ); + + await task.run(); + + expect(logger.warn).toHaveBeenCalledWith( + "[run] ALT_RESOLUTION for a MINT_ASSOC ref resolved to no address; instruction may be pinned", + { data: { altAddress: "ALT1", entryIndex: 0 } }, + ); + }); + + it("tokenAmountAltRefs: warns and skips when ALT_RESOLUTION fetch returns no descriptor", async () => { + const getContexts = vi.fn(async () => []); + const { task, logger } = makeTask( + [], + [], + { + ...NO_CHALLENGE_BOUND, + tokenAmountAltRefs: [{ altAddress: "ALT2", entryIndex: 1 }], + }, + getContexts, + ); + + await task.run(); + + expect(logger.warn).toHaveBeenCalledWith( + "[run] ALT_RESOLUTION fetch failed for a TOKEN_AMOUNT.TOKEN ref; instruction may be pinned", + { data: { altAddress: "ALT2", entryIndex: 1 } }, + ); + }); + + it("tokenAmountAltRefs: warns when the resolved ATA's TOKEN_ACCOUNT_STATE fetch fails", async () => { + const getContexts = vi.fn( + async (_input: any, types: ClearSignContextType[]) => { + if (types[0] === ClearSignContextType.SOLANA_ALT_RESOLUTION) + return [altResolutionCtx("ATA2")]; + // TOKEN_INFO misses (not a mint) and TOKEN_ACCOUNT_STATE misses too. + return []; + }, + ); + const { task, logger } = makeTask( + [], + [], + { + ...NO_CHALLENGE_BOUND, + tokenAmountAltRefs: [{ altAddress: "ALT2", entryIndex: 1 }], + }, + getContexts, + ); + + await task.run(); + + expect(logger.warn).toHaveBeenCalledWith( + "[run] TOKEN_ACCOUNT_STATE fetch failed for a resolved TOKEN_AMOUNT.TOKEN ATA; instruction may be pinned", + { data: { tokenAccount: "ATA2" } }, + ); + }); + + it("tokenAmountAltRefs: warns when the attested state carries no mint", async () => { + const getContexts = vi.fn( + async (_input: any, types: ClearSignContextType[]) => { + if (types[0] === ClearSignContextType.SOLANA_ALT_RESOLUTION) + return [altResolutionCtx("ATA2")]; + if (types[0] === ClearSignContextType.SOLANA_TOKEN_ACCOUNT_STATE) + return [tokenAccountStateCtx(undefined as unknown as string)]; + return []; + }, + ); + const { task, logger } = makeTask( + [], + [], + { + ...NO_CHALLENGE_BOUND, + tokenAmountAltRefs: [{ altAddress: "ALT2", entryIndex: 1 }], + }, + getContexts, + ); + + await task.run(); + + expect(logger.warn).toHaveBeenCalledWith( + "[run] TOKEN_ACCOUNT_STATE for a resolved TOKEN_AMOUNT.TOKEN ATA carried no mint; instruction may be pinned", + { data: { tokenAccount: "ATA2" } }, + ); + }); + + it("tokenAmountAltRefs: warns when TOKEN_INFO fetch fails for the attested mint", async () => { + const getContexts = vi.fn( + async (_input: any, types: ClearSignContextType[]) => { + if (types[0] === ClearSignContextType.SOLANA_ALT_RESOLUTION) + return [altResolutionCtx("ATA2")]; + if (types[0] === ClearSignContextType.SOLANA_TOKEN_ACCOUNT_STATE) + return [tokenAccountStateCtx("MINT3")]; + // TOKEN_INFO: optimistic probe on "ATA2" misses; the fallback probe + // on the attested mint "MINT3" misses too. + return []; + }, + ); + const { task, logger } = makeTask( + [], + [], + { + ...NO_CHALLENGE_BOUND, + tokenAmountAltRefs: [{ altAddress: "ALT2", entryIndex: 1 }], + }, + getContexts, + ); + + await task.run(); + + expect(logger.warn).toHaveBeenCalledWith( + "[run] TOKEN_INFO fetch failed for a resolved TOKEN_AMOUNT.TOKEN mint; instruction may be pinned", + { data: { mint: "MINT3" } }, + ); + }); + + it("tokenAccountStateAltRefs: warns and skips when ALT_RESOLUTION fetch returns no descriptor", async () => { + const getContexts = vi.fn(async () => []); + const { task, logger } = makeTask( + [], + [], + { + ...NO_CHALLENGE_BOUND, + tokenAccountStateAltRefs: [{ altAddress: "ALT5", entryIndex: 4 }], + }, + getContexts, + ); + + await task.run(); + + expect(logger.warn).toHaveBeenCalledWith( + "[run] ALT_RESOLUTION fetch failed for a TOKEN_ACCOUNT_STATE ref; instruction may be pinned", + { data: { altAddress: "ALT5", entryIndex: 4 } }, + ); + }); + + it("tokenAccountStateAltRefs: warns and skips when ALT_RESOLUTION resolves to no address", async () => { + const getContexts = vi.fn( + async (_input: any, types: ClearSignContextType[]) => { + if (types[0] === ClearSignContextType.SOLANA_ALT_RESOLUTION) + return [altResolutionCtx(undefined as unknown as string)]; + return []; + }, + ); + const { task, logger } = makeTask( + [], + [], + { + ...NO_CHALLENGE_BOUND, + tokenAccountStateAltRefs: [{ altAddress: "ALT5", entryIndex: 4 }], + }, + getContexts, + ); + + await task.run(); + + expect(logger.warn).toHaveBeenCalledWith( + "[run] ALT_RESOLUTION for a TOKEN_ACCOUNT_STATE ref resolved to no address; instruction may be pinned", + { data: { altAddress: "ALT5", entryIndex: 4 } }, + ); + }); + + it("fetchAndStreamTokenInfo: warns when TOKEN_INFO fetch returns no descriptor for a directly-streamed mint", async () => { + const getContexts = vi.fn( + async (_input: any, types: ClearSignContextType[]) => { + if (types[0] === ClearSignContextType.SOLANA_TOKEN_ACCOUNT_STATE) + return [tokenAccountStateCtx("MINT1")]; + return []; + }, + ); + const { task, logger } = makeTask( + [], + [], + { ...NO_CHALLENGE_BOUND, tokenAccountStates: ["ATA1"] }, + getContexts, + ); + + await task.run(); + + expect(logger.warn).toHaveBeenCalledWith( + "[run] TOKEN_INFO fetch returned no descriptor; instruction may be pinned", + { data: { mint: "MINT1" } }, + ); + }); + it("returns a failed CommandResult when the device rejects GENERIC PREVIEW", async () => { const made = makeTask([], []); made.api.sendCommand.mockResolvedValue( diff --git a/packages/signer/signer-solana/src/internal/app-binder/task/ProvideGenericClearSignContextTask.ts b/packages/signer/signer-solana/src/internal/app-binder/task/ProvideGenericClearSignContextTask.ts index f7dcf91cc5..9740185785 100644 --- a/packages/signer/signer-solana/src/internal/app-binder/task/ProvideGenericClearSignContextTask.ts +++ b/packages/signer/signer-solana/src/internal/app-binder/task/ProvideGenericClearSignContextTask.ts @@ -189,8 +189,30 @@ export class ProvideGenericClearSignContextTask { tokenAmountAltRefs, tokenAccountStateAltRefs, mintAltRefs, + trustedNameAltRefs, } = this.args.challengeBoundRequirements; + // Marker set for `trustedNameAltRefs`: these entries request no + // ALT_RESOLUTION of their own (one of the four loops below already covers + // the slot). Once any loop resolves a matching entry, its address also + // gets a TRUSTED_NAME fetch, alongside the static `trustedNames` below. + const trustedNameAltKeys = new Set( + trustedNameAltRefs.map( + ({ altAddress, entryIndex }) => `${altAddress}:${entryIndex}`, + ), + ); + const altTrustedNameAddresses = new Set(); + const collectAltTrustedName = ( + altAddress: string, + entryIndex: number, + resolvedAddress: string | undefined, + ): void => { + if (!resolvedAddress) return; + if (trustedNameAltKeys.has(`${altAddress}:${entryIndex}`)) { + altTrustedNameAddresses.add(resolvedAddress); + } + }; + // Track mints already streamed (from pre-fetched pool contexts) to avoid duplicates. const streamedMints = new Set(); for (const ctx of this.args.poolContexts) { @@ -247,20 +269,6 @@ export class ProvideGenericClearSignContextTask { } for (const { altAddress, entryIndex } of altResolutions) { - await this.provideChallengeBoundDescriptor( - (challenge) => ({ - deviceModelId, - requests: [{ altAddress, entryIndex, challenge }], - }), - ClearSignContextType.SOLANA_ALT_RESOLUTION, - ); - } - - // ALT-backed MINT entries from MINT_ASSOCIATIONS. The device needs the - // resolved mint pubkey at finalize (to build the MINT_ASSOC binding map), - // so ALT_RESOLUTION is always streamed. TOKEN_INFO is attempted afterwards - // for display purposes only, failure is silent (shows ??? but finalize passes). - for (const { altAddress, entryIndex } of mintAltRefs) { const altContexts = await this.provideChallengeBoundDescriptorAndReturn( (challenge) => ({ deviceModelId, @@ -268,16 +276,7 @@ export class ProvideGenericClearSignContextTask { }), ClearSignContextType.SOLANA_ALT_RESOLUTION, ); - if (altContexts.length === 0) { - // Unresolved ALT_RESOLUTION for a MINT_ASSOC entry pins the - // instruction (G-051): the mint stays undisplayed and the merge - // cannot compact it away. - this.logger.warn( - "[run] ALT_RESOLUTION fetch failed for a MINT_ASSOC ref; instruction may be pinned", - { data: { altAddress, entryIndex } }, - ); - continue; - } + if (!trustedNameAltKeys.has(`${altAddress}:${entryIndex}`)) continue; for (const altCtx of altContexts) { if ( altCtx.type !== ClearSignContextType.SOLANA_ALT_RESOLUTION || @@ -285,22 +284,36 @@ export class ProvideGenericClearSignContextTask { ) { continue; } - const resolvedAddress = (altCtx as SolanaAltResolutionContextSuccess) - .payload.resolvedAddress; - if (!resolvedAddress) { - this.logger.warn( - "[run] ALT_RESOLUTION for a MINT_ASSOC ref resolved to no address; instruction may be pinned", - { data: { altAddress, entryIndex } }, - ); - continue; - } - if (!streamedMints.has(resolvedAddress)) { - streamedMints.add(resolvedAddress); - await this.fetchAndStreamTokenInfo(resolvedAddress, deviceModelId); - } + collectAltTrustedName( + altAddress, + entryIndex, + (altCtx as SolanaAltResolutionContextSuccess).payload.resolvedAddress, + ); } } + // ALT-backed MINT entries from MINT_ASSOCIATIONS. The device needs the + // resolved mint pubkey at finalize (to build the MINT_ASSOC binding map), + // so ALT_RESOLUTION is always streamed. TOKEN_INFO is attempted afterwards + // for display purposes only, failure is silent (shows ??? but finalize passes). + for (const { altAddress, entryIndex } of mintAltRefs) { + const resolvedAddress = await this.resolveAltRefAddress( + altAddress, + entryIndex, + deviceModelId, + collectAltTrustedName, + { + empty: + "[run] ALT_RESOLUTION fetch failed for a MINT_ASSOC ref; instruction may be pinned", + unresolved: + "[run] ALT_RESOLUTION for a MINT_ASSOC ref resolved to no address; instruction may be pinned", + }, + ); + if (!resolvedAddress || streamedMints.has(resolvedAddress)) continue; + streamedMints.add(resolvedAddress); + await this.fetchAndStreamTokenInfo(resolvedAddress, deviceModelId); + } + // ALT-backed PARAM_TOKEN_AMOUNT.TOKEN refs. The device needs the resolved // address at finalize (TOKEN_AMOUNT ACCOUNT_INDEX lookup via // pubkey_from_account_index), so ALT_RESOLUTION is always streamed. @@ -308,104 +321,82 @@ export class ProvideGenericClearSignContextTask { // then TOKEN_ACCOUNT_STATE + TOKEN_INFO (fallback: address is an ATA). // If both fail, finalize still passes, the device will just show ???. for (const { altAddress, entryIndex } of tokenAmountAltRefs) { - const altContexts = await this.provideChallengeBoundDescriptorAndReturn( + const resolvedAddress = await this.resolveAltRefAddress( + altAddress, + entryIndex, + deviceModelId, + collectAltTrustedName, + { + empty: + "[run] ALT_RESOLUTION fetch failed for a TOKEN_AMOUNT.TOKEN ref; instruction may be pinned", + unresolved: + "[run] ALT_RESOLUTION for a TOKEN_AMOUNT.TOKEN ref resolved to no address; instruction may be pinned", + }, + ); + if (!resolvedAddress || streamedMints.has(resolvedAddress)) continue; + + // Optimistic: resolved address is a mint. + const tokenInfoContexts = await this.args.contextModule.getContexts( + { deviceModelId, mints: [resolvedAddress], network: this.network }, + [ClearSignContextType.SOLANA_TOKEN_INFO], + ); + const tokenInfoCtx = tokenInfoContexts.find( + (c) => c.type === ClearSignContextType.SOLANA_TOKEN_INFO, + ); + if (tokenInfoCtx) { + streamedMints.add(resolvedAddress); + await this.provideDescriptor(tokenInfoCtx); + continue; + } + + // Fallback: resolved address may be an ATA, fetch TOKEN_ACCOUNT_STATE + // to get the mint, then stream both if TOKEN_INFO is available. + if (streamedTokenAccounts.has(resolvedAddress)) continue; + const stateCtx = await this.fetchChallengeBoundDescriptorOnly( (challenge) => ({ deviceModelId, - requests: [{ altAddress, entryIndex, challenge }], + requests: [{ tokenAccount: resolvedAddress, challenge }], }), - ClearSignContextType.SOLANA_ALT_RESOLUTION, + ClearSignContextType.SOLANA_TOKEN_ACCOUNT_STATE, ); - if (altContexts.length === 0) { - // Unresolved ALT_RESOLUTION for a TOKEN_AMOUNT.TOKEN ref pins the - // instruction (G-051): the amount's token cannot be displayed and - // the merge cannot compact it away. + if (!stateCtx || !isSolanaContextSuccess(stateCtx)) { this.logger.warn( - "[run] ALT_RESOLUTION fetch failed for a TOKEN_AMOUNT.TOKEN ref; instruction may be pinned", - { data: { altAddress, entryIndex } }, + "[run] TOKEN_ACCOUNT_STATE fetch failed for a resolved TOKEN_AMOUNT.TOKEN ATA; instruction may be pinned", + { data: { tokenAccount: resolvedAddress } }, ); continue; } - for (const altCtx of altContexts) { - if ( - altCtx.type !== ClearSignContextType.SOLANA_ALT_RESOLUTION || - !isSolanaContextSuccess(altCtx) - ) { - continue; - } - const resolvedAddress = (altCtx as SolanaAltResolutionContextSuccess) - .payload.resolvedAddress; - if (!resolvedAddress) { - this.logger.warn( - "[run] ALT_RESOLUTION for a TOKEN_AMOUNT.TOKEN ref resolved to no address; instruction may be pinned", - { data: { altAddress, entryIndex } }, - ); - continue; - } - if (streamedMints.has(resolvedAddress)) continue; - - // Optimistic: resolved address is a mint. - const tokenInfoContexts = await this.args.contextModule.getContexts( - { deviceModelId, mints: [resolvedAddress], network: this.network }, - [ClearSignContextType.SOLANA_TOKEN_INFO], - ); - const tokenInfoCtx = tokenInfoContexts.find( - (c) => c.type === ClearSignContextType.SOLANA_TOKEN_INFO, - ); - if (tokenInfoCtx) { - streamedMints.add(resolvedAddress); - await this.provideDescriptor(tokenInfoCtx); - continue; - } - // Fallback: resolved address may be an ATA, fetch TOKEN_ACCOUNT_STATE - // to get the mint, then stream both if TOKEN_INFO is available. - if (streamedTokenAccounts.has(resolvedAddress)) continue; - const stateCtx = await this.fetchChallengeBoundDescriptorOnly( - (challenge) => ({ - deviceModelId, - requests: [{ tokenAccount: resolvedAddress, challenge }], - }), - ClearSignContextType.SOLANA_TOKEN_ACCOUNT_STATE, + const mint = (stateCtx as SolanaTokenAccountStateContextSuccess).payload + .mint; + if (!mint) { + this.logger.warn( + "[run] TOKEN_ACCOUNT_STATE for a resolved TOKEN_AMOUNT.TOKEN ATA carried no mint; instruction may be pinned", + { data: { tokenAccount: resolvedAddress } }, ); - if (!stateCtx || !isSolanaContextSuccess(stateCtx)) { - this.logger.warn( - "[run] TOKEN_ACCOUNT_STATE fetch failed for a resolved TOKEN_AMOUNT.TOKEN ATA; instruction may be pinned", - { data: { tokenAccount: resolvedAddress } }, - ); - continue; - } - - const mint = (stateCtx as SolanaTokenAccountStateContextSuccess).payload - .mint; - if (!mint) { - this.logger.warn( - "[run] TOKEN_ACCOUNT_STATE for a resolved TOKEN_AMOUNT.TOKEN ATA carried no mint; instruction may be pinned", - { data: { tokenAccount: resolvedAddress } }, - ); - continue; - } - if (streamedMints.has(mint)) continue; + continue; + } + if (streamedMints.has(mint)) continue; - const mintTokenInfoContexts = await this.args.contextModule.getContexts( - { deviceModelId, mints: [mint], network: this.network }, - [ClearSignContextType.SOLANA_TOKEN_INFO], - ); - const mintTokenInfoCtx = mintTokenInfoContexts.find( - (c) => c.type === ClearSignContextType.SOLANA_TOKEN_INFO, + const mintTokenInfoContexts = await this.args.contextModule.getContexts( + { deviceModelId, mints: [mint], network: this.network }, + [ClearSignContextType.SOLANA_TOKEN_INFO], + ); + const mintTokenInfoCtx = mintTokenInfoContexts.find( + (c) => c.type === ClearSignContextType.SOLANA_TOKEN_INFO, + ); + if (!mintTokenInfoCtx) { + this.logger.warn( + "[run] TOKEN_INFO fetch failed for a resolved TOKEN_AMOUNT.TOKEN mint; instruction may be pinned", + { data: { mint } }, ); - if (!mintTokenInfoCtx) { - this.logger.warn( - "[run] TOKEN_INFO fetch failed for a resolved TOKEN_AMOUNT.TOKEN mint; instruction may be pinned", - { data: { mint } }, - ); - continue; - } - - streamedTokenAccounts.add(resolvedAddress); - await this.provideDescriptor(stateCtx); - streamedMints.add(mint); - await this.provideDescriptor(mintTokenInfoCtx); + continue; } + + streamedTokenAccounts.add(resolvedAddress); + await this.provideDescriptor(stateCtx); + streamedMints.add(mint); + await this.provideDescriptor(mintTokenInfoCtx); } // ALT-backed accounts needing an attested TOKEN_ACCOUNT_STATE: IS_SIGNER @@ -418,69 +409,56 @@ export class ProvideGenericClearSignContextTask { // was a mint, not a token account, after all) — which is what makes this // bucket subsume the two below it in build()'s priority order. for (const { altAddress, entryIndex } of tokenAccountStateAltRefs) { - const altContexts = await this.provideChallengeBoundDescriptorAndReturn( + const resolvedAddress = await this.resolveAltRefAddress( + altAddress, + entryIndex, + deviceModelId, + collectAltTrustedName, + { + empty: + "[run] ALT_RESOLUTION fetch failed for a TOKEN_ACCOUNT_STATE ref; instruction may be pinned", + unresolved: + "[run] ALT_RESOLUTION for a TOKEN_ACCOUNT_STATE ref resolved to no address; instruction may be pinned", + }, + ); + if (!resolvedAddress || streamedTokenAccounts.has(resolvedAddress)) { + continue; + } + + const stateCtx = await this.fetchChallengeBoundDescriptorOnly( (challenge) => ({ deviceModelId, - requests: [{ altAddress, entryIndex, challenge }], + requests: [{ tokenAccount: resolvedAddress, challenge }], }), - ClearSignContextType.SOLANA_ALT_RESOLUTION, + ClearSignContextType.SOLANA_TOKEN_ACCOUNT_STATE, ); - if (altContexts.length === 0) { - // Unresolved ALT_RESOLUTION for an owner/mint-map target pins the - // instruction (G-051): neither the IS_SIGNER hide nor the mint - // display can be established for it. - this.logger.warn( - "[run] ALT_RESOLUTION fetch failed for a TOKEN_ACCOUNT_STATE ref; instruction may be pinned", - { data: { altAddress, entryIndex } }, - ); + if (stateCtx && isSolanaContextSuccess(stateCtx)) { + streamedTokenAccounts.add(resolvedAddress); + await this.provideDescriptor(stateCtx); + const mint = (stateCtx as SolanaTokenAccountStateContextSuccess).payload + .mint; + if (mint && !streamedMints.has(mint)) { + streamedMints.add(mint); + await this.fetchAndStreamTokenInfo(mint, deviceModelId); + } continue; } - for (const altCtx of altContexts) { - if ( - altCtx.type !== ClearSignContextType.SOLANA_ALT_RESOLUTION || - !isSolanaContextSuccess(altCtx) - ) { - continue; - } - const resolvedAddress = (altCtx as SolanaAltResolutionContextSuccess) - .payload.resolvedAddress; - if (!resolvedAddress) { - this.logger.warn( - "[run] ALT_RESOLUTION for a TOKEN_ACCOUNT_STATE ref resolved to no address; instruction may be pinned", - { data: { altAddress, entryIndex } }, - ); - continue; - } - if (streamedTokenAccounts.has(resolvedAddress)) continue; - - const stateCtx = await this.fetchChallengeBoundDescriptorOnly( - (challenge) => ({ - deviceModelId, - requests: [{ tokenAccount: resolvedAddress, challenge }], - }), - ClearSignContextType.SOLANA_TOKEN_ACCOUNT_STATE, - ); - if (stateCtx && isSolanaContextSuccess(stateCtx)) { - streamedTokenAccounts.add(resolvedAddress); - await this.provideDescriptor(stateCtx); - const mint = (stateCtx as SolanaTokenAccountStateContextSuccess) - .payload.mint; - if (mint && !streamedMints.has(mint)) { - streamedMints.add(mint); - await this.fetchAndStreamTokenInfo(mint, deviceModelId); - } - continue; - } - // No attested state: the resolved address may be a mint itself. - if (!streamedMints.has(resolvedAddress)) { - streamedMints.add(resolvedAddress); - await this.fetchAndStreamTokenInfo(resolvedAddress, deviceModelId); - } + // No attested state: the resolved address may be a mint itself. + if (!streamedMints.has(resolvedAddress)) { + streamedMints.add(resolvedAddress); + await this.fetchAndStreamTokenInfo(resolvedAddress, deviceModelId); } } - for (const address of trustedNames) { + // Fetched last, after every ALT_RESOLUTION loop above has had a chance to + // populate `altTrustedNameAddresses`. Deduped against the static + // `trustedNames`: the same account can be named statically by one + // instruction and reached only through an ALT by another. + const namesToFetch = new Set(trustedNames); + for (const address of altTrustedNameAddresses) namesToFetch.add(address); + + for (const address of namesToFetch) { await this.provideChallengeBoundDescriptor( (challenge) => ({ deviceModelId, @@ -557,6 +535,58 @@ export class ProvideGenericClearSignContextTask { return matched; } + /** + * Shared by the MINT_ASSOC, TOKEN_AMOUNT.TOKEN and TOKEN_ACCOUNT_STATE + * alt-ref loops in {@link streamChallengeBoundDescriptors}: streams the + * ALT_RESOLUTION descriptor for a single ref, feeds any resolved address to + * `collectAltTrustedName`, and returns that address — or `undefined` if the + * ref could not be resolved, having already logged the matching G-051 + * pinning warning (`warnings.empty` when no descriptor came back at all, + * `warnings.unresolved` when it came back with no `resolvedAddress`). + */ + private async resolveAltRefAddress( + altAddress: string, + entryIndex: number, + deviceModelId: DeviceModelId, + collectAltTrustedName: ( + altAddress: string, + entryIndex: number, + resolvedAddress: string | undefined, + ) => void, + warnings: { empty: string; unresolved: string }, + ): Promise { + const altContexts = await this.provideChallengeBoundDescriptorAndReturn( + (challenge) => ({ + deviceModelId, + requests: [{ altAddress, entryIndex, challenge }], + }), + ClearSignContextType.SOLANA_ALT_RESOLUTION, + ); + if (altContexts.length === 0) { + this.logger.warn(warnings.empty, { data: { altAddress, entryIndex } }); + return undefined; + } + for (const altCtx of altContexts) { + if ( + altCtx.type !== ClearSignContextType.SOLANA_ALT_RESOLUTION || + !isSolanaContextSuccess(altCtx) + ) { + continue; + } + const resolvedAddress = (altCtx as SolanaAltResolutionContextSuccess) + .payload.resolvedAddress; + collectAltTrustedName(altAddress, entryIndex, resolvedAddress); + if (!resolvedAddress) { + this.logger.warn(warnings.unresolved, { + data: { altAddress, entryIndex }, + }); + return undefined; + } + return resolvedAddress; + } + return undefined; + } + /** `GET CHALLENGE`, then fetch the descriptor bound to it, then stream it (best-effort). */ private async provideChallengeBoundDescriptor( buildInput: (challenge: string) => unknown,