Skip to content

Stop submit() from re-signing with a new sequence on transport retry #86

Description

@N-thnI

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

  • Reserve the sequence once, before the loop, and re-sign the same sequence on retry so a retry is byte-identical to the original transaction
  • Add an idempotency key to the submit body so the relayer can collapse duplicates
  • On AllEndpointsFailed / RpcTimeout, treat the outcome as indeterminate: poll for the transaction hash before deciding to retry, and never blind-retry
  • Space attempts apart rather than retrying in a tight loop
  • Leave the BadSequence path at line 264 as-is — refreshing and re-reserving is correct there

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

  • A fetch stub that times out on attempt 1 while recording the submission produces exactly one on-chain effect
  • A retry after a transport failure carries the same sequence as the original attempt
  • The cached account sequence contains no gap after a failed-then-retried submission
  • The existing BadSequence refresh-and-retry behaviour still passes unchanged
  • npm test, npm run typecheck, npm run lint, and npm run build all pass

Definition of Done

  • Reviewed by lead maintainer
  • Pull request merged via verified status check

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.

Metadata

Metadata

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third Campaign

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions