Skip to content

Commit 059c83d

Browse files
fix(signing): label contract args from the spec's parameter list (#1011)
* fix(signing): label contract args from the spec's parameter list Argument names came from the JSON-Schema `required` array, which is not the parameter list -- `Spec.jsonSchema()` omits `Option<T>` params from it. A non-trailing optional shifted every later label onto the wrong value, and an all-optional function emitted no `required` at all, so indexing it threw and took down the signing view. Names now come from `properties.args.properties`, the complete ordered list. On an arity mismatch or integer-like keys the derivation returns null and rows render unlabelled rather than mislabelled. list. On an arity mismatch or integer-like keys the derivation returns null and rows render unlabelled rather than mislabelled. Also adds a note qualifying spec-derived names as the contract's own claim, drops late spec responses so they can't label a different invocation, and skips the lookup for auth entries entirely -- `require_auth_for_args` can substitute an arbitrary same-arity list, so the length check doesn't protect them (stellar/freighter#2196). * docs(soroban): correct the contract-spec docstring's parameter list The `getContractSpecs` docstring still told the reader to take parameter names from `properties.args.required` -- the exact mistake this branch fixes -- and its two examples verified only because neither sample function has an `Option<T>` parameter. Prose and both examples now point at `properties.args.properties` and say why `required` is not the parameter list. * refactor(soroban): type the contract spec instead of casting into it `getContractFnArgNames` took `Record<string, any>` and cast its way back to the one declared leaf type, and `checkContractSupportsMuxed` carried a second, inline copy of the same shape. Declaring the intermediate levels lets the optional chain resolve on its own, so both casts go away and one named type describes the response. Narrowing `getContractSpecs` to match drops the last `any` in that region, and the eslint-disable with it. Also corrects the `getContractSpecs` docstring, which still pointed at `properties.args.required` -- the mistake this branch fixes. Its two examples verified only because neither sample function has an `Option<T>` parameter. * fix(signing): drop the unreachable auth-entry spec note The note was rendered under `showHeader && argNames?.length`, but every call site that shows the heading is one where names are never resolved -- auth entries suppress the lookup, the rest pass no contract id -- and the one site with names owns its heading and renders the note itself. The branch could not fire, leaving its `authorizations.contractSpecNote` key dead in `en` and `pt`. Removes the branch, the `translationKey` prop that existed only to serve it, and both locale entries. * test(signing): pin the auth-entry spec-label suppression Suppressing spec-derived labels on auth entries is the one behaviour this branch changes rather than ports, and nothing asserted it -- dropping `isAuthEntry` from either call site, or the guard from the hook, would have regressed silently. Covers both call sites: the spec mock resolves names that would label the rows, and each test asserts it is never called and the rows stay bare. Verified by mutation -- all three removals fail these tests. * fix(signing): re-raise the spec lookup's loading flag on a refetch The effect drops the resolved parameter names on every run, but the loading flag was only ever lowered. A second run left (isLoading: false, argNames: null), which reads as "the lookup finished and produced nothing" -- unlabelled rows and no spec note, with no spinner while the refetch was in flight. Raise the flag alongside the clear so the two stay in step. Not reachable from the signing screen today: every effect dep is fixed for the screen's lifetime, and networkDetails is a module-level constant, so this disarms the trap rather than fixing a live bug. The clear itself stays unconditional -- a name resolved for one invocation must never outlive it -- and the second test pins that, so a failed refetch settles on unlabelled rows instead of stale names. * Update contract spec note translation key Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(signing): key the contract-spec lookup to its invocation The lookup dropped its names inside a passive effect, so a changed invocation committed one render carrying the previous one's argNames with isLoading already false -- new values wearing old parameter names. Store the result under the key it was fetched for and compare that key during render, which closes the window and collapses two useStates into one. That window was unreachable only because SignTransactionOperationDetails latched its operations into a ref, a workaround for useSignTransactionDetails re-parsing the XDR every render. Memoize the parse and drop the latch, so the sheet also follows a changed transaction instead of pinning the first one. Also move contractSpecNote to the operations namespace its only caller reads, which was rendering the raw key to users. * fix(signing): drop resolved arg names for an unresolved invocation `isAuthEntry` gated the spec fetch but not the read of its result, and it is not part of the invocation key. A hook instance that resolved names and then flipped to an auth entry at the same contract, function and arity kept returning them -- spec labels on args that need not be the function's declared parameters. No call site can reach that today; every one passes a constant. Gating the read on `shouldResolve` states the invariant instead of relying on that. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent ea66f5a commit 059c83d

15 files changed

Lines changed: 1044 additions & 121 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { renderHook, waitFor } from "@testing-library/react-native";
2+
import { useContractArgNames } from "components/screens/SignTransactionDetails/components/KeyVal";
3+
import { ContractSpecSchema } from "helpers/soroban";
4+
import { getContractSpecs } from "services/backend";
5+
6+
jest.mock("ducks/auth", () => ({
7+
useAuthenticationStore: () => ({ network: "PUBLIC" }),
8+
}));
9+
10+
jest.mock("services/backend", () => ({
11+
getContractSpecs: jest.fn(),
12+
}));
13+
14+
const CONTRACT_A = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4";
15+
const CONTRACT_B = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
16+
17+
const specFor = (fnName: string, argNames: string[]): ContractSpecSchema => ({
18+
definitions: {
19+
[fnName]: {
20+
properties: {
21+
args: {
22+
properties: Object.fromEntries(argNames.map((name) => [name, {}])),
23+
required: argNames,
24+
},
25+
},
26+
},
27+
},
28+
});
29+
30+
const TRANSFER_SPEC = specFor("transfer", ["from", "to"]);
31+
32+
describe("useContractArgNames", () => {
33+
const getContractSpecsMock = getContractSpecs as jest.MockedFunction<
34+
typeof getContractSpecs
35+
>;
36+
37+
beforeEach(() => {
38+
getContractSpecsMock.mockReset();
39+
});
40+
41+
// The names and the loading flag are reset together at the top of the
42+
// effect. If only the names were dropped, `(isLoading: false, argNames:
43+
// null)` would be indistinguishable from "the lookup finished and produced
44+
// nothing", so the rows would render unlabelled -- and the spec note would
45+
// disappear -- while a refetch was still in flight.
46+
it("returns to the loading state when the invocation changes", async () => {
47+
getContractSpecsMock
48+
.mockResolvedValueOnce(TRANSFER_SPEC)
49+
// Stays pending, so the in-flight state is observable.
50+
.mockImplementationOnce(() => new Promise<never>(() => {}));
51+
52+
const { result, rerender } = renderHook(
53+
(props: { contractId: string; fnName: string; argCount: number }) =>
54+
useContractArgNames(props),
55+
{
56+
initialProps: {
57+
contractId: CONTRACT_A,
58+
fnName: "transfer",
59+
argCount: 2,
60+
},
61+
},
62+
);
63+
64+
await waitFor(() => expect(result.current.isLoading).toBe(false));
65+
expect(result.current.argNames).toEqual(["from", "to"]);
66+
67+
rerender({ contractId: CONTRACT_B, fnName: "swap", argCount: 2 });
68+
69+
expect(result.current.isLoading).toBe(true);
70+
expect(result.current.argNames).toBeNull();
71+
});
72+
73+
// Dropping the names up front is deliberate: a name resolved for one
74+
// invocation must never outlive it. So a failed refetch settles on
75+
// unlabelled rows rather than resurrecting the names it already had.
76+
it("does not fall back to the previous names when a refetch fails", async () => {
77+
getContractSpecsMock
78+
.mockResolvedValueOnce(TRANSFER_SPEC)
79+
.mockRejectedValueOnce(new Error("no spec"));
80+
81+
const { result, rerender } = renderHook(
82+
(props: { contractId: string; fnName: string; argCount: number }) =>
83+
useContractArgNames(props),
84+
{
85+
initialProps: {
86+
contractId: CONTRACT_A,
87+
fnName: "transfer",
88+
argCount: 2,
89+
},
90+
},
91+
);
92+
93+
await waitFor(() =>
94+
expect(result.current.argNames).toEqual(["from", "to"]),
95+
);
96+
97+
rerender({ contractId: CONTRACT_B, fnName: "swap", argCount: 2 });
98+
99+
await waitFor(() => expect(result.current.isLoading).toBe(false));
100+
expect(result.current.argNames).toBeNull();
101+
});
102+
103+
// The guarantee is about the render itself, not about the state after
104+
// effects have flushed. Resetting inside the effect would still satisfy the
105+
// assertions above -- `rerender` flushes effects before they run -- while
106+
// committing one frame that pairs the new invocation's values with the
107+
// previous one's names. So record what every render actually saw.
108+
it("never renders the previous invocation's names against a new one", async () => {
109+
getContractSpecsMock
110+
.mockResolvedValueOnce(TRANSFER_SPEC)
111+
// Stays pending, so any stale frame has time to be observed.
112+
.mockImplementationOnce(() => new Promise<never>(() => {}));
113+
114+
const renders: Array<{ argNames: string[] | null; isLoading: boolean }> =
115+
[];
116+
117+
const { result, rerender } = renderHook(
118+
(props: { contractId: string; fnName: string; argCount: number }) => {
119+
const current = useContractArgNames(props);
120+
renders.push({ ...current });
121+
return current;
122+
},
123+
{
124+
initialProps: {
125+
contractId: CONTRACT_A,
126+
fnName: "transfer",
127+
argCount: 2,
128+
},
129+
},
130+
);
131+
132+
await waitFor(() =>
133+
expect(result.current.argNames).toEqual(["from", "to"]),
134+
);
135+
136+
renders.length = 0;
137+
rerender({ contractId: CONTRACT_B, fnName: "swap", argCount: 2 });
138+
139+
expect(renders.length).toBeGreaterThan(0);
140+
renders.forEach((render) => {
141+
expect(render.argNames).toBeNull();
142+
expect(render.isLoading).toBe(true);
143+
});
144+
});
145+
146+
// An auth entry resolves nothing, so it has no in-flight state to report.
147+
// Reporting one anyway would flash a spinner over rows that are always
148+
// going to render unlabelled.
149+
it("never reports loading for an invocation it will not resolve", () => {
150+
const { result } = renderHook(() =>
151+
useContractArgNames({
152+
contractId: CONTRACT_A,
153+
fnName: "transfer",
154+
argCount: 2,
155+
isAuthEntry: true,
156+
}),
157+
);
158+
159+
expect(result.current.isLoading).toBe(false);
160+
expect(result.current.argNames).toBeNull();
161+
expect(getContractSpecsMock).not.toHaveBeenCalled();
162+
});
163+
164+
// `isAuthEntry` is not part of the invocation key, so names resolved for the
165+
// operation view would survive a switch to an auth entry at the same
166+
// contract, function and arity -- spec labels on args that need not be the
167+
// function's declared parameters, which is what the flag exists to prevent.
168+
it("drops resolved names when the same invocation becomes an auth entry", async () => {
169+
getContractSpecsMock.mockResolvedValueOnce(TRANSFER_SPEC);
170+
171+
const { result, rerender } = renderHook(
172+
(props: {
173+
contractId: string;
174+
fnName: string;
175+
argCount: number;
176+
isAuthEntry: boolean;
177+
}) => useContractArgNames(props),
178+
{
179+
initialProps: {
180+
contractId: CONTRACT_A,
181+
fnName: "transfer",
182+
argCount: 2,
183+
isAuthEntry: false,
184+
},
185+
},
186+
);
187+
188+
await waitFor(() =>
189+
expect(result.current.argNames).toEqual(["from", "to"]),
190+
);
191+
192+
rerender({
193+
contractId: CONTRACT_A,
194+
fnName: "transfer",
195+
argCount: 2,
196+
isAuthEntry: true,
197+
});
198+
199+
expect(result.current.argNames).toBeNull();
200+
// No spinner either: the rows are always going to render unlabelled.
201+
expect(result.current.isLoading).toBe(false);
202+
expect(getContractSpecsMock).toHaveBeenCalledTimes(1);
203+
});
204+
});

‎__tests__/components/screens/SignTransactionDetails/components/Operations.test.tsx‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
Address,
66
Asset,
77
BASE_FEE,
8+
nativeToScVal,
89
Networks,
910
Operation,
1011
OperationRecord,
@@ -16,6 +17,7 @@ import { render } from "@testing-library/react-native";
1617
import Operations from "components/screens/SignTransactionDetails/components/Operations";
1718
import { truncateAddress } from "helpers/stellar";
1819
import React from "react";
20+
import { getContractSpecs } from "services/backend";
1921

2022
// Render i18n keys verbatim so assertions target the value rows, not labels.
2123
jest.mock("react-i18next", () => ({
@@ -52,6 +54,11 @@ jest.mock("services/blockaid/api", () => ({
5254
scanToken: jest.fn().mockResolvedValue(undefined),
5355
}));
5456

57+
// The contract spec is the only source of parameter names; stub it per test.
58+
jest.mock("services/backend", () => ({
59+
getContractSpecs: jest.fn().mockRejectedValue(new Error("no spec")),
60+
}));
61+
5562
// Use the real number/asset formatting.
5663
jest.mock("helpers/formatAmount", () =>
5764
jest.requireActual("helpers/formatAmount"),
@@ -529,3 +536,162 @@ describe("SignTransactionDetails > Operations: hash-based signer keys", () => {
529536
).toBeTruthy();
530537
});
531538
});
539+
540+
describe("SignTransactionDetails > Operations: contract argument labels", () => {
541+
const CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4";
542+
// gauge_schedule_reward(router, distributor, gauge, start_at: Option<u64>,
543+
// duration, tps) as `Spec.jsonSchema()` emits it: `required` omits the
544+
// Option, so indexing it positionally slid every later label up one row.
545+
const GAUGE_SPEC = {
546+
definitions: {
547+
gauge_schedule_reward: {
548+
properties: {
549+
args: {
550+
properties: {
551+
router: {},
552+
distributor: {},
553+
gauge: {},
554+
start_at: {},
555+
duration: {},
556+
tps: {},
557+
},
558+
required: ["router", "distributor", "gauge", "duration", "tps"],
559+
},
560+
},
561+
},
562+
},
563+
};
564+
const ALL_OPTIONAL_SPEC = {
565+
definitions: {
566+
maybe: {
567+
properties: { args: { properties: { first: {}, second: {} } } },
568+
},
569+
},
570+
};
571+
572+
const invokeContract = (fnName: string, args: xdr.ScVal[]) =>
573+
operationsFor(
574+
Operation.invokeContractFunction({
575+
contract: CONTRACT,
576+
function: fnName,
577+
args,
578+
}),
579+
);
580+
581+
const getContractSpecsMock = getContractSpecs as jest.MockedFunction<
582+
typeof getContractSpecs
583+
>;
584+
585+
beforeEach(() => {
586+
getContractSpecsMock.mockReset();
587+
});
588+
589+
it("keeps every label on its own value when a middle parameter is optional", async () => {
590+
const START_AT = "1750000000";
591+
const DURATION = "604800";
592+
const TPS = "42";
593+
getContractSpecsMock.mockResolvedValue(GAUGE_SPEC);
594+
595+
const { findAllByTestId, getAllByTestId, getByTestId } = render(
596+
<Operations
597+
operations={invokeContract("gauge_schedule_reward", [
598+
new Address(CONTRACT).toScVal(),
599+
new Address(SOURCE).toScVal(),
600+
new Address(CONTRACT).toScVal(),
601+
nativeToScVal(BigInt(START_AT), { type: "u64" }),
602+
nativeToScVal(BigInt(DURATION), { type: "u64" }),
603+
nativeToScVal(BigInt(TPS), { type: "i128" }),
604+
])}
605+
/>,
606+
);
607+
608+
await findAllByTestId("ParameterKey", {}, FIND);
609+
const keys = getAllByTestId("ParameterKey").map(
610+
(node) => node.props.children,
611+
);
612+
const values = getAllByTestId("ParameterValue").map(
613+
(node) => node.props.children,
614+
);
615+
616+
expect(keys).toEqual([
617+
"router",
618+
"distributor",
619+
"gauge",
620+
"start_at",
621+
"duration",
622+
"tps",
623+
]);
624+
// The timestamp must sit under start_at, not under duration.
625+
expect(values[3]).toBe(START_AT);
626+
expect(values[4]).toBe(DURATION);
627+
expect(values[5]).toBe(TPS);
628+
629+
// Names came from the spec, so they are qualified as the contract's own
630+
// claim rather than presented as verified.
631+
expect(getByTestId("ContractSpecNote")).toBeTruthy();
632+
});
633+
634+
it("labels a function whose parameters are all optional instead of throwing", async () => {
635+
// `required` is absent entirely here -- reading it positionally threw and
636+
// took the whole signing view down with it.
637+
getContractSpecsMock.mockResolvedValue(ALL_OPTIONAL_SPEC);
638+
639+
const { findAllByTestId, getAllByTestId } = render(
640+
<Operations
641+
operations={invokeContract("maybe", [
642+
nativeToScVal(1, { type: "u32" }),
643+
nativeToScVal(2, { type: "u32" }),
644+
])}
645+
/>,
646+
);
647+
648+
await findAllByTestId("ParameterKey", {}, FIND);
649+
expect(
650+
getAllByTestId("ParameterKey").map((node) => node.props.children),
651+
).toEqual(["first", "second"]);
652+
});
653+
654+
it("renders rows unlabelled, and no spec note, when the spec is unavailable", async () => {
655+
getContractSpecsMock.mockRejectedValue(new Error("no spec"));
656+
657+
const { findAllByTestId, getAllByTestId, queryByTestId } = render(
658+
<Operations
659+
operations={invokeContract("transfer", [
660+
new Address(SOURCE).toScVal(),
661+
new Address(CONTRACT).toScVal(),
662+
nativeToScVal(BigInt(100), { type: "i128" }),
663+
])}
664+
/>,
665+
);
666+
667+
await findAllByTestId("ParameterValue", {}, FIND);
668+
const keys = getAllByTestId("ParameterKey");
669+
670+
expect(keys).toHaveLength(3);
671+
// No spec means no trustworthy names, so rows render unlabelled rather
672+
// than borrowing a label from somewhere else.
673+
keys.forEach((node) => expect(node.props.children).toBeUndefined());
674+
// Nothing was labelled, so there is no claim to disclaim.
675+
expect(queryByTestId("ContractSpecNote")).toBeNull();
676+
});
677+
678+
it("renders rows unlabelled when the spec names a different number of parameters", async () => {
679+
getContractSpecsMock.mockResolvedValue(GAUGE_SPEC);
680+
681+
const { findAllByTestId, getAllByTestId, queryByTestId } = render(
682+
<Operations
683+
operations={invokeContract("gauge_schedule_reward", [
684+
new Address(CONTRACT).toScVal(),
685+
new Address(SOURCE).toScVal(),
686+
])}
687+
/>,
688+
);
689+
690+
await findAllByTestId("ParameterValue", {}, FIND);
691+
692+
getAllByTestId("ParameterKey").forEach((node) =>
693+
expect(node.props.children).toBeUndefined(),
694+
);
695+
expect(queryByTestId("ContractSpecNote")).toBeNull();
696+
});
697+
});

0 commit comments

Comments
 (0)