Skip to content

Commit 7cfc48a

Browse files
fix(sign-transaction): render hex signer keys instead of throwing
SDK 17's revokeSponsorship signer arm returns sha256Hash and preAuthTx as already-hex strings (convertXdrSignerKeyToObject calls uint8ArrayToHex), while setOptions returns real Uint8Arrays. formattedBuffer asserted bytes, so reviewing a revokeSignerSponsorship op with a hash-based signer threw `TypeError: Expected Uint8Array, got string` from a render body and put the ErrorBoundary screen where the sign/reject decision belongs. formattedBuffer now accepts Uint8Array | string, passing a string through rather than re-encoding it -- encoding it again hexes the ASCII of the hex, which is what the old Buffer.from(<hex string>) path did. That silent corruption is the wrong-hex display reported in #2838, so this closes it too: the row now reads DEAD…BEEF where it used to read 6465…6566. The types were not the problem; the casts were. RevokeSignerOpts already declares these fields as `Uint8Array | string` -- correctly -- and it was `signer as unknown as Signer` at the call site that discarded the string, because setOptions' Signer type declares bare Uint8Array. Both signer components now derive their prop type from the operation unions: type RevokeSignerKey = Operation.RevokeSignerSponsorship["signer"] type SetOptionsSignerKey = NonNullable<Extract<OperationRecord, { type: "setOptions" }>["signer"]> so the compiler tracks reality and both `as unknown as Signer` casts are gone. Verified that dropping the cast without this change is a typecheck error, i.e. the compiler would have caught the original bug. The `in` checks became discriminant truthiness checks, which is how the SDK's `?: never` unions are meant to be narrowed. The setOptions path is fixed alongside the reported one. It returns real bytes today so it was never broken, but it carried the identical cast on the adjacent line and would have hidden the same defect on the next SDK shape change. Tests cover formattedBuffer for both input shapes (it had none) and assert the rendered hex for decoded revokeSignerSponsorship ops, driving the real TransactionBuilder decode path rather than a hand-built signer object.
1 parent 64607e2 commit 7cfc48a

5 files changed

Lines changed: 139 additions & 22 deletions

File tree

extension/src/popup/components/__tests__/OperationsKeyVal.test.tsx

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
import React from "react";
22
import { render, waitFor, screen, cleanup } from "@testing-library/react";
3-
import { Address, Keypair, Operation, StrKey, xdr } from "stellar-sdk";
3+
import {
4+
Account,
5+
Address,
6+
Keypair,
7+
Networks,
8+
Operation,
9+
StrKey,
10+
TransactionBuilder,
11+
xdr,
12+
} from "stellar-sdk";
413

514
import { mockAccounts, TEST_PUBLIC_KEY, Wrapper } from "popup/__testHelpers__";
6-
import { KeyValueInvokeHostFn } from "../signTransaction/Operations/KeyVal";
15+
import {
16+
KeyValueInvokeHostFn,
17+
KeyValueSignerKeyOptions,
18+
} from "../signTransaction/Operations/KeyVal";
719
import * as internalApi from "@shared/api/internal";
820
import { APPLICATION_STATE } from "@shared/constants/applicationState";
921
import {
@@ -307,4 +319,59 @@ describe("Operations KeyVal", () => {
307319
});
308320
});
309321
});
322+
323+
describe("KeyValueSignerKeyOptions", () => {
324+
// SDK 17's revokeSignerSponsorship arm returns sha256Hash / preAuthTx as
325+
// hex strings rather than bytes, so decode a real op instead of hand-rolling
326+
// the shape.
327+
const decodeRevokeSignerOp = (signer: {
328+
sha256Hash?: any;
329+
preAuthTx?: any;
330+
}) => {
331+
const tx = new TransactionBuilder(new Account(TEST_PUBLIC_KEY, "0"), {
332+
fee: "100",
333+
networkPassphrase: Networks.TESTNET,
334+
})
335+
.addOperation(
336+
Operation.revokeSignerSponsorship({
337+
account: TEST_PUBLIC_KEY,
338+
signer: signer as any,
339+
}),
340+
)
341+
.setTimeout(0)
342+
.build();
343+
344+
return TransactionBuilder.fromXdr(
345+
tx.toEnvelope().toXdr("base64"),
346+
Networks.TESTNET,
347+
).operations[0] as Operation.RevokeSignerSponsorship;
348+
};
349+
350+
const readValue = (label: string) =>
351+
screen
352+
.getByText(label)
353+
.parentNode?.querySelector("[data-testid='OperationKeyVal__value']");
354+
355+
it("renders a sha256Hash signer key as its true hex value", async () => {
356+
const op = decodeRevokeSignerOp({
357+
sha256Hash: new Uint8Array(32).fill(0xab),
358+
});
359+
360+
render(<KeyValueSignerKeyOptions signer={op.signer} />);
361+
await waitFor(() => screen.getAllByTestId("OperationKeyVal"));
362+
363+
expect(readValue("Signer Sha256 Hash")).toHaveTextContent("ABAB…ABAB");
364+
});
365+
366+
it("renders a preAuthTx signer key as its true hex value", async () => {
367+
const op = decodeRevokeSignerOp({
368+
preAuthTx: new Uint8Array(32).fill(0xcd),
369+
});
370+
371+
render(<KeyValueSignerKeyOptions signer={op.signer} />);
372+
await waitFor(() => screen.getAllByTestId("OperationKeyVal"));
373+
374+
expect(readValue("Pre Auth Transaction")).toHaveTextContent("CDCD…CDCD");
375+
});
376+
});
310377
});

