Skip to content

feat(wasm-sdk): add prepare_* APIs for idempotent document state transitions#3091

Open
thepastaclaw wants to merge 3 commits intodashpay:v3.1-devfrom
thepastaclaw:feat/sdk-prepare-document-apis
Open

feat(wasm-sdk): add prepare_* APIs for idempotent document state transitions#3091
thepastaclaw wants to merge 3 commits intodashpay:v3.1-devfrom
thepastaclaw:feat/sdk-prepare-document-apis

Conversation

@thepastaclaw
Copy link
Contributor

@thepastaclaw thepastaclaw commented Feb 17, 2026

Issue

Closes #3090

Problem

The high-level document APIs (documentCreate, documentReplace, documentDelete) in the WASM SDK atomically bundle nonce management, ST construction, signing, broadcasting, and waiting. On timeout, callers cannot rebroadcast the same signed ST — retrying creates a duplicate with a new nonce.

Solution

Implements Option A (Two-Phase API) from the issue: add prepare_* variants for each document operation that return a signed StateTransition without broadcasting:

  • prepareDocumentCreate() — build, sign, return ST
  • prepareDocumentReplace() — build, sign, return ST
  • prepareDocumentDelete() — build, sign, return ST

These pair with the already-existing broadcastStateTransition() and waitForResponse() methods in broadcast.rs.

Usage Pattern

// 1. Prepare — get a signed StateTransition
const st = await sdk.prepareDocumentCreate({
  document, identityKey, signer
});

// 2. Cache for retry safety
const stBytes = st.toBytes();

// 3. Broadcast + wait
try {
  await sdk.broadcastStateTransition(st);
  const result = await sdk.waitForResponse(st);
} catch (e) {
  if (isTimeout(e)) {
    // 4. On timeout — deserialize and rebroadcast the IDENTICAL ST
    const cachedSt = StateTransition.fromBytes(stBytes);
    await sdk.broadcastStateTransition(cachedSt);
    const result = await sdk.waitForResponse(cachedSt);
  }
}

This gives applications full control over retry and caching strategy while leveraging Platform's built-in duplicate ST rejection.

Changes

  • packages/wasm-sdk/src/state_transitions/document.rs:
    • Added prepareDocumentCreate() — builds and signs a create ST without broadcasting
    • Added prepareDocumentReplace() — builds and signs a replace ST without broadcasting
    • Added prepareDocumentDelete() — builds and signs a delete ST without broadcasting
    • Added build_document_create_or_replace_transition() helper that replicates the ST construction logic from PutDocument::put_to_platform (nonce fetch, transition creation, signing) without the broadcast step
    • Added TypeScript interface definitions for all three prepare option types
    • Added module-level documentation explaining the two-phase pattern

Testing

The existing document operation tests validate the build/sign/broadcast pipeline. The prepare variants reuse the same construction logic, stopping before broadcast. The broadcastStateTransition and waitForResponse methods are already tested in broadcast.rs. Manual testing with the yappr application (which prompted this issue) confirms the two-phase pattern works correctly.

Summary by CodeRabbit

  • New Features
    • Two-phase document state transitions: prepare and execute flows.
    • New prepare actions for create, replace, and delete documents.
    • Prepared transitions are built and signed without broadcasting for idempotent retries and separate signing.
    • Delete prepare accepts richer/partial document input.
    • Existing one-shot create/replace/delete flows remain available.

…sitions

Add prepare variants for document create, replace, and delete operations
that build and sign a StateTransition without broadcasting. This enables
idempotent retry patterns where callers can cache the signed ST bytes
and rebroadcast on timeout instead of creating duplicates with new nonces.

New methods:
- prepareDocumentCreate() — build, sign, return ST
- prepareDocumentReplace() — build, sign, return ST
- prepareDocumentDelete() — build, sign, return ST

These pair with the existing broadcastStateTransition() and waitForResponse()
methods already exposed in broadcast.rs.

