Skip to content

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

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

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

Conversation

@thepastaclaw
Copy link
Copy Markdown
Collaborator

@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.

Validation

Build verification

# WASM SDK builds successfully with new prepare_* APIs
cargo check -p wasm-sdk

Existing test coverage

The prepare_* methods reuse the same internal construction paths as the existing all-in-one document operations:

  • prepareDocumentCreate / prepareDocumentReplace delegate to the new build_document_create_or_replace_transition() helper, which mirrors the nonce-fetch → transition-build → sign pipeline from PutDocument::put_to_platform
  • prepareDocumentDelete uses DocumentDeleteTransitionBuilder::sign(), the same builder used by the existing documentDelete flow
  • broadcastStateTransition() and waitForResponse() (the "execute" half) are already tested in broadcast.rs

Existing CI test suites (cargo test -p wasm-sdk, platform integration tests) validate these shared code paths.

Manual / integration testing

Input validation

  • prepareDocumentReplace rejects documents with INITIAL_REVISION (guards against accidental create-as-replace)
  • prepareDocumentCreate validates entropy is present and exactly 32 bytes
  • prepareDocumentDelete accepts both full Document instances and minimal identifier objects

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 locally (not broadcast) to allow idempotent retries and separate signing.
    • Delete prepare accepts richer or partial document input.
    • Existing one-shot create/replace/delete flows remain available.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 17, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 68ab2173-132e-4e9d-9d34-4cf45eadbf27

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a two‑phase Prepare+Execute API to the WASM SDK for document state transitions by introducing prepare_document_create, prepare_document_replace, and prepare_document_delete which build, sign, and return signed StateTransition objects without broadcasting 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 Helper
packages/wasm-sdk/src/state_transitions/.../document.rs
Added internal helper build_document_create_or_replace_transition(...) to construct and sign create/replace transitions, handling entropy for create and no entropy for replace.
Delete Prepare Handling
packages/wasm-sdk/src/state_transitions/document.rs
Prepare delete accepts either a full Document or a plain identifier object and produces a signed delete StateTransition without broadcasting.
Wiring & Imports
packages/wasm-sdk/src/state_transitions/...
Adjusted imports and wiring for nonce/entropy handling, RNG, BatchTransition, StateTransitionWasm, and signature flow to support two‑phase prepare flows.
Manifest / Metadata
(project)
Large manifest/exports and binding additions recorded (+435/-1) reflecting added APIs and TS 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 carved the bytes with careful paws,
I signed them true without a pause.
Tuck them in cache, retry with delight,
Same little hop, same perfect flight. 🥕

🚥 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 is the core objective of this PR.
Linked Issues check ✅ Passed The PR implements Option A (two-phase API) from issue #3090 by adding prepare_document_create, prepare_document_replace, and prepare_document_delete methods that build and sign StateTransitions without broadcasting, enabling idempotent retries.
Out of Scope Changes check ✅ Passed All changes are scoped to implementing the two-phase prepare API for document operations. No unrelated features or refactoring were introduced outside the defined objectives.
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 unit tests (beta)
  • Create PR with unit tests

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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
Collaborator 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
Copy Markdown
Collaborator 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.

@thepastaclaw
Copy link
Copy Markdown
Collaborator 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
Copy Markdown
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.

Comment thread packages/wasm-sdk/src/state_transitions/document.rs
Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
Collaborator 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
Copy Markdown
Collaborator 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
Copy Markdown
Collaborator 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.

@PastaPastaPasta
Copy link
Copy Markdown
Member

I think in my usage, the timeout that bites us is the wait_for_response claw; does the PutSettingsInput affect wait_for_response? will it wait_for_response multiple times if the first one fails?

@PastaPastaPasta
Copy link
Copy Markdown
Member

So if you set retries: 5 in PutSettings, both the broadcast and the wait-for-response will retry up to 5 times on transient failures.

This isn't very consistent with what I've seen in using the sdk in yappr; is the default configuration setup to retry?

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Re: your questions about retry behavior:

Does PutSettings affect wait_for_response? Yes — wait_for_response in broadcast.rs:107-109 builds retry_settings from PutSettings.request_settings, then passes them to the retry() function in sync.rs. Both broadcast and wait use the same retry infrastructure.

Default config: DEFAULT_RETRIES is 5 (defined in rs-dapi-client/src/request_settings.rs:10). When no explicit PutSettings is passed, the SDK falls back to sdk.dapi_client_settings, which uses DEFAULT_RETRIES = 5 as the fallback.

What's actually retried: The retry loop in sync.rs:165 retries the entire request closure, which for wait_for_response means re-sending the WaitForStateTransitionResult gRPC request. So if the wait times out or gets a transient error, it retries up to 5 times.

But — if the wait_for_response itself succeeds but returns a platform error (e.g., state transition was rejected), that's NOT retried — it's returned as a ConsensusError. Only transport-level failures trigger retries.

So if you're hitting timeouts in yappr where the wait never completes even with retries, it might be that the timeout per-attempt is too short, or the retries are exhausted. The wait_timeout_ms in PutSettings controls per-attempt timeout, and that combined with 5 retries gives you 5 * wait_timeout_ms total wait time.

This is exactly why I think prepare_* APIs still have value — they let you control the retry/wait strategy at the application level rather than being constrained by the SDK's built-in retry loop.

@dashpay dashpay deleted a comment from thepastaclaw Feb 20, 2026
@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Re: your questions about retry/timeout behavior:

Does PutSettingsInput affect wait_for_response?
Yes — both broadcast_and_wait and wait_for_response use the same PutSettings. The settings flow through to RequestSettings which controls DAPI-level retries and timeouts. So if you set retries: 5, both the broadcast call AND the wait-for-response call will individually retry up to that many times on transient DAPI failures (connection timeouts, unavailable nodes, etc.).

Are retries enabled by default?
Looking at the code: when settings is None, both functions fall back to sdk.dapi_client_settings (the SDK-wide defaults). The actual default retry count depends on what's configured in the SDK initialization — but I suspect the default retries may be 0 or very low, which would explain your yappr experience where timeouts just fail without recovery.

The real gap (which this PR was trying to address) is that even with retries configured, the retry happens at the DAPI request level — if the broadcast succeeds but wait_for_response times out, a retry would re-broadcast a new nonce rather than re-polling for the original TX. The prepare_* pattern gives callers a handle to the signed ST so they can retry the wait without re-broadcasting.

@thepastaclaw thepastaclaw force-pushed the feat/sdk-prepare-document-apis branch from 67793d7 to a4ad291 Compare February 20, 2026 21:39
@PastaPastaPasta
Copy link
Copy Markdown
Member

The real gap (which this PR was trying to address) is that even with retries configured, the retry happens at the DAPI request level — if the broadcast succeeds but wait_for_response times out, a retry would re-broadcast a new nonce rather than re-polling for the original TX. The prepare_* pattern gives callers a handle to the signed ST so they can retry the wait without re-broadcasting.

Claw please back this up with code references

@shumkov if this is the case, this is a big problem for clients like yappr, and may have been the cause of many of our double posting issues.

