Description
Stop submit() from signing a second, different transaction after a transport failure.
Problem Statement
src/contract/write.ts:239-272 — the retry loop reserves a new sequence on every attempt:
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const sequence = this.nonceManager
? await this.nonceManager.reserve(signerAccount) // line 241
: undefined;
const signed = await this.signer.sign(invocation); // line 249
...
if (!isTransportRetry(normalized.code) || attempt >= maxAttempts) throw normalized;
}
isTransportRetry (line 289) retries on AllEndpointsFailed and RpcTimeout — exactly the two cases where the transaction may already have executed on-chain. A timeout is not evidence of non-execution.
Because attempt 2 reserves a fresh sequence, it produces a different transaction for the same operation. Stellar's duplicate-sequence protection never engages, so both can land. vote, startRewardStream and emergencyRecover can each execute twice.
RpcClient.request has already broadcast the POST to every healthy endpoint before throwing AllEndpointsFailed, so the window is wide rather than theoretical.
Two further problems in the same loop:
- There is no delay between attempts — it retries immediately.
- Attempt 1's sequence is consumed locally but never on-chain, leaving a gap in the cached sequence.
This is distinct from #4. That issue returns a leaked reservation on the nonce side; this one is about submit() re-signing with new sequence material. Different file, different defect.
Proposed Changes
Technical Implementation Scaffolding
- Target Repository: vero-sdk
- Target Path: src/contract/write.ts
- Branch Naming: fix/issue--submit-idempotency
- Authority Context: Security-sensitive — duplicate execution of value-bearing calls
Acceptance Criteria
Definition of Done
This issue is self-contained. Everything it needs already exists on main; it does not wait on any other issue. Deliver the change and its tests in one PR.
Description
Stop
submit()from signing a second, different transaction after a transport failure.Problem Statement
src/contract/write.ts:239-272— the retry loop reserves a new sequence on every attempt:isTransportRetry(line 289) retries onAllEndpointsFailedandRpcTimeout— exactly the two cases where the transaction may already have executed on-chain. A timeout is not evidence of non-execution.Because attempt 2 reserves a fresh sequence, it produces a different transaction for the same operation. Stellar's duplicate-sequence protection never engages, so both can land.
vote,startRewardStreamandemergencyRecovercan each execute twice.RpcClient.requesthas already broadcast the POST to every healthy endpoint before throwingAllEndpointsFailed, so the window is wide rather than theoretical.Two further problems in the same loop:
This is distinct from #4. That issue returns a leaked reservation on the nonce side; this one is about
submit()re-signing with new sequence material. Different file, different defect.Proposed Changes
AllEndpointsFailed/RpcTimeout, treat the outcome as indeterminate: poll for the transaction hash before deciding to retry, and never blind-retryBadSequencepath at line 264 as-is — refreshing and re-reserving is correct thereTechnical Implementation Scaffolding
Acceptance Criteria
BadSequencerefresh-and-retry behaviour still passes unchangednpm test,npm run typecheck,npm run lint, andnpm run buildall passDefinition of Done
This issue is self-contained. Everything it needs already exists on
main; it does not wait on any other issue. Deliver the change and its tests in one PR.