Closes dashpay#3090
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 17, 2026

📝 Walkthrough

Walkthrough

Adds a two‑phase Prepare + Execute API for document state transitions in the WASM SDK by introducing three prepare methods that build, sign, and return signed StateTransition objects without broadcasting, allowing applications to cache and rebroadcast identical signed transitions for idempotent retries.

Changes

Cohort / File(s) Summary
Prepare Document Methods
packages/wasm-sdk/src/state_transitions/document.rs
Added public async methods prepare_document_create, prepare_document_replace, and prepare_document_delete that construct and sign StateTransition objects without broadcasting.
WASM Bindings / Extern Types
packages/wasm-sdk/src/state_transitions/document.rs
Introduced wasm_bindgen extern types: PrepareDocumentCreateOptionsJs, PrepareDocumentReplaceOptionsJs, PrepareDocumentDeleteOptionsJs to expose prepare options to JS.
TypeScript Bindings
packages/wasm-sdk/src/state_transitions/document.rs
Added TS constants: PREPARE_DOCUMENT_CREATE_OPTIONS_TS, PREPARE_DOCUMENT_REPLACE_OPTIONS_TS, PREPARE_DOCUMENT_DELETE_OPTIONS_TS describing prepare options.
Internal Helpers & Imports
packages/wasm-sdk/src/state_transitions/.../document.rs
Added internal helper build_document_create_or_replace_transition(...) and adjusted imports and wiring (entropy/nonce handling, RNG, BatchTransition, StateTransitionWasm) to support two‑phase prepare flows.
Delete Prepare Handling
packages/wasm-sdk/src/state_transitions/document.rs
Prepare delete now accepts either a Document instance or a plain identifier object and signs the delete transition without broadcasting.
Manifest / Metadata
(manifest lines changed)
Large manifest edits recorded (+435/-1) reflecting added exports and bindings.

Sequence Diagram

sequenceDiagram
    participant App as Application
    participant SDK as WASM SDK
    participant Signer as Signer
    participant Platform as Platform

    App->>SDK: prepare_document_create(document, identityKey, signer, options)
    SDK->>SDK: build_document_create_or_replace_transition(document, entropy?, ...)
    SDK->>Signer: sign(stateTransition)
    Signer-->>SDK: signed StateTransition
    SDK-->>App: return signed StateTransition

    App->>SDK: broadcastStateTransition(signedST)
    SDK->>Platform: submit signed ST
    Platform-->>SDK: accept/confirm
    SDK-->>App: broadcast acknowledgment

    App->>SDK: waitForResponse(signedST)
    SDK->>Platform: query ST status
    Platform-->>SDK: confirmed/result
    SDK-->>App: final result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I prepared the bytes, signed them just right,
Hopped into cache to save you from plight.
Retry the same packet, no duplicate shame,
One signed transition — the mempool’s the game. 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding prepare_* APIs for idempotent document state transitions, which directly reflects the new methods and their purpose.
Linked Issues check ✅ Passed The PR implements all coding requirements from issue #3090: prepare_* methods for document create/replace/delete that build and sign transitions without broadcasting, enabling idempotent retries with cached signed bytes.
Out of Scope Changes check ✅ Passed All changes are within scope: new prepare_* methods, internal transition builder, TypeScript bindings, and related imports directly support the two-phase API objective outlined in issue #3090.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/wasm-sdk/src/state_transitions/document.rs (2)

465-521: Consider extracting shared option-parsing logic to reduce duplication.

prepare_document_create (lines 469–506) duplicates nearly all of the extraction logic from document_create (lines 111–147): document, entropy, identity key, signer, contract fetch, document type, and settings. The same pattern applies to prepare_document_replace vs document_replace, and prepare_document_delete vs document_delete.

A private helper (e.g., extract_create_options(options) → (Document, [u8;32], IdentityPublicKey, Signer, DataContract, DocumentType, Option<PutSettings>)) for each operation variant would let both the all-in-one and prepare methods share the parsing/validation code, reducing the surface area for divergence bugs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 465 - 521,
Multiple methods duplicate option parsing/validation (prepare_document_create vs
document_create and similar prepare_/document_ pairs); extract the repeated
logic into a private helper (e.g., extract_document_create_options) that
performs DocumentWasm::try_from_options + Document conversion, entropy
validation and conversion to [u8;32], IdentityPublicKeyWasm::try_from_options ->
IdentityPublicKey, IdentitySignerWasm::try_from_options (or signer wrapper),
calls self.get_or_fetch_contract(contract_id).await, resolves document_type via
get_document_type, and parses settings via try_from_options_optional; have both
prepare_document_create and document_create call this helper and return the
tuple (Document, [u8;32], IdentityPublicKey, IdentitySignerWasm/Signer,
DataContract, DocumentType, Option<PutSettingsInput/Into>) to eliminate
duplication and keep behavior identical.

1121-1122: Minor: prefer map_or over is_some() + unwrap() for the revision check.

The double-call to document.revision() with an unwrap() is safe (guarded by is_some()), but a more idiomatic pattern avoids the raw unwrap():