extension/src/popup/components/signTransaction/Operations/KeyVal/index.tsx

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
LiquidityPoolAsset,
99
nativeToScVal,
1010
Operation,
11-
Signer,
11+
OperationRecord,
1212
StrKey,
1313
xdr,
1414
} from "stellar-sdk";
@@ -148,11 +148,20 @@ export const KeyValueInvocation = ({
148148
);
149149
};
150150

151-
export const KeyValueSigner = ({ signer }: { signer: Signer }) => {
151+
/**
152+
* The signer shape a `setOptions` operation carries. Like {@link RevokeSignerKey}
153+
* its hash fields are `Uint8Array | string`, so it must not be narrowed to
154+
* `Signer` either -- even though this path returns real bytes today.
155+
*/
156+
type SetOptionsSignerKey = NonNullable<
157+
Extract<OperationRecord, { type: "setOptions" }>["signer"]
158+
>;
159+
160+
export const KeyValueSigner = ({ signer }: { signer: SetOptionsSignerKey }) => {
152161
const { t } = useTranslation();
153162

154163
function renderSignerType() {
155-
if ("ed25519PublicKey" in signer) {
164+
if (signer.ed25519PublicKey) {
156165
return (
157166
<KeyValueWithPublicKey
158167
operationKey={t("Signer")}
@@ -161,7 +170,7 @@ export const KeyValueSigner = ({ signer }: { signer: Signer }) => {
161170
);
162171
}
163172

164-
if ("sha256Hash" in signer) {
173+
if (signer.sha256Hash) {
165174
return (
166175
<KeyValueList
167176
operationKey={t("Signer")}
@@ -170,7 +179,7 @@ export const KeyValueSigner = ({ signer }: { signer: Signer }) => {
170179
);
171180
}
172181

173-
if ("preAuthTx" in signer) {
182+
if (signer.preAuthTx) {
174183
return (
175184
<KeyValueList
176185
operationKey={t("Signer")}
@@ -179,7 +188,7 @@ export const KeyValueSigner = ({ signer }: { signer: Signer }) => {
179188
);
180189
}
181190

182-
if ("ed25519SignedPayload" in signer) {
191+
if (signer.ed25519SignedPayload) {
183192
return (
184193
<KeyValueList
185194
operationKey={t("Signer")}
@@ -345,10 +354,21 @@ export const KeyValueClaimants = ({ claimants }: { claimants: Claimant[] }) => {
345354
);
346355
};
347356

348-
export const KeyValueSignerKeyOptions = ({ signer }: { signer: Signer }) => {
357+
/**
358+
* The signer shape a `revokeSignerSponsorship` operation carries. Unlike
359+
* `setOptions`' `Signer`, its hash fields are `Uint8Array | string` -- which is
360+
* the truth at runtime, so this must not be narrowed to `Signer`.
361+
*/
362+
type RevokeSignerKey = Operation.RevokeSignerSponsorship["signer"];
363+
364+
export const KeyValueSignerKeyOptions = ({
365+
signer,
366+
}: {
367+
signer: RevokeSignerKey;
368+
}) => {
349369
const { t } = useTranslation();
350370

351-
if ("ed25519PublicKey" in signer) {
371+
if (signer.ed25519PublicKey) {
352372
return (
353373
<KeyValueWithPublicKey
354374
operationKey={t("Signer Key")}
@@ -357,7 +377,7 @@ export const KeyValueSignerKeyOptions = ({ signer }: { signer: Signer }) => {
357377
);
358378
}
359379

360-
if ("sha256Hash" in signer) {
380+
if (signer.sha256Hash) {
361381
return (
362382
<KeyValueList
363383
operationKey={t("Signer Sha256 Hash")}
@@ -366,7 +386,7 @@ export const KeyValueSignerKeyOptions = ({ signer }: { signer: Signer }) => {
366386
);
367387
}
368388

369-
if ("preAuthTx" in signer) {
389+
if (signer.preAuthTx) {
370390
return (
371391
<KeyValueList
372392
operationKey={t("Pre Auth Transaction")}
@@ -375,7 +395,7 @@ export const KeyValueSignerKeyOptions = ({ signer }: { signer: Signer }) => {
375395
);
376396
}
377397

378-
if ("ed25519SignedPayload" in signer) {
398+
if (signer.ed25519SignedPayload) {
379399
return (
380400
<KeyValueList
381401
operationKey={t("Signed Payload")}

extension/src/popup/components/signTransaction/Operations/index.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { useEffect } from "react";
22
import { Badge, Icon, IconButton } from "@stellar/design-system";
33
import { useSelector } from "react-redux";
44
import { useTranslation } from "react-i18next";
5-
import { OperationRecord, Signer, xdr } from "stellar-sdk";
5+
import { OperationRecord, xdr } from "stellar-sdk";
66

77
import {
88
FLAG_TYPES,
@@ -417,11 +417,7 @@ export const Operations = ({
417417
} = op;
418418
return (
419419
<>
420-
{signer && (
421-
// v16 types the parsed setOptions signer as the builder opts
422-
// type; at runtime it is a parsed Signer (Buffer-backed fields).
423-
<KeyValueSigner signer={signer as unknown as Signer} />
424-
)}
420+
{signer && <KeyValueSigner signer={signer} />}
425421
{inflationDest && (
426422
<KeyValueWithPublicKey
427423
operationKey={t("Inflation Destination")}
@@ -819,7 +815,7 @@ export const Operations = ({
819815
const { account, signer } = op;
820816
return (
821817
<>
822-
<KeyValueSignerKeyOptions signer={signer as unknown as Signer} />
818+
<KeyValueSignerKeyOptions signer={signer} />
823819
<KeyValueWithPublicKey
824820
operationKey={t("Account")}
825821
operationValue={account}

extension/src/popup/helpers/__tests__/formatters.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import BigNumber from "bignumber.js";
22
import {
33
formatAmountPreserveCursor,
44
formatFiatAmount,
5+
formattedBuffer,
56
getValidBigNumber,
67
isValidPositiveAmount,
78
normalizeNumericString,
@@ -175,3 +176,22 @@ describe("formatFiatAmount", () => {
175176
expect(formatFiatAmount("not a number")).toBe("$0.00");
176177
});
177178
});
179+
180+
describe("formattedBuffer", () => {
181+
const bytes = new Uint8Array(32).fill(0xab);
182+
const hex = "ab".repeat(32);
183+
184+
it("renders raw bytes as truncated uppercase hex", () => {
185+
expect(formattedBuffer(bytes)).toBe("ABAB\u2026ABAB");
186+
});
187+
188+
it("passes through a string that is already hex", () => {
189+
// SDK 17's revokeSignerSponsorship arm hands us hex strings, not bytes,
190+
// for sha256Hash / preAuthTx signer keys.
191+
expect(formattedBuffer(hex)).toBe("ABAB\u2026ABAB");
192+
});
193+
194+
it("renders bytes and their hex string identically", () => {
195+
expect(formattedBuffer(hex)).toBe(formattedBuffer(bytes));
196+
});
197+
});

extension/src/popup/helpers/formatters.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,22 @@ export const formatAmount = (val: string) => {
144144
return formattedWholeVal;
145145
};
146146

147-
export const formattedBuffer = (data: Uint8Array) =>
148-
truncatedPublicKey(xdr.encodeBytes(data, "hex").toUpperCase());
147+
/**
148+
* Renders a hash for display as truncated uppercase hex.
149+
*
150+
* Accepts a string as well as bytes because the SDK is inconsistent: the
151+
* `revokeSponsorship` signer arm returns `sha256Hash` / `preAuthTx` as
152+
* already-hex strings (`convertXdrSignerKeyToObject`), while `setOptions`
153+
* returns real `Uint8Array`s. A string is passed through rather than re-encoded
154+
* -- encoding it again would hex the ASCII of the hex.
155+
*/
156+
export const formattedBuffer = (data: Uint8Array | string) =>
157+
truncatedPublicKey(
158+
(typeof data === "string"
159+
? data
160+
: xdr.encodeBytes(data, "hex")
161+
).toUpperCase(),
162+
);
149163

150164
export const scrubPathGkey = (route: string, url: string) => {
151165
try {

0 commit comments

Comments
 (0)