We also had some issues where the sdk itself would just timeout / fail, and then user would click post again, and then it turns out that the first one worked. IDK how this case was happening if sdk had retries as shown here... (claw maybe look if yappr needs to manually configure in sdk options this retrying? or maybe we are setting some other option and that is clobbering it or something? don't look in latest yappr, look a bit further back in history before our hacky fix for double posts)

Copy link
Copy Markdown
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 (3)
packages/wasm-sdk/src/state_transitions/document.rs (3)

465-465: Register new prepare* methods in api-definitions.json and regenerate docs.

The three new #[wasm_bindgen] exports (prepareDocumentCreate, prepareDocumentReplace, prepareDocumentDelete) are now part of the public JS/TS API surface but won't appear in the generated docs.html or AI_REFERENCE.md unless they're added to api-definitions.json and docs are regenerated with python3 generate_docs.py. Based on learnings, both files are auto-generated from that JSON config and manual edits would be overwritten.

Based on learnings: "In packages/wasm-sdk/, the AI_REFERENCE.md file is auto-generated from api-definitions.json. Any documentation fixes should be made in api-definitions.json rather than directly in AI_REFERENCE.md", and "WASM SDK (packages/wasm-sdk) WebAssembly bindings must be built with ./build.sh and documentation kept in sync using python3 generate_docs.py".

🤖 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 465, Add the
three new wasm exports to the SDK docs config: update api-definitions.json to
register prepareDocumentCreate, prepareDocumentReplace, and
prepareDocumentDelete (the #[wasm_bindgen(js_name = "...")] exports in
document.rs) with appropriate signatures and descriptions, then regenerate the
WebAssembly bindings/docs by running ./build.sh and python3 generate_docs.py so
docs.html and AI_REFERENCE.md reflect the new public API surface.

466-522: Consider extracting shared option-parsing logic to reduce duplication.

prepare_document_create (lines 466–522) duplicates ~30 lines of option extraction that are also present in document_create (lines 107–164). The only meaningful difference between them is the final call — build_document_create_or_replace_transition vs put_to_platform_and_wait_for_response. Extracting the shared preamble into a private helper struct or a typed DocumentCreateArgs would halve the maintenance surface.

This duplication also exists between prepare_document_replace and document_replace.

(Already noted in PR discussion as a known follow-up — flagging here for 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 - 522,
prepare_document_create duplicates the same options-parsing preamble found in
document_create (and similarly for prepare_document_replace/document_replace);
extract that shared logic into a small private helper (e.g., a
DocumentCreateArgs or parse_document_create_args function) that returns a struct
containing the parsed Document/DocumentWasm, contract_id, document_type_name,
entropy_array ([u8;32]), IdentityPublicKey (from IdentityPublicKeyWasm),
IdentitySignerWasm, and optional PutSettingsInput, reusing existing helpers like
DocumentWasm::try_from_options, IdentityPublicKeyWasm::try_from_options,
IdentitySignerWasm::try_from_options, get_or_fetch_contract, get_document_type
and try_from_options_optional; then refactor prepare_document_create to call the
new helper and pass its fields into build_document_create_or_replace_transition,
and refactor document_create to call the same helper and pass its fields into
put_to_platform_and_wait_for_response so both flows share the parsed arguments.

1147-1161: Fallback random-entropy path is unreachable from all current callers.

When document_state_transition_entropy is None and the document is in create mode (revision == None or revision == INITIAL_REVISION), the code falls into a random-entropy branch that generates a new StdRng and rewrites the document ID. This path is currently dead:

  • prepare_document_create always supplies Some(entropy_array) (validated and required).
  • prepare_document_replace always supplies None entropy but the revision guard ensures the replace branch is taken.

This is not a bug—the helper preserves the flexibility of the original put_to_platform logic. However, if StdRng::from_entropy() has platform-specific behaviour in the WASM target (i.e., getrandom with the js feature), it's worth a quick sanity check in case a future caller accidentally hits this path.

🤖 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 1147 -
1161, The fallback branch that generates entropy with StdRng::from_entropy()
(used in the document_state_transition_entropy None path and
Document::generate_document_id_v0) is effectively unreachable today but may hit
WASM where StdRng::from_entropy() behavior differs; update the fallback to be
explicit: either (A) mark it unreachable/assert (so callers must supply entropy)
or (B) replace StdRng::from_entropy() with a platform-safe entropy source (e.g.,
OsRng/getrandom) guarded by cfg(target_arch = "wasm32") and return a clear error
if secure entropy is unavailable; adjust callers or document this in
prepare_document_create / prepare_document_replace and keep references to
Document::generate_document_id_v0, document_state_transition_entropy,
StdRng::from_entropy, prepare_document_create, prepare_document_replace, and
put_to_platform to locate the code.
🤖 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`:
- Line 465: Add the three new wasm exports to the SDK docs config: update
api-definitions.json to register prepareDocumentCreate, prepareDocumentReplace,
and prepareDocumentDelete (the #[wasm_bindgen(js_name = "...")] exports in
document.rs) with appropriate signatures and descriptions, then regenerate the
WebAssembly bindings/docs by running ./build.sh and python3 generate_docs.py so
docs.html and AI_REFERENCE.md reflect the new public API surface.
- Around line 466-522: prepare_document_create duplicates the same
options-parsing preamble found in document_create (and similarly for
prepare_document_replace/document_replace); extract that shared logic into a
small private helper (e.g., a DocumentCreateArgs or parse_document_create_args
function) that returns a struct containing the parsed Document/DocumentWasm,
contract_id, document_type_name, entropy_array ([u8;32]), IdentityPublicKey
(from IdentityPublicKeyWasm), IdentitySignerWasm, and optional PutSettingsInput,
reusing existing helpers like DocumentWasm::try_from_options,
IdentityPublicKeyWasm::try_from_options, IdentitySignerWasm::try_from_options,
get_or_fetch_contract, get_document_type and try_from_options_optional; then
refactor prepare_document_create to call the new helper and pass its fields into
build_document_create_or_replace_transition, and refactor document_create to
call the same helper and pass its fields into
put_to_platform_and_wait_for_response so both flows share the parsed arguments.
- Around line 1147-1161: The fallback branch that generates entropy with
StdRng::from_entropy() (used in the document_state_transition_entropy None path
and Document::generate_document_id_v0) is effectively unreachable today but may
hit WASM where StdRng::from_entropy() behavior differs; update the fallback to
be explicit: either (A) mark it unreachable/assert (so callers must supply
entropy) or (B) replace StdRng::from_entropy() with a platform-safe entropy
source (e.g., OsRng/getrandom) guarded by cfg(target_arch = "wasm32") and return
a clear error if secure entropy is unavailable; adjust callers or document this
in prepare_document_create / prepare_document_replace and keep references to
Document::generate_document_id_v0, document_state_transition_entropy,
StdRng::from_entropy, prepare_document_create, prepare_document_replace, and
put_to_platform to locate the code.

@thepastaclaw thepastaclaw force-pushed the feat/sdk-prepare-document-apis branch from 64ee784 to a868b10 Compare February 21, 2026 18:02
@thepastaclaw thepastaclaw marked this pull request as draft February 25, 2026 08:22
@github-actions github-actions Bot added this to the v3.1.0 milestone Feb 25, 2026
@thepastaclaw thepastaclaw force-pushed the feat/sdk-prepare-document-apis branch from a868b10 to 1cee881 Compare February 25, 2026 16:22
…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
Addresses CodeRabbit nitpick - more idiomatic Rust pattern that avoids
calling revision() twice and the unnecessary unwrap().
thepastaclaw and others added 2 commits March 12, 2026 15:17
…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>
Clippy 1.92 treats unnecessary_map_or as a warning, which CI promotes
to error via -D warnings.
@thepastaclaw thepastaclaw force-pushed the feat/sdk-prepare-document-apis branch from 1cee881 to 24fbe7d Compare March 12, 2026 20:18
Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

The new prepare_* APIs are well-structured and faithfully mirror upstream rs-sdk logic. The main gap is the omission of client-side structure validation that the upstream put_to_platform performs before broadcast. Platform will catch invalid STs server-side, so this is not blocking, but it degrades error quality and wastes nonces. No blocking issues found.

Reviewed commit: 24fbe7d

🟡 4 suggestion(s) | 💬 1 nitpick(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1174-1177: Missing client-side structure validation before returning signed ST
  The upstream `PutDocument::put_to_platform` (rs-sdk `put_document.rs:113`) calls `ensure_valid_state_transition_structure(&transition, sdk.version())?` after building the transition. The `build_document_create_or_replace_transition` helper skips this, so `prepareDocumentCreate` and `prepareDocumentReplace` can return structurally invalid signed transitions to JS callers.

While `ensure_valid_state_transition_structure` is `pub(crate)` in rs-sdk and inaccessible, the underlying `StateTransitionStructureValidation` trait is public in `rs-dpp` and available via `dash-sdk` (through the `dash-sdk-features` → `state-transition-signing` → `state-transition-validation` feature chain). The fix is to call `validate_structure` directly.

Impact: invalid STs would be caught server-side on broadcast, but the error message would be confusing, and the nonce would already be consumed.
- [SUGGESTION] lines 466-522: prepareDocumentCreate lacks symmetric revision guard
  `prepareDocumentReplace` (lines 574-584) explicitly guards against documents with no revision or `INITIAL_REVISION`, rejecting create-eligible documents with a clear error. However, `prepareDocumentCreate` has no corresponding guard — a document with `revision > INITIAL_REVISION` silently produces a *replace* transition via the helper's branching logic at line 1132-1134.

This matches upstream `put_to_platform` behavior (which also silently switches between create/replace), but the explicit `prepare*` API names create a stronger expectation that the method does what it says. The asymmetry between the two prepare methods is surprising.
- [SUGGESTION] lines 448-750: No test coverage for new prepare_* APIs
  Three new public WASM entry points (`prepareDocumentCreate`, `prepareDocumentReplace`, `prepareDocumentDelete`) were added with zero test coverage. Grepping `packages/wasm-sdk/tests/` for `prepare` returns no matches. Key untested paths: serialization roundtrip of the returned ST, retry idempotency (rebroadcasting identical bytes), the revision guard in `prepareDocumentReplace`, and the Document-vs-plain-object union input in `prepareDocumentDelete`.
- [SUGGESTION] lines 1-17: Module docs should mention nonce consumption semantics
  Each `prepare_*` call invokes `get_identity_contract_nonce` with `bump=true` (line 1125), consuming a nonce. If the caller discards the returned ST (e.g., due to an application error between prepare and broadcast), that nonce is permanently wasted. The module-level docs (lines 1-17) explain the retry pattern but don't mention this cost. Users who call prepare speculatively or discard STs will silently accumulate nonce gaps.

Comment thread packages/wasm-sdk/src/state_transitions/document.rs Outdated
Comment thread packages/wasm-sdk/src/state_transitions/document.rs
Comment thread packages/wasm-sdk/src/state_transitions/document.rs
Comment thread packages/wasm-sdk/src/state_transitions/document.rs
Comment thread packages/wasm-sdk/src/state_transitions/document.rs
@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

thepastaclaw commented Apr 8, 2026

Review Gate

Commit: b6d0637b

  • Debounce: 198m ago (need 30m)

  • CI checks: builds passed, 0/0 tests passed

  • CodeRabbit review: comment found

  • Off-peak hours: peak window (5am-11am PT) — currently 07:12 AM PT Friday

  • Run review now (check to override)

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Re-checked this draft today on head 24fbe7d1.

I replied inline to my own non-blocking follow-up comments and resolved those threads so the PR thread state matches the intended severity. That leaves the older Feb 17 CHANGES_REQUESTED from @shumkov as the only remaining review blocker.

The concrete snippets + later discussion about PutSettings retry behavior vs app-level recovery are already in-thread above. @shumkov if there is still a specific blocking gap here, can you point me at it directly? Otherwise this looks like it is waiting on reviewer state cleanup rather than a fresh code change.

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Small correction / code-backed clarification to my earlier retry comments:

Inside a single documentCreate() / documentReplace() call, the SDK does not mint a new nonce on each retry. The retry loop reuses the same signed state transition. The new nonce problem happens when the original high-level call has already returned an error (or the app/process died) and the application calls the high-level API again.

Code path on this branch:

  • packages/wasm-sdk/src/state_transitions/document.rs: documentCreate() calls document.put_to_platform_and_wait_for_response(...).
  • packages/rs-sdk/src/platform/transition/put_document.rs: put_to_platform() fetches the nonce with sdk.get_identity_contract_nonce(..., true, settings), builds/signs the transition, broadcasts it, and returns that StateTransition. Then put_to_platform_and_wait_for_response() passes that same state_transition into Self::wait_for_response(...).
  • packages/rs-sdk/src/platform/transition/broadcast.rs: both broadcast() and wait_for_response() wrap their request execution in retry(...) over the same &self transition, so transport-level retries within one call are against the same signed ST, not a newly built one.
  • packages/rs-dapi-client/src/request_settings.rs: default low-level DEFAULT_RETRIES is 5.

So the gap this PR is addressing is narrower and more specific:

  1. app calls documentCreate()
  2. SDK allocates nonce N, signs ST, broadcasts, waits
  3. call eventually returns an error to the app (or the app crashes / reloads)
  4. the signed ST is gone from the caller's perspective
  5. caller retries by calling documentCreate() again
  6. that reruns put_to_platform() and allocates a fresh nonce, because the original ST was never exposed

That is what the two-phase API fixes:

  • prepareDocumentCreate() / prepareDocumentReplace() / prepareDocumentDelete() in packages/wasm-sdk/src/state_transitions/document.rs return the signed StateTransition before broadcast
  • broadcastStateTransition() and waitForResponse() in packages/wasm-sdk/src/state_transitions/broadcast.rs let the app persist/rebroadcast/re-wait on that exact same ST across app-level failures

So: built-in retries help for in-call transient transport failures; prepare_* helps for cross-call / app-level recovery where the caller needs durable access to the signed bytes.

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Code-backed follow-up on the old yappr behavior @PastaPastaPasta asked about:

I checked yappr history before the manual ST-caching workaround landed. In commit 4f86f767 (the parent of yapprs aff7109 fix):\n\n- lib/services/state-transition-service.ts:196-201 called the high-level API directly:\n\nts\nawait sdk.documents.create({\n document,\n identityKey: signingKey,\n signer\n});\n\n\n- That path did not pass any per-call settings / PutSettings override for retries or wait timeout.\n- lib/services/evo-sdk-service.ts:62-72 initialized the SDK in trusted mode with a short global timeout:\n\nts\nthis.sdk = EvoSDK.testnetTrusted({\n settings: {\n timeoutMs: 8000,\n }\n});\n\n\nSo: I do not see old yappr explicitly clobbering retries to 0. What I do see is:\n\n1. a plain sdk.documents.create(...) call with no exposed ST handle\n2. a short app-level SDK timeout (8000ms)\n3. user-visible failures/timeouts causing another top-level create attempt\n\nOn the Platform side, the current code path is:\n\n- packages/wasm-sdk/src/state_transitions/document.rsdocumentCreate() delegates to put_to_platform_and_wait_for_response(...)\n- packages/rs-sdk/src/platform/transition/put_document.rs:61-67 fetches the nonce with bump=true, builds/signs/broadcasts the ST, and :120-142 then waits on that same ST\n- packages/rs-sdk/src/platform/transition/broadcast.rs:48-110 shows both broadcast() and wait_for_response() wrapping request execution in retry(...) against the same &self state transition\n- packages/rs-dapi-client/src/request_settings.rs:10 still shows the low-level default retries constant as 5\n\nSo the corrected distinction is:\n\n- inside one documentCreate() call: transport retries reuse the same signed ST\n- after that call has already failed / timed out / the app reloaded: the caller has no handle to the original signed ST, so calling documentCreate() again allocates a fresh nonce and creates a different transition\n\nThat second case is exactly what old yappr was vulnerable to before aff7109, and it is the gap this PR is trying to close.\n\nSo I still think the split is:\n\n- built-in retry = good for in-call transient DAPI failures\n- prepare_* = needed for cross-call / app-level recovery where the caller must persist and reuse the exact signed bytes\n\nIf there is still a specific blocking objection to that narrower claim, point me at it directly and I can address it.

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Re-checking this on the current draft head 24fbe7d1.

The original Feb 17 requested-change ask was “please provide a snippet of code which doesn't work”. That ask is already answered in-thread with concrete examples and later code-backed clarification:

At this point I do not see an unresolved implementation change tied to that review on the current head:

  • all review threads are resolved
  • the only remaining CHANGES_REQUESTED state is the old Feb 17 summary review
  • the head SHA is still the same one those later clarifications were written against

So unless there is a specific remaining blocking objection to the narrower claim here — built-in retries help within one high-level call, while prepare_* is about cross-call / app-level recovery after the signed ST is no longer available to the caller — this looks like reviewer-state cleanup rather than a fresh code-change request.

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

@shumkov re-checked this draft on the current head 24fbe7d1.

At this point I do not see an unresolved code change request on this head:

So unless there is still a specific blocking objection to the narrower claim here — built-in retries help within one high-level call, while prepare_* is about cross-call / app-level recovery after the signed ST is no longer available to the caller — this looks like stale review state rather than a fresh implementation gap.

If there is still a concrete blocker on the current head, point me at it directly and I’ll address it. Otherwise, could you clear the old CHANGES_REQUESTED state on this draft?

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

The PR adds the intended two-phase prepareDocument* WASM APIs, but introduces an asymmetric validation gap: prepareDocumentCreate accepts documents whose revision > INITIAL_REVISION and silently routes them through the REPLACE branch of the shared helper, producing a wrongly-typed signed ST. The shared helper also skips the ensure_valid_state_transition_structure check that the existing PutDocument::put_to_platform performs before broadcast, so structurally invalid transitions may be cached for retry and only fail at broadcast time. Minor issues: the replace guard also lets revision=0 through, there are no tests for any new prepare-specific validation branches, the helper contains an unreachable random-entropy fallback, and the nonce-bumping side effect of every prepare call is undocumented.

Reviewed commit: 24fbe7d

🔴 1 blocking | 🟡 4 suggestion(s) | 💬 1 nitpick(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [BLOCKING] lines 466-522: prepareDocumentCreate silently produces a REPLACE transition when the input document has a non-INITIAL revision
  `prepare_document_create` validates entropy but never checks `document.revision()`. It forwards the call to `build_document_create_or_replace_transition`, whose branch decision at lines 1132-1134 is `document.revision().is_some_and(|rev| rev != INITIAL_REVISION)` → REPLACE. A caller that passes a Document with `revision = 2` (e.g., re-hydrated from storage) will receive a signed *replacement* state transition; the supplied `entropy` is ignored, and the resulting ST will fail at broadcast with DocumentNotFound — or, worse, mutate an existing document that happens to share the ID. Commit 466c8255b2 added the inverse guard in `prepare_document_replace` (rejecting None/INITIAL_REVISION); the symmetric guard is missing on the create side. Reject any document whose revision is `Some(rev)` with `rev != INITIAL_REVISION` so the API name matches the produced transition kind.
- [SUGGESTION] lines 1112-1177: Prepared create/replace transitions skip ensure_valid_state_transition_structure
  `PutDocument::put_to_platform` (packages/rs-sdk/src/platform/transition/put_document.rs:113) calls `ensure_valid_state_transition_structure(&transition, sdk.version())?` after building the transition and before broadcasting. `build_document_create_or_replace_transition` omits this step, so `prepareDocumentCreate`/`prepareDocumentReplace` can return a signed, cacheable ST whose structure is invalid — the failure only surfaces later at broadcast, after the caller has already burned an identity-contract nonce and may have persisted the bytes for retry. This defeats a core reason to prepare-then-broadcast. Mirror the `ensure_valid_state_transition_structure` call inside the helper (and apply the same invariant to `prepare_document_delete` for parity) so the prepare path enforces the same structural invariants as the one-shot path.
- [SUGGESTION] lines 574-584: prepareDocumentReplace accepts revision 0, which can never produce a valid replace
  The guard at lines 580-584 only rejects `None` and `INITIAL_REVISION` (1). Because Revision is `u64`, a caller can still pass `revision = 0`, which falls through to `build_document_create_or_replace_transition` — where `is_some_and(|rev| rev != INITIAL_REVISION)` routes it to REPLACE. The resulting ST can never be valid against platform state (no document in platform has revision 0), so the prepare API hands the caller a signed object that is guaranteed to fail at broadcast. Collapse the two-step guard into `revision <= INITIAL_REVISION` so the API fails fast on this degenerate input.
- [SUGGESTION] lines 411-754: New prepare_* public API has no tests covering its validation branches
  The PR adds ~430 lines of new public WASM API surface (`prepareDocumentCreate`, `prepareDocumentReplace`, `prepareDocumentDelete`, plus the shared helper) with no unit or `wasm_bindgen_test` coverage. The prepare-specific branches have no equivalents in the all-in-one path: the entropy presence/length check (479-487), the revision guard in `prepareDocumentReplace` (574-584), the Document-vs-plain-object branch in `prepareDocumentDelete` (689-709), and the helper's branch selection (1132-1174) are all untested. Given that a regression here silently produces a wrong-typed transition (see the create/replace asymmetry finding), at minimum add tests that assert: (a) `prepareDocumentReplace` errors on `revision = None` and `revision = INITIAL_REVISION`; (b) `prepareDocumentCreate` errors on missing/non-32-byte entropy; (c) the produced transition's batch transition kind matches the API called.
- [SUGGESTION] lines 1121-1128: Prepare API permanently advances the identity-contract nonce on every call
  `get_identity_contract_nonce(..., bump_first_nonce=true, ...)` at 1121-1128 advances the on-network nonce as part of returning the next value, so every successful `prepareDocument*` call consumes a nonce slot regardless of whether the caller ever broadcasts. A plausible use of a prepare API is 'build a few candidate transitions and pick one to broadcast' — under this implementation unbroadcast prepares waste nonce slots and can cause subsequent legitimate transitions to land out of order. This is inherent to producing a signable ST in advance, but it is not documented in the TS interface comment or the Rust doc comments. Either document the invariant ('every prepare call advances the identity-contract nonce; do not call prepare unless you intend to broadcast') or expose the nonce as a caller-supplied parameter so the SDK does not have to bump it on every prepare.

Comment on lines +466 to +522
pub async fn prepare_document_create(
&self,
options: PrepareDocumentCreateOptionsJs,
) -> Result<StateTransitionWasm, WasmSdkError> {
// Extract document from options
let document_wasm = DocumentWasm::try_from_options(&options, "document")?;
let document: Document = document_wasm.clone().into();

// Get metadata from document
let contract_id: Identifier = document_wasm.data_contract_id().into();
let document_type_name = document_wasm.document_type_name();

// Get entropy from document
let entropy = document_wasm.entropy().ok_or_else(|| {
WasmSdkError::invalid_argument("Document must have entropy set for creation")
})?;

if entropy.len() != 32 {
return Err(WasmSdkError::invalid_argument(
"Document entropy must be exactly 32 bytes",
));
}

let mut entropy_array = [0u8; 32];
entropy_array.copy_from_slice(&entropy);

// Extract identity key from options
let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?;
let identity_key: IdentityPublicKey = identity_key_wasm.into();

// Extract signer from options
let signer = IdentitySignerWasm::try_from_options(&options, "signer")?;

// Fetch the data contract (using cache)
let data_contract = self.get_or_fetch_contract(contract_id).await?;

// Get document type (owned)
let document_type = get_document_type(&data_contract, &document_type_name)?;

// Extract settings from options
let settings =
try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into);

// Build and sign the state transition without broadcasting
let state_transition = build_document_create_or_replace_transition(
&document,
&document_type,
Some(entropy_array),
&identity_key,
&signer,
self.inner_sdk(),
settings,
)
.await?;

Ok(state_transition.into())
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🔴 Blocking: prepareDocumentCreate silently produces a REPLACE transition when the input document has a non-INITIAL revision

prepare_document_create validates entropy but never checks document.revision(). It forwards the call to build_document_create_or_replace_transition, whose branch decision at lines 1132-1134 is document.revision().is_some_and(|rev| rev != INITIAL_REVISION) → REPLACE. A caller that passes a Document with revision = 2 (e.g., re-hydrated from storage) will receive a signed replacement state transition; the supplied entropy is ignored, and the resulting ST will fail at broadcast with DocumentNotFound — or, worse, mutate an existing document that happens to share the ID. Commit 466c825 added the inverse guard in prepare_document_replace (rejecting None/INITIAL_REVISION); the symmetric guard is missing on the create side. Reject any document whose revision is Some(rev) with rev != INITIAL_REVISION so the API name matches the produced transition kind.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [BLOCKING] lines 466-522: prepareDocumentCreate silently produces a REPLACE transition when the input document has a non-INITIAL revision
  `prepare_document_create` validates entropy but never checks `document.revision()`. It forwards the call to `build_document_create_or_replace_transition`, whose branch decision at lines 1132-1134 is `document.revision().is_some_and(|rev| rev != INITIAL_REVISION)` → REPLACE. A caller that passes a Document with `revision = 2` (e.g., re-hydrated from storage) will receive a signed *replacement* state transition; the supplied `entropy` is ignored, and the resulting ST will fail at broadcast with DocumentNotFound — or, worse, mutate an existing document that happens to share the ID. Commit 466c8255b2 added the inverse guard in `prepare_document_replace` (rejecting None/INITIAL_REVISION); the symmetric guard is missing on the create side. Reject any document whose revision is `Some(rev)` with `rev != INITIAL_REVISION` so the API name matches the produced transition kind.

Comment on lines +1112 to +1177
async fn build_document_create_or_replace_transition(
document: &Document,
document_type: &DocumentType,
document_state_transition_entropy: Option<[u8; 32]>,
identity_public_key: &IdentityPublicKey,
signer: &IdentitySignerWasm,
sdk: &dash_sdk::Sdk,
settings: Option<dash_sdk::platform::transition::put_settings::PutSettings>,
) -> Result<dash_sdk::dpp::state_transition::StateTransition, WasmSdkError> {
let new_identity_contract_nonce = sdk
.get_identity_contract_nonce(
document.owner_id(),
document_type.data_contract_id(),
true,
settings,
)
.await?;

let put_settings = settings.unwrap_or_default();

let transition = if document
.revision()
.is_some_and(|rev| rev != INITIAL_REVISION)
{
BatchTransition::new_document_replacement_transition_from_document(
document.clone(),
document_type.as_ref(),
identity_public_key,
new_identity_contract_nonce,
put_settings.user_fee_increase.unwrap_or_default(),
None, // token_payment_info
signer,
sdk.version(),
put_settings.state_transition_creation_options,
)
} else {
let (doc, entropy) = document_state_transition_entropy
.map(|entropy| (document.clone(), entropy))
.unwrap_or_else(|| {
let mut rng = StdRng::from_entropy();
let mut doc = document.clone();
let entropy = rng.gen::<[u8; 32]>();
doc.set_id(Document::generate_document_id_v0(
&document_type.data_contract_id(),
&doc.owner_id(),
document_type.name(),
entropy.as_slice(),
));
(doc, entropy)
});
BatchTransition::new_document_creation_transition_from_document(
doc,
document_type.as_ref(),
entropy,
identity_public_key,
new_identity_contract_nonce,
put_settings.user_fee_increase.unwrap_or_default(),
None, // token_payment_info
signer,
sdk.version(),
put_settings.state_transition_creation_options,
)
}?;

Ok(transition)
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🟡 Suggestion: Prepared create/replace transitions skip ensure_valid_state_transition_structure

PutDocument::put_to_platform (packages/rs-sdk/src/platform/transition/put_document.rs:113) calls ensure_valid_state_transition_structure(&transition, sdk.version())? after building the transition and before broadcasting. build_document_create_or_replace_transition omits this step, so prepareDocumentCreate/prepareDocumentReplace can return a signed, cacheable ST whose structure is invalid — the failure only surfaces later at broadcast, after the caller has already burned an identity-contract nonce and may have persisted the bytes for retry. This defeats a core reason to prepare-then-broadcast. Mirror the ensure_valid_state_transition_structure call inside the helper (and apply the same invariant to prepare_document_delete for parity) so the prepare path enforces the same structural invariants as the one-shot path.

source: ['claude', 'codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1112-1177: Prepared create/replace transitions skip ensure_valid_state_transition_structure
  `PutDocument::put_to_platform` (packages/rs-sdk/src/platform/transition/put_document.rs:113) calls `ensure_valid_state_transition_structure(&transition, sdk.version())?` after building the transition and before broadcasting. `build_document_create_or_replace_transition` omits this step, so `prepareDocumentCreate`/`prepareDocumentReplace` can return a signed, cacheable ST whose structure is invalid — the failure only surfaces later at broadcast, after the caller has already burned an identity-contract nonce and may have persisted the bytes for retry. This defeats a core reason to prepare-then-broadcast. Mirror the `ensure_valid_state_transition_structure` call inside the helper (and apply the same invariant to `prepare_document_delete` for parity) so the prepare path enforces the same structural invariants as the one-shot path.

Comment on lines +574 to +584
// Guard: reject documents with no revision or INITIAL_REVISION — those are creates, not replaces
let revision = document.revision().ok_or_else(|| {
WasmSdkError::invalid_argument(
"Document must have a revision set for replace. Use prepareDocumentCreate for new documents.",
)
})?;
if revision == INITIAL_REVISION {
return Err(WasmSdkError::invalid_argument(
"Document revision is INITIAL_REVISION (1). Replace requires revision > 1. Use prepareDocumentCreate for new documents.",
));
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🟡 Suggestion: prepareDocumentReplace accepts revision 0, which can never produce a valid replace

The guard at lines 580-584 only rejects None and INITIAL_REVISION (1). Because Revision is u64, a caller can still pass revision = 0, which falls through to build_document_create_or_replace_transition — where is_some_and(|rev| rev != INITIAL_REVISION) routes it to REPLACE. The resulting ST can never be valid against platform state (no document in platform has revision 0), so the prepare API hands the caller a signed object that is guaranteed to fail at broadcast. Collapse the two-step guard into revision <= INITIAL_REVISION so the API fails fast on this degenerate input.

💡 Suggested change
Suggested change
// Guard: reject documents with no revision or INITIAL_REVISION — those are creates, not replaces
let revision = document.revision().ok_or_else(|| {
WasmSdkError::invalid_argument(
"Document must have a revision set for replace. Use prepareDocumentCreate for new documents.",
)
})?;
if revision == INITIAL_REVISION {
return Err(WasmSdkError::invalid_argument(
"Document revision is INITIAL_REVISION (1). Replace requires revision > 1. Use prepareDocumentCreate for new documents.",
));
}
let revision = document.revision().ok_or_else(|| {
WasmSdkError::invalid_argument(
"Document must have a revision set for replace. Use prepareDocumentCreate for new documents.",
)
})?;
if revision <= INITIAL_REVISION {
return Err(WasmSdkError::invalid_argument(
"Document revision must be greater than INITIAL_REVISION (1). Use prepareDocumentCreate for new documents.",
));
}

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 574-584: prepareDocumentReplace accepts revision 0, which can never produce a valid replace
  The guard at lines 580-584 only rejects `None` and `INITIAL_REVISION` (1). Because Revision is `u64`, a caller can still pass `revision = 0`, which falls through to `build_document_create_or_replace_transition` — where `is_some_and(|rev| rev != INITIAL_REVISION)` routes it to REPLACE. The resulting ST can never be valid against platform state (no document in platform has revision 0), so the prepare API hands the caller a signed object that is guaranteed to fail at broadcast. Collapse the two-step guard into `revision <= INITIAL_REVISION` so the API fails fast on this degenerate input.

Comment on lines 411 to 754
}
}

// ============================================================================
// Prepare Document Create (Two-Phase API)
// ============================================================================

/// TypeScript interface for prepare document create options
#[wasm_bindgen(typescript_custom_section)]
const PREPARE_DOCUMENT_CREATE_OPTIONS_TS: &'static str = r#"
/**
* Options for preparing a document creation state transition without broadcasting.
*
* Use this for idempotent retry patterns:
* 1. Call `prepareDocumentCreate()` to get a signed `StateTransition`
* 2. Cache `stateTransition.toBytes()` for retry safety
* 3. Call `broadcastStateTransition(st)` + `waitForResponse(st)`
* 4. On timeout, deserialize cached bytes and rebroadcast the **identical** ST
*/
export interface PrepareDocumentCreateOptions {
/** The document to create. */
document: Document;
/** The identity public key to use for signing. */
identityKey: IdentityPublicKey;
/** Signer containing the private key for the identity key. */
signer: IdentitySigner;
/** Optional settings (retries, timeouts, userFeeIncrease). */
settings?: PutSettings;
}
"#;

#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(typescript_type = "PrepareDocumentCreateOptions")]
pub type PrepareDocumentCreateOptionsJs;
}

#[wasm_bindgen]
impl WasmSdk {
/// Prepare a document creation state transition without broadcasting.
///
/// This method handles nonce management, ST construction, and signing, but does
/// **not** broadcast or wait for a response. The returned `StateTransition` can be:
///
/// - Serialized with `toBytes()` and cached for retry safety
/// - Broadcast with `broadcastStateTransition(st)`
/// - Awaited with `waitForResponse(st)`
///
/// This is the "prepare" half of the two-phase API. Use it when you need
/// idempotent retry behavior — on timeout, you can rebroadcast the exact same
/// signed transition instead of creating a new one with a new nonce.
///
/// @param options - Creation options including document, identity key, and signer
/// @returns The signed StateTransition ready for broadcasting
#[wasm_bindgen(js_name = "prepareDocumentCreate")]
pub async fn prepare_document_create(
&self,
options: PrepareDocumentCreateOptionsJs,
) -> Result<StateTransitionWasm, WasmSdkError> {
// Extract document from options
let document_wasm = DocumentWasm::try_from_options(&options, "document")?;
let document: Document = document_wasm.clone().into();

// Get metadata from document
let contract_id: Identifier = document_wasm.data_contract_id().into();
let document_type_name = document_wasm.document_type_name();

// Get entropy from document
let entropy = document_wasm.entropy().ok_or_else(|| {
WasmSdkError::invalid_argument("Document must have entropy set for creation")
})?;

if entropy.len() != 32 {
return Err(WasmSdkError::invalid_argument(
"Document entropy must be exactly 32 bytes",
));
}

let mut entropy_array = [0u8; 32];
entropy_array.copy_from_slice(&entropy);

// Extract identity key from options
let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?;
let identity_key: IdentityPublicKey = identity_key_wasm.into();

// Extract signer from options
let signer = IdentitySignerWasm::try_from_options(&options, "signer")?;

// Fetch the data contract (using cache)
let data_contract = self.get_or_fetch_contract(contract_id).await?;

// Get document type (owned)
let document_type = get_document_type(&data_contract, &document_type_name)?;

// Extract settings from options
let settings =
try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into);

// Build and sign the state transition without broadcasting
let state_transition = build_document_create_or_replace_transition(
&document,
&document_type,
Some(entropy_array),
&identity_key,
&signer,
self.inner_sdk(),
settings,
)
.await?;

Ok(state_transition.into())
}
}

// ============================================================================
// Prepare Document Replace (Two-Phase API)
// ============================================================================

/// TypeScript interface for prepare document replace options
#[wasm_bindgen(typescript_custom_section)]
const PREPARE_DOCUMENT_REPLACE_OPTIONS_TS: &'static str = r#"
/**
* Options for preparing a document replace state transition without broadcasting.
*
* Use this for idempotent retry patterns. See `prepareDocumentCreate` for the full pattern.
*/
export interface PrepareDocumentReplaceOptions {
/** The document with updated data (same ID, incremented revision). */
document: Document;
/** The identity public key to use for signing. */
identityKey: IdentityPublicKey;
/** Signer containing the private key for the identity key. */
signer: IdentitySigner;
/** Optional settings (retries, timeouts, userFeeIncrease). */
settings?: PutSettings;
}
"#;

#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(typescript_type = "PrepareDocumentReplaceOptions")]
pub type PrepareDocumentReplaceOptionsJs;
}

#[wasm_bindgen]
impl WasmSdk {
/// Prepare a document replace state transition without broadcasting.
///
/// This method handles nonce management, ST construction, and signing, but does
/// **not** broadcast or wait for a response. See `prepareDocumentCreate` for
/// the full two-phase usage pattern.
///
/// @param options - Replace options including document, identity key, and signer
/// @returns The signed StateTransition ready for broadcasting
#[wasm_bindgen(js_name = "prepareDocumentReplace")]
pub async fn prepare_document_replace(
&self,
options: PrepareDocumentReplaceOptionsJs,
) -> Result<StateTransitionWasm, WasmSdkError> {
// Extract document from options
let document_wasm = DocumentWasm::try_from_options(&options, "document")?;
let document: Document = document_wasm.clone().into();

// Guard: reject documents with no revision or INITIAL_REVISION — those are creates, not replaces
let revision = document.revision().ok_or_else(|| {
WasmSdkError::invalid_argument(
"Document must have a revision set for replace. Use prepareDocumentCreate for new documents.",
)
})?;
if revision == INITIAL_REVISION {
return Err(WasmSdkError::invalid_argument(
"Document revision is INITIAL_REVISION (1). Replace requires revision > 1. Use prepareDocumentCreate for new documents.",
));
}

// Get metadata from document
let contract_id: Identifier = document_wasm.data_contract_id().into();
let document_type_name = document_wasm.document_type_name();

// Extract identity key from options
let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?;
let identity_key: IdentityPublicKey = identity_key_wasm.into();

// Extract signer from options
let signer = IdentitySignerWasm::try_from_options(&options, "signer")?;

// Fetch the data contract (using cache)
let data_contract = self.get_or_fetch_contract(contract_id).await?;

// Get document type (owned)
let document_type = get_document_type(&data_contract, &document_type_name)?;

// Extract settings from options
let settings =
try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into);

// Build and sign the state transition without broadcasting
let state_transition = build_document_create_or_replace_transition(
&document,
&document_type,
None, // entropy not needed for replace
&identity_key,
&signer,
self.inner_sdk(),
settings,
)
.await?;

Ok(state_transition.into())
}
}

// ============================================================================
// Prepare Document Delete (Two-Phase API)
// ============================================================================

/// TypeScript interface for prepare document delete options
#[wasm_bindgen(typescript_custom_section)]
const PREPARE_DOCUMENT_DELETE_OPTIONS_TS: &'static str = r#"
/**
* Options for preparing a document delete state transition without broadcasting.
*
* Use this for idempotent retry patterns. See `prepareDocumentCreate` for the full pattern.
*/
export interface PrepareDocumentDeleteOptions {
/**
* The document to delete — either a Document instance or an object with identifiers.
*/
document: Document | {
id: IdentifierLike;
ownerId: IdentifierLike;
dataContractId: IdentifierLike;
documentTypeName: string;
};
/** The identity public key to use for signing. */
identityKey: IdentityPublicKey;
/** Signer containing the private key for the identity key. */
signer: IdentitySigner;
/** Optional settings (retries, timeouts, userFeeIncrease). */
settings?: PutSettings;
}
"#;

#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(typescript_type = "PrepareDocumentDeleteOptions")]
pub type PrepareDocumentDeleteOptionsJs;
}

#[wasm_bindgen]
impl WasmSdk {
/// Prepare a document delete state transition without broadcasting.
///
/// This method handles nonce management, ST construction, and signing, but does
/// **not** broadcast or wait for a response. See `prepareDocumentCreate` for
/// the full two-phase usage pattern.
///
/// @param options - Delete options including document identifiers, identity key, and signer
/// @returns The signed StateTransition ready for broadcasting
#[wasm_bindgen(js_name = "prepareDocumentDelete")]
pub async fn prepare_document_delete(
&self,
options: PrepareDocumentDeleteOptionsJs,
) -> Result<StateTransitionWasm, WasmSdkError> {
// Extract document field - can be either a Document instance or plain object
let document_js = js_sys::Reflect::get(&options, &JsValue::from_str("document"))
.map_err(|_| WasmSdkError::invalid_argument("document is required"))?;

if document_js.is_undefined() || document_js.is_null() {
return Err(WasmSdkError::invalid_argument("document is required"));
}

// Check if it's a Document instance or a plain object with fields
let (document_id, owner_id, contract_id, document_type_name): (
Identifier,
Identifier,
Identifier,
String,
) = if get_class_type(&document_js).ok().as_deref() == Some("Document") {
let doc: DocumentWasm = document_js
.to_wasm::<DocumentWasm>("Document")
.map(|boxed| (*boxed).clone())?;
let doc_inner: Document = doc.clone().into();
(
doc.id().into(),
doc_inner.owner_id(),
doc.data_contract_id().into(),
doc.document_type_name(),
)
} else {
(
IdentifierWasm::try_from_options(&document_js, "id")?.into(),
IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(),
IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(),
try_from_options_with(&document_js, "documentTypeName", |v| {
try_to_string(v, "documentTypeName")
})?,
)
};

// Extract identity key from options
let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?;
let identity_key: IdentityPublicKey = identity_key_wasm.into();

// Extract signer from options
let signer = IdentitySignerWasm::try_from_options(&options, "signer")?;

// Fetch the data contract (using cache)
let data_contract = self.get_or_fetch_contract(contract_id).await?;

// Extract settings from options
let settings =
try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into);

// Build the delete transition using the builder's sign method (which does NOT broadcast)
let builder = DocumentDeleteTransitionBuilder::new(
Arc::new(data_contract),
document_type_name,
document_id,
owner_id,
);

let builder = if let Some(s) = settings {
builder.with_settings(s)
} else {
builder
};

let state_transition = builder
.sign(
self.inner_sdk(),
&identity_key,
&signer,
self.inner_sdk().version(),
)
.await?;

Ok(state_transition.into())
}
}

// ============================================================================
// Document Transfer
// ============================================================================
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🟡 Suggestion: New prepare_ public API has no tests covering its validation branches*

The PR adds ~430 lines of new public WASM API surface (prepareDocumentCreate, prepareDocumentReplace, prepareDocumentDelete, plus the shared helper) with no unit or wasm_bindgen_test coverage. The prepare-specific branches have no equivalents in the all-in-one path: the entropy presence/length check (479-487), the revision guard in prepareDocumentReplace (574-584), the Document-vs-plain-object branch in prepareDocumentDelete (689-709), and the helper's branch selection (1132-1174) are all untested. Given that a regression here silently produces a wrong-typed transition (see the create/replace asymmetry finding), at minimum add tests that assert: (a) prepareDocumentReplace errors on revision = None and revision = INITIAL_REVISION; (b) prepareDocumentCreate errors on missing/non-32-byte entropy; (c) the produced transition's batch transition kind matches the API called.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 411-754: New prepare_* public API has no tests covering its validation branches
  The PR adds ~430 lines of new public WASM API surface (`prepareDocumentCreate`, `prepareDocumentReplace`, `prepareDocumentDelete`, plus the shared helper) with no unit or `wasm_bindgen_test` coverage. The prepare-specific branches have no equivalents in the all-in-one path: the entropy presence/length check (479-487), the revision guard in `prepareDocumentReplace` (574-584), the Document-vs-plain-object branch in `prepareDocumentDelete` (689-709), and the helper's branch selection (1132-1174) are all untested. Given that a regression here silently produces a wrong-typed transition (see the create/replace asymmetry finding), at minimum add tests that assert: (a) `prepareDocumentReplace` errors on `revision = None` and `revision = INITIAL_REVISION`; (b) `prepareDocumentCreate` errors on missing/non-32-byte entropy; (c) the produced transition's batch transition kind matches the API called.

Comment on lines +1121 to +1128
let new_identity_contract_nonce = sdk
.get_identity_contract_nonce(
document.owner_id(),
document_type.data_contract_id(),
true,
settings,
)
.await?;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🟡 Suggestion: Prepare API permanently advances the identity-contract nonce on every call

get_identity_contract_nonce(..., bump_first_nonce=true, ...) at 1121-1128 advances the on-network nonce as part of returning the next value, so every successful prepareDocument* call consumes a nonce slot regardless of whether the caller ever broadcasts. A plausible use of a prepare API is 'build a few candidate transitions and pick one to broadcast' — under this implementation unbroadcast prepares waste nonce slots and can cause subsequent legitimate transitions to land out of order. This is inherent to producing a signable ST in advance, but it is not documented in the TS interface comment or the Rust doc comments. Either document the invariant ('every prepare call advances the identity-contract nonce; do not call prepare unless you intend to broadcast') or expose the nonce as a caller-supplied parameter so the SDK does not have to bump it on every prepare.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1121-1128: Prepare API permanently advances the identity-contract nonce on every call
  `get_identity_contract_nonce(..., bump_first_nonce=true, ...)` at 1121-1128 advances the on-network nonce as part of returning the next value, so every successful `prepareDocument*` call consumes a nonce slot regardless of whether the caller ever broadcasts. A plausible use of a prepare API is 'build a few candidate transitions and pick one to broadcast' — under this implementation unbroadcast prepares waste nonce slots and can cause subsequent legitimate transitions to land out of order. This is inherent to producing a signable ST in advance, but it is not documented in the TS interface comment or the Rust doc comments. Either document the invariant ('every prepare call advances the identity-contract nonce; do not call prepare unless you intend to broadcast') or expose the nonce as a caller-supplied parameter so the SDK does not have to bump it on every prepare.

Comment on lines +1148 to +1161
let (doc, entropy) = document_state_transition_entropy
.map(|entropy| (document.clone(), entropy))
.unwrap_or_else(|| {
let mut rng = StdRng::from_entropy();
let mut doc = document.clone();
let entropy = rng.gen::<[u8; 32]>();
doc.set_id(Document::generate_document_id_v0(
&document_type.data_contract_id(),
&doc.owner_id(),
document_type.name(),
entropy.as_slice(),
));
(doc, entropy)
});
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

💬 Nitpick: Random-entropy fallback in the shared helper is unreachable from current callers

The fallback that calls StdRng::from_entropy() and regenerates the document ID only fires when the helper is invoked with document_state_transition_entropy: None AND a revision of None or INITIAL_REVISION. prepare_document_create requires entropy upfront (lines 479-487), so it never passes None. prepare_document_replace rejects None/INITIAL_REVISION revisions (574-584), so it always takes the REPLACE branch. In the original put_to_platform this fallback makes sense because it's a single entry point, but here it is silent dead code. Either remove the fallback and require explicit entropy on the create branch, or add a comment documenting the contract so a future caller can rely on it without accidentally broadcasting a transition whose document ID is non-deterministic from the caller's perspective.

source: ['claude']

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

Findings verify correctly against the checkout. The structure-validation helper is indeed a no-op for document Batch transitions (confirmed via rs-dpp state_transition/mod.rs:1409-1426 returning UnsupportedFeatureError, which validate_state_transition_structure explicitly passes through), but since this exactly matches rs-sdk's pre-broadcast behavior it is not a regression — we downgrade from blocking to suggestion and combine the two reports. The other issues (nonce leak on mid-pipeline failure, asymmetric revision guards vs one-shot APIs, shallow functional tests, owner_id asymmetry in prepare_document_delete) are all real and kept as suggestions/nitpicks.

Reviewed commit: 3d84f1b

🟡 4 suggestion(s) | 💬 1 nitpick(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1258-1287: validate_state_transition_structure is a no-op for every document Batch transition
  `validate_state_transition_structure` calls `StateTransition::validate_structure` and then deliberately treats `BasicError::UnsupportedFeatureError` as success. In `packages/rs-dpp/src/state_transition/mod.rs:1409-1426`, the `StateTransition::Batch(_)` arm returns exactly that error, and every transition produced by `prepareDocumentCreate/Replace/Delete` is a `Batch`. So the validation call added at document.rs:553 and 663 (and the delete site) always short-circuits to `Ok(())` for the prepare paths despite comments stating that structure is validated. The included unit test `validate_accepts_unsupported_feature_errors` exercises an `IdentityCreditTransfer` transition, so it cannot catch this. This matches rs-sdk's `ensure_valid_state_transition_structure`, which is also a no-op for these variants, so it is not a regression — but the comments and module docs over-promise. Either drop the call and the misleading comment, or call Batch-specific structural validators (e.g. validating each `DocumentBaseTransition`) so the guard provides real value.
- [SUGGESTION] lines 1175-1228: Identity-contract nonce is consumed even when the prepare call fails after the bump
  `build_document_create_or_replace_transition` calls `sdk.get_identity_contract_nonce(..., true, ...)` at line 1175, which advances the cached/server-known nonce before the transition is built and signed. If `BatchTransition::new_document_*_transition_from_document` fails (signer error, internal validation) or the post-build `validate_state_transition_structure` returns an error, the caller never receives a usable transition but the nonce has already been bumped. The module docs flag this for the case where the caller discards a successful result, but in-process failure paths leak silently with no recovery. Since this API is explicitly designed for retry workflows where nonce leaks matter more than usual, consider calling `sdk.refresh_identity_nonce(&owner_id)` (or equivalent) on the error path so the next attempt re-fetches and doesn't widen the gap.
- [SUGGESTION] lines 487-626: Revision guards are inconsistent between prepareDocument* and documentCreate/documentReplace
  `prepare_document_create` rejects documents with `revision != INITIAL_REVISION` (lines 495-502) and `prepare_document_replace` rejects documents with `revision <= INITIAL_REVISION` (lines 616-626). These guards are correct — `build_document_create_or_replace_transition` routes by revision and silently picks the wrong branch otherwise. The existing `document_create` (lines 119-176) and `document_replace` (lines 236-) call `put_to_platform_and_wait_for_response` with no equivalent guards, so the same misuse silently produces the wrong transition kind in the all-in-one path. Lift the same guards into the one-shot APIs (or factor them into a shared validator) so both paths reject the same inputs.

In `packages/wasm-sdk/tests/functional/transitions/documents.spec.ts`:
- [SUGGESTION] lines 284-409: Functional tests don't exercise the PR's central idempotency claim
  The new functional tests only assert that `prepareDocument*` returns a Batch transition with the expected inner action type. That is necessary but not sufficient for the PR's central claim — that a prepared ST can be cached, broadcast, and (on timeout) rebroadcast without the platform creating a duplicate or rejecting it. There are no assertions that `broadcastStateTransition(st)` succeeds for a prepared ST, that `st.toBytes()` → `StateTransition.fromBytes(...)` → broadcast yields the same on-chain effect, or that a second broadcast of the same ST is accepted as a duplicate. These behaviors are what users will rely on; the manual yappr testing referenced in the PR description should be encoded as automated tests.

Comment on lines +1258 to +1287
fn validate_state_transition_structure(
state_transition: &dash_sdk::dpp::state_transition::StateTransition,
platform_version: &dash_sdk::dpp::version::PlatformVersion,
) -> Result<(), WasmSdkError> {
use dash_sdk::dpp::consensus::basic::BasicError;
use dash_sdk::dpp::consensus::ConsensusError;
use dash_sdk::dpp::state_transition::StateTransitionStructureValidation;

let validation_result = state_transition.validate_structure(platform_version);
if validation_result.is_valid() {
return Ok(());
}

let all_unsupported = validation_result.errors.iter().all(|e| {
matches!(
e,
ConsensusError::BasicError(BasicError::UnsupportedFeatureError(_))
)
});
if all_unsupported {
return Ok(());
}

// Surface the first error through the ProtocolError → WasmSdkError conversion chain.
// `is_valid()` returned false, so `errors` is non-empty; fall back defensively.
let first = validation_result.errors.into_iter().next().ok_or_else(|| {
WasmSdkError::generic("state transition structure validation failed without an error")
})?;
Err(dash_sdk::dpp::ProtocolError::from(first).into())
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🟡 Suggestion: validate_state_transition_structure is a no-op for every document Batch transition

validate_state_transition_structure calls StateTransition::validate_structure and then deliberately treats BasicError::UnsupportedFeatureError as success. In packages/rs-dpp/src/state_transition/mod.rs:1409-1426, the StateTransition::Batch(_) arm returns exactly that error, and every transition produced by prepareDocumentCreate/Replace/Delete is a Batch. So the validation call added at document.rs:553 and 663 (and the delete site) always short-circuits to Ok(()) for the prepare paths despite comments stating that structure is validated. The included unit test validate_accepts_unsupported_feature_errors exercises an IdentityCreditTransfer transition, so it cannot catch this. This matches rs-sdk's ensure_valid_state_transition_structure, which is also a no-op for these variants, so it is not a regression — but the comments and module docs over-promise. Either drop the call and the misleading comment, or call Batch-specific structural validators (e.g. validating each DocumentBaseTransition) so the guard provides real value.

source: ['claude', 'codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1258-1287: validate_state_transition_structure is a no-op for every document Batch transition
  `validate_state_transition_structure` calls `StateTransition::validate_structure` and then deliberately treats `BasicError::UnsupportedFeatureError` as success. In `packages/rs-dpp/src/state_transition/mod.rs:1409-1426`, the `StateTransition::Batch(_)` arm returns exactly that error, and every transition produced by `prepareDocumentCreate/Replace/Delete` is a `Batch`. So the validation call added at document.rs:553 and 663 (and the delete site) always short-circuits to `Ok(())` for the prepare paths despite comments stating that structure is validated. The included unit test `validate_accepts_unsupported_feature_errors` exercises an `IdentityCreditTransfer` transition, so it cannot catch this. This matches rs-sdk's `ensure_valid_state_transition_structure`, which is also a no-op for these variants, so it is not a regression — but the comments and module docs over-promise. Either drop the call and the misleading comment, or call Batch-specific structural validators (e.g. validating each `DocumentBaseTransition`) so the guard provides real value.

Comment on lines +1175 to +1228
let new_identity_contract_nonce = sdk
.get_identity_contract_nonce(
document.owner_id(),
document_type.data_contract_id(),
true,
settings,
)
.await?;

let put_settings = settings.unwrap_or_default();

let transition = if document
.revision()
.is_some_and(|rev| rev != INITIAL_REVISION)
{
BatchTransition::new_document_replacement_transition_from_document(
document.clone(),
document_type.as_ref(),
identity_public_key,
new_identity_contract_nonce,
put_settings.user_fee_increase.unwrap_or_default(),
None, // token_payment_info
signer,
sdk.version(),
put_settings.state_transition_creation_options,
)
} else {
let (doc, entropy) = document_state_transition_entropy
.map(|entropy| (document.clone(), entropy))
.unwrap_or_else(|| {
let mut rng = StdRng::from_entropy();
let mut doc = document.clone();
let entropy = rng.gen::<[u8; 32]>();
doc.set_id(Document::generate_document_id_v0(
&document_type.data_contract_id(),
&doc.owner_id(),
document_type.name(),
entropy.as_slice(),
));
(doc, entropy)
});
BatchTransition::new_document_creation_transition_from_document(
doc,
document_type.as_ref(),
entropy,
identity_public_key,
new_identity_contract_nonce,
put_settings.user_fee_increase.unwrap_or_default(),
None, // token_payment_info
signer,
sdk.version(),
put_settings.state_transition_creation_options,
)
}?;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🟡 Suggestion: Identity-contract nonce is consumed even when the prepare call fails after the bump

build_document_create_or_replace_transition calls sdk.get_identity_contract_nonce(..., true, ...) at line 1175, which advances the cached/server-known nonce before the transition is built and signed. If BatchTransition::new_document_*_transition_from_document fails (signer error, internal validation) or the post-build validate_state_transition_structure returns an error, the caller never receives a usable transition but the nonce has already been bumped. The module docs flag this for the case where the caller discards a successful result, but in-process failure paths leak silently with no recovery. Since this API is explicitly designed for retry workflows where nonce leaks matter more than usual, consider calling sdk.refresh_identity_nonce(&owner_id) (or equivalent) on the error path so the next attempt re-fetches and doesn't widen the gap.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 1175-1228: Identity-contract nonce is consumed even when the prepare call fails after the bump
  `build_document_create_or_replace_transition` calls `sdk.get_identity_contract_nonce(..., true, ...)` at line 1175, which advances the cached/server-known nonce before the transition is built and signed. If `BatchTransition::new_document_*_transition_from_document` fails (signer error, internal validation) or the post-build `validate_state_transition_structure` returns an error, the caller never receives a usable transition but the nonce has already been bumped. The module docs flag this for the case where the caller discards a successful result, but in-process failure paths leak silently with no recovery. Since this API is explicitly designed for retry workflows where nonce leaks matter more than usual, consider calling `sdk.refresh_identity_nonce(&owner_id)` (or equivalent) on the error path so the next attempt re-fetches and doesn't widen the gap.

Comment on lines +487 to +626
let document_wasm = DocumentWasm::try_from_options(&options, "document")?;
let document: Document = document_wasm.clone().into();

// Guard: reject documents with any explicit revision other than INITIAL_REVISION.
// `build_document_create_or_replace_transition` routes any `revision != INITIAL_REVISION`
// (including `0`) to the replace branch, so the only explicit values that are safe for
// create are `None` and `INITIAL_REVISION` (1). Without this guard, a `revision = 0`
// document would silently produce a replace transition, which is not what the caller asked for.
if let Some(revision) = document.revision() {
if revision != INITIAL_REVISION {
return Err(WasmSdkError::invalid_argument(format!(
"Document revision is {} but create requires revision to be unset or {}. Use prepareDocumentReplace for existing documents.",
revision, INITIAL_REVISION,
)));
}
}

// Get metadata from document
let contract_id: Identifier = document_wasm.data_contract_id().into();
let document_type_name = document_wasm.document_type_name();

// Get entropy from document
let entropy = document_wasm.entropy().ok_or_else(|| {
WasmSdkError::invalid_argument("Document must have entropy set for creation")
})?;

if entropy.len() != 32 {
return Err(WasmSdkError::invalid_argument(
"Document entropy must be exactly 32 bytes",
));
}

let mut entropy_array = [0u8; 32];
entropy_array.copy_from_slice(&entropy);

// Extract identity key from options
let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?;
let identity_key: IdentityPublicKey = identity_key_wasm.into();

// Extract signer from options
let signer = IdentitySignerWasm::try_from_options(&options, "signer")?;

// Fetch the data contract (using cache)
let data_contract = self.get_or_fetch_contract(contract_id).await?;

// Get document type (owned)
let document_type = get_document_type(&data_contract, &document_type_name)?;

// Extract settings from options
let settings =
try_from_options_optional::<PutSettingsInput>(&options, "settings")?.map(Into::into);

// Build and sign the state transition without broadcasting
let state_transition = build_document_create_or_replace_transition(
&document,
&document_type,
Some(entropy_array),
&identity_key,
&signer,
self.inner_sdk(),
settings,
)
.await?;

// Validate structure before handing the ST back to the caller, matching
// the check rs-sdk's `put_to_platform` performs before broadcasting.
validate_state_transition_structure(&state_transition, self.inner_sdk().version())?;

Ok(state_transition.into())
}
}

// ============================================================================
// Prepare Document Replace (Two-Phase API)
// ============================================================================

/// TypeScript interface for prepare document replace options
#[wasm_bindgen(typescript_custom_section)]
const PREPARE_DOCUMENT_REPLACE_OPTIONS_TS: &'static str = r#"
/**
* Options for preparing a document replace state transition without broadcasting.
*
* Use this for idempotent retry patterns. See `prepareDocumentCreate` for the full pattern.
*/
export interface PrepareDocumentReplaceOptions {
/** The document with updated data (same ID, incremented revision). */
document: Document;
/** The identity public key to use for signing. */
identityKey: IdentityPublicKey;
/** Signer containing the private key for the identity key. */
signer: IdentitySigner;
/** Optional settings (retries, timeouts, userFeeIncrease). */
settings?: PutSettings;
}
"#;

#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(typescript_type = "PrepareDocumentReplaceOptions")]
pub type PrepareDocumentReplaceOptionsJs;
}

#[wasm_bindgen]
impl WasmSdk {
/// Prepare a document replace state transition without broadcasting.
///
/// This method handles nonce management, ST construction, and signing, but does
/// **not** broadcast or wait for a response. See `prepareDocumentCreate` for
/// the full two-phase usage pattern.
///
/// **Nonce consumption:** A successful call consumes (advances) the identity-contract
/// nonce. Only call this when you intend to broadcast / persist-and-retry the returned
/// transition — discarding it leaks the nonce. See module docs for details.
///
/// @param options - Replace options including document, identity key, and signer
/// @returns The signed StateTransition ready for broadcasting
#[wasm_bindgen(js_name = "prepareDocumentReplace")]
pub async fn prepare_document_replace(
&self,
options: PrepareDocumentReplaceOptionsJs,
) -> Result<StateTransitionWasm, WasmSdkError> {
// Extract document from options
let document_wasm = DocumentWasm::try_from_options(&options, "document")?;
let document: Document = document_wasm.clone().into();

// Guard: replace requires revision > INITIAL_REVISION. Revisions 0 and 1 (and a
// missing revision) all indicate the caller really wants create semantics and are
// rejected here so we never accidentally produce a create transition from a
// replace call.
let revision = document.revision().ok_or_else(|| {
WasmSdkError::invalid_argument(
"Document must have a revision set for replace. Use prepareDocumentCreate for new documents.",
)
})?;
if revision <= INITIAL_REVISION {
return Err(WasmSdkError::invalid_argument(format!(
"Document revision is {} but replace requires revision > {}. Use prepareDocumentCreate for new documents.",
revision, INITIAL_REVISION,
)));
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🟡 Suggestion: Revision guards are inconsistent between prepareDocument and documentCreate/documentReplace*

prepare_document_create rejects documents with revision != INITIAL_REVISION (lines 495-502) and prepare_document_replace rejects documents with revision <= INITIAL_REVISION (lines 616-626). These guards are correct — build_document_create_or_replace_transition routes by revision and silently picks the wrong branch otherwise. The existing document_create (lines 119-176) and document_replace (lines 236-) call put_to_platform_and_wait_for_response with no equivalent guards, so the same misuse silently produces the wrong transition kind in the all-in-one path. Lift the same guards into the one-shot APIs (or factor them into a shared validator) so both paths reject the same inputs.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 487-626: Revision guards are inconsistent between prepareDocument* and documentCreate/documentReplace
  `prepare_document_create` rejects documents with `revision != INITIAL_REVISION` (lines 495-502) and `prepare_document_replace` rejects documents with `revision <= INITIAL_REVISION` (lines 616-626). These guards are correct — `build_document_create_or_replace_transition` routes by revision and silently picks the wrong branch otherwise. The existing `document_create` (lines 119-176) and `document_replace` (lines 236-) call `put_to_platform_and_wait_for_response` with no equivalent guards, so the same misuse silently produces the wrong transition kind in the all-in-one path. Lift the same guards into the one-shot APIs (or factor them into a shared validator) so both paths reject the same inputs.

Comment on lines +284 to +409
describe('prepareDocument* transition kind selection', () => {
// The prepare* APIs return a signed StateTransition without broadcasting.
// For document ops this is always a Batch state transition; the inner
// BatchedTransition encodes whether it's a create / replace / delete.
// DocumentTransitionActionType numeric codes (see wasm-dpp2
// document_transition.rs): Create=0, Replace=1, Delete=2.
const DOC_TRANSITION_CREATE = 0;
const DOC_TRANSITION_REPLACE = 1;
const DOC_TRANSITION_DELETE = 2;

function firstDocTransition(st) {
expect(st.actionType).to.equal('Batch');
const batch = sdk.BatchTransition.fromStateTransition(st);
const inner = batch.transitions[0].toTransition();
return inner;
}

it('prepareDocumentCreate produces a Create batched transition', async () => {
expect(testContractId).to.exist();
const { signer, identityKey } = createTestSignerAndKey(sdk, 1, 2);

const document = new sdk.Document({
properties: { message: 'prepare create' },
documentTypeName: 'mutableNote',
revision: 1,
dataContractId: testContractId,
ownerId: testData.identityId,
});

const st = await client.prepareDocumentCreate({
document,
identityKey,
signer,
});

const docTransition = firstDocTransition(st);
expect(docTransition.actionTypeNumber).to.equal(DOC_TRANSITION_CREATE);
});

it('prepareDocumentReplace produces a Replace batched transition', async () => {
expect(testContractId).to.exist();
const { signer, identityKey } = createTestSignerAndKey(sdk, 1, 2);

// Create a document first so we have a real ID to target.
const seedDoc = new sdk.Document({
properties: { message: 'prepare replace seed' },
documentTypeName: 'mutableNote',
revision: 1,
dataContractId: testContractId,
ownerId: testData.identityId,
});
await client.documentCreate({ document: seedDoc, identityKey, signer });
await new Promise((resolve) => { setTimeout(resolve, 2000); });

const replaceDoc = new sdk.Document({
id: seedDoc.id,
properties: { message: 'prepare replace updated' },
documentTypeName: 'mutableNote',
revision: 2,
dataContractId: testContractId,
ownerId: testData.identityId,
});

const st = await client.prepareDocumentReplace({
document: replaceDoc,
identityKey,
signer,
});

const docTransition = firstDocTransition(st);
expect(docTransition.actionTypeNumber).to.equal(DOC_TRANSITION_REPLACE);
});

it('prepareDocumentDelete accepts a Document instance and produces a Delete transition', async () => {
expect(testContractId).to.exist();
const { signer, identityKey } = createTestSignerAndKey(sdk, 1, 2);

const seedDoc = new sdk.Document({
properties: { message: 'prepare delete seed (Document)' },
documentTypeName: 'mutableNote',
revision: 1,
dataContractId: testContractId,
ownerId: testData.identityId,
});
await client.documentCreate({ document: seedDoc, identityKey, signer });
await new Promise((resolve) => { setTimeout(resolve, 2000); });

const st = await client.prepareDocumentDelete({
document: seedDoc,
identityKey,
signer,
});

const docTransition = firstDocTransition(st);
expect(docTransition.actionTypeNumber).to.equal(DOC_TRANSITION_DELETE);
});

it('prepareDocumentDelete accepts a plain identifier object and produces a Delete transition', async () => {
expect(testContractId).to.exist();
const { signer, identityKey } = createTestSignerAndKey(sdk, 1, 2);

const seedDoc = new sdk.Document({
properties: { message: 'prepare delete seed (object)' },
documentTypeName: 'mutableNote',
revision: 1,
dataContractId: testContractId,
ownerId: testData.identityId,
});
await client.documentCreate({ document: seedDoc, identityKey, signer });
await new Promise((resolve) => { setTimeout(resolve, 2000); });

const st = await client.prepareDocumentDelete({
document: {
id: seedDoc.id,
ownerId: testData.identityId,
dataContractId: testContractId,
documentTypeName: 'mutableNote',
},
identityKey,
signer,
});

const docTransition = firstDocTransition(st);
expect(docTransition.actionTypeNumber).to.equal(DOC_TRANSITION_DELETE);
});
});
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🟡 Suggestion: Functional tests don't exercise the PR's central idempotency claim

The new functional tests only assert that prepareDocument* returns a Batch transition with the expected inner action type. That is necessary but not sufficient for the PR's central claim — that a prepared ST can be cached, broadcast, and (on timeout) rebroadcast without the platform creating a duplicate or rejecting it. There are no assertions that broadcastStateTransition(st) succeeds for a prepared ST, that st.toBytes()StateTransition.fromBytes(...) → broadcast yields the same on-chain effect, or that a second broadcast of the same ST is accepted as a duplicate. These behaviors are what users will rely on; the manual yappr testing referenced in the PR description should be encoded as automated tests.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/tests/functional/transitions/documents.spec.ts`:
- [SUGGESTION] lines 284-409: Functional tests don't exercise the PR's central idempotency claim
  The new functional tests only assert that `prepareDocument*` returns a Batch transition with the expected inner action type. That is necessary but not sufficient for the PR's central claim — that a prepared ST can be cached, broadcast, and (on timeout) rebroadcast without the platform creating a duplicate or rejecting it. There are no assertions that `broadcastStateTransition(st)` succeeds for a prepared ST, that `st.toBytes()` → `StateTransition.fromBytes(...)` → broadcast yields the same on-chain effect, or that a second broadcast of the same ST is accepted as a duplicate. These behaviors are what users will rely on; the manual yappr testing referenced in the PR description should be encoded as automated tests.

Comment on lines +733 to +759
// Check if it's a Document instance or a plain object with fields
let (document_id, owner_id, contract_id, document_type_name): (
Identifier,
Identifier,
Identifier,
String,
) = if get_class_type(&document_js).ok().as_deref() == Some("Document") {
let doc: DocumentWasm = document_js
.to_wasm::<DocumentWasm>("Document")
.map(|boxed| (*boxed).clone())?;
let doc_inner: Document = doc.clone().into();
(
doc.id().into(),
doc_inner.owner_id(),
doc.data_contract_id().into(),
doc.document_type_name(),
)
} else {
(
IdentifierWasm::try_from_options(&document_js, "id")?.into(),
IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(),
IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(),
try_from_options_with(&document_js, "documentTypeName", |v| {
try_to_string(v, "documentTypeName")
})?,
)
};
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

💬 Nitpick: prepareDocumentDelete reads ownerId from Document instance but requires it in options for plain object

When the caller passes a Document instance, owner_id is read from the inner Document via doc_inner.owner_id() and any wrapper-level ownerId is ignored. When the caller passes a plain object, ownerId must be set explicitly. This asymmetry is not documented in the TypeScript interface at document.rs:681-697 — the comment says "either a Document instance or an object with identifiers" but does not note that the Document branch ignores any options-level ownerId. Add a one-line clarification in the TS doc.

source: ['claude']

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Addressed the follow-up review items on the branch in ed7dc8c.

What changed:

  • aligned documentCreate / documentReplace revision guards with the prepare APIs
  • refreshed the identity nonce cache when prepare create/replace fails after bumping the nonce
  • clarified the prepare-path structure-validation docs/comments: this still mirrors rs-sdk, but for document Batch transitions it is currently a DPP no-op until Batch validation is implemented upstream
  • added coverage for prepared document create serialize/reload/broadcast/rebroadcast behavior

I did not add custom Batch-specific validation in wasm-sdk because that would diverge from current rs-sdk behavior; the fix there was to make the docs/comments honest about today's behavior.

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

The PR is largely sound and the new prepare-document APIs mirror the existing rs-sdk flow closely. I verified the flagged paths against the current source and found one real nonce-cache issue in prepareDocumentDelete; the other findings either describe intentionally unreachable/private code, a documented test choice, or behavior inherited from the pre-existing one-shot delete boundary rather than a new defect in this PR.

Reviewed commit: ed7dc8c

🟡 1 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 785-798: prepareDocumentDelete leaks the local nonce cache on post-bump failures
  `prepareDocumentDelete` advances the identity-contract nonce inside `DocumentDeleteTransitionBuilder::sign()` via `get_identity_contract_nonce(..., true, ...)`, but unlike the create/replace prepare paths it never refreshes that cache if a later step fails. There are two leak paths here: `builder.sign(...).await?` can return an error after the nonce has already been bumped, and `validate_state_transition_structure(...)` also returns directly without a refresh. `prepareDocumentCreate` and `prepareDocumentReplace` explicitly refresh on both construction/signing failure and structure-validation failure, so delete is now the only asymmetric path. Today `Batch` structure validation is an `UnsupportedFeatureError` no-op in DPP, but the bug becomes user-visible as soon as batch validation is implemented or if delete signing fails after nonce allocation.

Comment on lines +785 to +798
let state_transition = builder
.sign(
self.inner_sdk(),
&identity_key,
&signer,
self.inner_sdk().version(),
)
.await?;

// Validate structure before handing the ST back, mirroring rs-sdk's
// pre-broadcast check. For document Batch transitions this currently
// ends up as a no-op because DPP returns UnsupportedFeatureError until
// that structure validation is implemented there.
validate_state_transition_structure(&state_transition, self.inner_sdk().version())?;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🟡 Suggestion: prepareDocumentDelete leaks the local nonce cache on post-bump failures

prepareDocumentDelete advances the identity-contract nonce inside DocumentDeleteTransitionBuilder::sign() via get_identity_contract_nonce(..., true, ...), but unlike the create/replace prepare paths it never refreshes that cache if a later step fails. There are two leak paths here: builder.sign(...).await? can return an error after the nonce has already been bumped, and validate_state_transition_structure(...) also returns directly without a refresh. prepareDocumentCreate and prepareDocumentReplace explicitly refresh on both construction/signing failure and structure-validation failure, so delete is now the only asymmetric path. Today Batch structure validation is an UnsupportedFeatureError no-op in DPP, but the bug becomes user-visible as soon as batch validation is implemented or if delete signing fails after nonce allocation.

source: ['claude']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/wasm-sdk/src/state_transitions/document.rs`:
- [SUGGESTION] lines 785-798: prepareDocumentDelete leaks the local nonce cache on post-bump failures
  `prepareDocumentDelete` advances the identity-contract nonce inside `DocumentDeleteTransitionBuilder::sign()` via `get_identity_contract_nonce(..., true, ...)`, but unlike the create/replace prepare paths it never refreshes that cache if a later step fails. There are two leak paths here: `builder.sign(...).await?` can return an error after the nonce has already been bumped, and `validate_state_transition_structure(...)` also returns directly without a refresh. `prepareDocumentCreate` and `prepareDocumentReplace` explicitly refresh on both construction/signing failure and structure-validation failure, so delete is now the only asymmetric path. Today `Batch` structure validation is an `UnsupportedFeatureError` no-op in DPP, but the bug becomes user-visible as soon as batch validation is implemented or if delete signing fails after nonce allocation.

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

Addressed the latest automated follow-up on the current head in b6d0637b.

prepareDocumentDelete now refreshes the local identity-contract nonce cache on the same two post-bump failure paths that create/replace already handled:

  • delete signing failure after nonce allocation
  • prepare-path structure-validation failure

So delete is no longer the odd one out if a prepare attempt fails after the nonce bump.

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

3 participants