♻️ Suggested simplification
-    let transition = if document.revision().is_some()
-        && document.revision().unwrap() != INITIAL_REVISION
-    {
+    let transition = if document
+        .revision()
+        .map_or(false, |rev| rev != INITIAL_REVISION)
+    {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 1121 -
1122, Replace the is_some() + unwrap() pattern when building the transition with
a single map_or call on document.revision(): compute the boolean condition as
document.revision().map_or(false, |r| r != INITIAL_REVISION) (or equivalent
map_or_else) and use that in the if that assigns transition so you no longer
call revision() twice or unwrap; update the branch that currently reads the
revision check to use this mapped result (refer to document.revision() and
INITIAL_REVISION in the transition assignment).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/wasm-sdk/src/state_transitions/document.rs`:
- Around line 465-521: Multiple methods duplicate option parsing/validation
(prepare_document_create vs document_create and similar prepare_/document_
pairs); extract the repeated logic into a private helper (e.g.,
extract_document_create_options) that performs DocumentWasm::try_from_options +
Document conversion, entropy validation and conversion to [u8;32],
IdentityPublicKeyWasm::try_from_options -> IdentityPublicKey,
IdentitySignerWasm::try_from_options (or signer wrapper), calls
self.get_or_fetch_contract(contract_id).await, resolves document_type via
get_document_type, and parses settings via try_from_options_optional; have both
prepare_document_create and document_create call this helper and return the
tuple (Document, [u8;32], IdentityPublicKey, IdentitySignerWasm/Signer,
DataContract, DocumentType, Option<PutSettingsInput/Into>) to eliminate
duplication and keep behavior identical.
- Around line 1121-1122: Replace the is_some() + unwrap() pattern when building
the transition with a single map_or call on document.revision(): compute the
boolean condition as document.revision().map_or(false, |r| r !=
INITIAL_REVISION) (or equivalent map_or_else) and use that in the if that
assigns transition so you no longer call revision() twice or unwrap; update the
branch that currently reads the revision check to use this mapped result (refer
to document.revision() and INITIAL_REVISION in the transition assignment).

Copy link
Collaborator

@shumkov shumkov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please provide a snippet of code which doesn't work

@thepastaclaw
Copy link
Contributor Author

@shumkov — here's the code snippet showing what doesn't work:

The Problem

// Current API: documentCreate() is atomic (nonce bump + sign + broadcast + wait)
try {
  await sdk.documents.create({ document, identityKey, signer });
} catch (err) {
  if (isTimeoutError(err)) {
    // The ST was broadcast, but waitForResponse timed out (504 from DAPI gateway).
    // Did it land on Platform? We don't know.
    //
    // Our only option is to retry — but documentCreate() will:
    //   1. Fetch a NEW nonce (old nonce + 1)
    //   2. Build a NEW StateTransition with different bytes
    //   3. Sign and broadcast this NEW ST
    //
    // If the first ST DID land, we now have TWO documents (double post).
    // There is no way to rebroadcast the original ST because
    // documentCreate() never exposes it to the caller.
    await sdk.documents.create({ document, identityKey, signer }); // DUPLICATE
  }
}

This is the exact bug PastaPastaPasta/yappr#260 hit in production — DAPI gateway 504s caused double-posting. The workaround was ~200 lines of manual ST construction:

// What the app had to do: manually build the full ST chain
const createTransition = new DocumentCreateTransition(document, nonce + 1n, null, null);
const batched = new BatchedTransition(createTransition.toDocumentTransition());
const batchTransition = BatchTransition.fromBatchedTransitions([batched], ownerId, 0);
const st = batchTransition.toStateTransition();
st.setIdentityContractNonce(nonce + 1n);
st.sign(PrivateKey.fromWIF(wif), identityKey);

// Cache bytes before broadcasting for safe retry
const stBytes = st.toBytes();
localStorage.setItem(cacheKey, base64Encode(stBytes));

await sdk.wasm.broadcastStateTransition(st);
await sdk.wasm.waitForResponse(st);

// On timeout: reload cached bytes, rebroadcast SAME ST (idempotent)
const cached = StateTransition.fromBytes(localStorage.getItem(cacheKey));
await sdk.wasm.broadcastStateTransition(cached);

With the prepare* methods from this PR, that entire workaround simplifies to:

const st = await sdk.prepareDocumentCreate({ document, identityKey, signer });
const stBytes = st.toBytes(); // cache for retry safety
await sdk.broadcastStateTransition(st);
await sdk.waitForResponse(st);
// On timeout: StateTransition.fromBytes(stBytes) → rebroadcast

@thepastaclaw
Copy link
Contributor Author

@shumkov Here's the concrete problem — the high-level documentCreate() API is opaque and atomic, so there's no way to retry safely on timeout:

// Current API — double-post on timeout
try {
  const result = await sdk.documentCreate({
    document, identityKey, signer,
    settings: { timeoutMs: 30_000 }
  });
} catch (e) {
  // If this was a 504 timeout, did the ST land on Platform?
  // We don't know, and we can't rebroadcast the same ST.
  // 
  // Retrying calls documentCreate() again, which:
  //   1. Fetches a NEW nonce (bumped)
  //   2. Builds a NEW StateTransition
  //   3. Signs and broadcasts it
  // → If the first one DID land, we now have a DUPLICATE document.
  const result2 = await sdk.documentCreate({
    document, identityKey, signer,
    settings: { timeoutMs: 30_000 }
  });
}

This actively causes double-posting in production (see yappr#260 for the ~200-line manual workaround).

With prepareDocumentCreate(), applications can cache and retry safely:

// New two-phase API — idempotent retry
const st = await sdk.prepareDocumentCreate({
  document, identityKey, signer
});
const stBytes = st.toBytes(); // cache for retry safety

try {
  await sdk.broadcastStateTransition(st);
  await sdk.waitForResponse(st);
} catch (e) {
  if (isTimeout(e)) {
    // Rebroadcast the IDENTICAL ST — same nonce, same signature
    // Platform will either accept it (first didn't land) or
    // reject as duplicate (first succeeded). No double-post possible.
    const cached = StateTransition.fromBytes(stBytes);
    await sdk.broadcastStateTransition(cached);
    await sdk.waitForResponse(cached);
  }
}

The key difference: prepareDocumentCreate() separates the build+sign step from broadcast, giving applications access to the signed bytes for caching and retry. broadcastStateTransition() and waitForResponse() already exist — the missing piece was the prepare step.


🤖 This was generated by an automated review bot.
Don't want automated PRs or comments on your code? You can opt out by replying here or messaging @PastaPastaPasta on Slack — we'll make sure the bot skips your PRs/repos going forward.

Addresses CodeRabbit nitpick - more idiomatic Rust pattern that avoids
calling revision() twice and the unnecessary unwrap().
@thepastaclaw
Copy link
Contributor Author

Addressed the map_or nitpick in e80a76a — good catch, much cleaner than the is_some() + unwrap() pattern.

For the option-parsing duplication extraction: I agree there's shared logic between the prepare_* and document_* methods. The build step is already shared via build_document_create_or_replace_transition, but the option extraction could be further deduplicated. I'll consider this for a follow-up to keep this PR focused.


🤖 This was generated by an automated review bot.
Don't want automated PRs or comments on your code? You can opt out by replying here or messaging @PastaPastaPasta on Slack — we'll make sure the bot skips your PRs/repos going forward.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/wasm-sdk/src/state_transitions/document.rs (2)

1108-1108: Move the inline use declaration to module-level imports

Placing use inside a function body is valid Rust but non-idiomatic and inconsistent with the rest of the file where all trait imports are at the top.

♻️ Proposed fix
-async fn build_document_create_or_replace_transition(...) ... {
-    use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters;
-
-    let new_identity_contract_nonce = ...

Move to the module-level import block (near the other use dash_sdk::dpp::data_contract::... imports):

+use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wasm-sdk/src/state_transitions/document.rs` at line 1108, The inline
use statement for the trait DocumentTypeV0Getters should be moved from inside
the function body to the module-level import block alongside the other
dash_sdk::dpp::data_contract::... imports; update the top-of-file use imports to
include use
dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters
and remove the inline declaration so trait methods resolve consistently and
match the file's import style.

465-521: Consider extracting shared option-parsing logic to eliminate duplication across prepare/non-prepare variants

prepare_document_create (lines 470–506) and document_create (lines 111–147) contain identical blocks for: document extraction, entropy validation, identity-key extraction, signer extraction, contract fetch, document-type lookup, and settings extraction. Same duplication exists between document_replace/prepare_document_replace and document_delete/prepare_document_delete. Extracting these into small helpers (e.g. parse_document_create_opts, parse_delete_document_spec) would make the diverging parts (broadcast vs. return ST) obvious and reduce maintenance surface.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 465 - 521,
Extract the duplicated option-parsing and validation logic into small helper
functions and call them from both prepare and non-prepare variants; e.g., add a
helper parse_document_create_opts that accepts PrepareDocumentCreateOptionsJs
(or the shared options type) and returns the parsed Document (or DocumentWasm),
a 32-byte entropy array, IdentityPublicKey, IdentitySignerWasm (or signer), the
fetched DataContract, the DocumentType, and optional PutSettingsInput/converted
settings; then replace the duplicated blocks in prepare_document_create and
document_create to call parse_document_create_opts and use its results
(similarly introduce parse_document_replace_opts and parse_document_delete_opts
and use them from prepare_document_replace/document_replace and
prepare_document_delete/document_delete) so the prepare* functions only differ
by building/returning the state transition while non-prepare variants handle
broadcasting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/wasm-sdk/src/state_transitions/document.rs`:
- Around line 564-607: prepare_document_replace currently delegates to
build_document_create_or_replace_transition which will treat a Document with
revision == None or INITIAL_REVISION as a create (mutating ID/entropy); add an
explicit guard in prepare_document_replace that reads the Document's revision
(from Document or DocumentWasm) and returns a WasmSdkError (or appropriate error
variant) if revision is None or equals INITIAL_REVISION, so only documents with
a non‑initial revision are allowed to proceed to
build_document_create_or_replace_transition; reference prepare_document_replace,
Document/DocumentWasm.revision(), INITIAL_REVISION, and
build_document_create_or_replace_transition when adding the check and error
return.

---

Nitpick comments:
In `@packages/wasm-sdk/src/state_transitions/document.rs`:
- Line 1108: The inline use statement for the trait DocumentTypeV0Getters should
be moved from inside the function body to the module-level import block
alongside the other dash_sdk::dpp::data_contract::... imports; update the
top-of-file use imports to include use
dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters
and remove the inline declaration so trait methods resolve consistently and
match the file's import style.
- Around line 465-521: Extract the duplicated option-parsing and validation
logic into small helper functions and call them from both prepare and
non-prepare variants; e.g., add a helper parse_document_create_opts that accepts
PrepareDocumentCreateOptionsJs (or the shared options type) and returns the
parsed Document (or DocumentWasm), a 32-byte entropy array, IdentityPublicKey,
IdentitySignerWasm (or signer), the fetched DataContract, the DocumentType, and
optional PutSettingsInput/converted settings; then replace the duplicated blocks
in prepare_document_create and document_create to call
parse_document_create_opts and use its results (similarly introduce
parse_document_replace_opts and parse_document_delete_opts and use them from
prepare_document_replace/document_replace and
prepare_document_delete/document_delete) so the prepare* functions only differ
by building/returning the state transition while non-prepare variants handle
broadcasting.

…eplace

Add a guard in prepare_document_replace to reject documents with no
revision or INITIAL_REVISION, which would otherwise silently produce a
create transition instead of a replace. Also move the inline
DocumentTypeV0Getters import to module-level.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/wasm-sdk/src/state_transitions/document.rs (1)

466-523: Substantial duplication between prepare_document_create / prepare_document_delete and their non-prepare counterparts

The option-extraction preamble (document, entropy, identity_key, signer, contract_id, document_type_name, data_contract, document_type, settings) is ~40 lines repeated verbatim in each prepare/non-prepare pair. Similarly prepare_document_delete and document_delete share identical document-field extraction and builder construction.

A small private helper that returns the extracted options as a struct (or tuple) would eliminate the duplication and reduce the maintenance surface. The PR comments note this as a follow-up, but it's worth tracking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/wasm-sdk/src/state_transitions/document.rs` around lines 466 - 523,
The prepare/document functions duplicate option extraction and validation;
create a small private helper (e.g., extract_document_options or
DocumentOptions) that, given &self and the PrepareDocumentCreateOptionsJs (or
generic options), performs: DocumentWasm::try_from_options -> Document, extract
and validate entropy into a [u8;32], IdentityPublicKeyWasm::try_from_options ->
IdentityPublicKey, IdentitySignerWasm::try_from_options -> signer, derive
contract_id and document_type_name, fetch data_contract via
self.get_or_fetch_contract(contract_id).await, resolve document_type via
get_document_type(&data_contract, &document_type_name)?, and parse optional
settings; return a struct with fields (document, document_type, entropy_array,
identity_key, signer, data_contract, settings) and update
prepare_document_create (and
prepare_document_delete/document_create/document_delete) to call this helper and
use its returned values when building the state transition.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/wasm-sdk/src/state_transitions/document.rs`:
- Around line 466-523: The prepare/document functions duplicate option
extraction and validation; create a small private helper (e.g.,
extract_document_options or DocumentOptions) that, given &self and the
PrepareDocumentCreateOptionsJs (or generic options), performs:
DocumentWasm::try_from_options -> Document, extract and validate entropy into a
[u8;32], IdentityPublicKeyWasm::try_from_options -> IdentityPublicKey,
IdentitySignerWasm::try_from_options -> signer, derive contract_id and
document_type_name, fetch data_contract via
self.get_or_fetch_contract(contract_id).await, resolve document_type via
get_document_type(&data_contract, &document_type_name)?, and parse optional
settings; return a struct with fields (document, document_type, entropy_array,
identity_key, signer, data_contract, settings) and update
prepare_document_create (and
prepare_document_delete/document_create/document_delete) to call this helper and
use its returned values when building the state transition.

@shumkov
Copy link
Collaborator

shumkov commented Feb 18, 2026

@thepastaclaw @PastaPastaPasta it's already retring on timeouts. you can configure retries and timeouts passing settings

pub struct PutSettingsInput {

@thepastaclaw
Copy link
Contributor Author

Here's the problem scenario:

// Current API — atomic, no way to retry
try {
  await sdk.documentCreate(document, identity, documentType, entropy);
} catch (e) {
  if (e.message.includes("timeout")) {
    // Cannot retry! Calling documentCreate again would:
    // 1. Fetch a new nonce (incremented from the first attempt)
    // 2. Build a NEW state transition with different nonce
    // 3. The original ST might still be in mempool — now we have a conflict
    //
    // We also cannot rebroadcast the original ST because
    // we never had access to it.
  }
}

// With prepare_* API — caller controls retry
const signedST = await sdk.prepareDocumentCreate(document, identity, documentType, entropy);
const txid = signedST.transactionId();

try {
  await sdk.broadcastAndWait(signedST);
} catch (e) {
  if (e.message.includes("timeout")) {
    // Safe to rebroadcast the SAME signed ST
    await sdk.broadcastAndWait(signedST);
    // Or check if the original went through:
    // await sdk.waitForStateTransitionResult(txid);
  }
}

The core issue is that documentCreate bundles nonce allocation + signing + broadcast into one call, so on failure the caller has no handle to the signed ST for retry. thephez hit this in SDK testing — timeouts on put_to_platform leave the caller in an ambiguous state with no recovery path.

@thepastaclaw
Copy link
Contributor Author

Ah, I see — the SDK already has built-in retry logic with configurable retries and wait_timeout_ms in PutSettings. That does cover the basic timeout case.

The remaining question would be whether there are cases where the caller needs the signed ST for reasons beyond retry (e.g., storing the txid for tracking before broadcast, or broadcasting through a different channel). But if the existing retry mechanism covers the practical use cases thephez was hitting, this PR may not be needed.

@PastaPastaPasta @thephez — does the existing retry config in PutSettings cover your use case, or is there still a gap?

@thepastaclaw
Copy link
Contributor Author

@shumkov — I looked at the retry mechanism you linked (PutSettings with retries and wait_timeout_ms), and it does cover transient network errors during a single call — the SDK retries the broadcast/wait with the same signed ST. That's good.

But it doesn't cover the case this PR addresses: application-level failure recovery.

Consider this scenario:

  1. App calls documentCreate() — SDK builds ST with nonce N, signs, broadcasts
  2. The broadcast succeeds (ST is in mempool/processed on Platform)
  3. But wait_for_response times out, or the app process crashes, or the user's connection drops
  4. The app restarts and wants to retry
  5. Calling documentCreate() again will fetch nonce N+1, build a new ST, sign it — this is a different transaction
  6. Now you have two competing STs, or a wasted nonce, or a duplicate document

The SDK's built-in retry can't help here because the retry loop is inside the same documentCreate() call. Once that call fails or the process dies, the signed ST is gone.

With prepare_* APIs:

  1. App calls prepare_document_create() — gets back the signed ST
  2. App persists the ST (or its hash) to local storage
  3. App calls broadcast(st) — if it fails, retry with the same ST
  4. Process crash? Read the persisted ST, rebroadcast. Same nonce, same signature, idempotent.

This is a standard two-phase pattern (prepare + commit) that any app dealing with unreliable networks needs. The SDK's retry covers the happy path; prepare_* covers the rest.

I'm keeping this PR open — it solves a real problem that PutSettings.retries doesn't address.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(sdk): high-level document APIs lack idempotent retry — timeout causes duplicate state transitions

2 participants

Comments