Skip to content

Commit 45a223e

Browse files
fix(cookbook): review round 4 - logout releases the nonce guard
C1: the busy guard added in round 3 had no release path. Logging out while a submission was tracking dropped the sdk-dapp session, so the terminal callbacks could never fire and the page stayed locked forever. A regression from the round-3 fix, not a pre-existing issue. SendEgldLifecycle gains abandon(reason), which releases the guard and clears the stored output and early terminal so a callback from the dropped session cannot resurrect the abandoned submission after reconnecting. Its doc comment states that the broadcast transaction may still be pending and the caller must refresh the account nonce before sending again; clearing busy alone would reintroduce the stale-nonce bug the guard exists to prevent. Adds an isBusy getter so the guard state is inspectable. Disconnect is guarded twice: the button is disabled while signing or tracking with an explanatory tooltip, and handleLogout() returns early on the same condition so a programmatic caller cannot bypass the UI. Test added: tracking -> logout -> stale callback ignored -> login -> send accepted -> done. C2: the SendEgldOutput comment now says sessionId keys the ...TransactionsSessions() maps and identifies the per-session callbacks, and names the flat hooks as taking no session id. Verified on Node 20.19.0: advisory gate green (harness plus all five fresh environments), 11/11 extractor tests, 4/4 lifecycle tests, strict TypeScript, 61/61 projects built, 8/8 Vite and 2/2 Next production builds, clean Docusaurus build with no new broken links. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8890854 commit 45a223e

2 files changed

Lines changed: 119 additions & 4 deletions

File tree

docs/sdk-and-tools/sdk-js/cookbook/start-here/sign-and-send.mdx

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -404,8 +404,10 @@ export interface SendEgldInput {
404404
}
405405

406406
/**
407-
* Output of a successful sign + send + track call. The sessionId is the key
408-
* the React hooks (useGetPendingTransactions, etc.) use to render UI state.
407+
* Output of a successful sign + send + track call. The sessionId keys the
408+
* ...TransactionsSessions() maps and identifies the per-session success and
409+
* failure callbacks. The flat hooks (useGetPendingTransactions and friends)
410+
* take no session id and return global arrays.
409411
*/
410412
export interface SendEgldOutput {
411413
sessionId: string;
@@ -700,6 +702,34 @@ export class SendEgldLifecycle {
700702
}
701703
}
702704

705+
/** True while a submission holds the nonce guard. */
706+
public get isBusy(): boolean {
707+
return this.busy;
708+
}
709+
710+
/**
711+
* Release the guard when the tracked session can no longer reach a terminal
712+
* state, which is what happens on logout: sdk-dapp resets its store, drops
713+
* the session, and the success and failure callbacks registered for it can
714+
* never fire again.
715+
*
716+
* The broadcast transaction may still be pending on the network, so the
717+
* caller MUST refresh the account nonce from the network before submitting
718+
* anything else. Releasing the guard without that refresh is exactly how the
719+
* stale-nonce bug returns.
720+
*/
721+
public abandon(reason: string): void {
722+
this.busy = false;
723+
this.output = null;
724+
this.earlyTerminal = null;
725+
this.transition({
726+
status: 'error',
727+
sessionId: null,
728+
transactionHash: null,
729+
error: reason,
730+
});
731+
}
732+
703733
private finish(terminal: TerminalState): void {
704734
if (!this.busy) {
705735
return;
@@ -762,6 +792,8 @@ export interface UseSendEgld {
762792
state: SendEgldState;
763793
send: (input: SendEgldInput) => Promise<SendEgldOutput | null>;
764794
reset: () => void;
795+
/** Release the nonce guard when the tracked session is gone (logout). */
796+
abandon: (reason: string) => void;
765797
}
766798

767799
export function useSendEgld(): UseSendEgld {
@@ -782,7 +814,11 @@ export function useSendEgld(): UseSendEgld {
782814
lifecycleRef.current?.reset();
783815
}, []);
784816

785-
return { state, send, reset };
817+
const abandon = useCallback((reason: string): void => {
818+
lifecycleRef.current?.abandon(reason);
819+
}, []);
820+
821+
return { state, send, reset, abandon };
786822
}
787823
```
788824

@@ -820,7 +856,7 @@ export default function SendPage(): JSX.Element {
820856
const isLoggedIn = useGetIsLoggedIn();
821857
const account = useGetAccount();
822858
const { network } = useGetNetworkConfig();
823-
const { state, send, reset } = useSendEgld();
859+
const { state, send, reset, abandon } = useSendEgld();
824860

825861
// useGetPendingTransactions() returns SignedTransactionType[] — a flat
826862
// array of currently-pending transactions, NOT a sessionId-keyed map (see
@@ -845,8 +881,19 @@ export default function SendPage(): JSX.Element {
845881
};
846882

847883
const handleLogout = async (): Promise<void> => {
884+
// Logging out mid-submission drops the tracked session inside sdk-dapp, so
885+
// the terminal callbacks can never fire and the nonce guard would stay
886+
// held forever. Refuse while a submission is active; the button is
887+
// disabled too, but a programmatic caller must not get past this either.
888+
if (isSubmissionPending) {
889+
return;
890+
}
848891
const provider = getAccountProvider();
849892
await provider.logout();
893+
// Nothing is in flight here, so releasing the guard is safe. If you add a
894+
// force-disconnect path later, call abandon() and then refresh the account
895+
// nonce from the network before allowing another send.
896+
abandon('Disconnected.');
850897
};
851898

852899
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
@@ -893,6 +940,12 @@ export default function SendPage(): JSX.Element {
893940
onClick={() => {
894941
void handleLogout();
895942
}}
943+
disabled={isSubmissionPending}
944+
title={
945+
isSubmissionPending
946+
? 'Wait for the current transaction to settle before disconnecting.'
947+
: undefined
948+
}
896949
style={{ ...buttonStyle, marginBottom: '1.5rem' }}
897950
>
898951
Disconnect

testing/cookbook-ts/sign-and-send-lifecycle.runtime.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,65 @@ test("rejects a second rapid submission until the first session is terminal", as
107107
await callbacks?.onSuccess(output.sessionId);
108108
assert.equal(lifecycle.getState().status, "done");
109109
});
110+
111+
test("logout while tracking releases the guard and a later send is accepted", async () => {
112+
const states: SendEgldState[] = [];
113+
const lifecycle = new SendEgldLifecycle((next) => states.push(next));
114+
const sent = deferred<SendEgldOutput>();
115+
let callbacks: SendEgldCallbacks | undefined;
116+
let operationCalls = 0;
117+
118+
const operation = async (
119+
_input: SendEgldInput,
120+
registered: SendEgldCallbacks,
121+
): Promise<SendEgldOutput> => {
122+
operationCalls += 1;
123+
callbacks = registered;
124+
return sent.promise;
125+
};
126+
127+
const first = lifecycle.send(input, operation);
128+
sent.resolve(output);
129+
assert.deepEqual(await first, output);
130+
assert.equal(lifecycle.getState().status, "tracking");
131+
assert.equal(lifecycle.isBusy, true);
132+
133+
// Logging out drops the tracked session inside sdk-dapp: the terminal
134+
// callbacks registered for it can never fire again. Without abandon() the
135+
// guard would stay held and the page would be permanently locked.
136+
lifecycle.abandon("Disconnected.");
137+
138+
assert.equal(lifecycle.isBusy, false);
139+
assert.equal(lifecycle.getState().status, "error");
140+
assert.equal(lifecycle.getState().error, "Disconnected.");
141+
assert.equal(lifecycle.getState().sessionId, null);
142+
143+
// The stale session's callback must not resurrect the abandoned submission.
144+
await callbacks?.onSuccess(output.sessionId);
145+
assert.equal(lifecycle.getState().status, "error");
146+
147+
// After reconnecting, the page works again.
148+
const afterLogin = deferred<SendEgldOutput>();
149+
const secondOutput: SendEgldOutput = {
150+
sessionId: "session-2",
151+
transactionHash: "hash-2",
152+
};
153+
const secondOperation = async (
154+
_input: SendEgldInput,
155+
registered: SendEgldCallbacks,
156+
): Promise<SendEgldOutput> => {
157+
operationCalls += 1;
158+
callbacks = registered;
159+
return afterLogin.promise;
160+
};
161+
162+
const second = lifecycle.send(input, secondOperation);
163+
afterLogin.resolve(secondOutput);
164+
assert.deepEqual(await second, secondOutput);
165+
assert.equal(operationCalls, 2);
166+
assert.equal(lifecycle.getState().status, "tracking");
167+
168+
await callbacks?.onSuccess(secondOutput.sessionId);
169+
assert.equal(lifecycle.getState().status, "done");
170+
assert.ok(states.length > 0);
171+
});

0 commit comments

Comments
 (0)