Skip to content

feat: record inventory with shared reservations and concurrent swap reselection - #82

Open
iamalwaysuncomfortable wants to merge 9 commits into
masterfrom
feat/record-inventory
Open

iamalwaysuncomfortable wants to merge 9 commits into
masterfrom
feat/record-inventory

Conversation

@iamalwaysuncomfortable

@iamalwaysuncomfortable iamalwaysuncomfortable commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Python port of Veil PR ProvableHQ/veil#172 (head 2e324d5): private record inventory for credits.aleo and ARC-20/ARC-22 tokens, shared record reservations, recovery, and concurrent Shield Swap trades. No CLI; the journal needs no disk.

What it adds

aleo.inventory (core SDK) — the seven Veil actions with the same names in snake_case: get_record_inventory, plan_record_inventory, rebalance_record_inventory, reconcile_record_inventory, join_records, split_record, auto_join. Same core types as dataclasses (RecordAsset, InventoryTarget, InventoryPlan, InventoryStep, RecordSpend, RecordManagementConfig, InventoryResult, …), the planner ported line for line with Veil's planner tests as known answers (one extension: a split cuts whole lots instead of halving when halving would strand value below the minimum, which only changes plans Veil rejects; #83), the join-helper registry (credits/stablecoin static helpers, main_/test_aj_arc20_2_15.aleo dynamic routers) with deployed-signature checks, and the final output-resolution rules from c1b1aa9/e28412b.

  • RecordInventory / AsyncRecordInventory share one sans-IO engine (_engine.py): every action is a generator that yields facade requests; the two clients only perform them. The reservation, checkpoint, fee-ceiling, and output-matching rules exist once.
  • RecordInventoryStore protocol (list / acquire / update, identical to Veil's) with three shipped backends: MemoryRecordInventoryStore (default, one process), JsonFileRecordInventoryStore(path) (one JSON file under a file lock, readable in an editor), and SqliteRecordInventoryStore(path) (stdlib sqlite3, same table layout and row JSON as Veil's store, so the two SDKs can share a journal). Both file-backed stores record the writer's pid and host and release a reserved entry whose local SDK writer (accountType: local) has exited, as Veil's SQLite store does; a wallet client's rpc entry is kept. Scope strings match Veil's JSON.stringify([chainId, address]). One contract suite runs against all three.
  • RecordReservationConflictError(record_ids) is raised only when an input cannot be acquired before proving; a nested conflict after acquisition is wrapped so callers never retry a spend that may have reached the network.
  • Two liveness improvements over Veil: a broadcast the node refuses with a 4xx on its first attempt (never admitted) releases its reservation instead of keeping it, and reconcile_record_inventory(release_reserved=True) cancels local entries a crashed writer left in reserved (they provably never reached the network). Uncertain outcomes (timeouts, lost responses, 5xx, 404 on lookup) keep the reservation, a refusal that follows an uncertain attempt keeps it too (the managed broadcast reports every retry to the checkpoint), and discard() is inert from the moment a broadcast attempt starts (a discarded result is refused by submit_transaction, since its records may be held by another transaction by then).

Facade (Option B). Aleo(provider, record_management=RecordManagementConfig(enabled=True, …)) and aleo.inventory. On a managed client the journal follows the two halves of a write, as snarkVM separates them: build_transaction reserves the record inputs before proving and saves the proved transaction (enforcing max_fee_microcredits), submit_transaction records the broadcast (a 4xx refusal releases the reservation, an unknown outcome keeps it), transact is the two in sequence, and delegate does both through the prover (it asks the prover not to broadcast and the SDK submits). TransactionResult.discard() releases a built transaction that will not be sent. Code that already builds and submits by hand, such as Shield Swap's DexCall, is protected without changes. Private fees are coordinated: the Python facade learns the exact base fee only after the execution is proved, so fee_record may now be a callable; the journal selects the smallest eligible credits record at that point, folds it into the same entry as the execution inputs with fee_record_id (Veil's layout, so every later checkpoint is one store update and a crash cannot keep the transaction while releasing its fee record), verifies the prover spent it (tag check), and keeps it held after a rejected execution. Inventory actions prove through the facade's unmanaged paths (withoutManagement in Veil), so split_record and friends work under proving="local" on a managed client. Unmanaged clients are unchanged.

Shield Swap. dex.inventory is the facade's inventory. plan_inventory(token, target) and rebalance_inventory(...) target the token's underlying_program. Record selection skips reserved nonces. With management enabled and automatic selection, a swap that loses its record to another writer moves to the next eligible record from the same scan (smallest first, each tried once) inside DexCall/AsyncDexCall, re-checking account, network, and management first; explicit token_record stays fixed and execution errors are never retried (Veil 2f168a6). DexCall.transact now goes through the journal on managed clients. AGENTS.md regenerated (size limit 34k → 36k).

Example. shield-swap-sdk/examples/concurrent-swaps/ previews or executes "six balanced USDCx records, six concurrent USDCx → ETH swaps, claim each", on the SDK's SQLite store, with a batch.pending marker and recovery steps (Veil 2e324d5).

Design calls

  • Middle-path placement (plain class + facade property) so trades and maintenance share one journal per client; configuration is fixed at construction.
  • Delegated proving by default; proving="local" override.
  • Fee record chosen after the execution proof instead of before (Veil estimates first). Keeps the store protocol at three methods; update gained records/fee_record_id patch keys so the fee record can join the inputs' entry.
  • No browser-wallet ("rpc") paths or field grants: every record arrives as scanner plaintext.

Validation

  • sdk: 1139 fast tests pass (-m "not slow", e2e excluded; one pre-existing live block-lookup test is flaky), pyright strict 0 errors. New: test_inventory_planner.py, test_inventory_assets.py, test_inventory_store.py, test_inventory_engine.py (world tests ported from inventory, autojoinHelpers, configuration, privateFees), test_facade_inventory.py.
  • shield-swap-sdk: 541 tests pass; pyright unchanged at the 15 pre-existing errors measured at HEAD. New: tests/test_inventory.py (ported from concurrentSwaps.test.ts, incl. a six-way race on one record), tests/test_concurrent_swaps_example.py.
  • Live on testnet (tests/integration/test_record_inventory_live.py, ported from Veil's recordInventory.e2e, recordInventoryStrategy.e2e, and concurrentInventory.e2e suites; 4 passed in 7m31s on 2026-10-23 with the e2e account): rebalance to six equal USDCx and ETH records; one explicit USDCx → ETH → USDCx round trip followed by a seven-record aj_test_usdcx_stablecoin_* helper join and five splits; six concurrent USDCx → ETH swaps with automatic selection where all six first chose the same record, 15 reservation conflicts were resolved by reselection, 5 proving executions overlapped, and every swap spent a distinct input; six concurrent ETH → USDCx swaps back; every output claimed; journal reopened with 28 confirmed transactions and no input consumed twice. Run four times on 2026-10-23: on the JSON file store (7m31s), on the SQLite store (7m26s), again on SQLite after the managed build/submit split (7m13s, 3 and 6 overlapping provings per batch), after the review fixes (7m20s), and after the in-flight broadcast guard (9m06s), each 28 tx with 15 conflicts resolved and the swap journal attached.

…eselection

Port of Veil PR #172 (head 2e324d5) into the Python SDK.

aleo.inventory: get/plan/rebalance/reconcile record inventory, join_records,
split_record, auto_join over credits.aleo and ARC-20/ARC-22 tokens. One
sans-IO engine drives both RecordInventory and AsyncRecordInventory. The
planner, join-helper registry, and output resolution follow Veil; its planner
tests are the known answers. RecordInventoryStore is a three-method protocol
with an in-memory implementation; scopes match Veil's so journals can be
shared.

Facade: Aleo(provider, record_management=...) and aleo.inventory. On a
managed client every transact and delegate reserves its record inputs before
proving, saves the proved transaction before broadcast, journals submission,
and enforces the fee ceiling. Private fees pick and reserve the credits
record once the exact fee is known (fee_record may be a callable). A node
rejected broadcast releases its reservation; uncertain outcomes keep it.
transact_with_result returns the built transaction beside the id.

Shield Swap: plan_inventory and rebalance_inventory on the token's
underlying program, reserved records excluded from selection, and swaps that
lose a record to another writer move to the next eligible record before
proving. Concurrent-swaps example with a file-backed store and recovery
steps. AGENTS.md regenerated.
Port of Veil's recordInventory, recordInventoryStrategy, and
concurrentInventory e2e suites for the Shield Swap SDK. Keeps six equal
USDCx and ETH records in the e2e account, trades an explicit round trip and
repairs the inventory through the stablecoin join helper, submits six swaps
at once in each direction with automatic record selection under a forced
first-selection collision, claims every output, and reopens the journal to
verify confirmed checkpoints and distinct inputs. Passed on testnet in 7m31s
(28 transactions). Journals live under .shield-swap/, now ignored.
JsonFileRecordInventoryStore keeps the journal in one JSON file under a
file lock; SqliteRecordInventoryStore uses the stdlib sqlite3 module with
the same table layout and row JSON as Veil's store, so the two SDKs can
share a journal. Both record the writer's pid and host and release a
reserved entry whose local writer has exited before proving. The store
contract tests run against all three stores. The concurrent-swaps example
and the live inventory suite use the SQLite store instead of a private
class; the README compares the three stores.
The example now reads top to bottom like the first-swap example: client
setup, the unresolved-work check, planning, execution, and the concurrent
batch are inline, with only the per-trade thread body as a nested function.
The offline test checks the script's flags and its refusal to run without a
key instead of importing helpers.
The atomic rewrite created its temporary file with the default umask and
then replaced the 0600 journal with it. Create the temporary file with
mode 0600 and assert the mode after a write in the durable store test.
On a client with record management enabled, build_transaction now reserves
the call's record inputs before proving and saves the proved transaction to
the journal, and submit_transaction records the broadcast (a 4xx refusal
releases the reservation, an unknown outcome keeps it). transact is the two
in sequence again, matching how snarkVM separates building a transaction
from broadcasting it, and transact_with_result is removed. A built
transaction that will not be sent is released with result.discard().
Shield Swap's DexCall returns to the plain build-then-submit split and is
protected without changes. The checkpoint rules move into one
SpendCheckpoint class shared by the engine and the facade.

@iamalwaysuncomfortable iamalwaysuncomfortable left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reviewed Python head 0fe8ddb36074924794e4cac3ebbc330ba8621b59 against Veil PR #172 at 1d24487c, including the older 2e324d5 commit named in this PR. I found four issues to fix before merging.

  1. [P1] Managed inventory operations fail with local proving. The inventory engine reserves the inputs, then calls bound.build_transaction(), which now tries to reserve those same inputs again. I reproduced split_record() failing before any proof or broadcast with “Record spending failed after acquiring inputs.” The async path has the same structure. Veil explicitly bypasses management for nested execution; Python needs an equivalent internal path. Location

  2. [P1] A crash can release a private-fee record while retaining a transaction that spends it. Python stores execution inputs and the fee record in separate entries, then checkpoints them with separate updates. I killed a subprocess between those updates: reopening SQLite retained the execution entry as prepared, including the complete transaction, but cancelled the fee reservation. Recovery can therefore rebroadcast that transaction while another writer acquires its fee record. Veil uses one entry containing both execution and fee inputs. These checkpoints need atomic persistence or recovery that preserves the relationship between entries. Location

  3. [P1] Reading a shared Veil journal can incorrectly release wallet reservations. Python’s dead-process recovery cancels every reserved entry belonging to that process, without checking account_type. Veil deliberately preserves rpc entries because a wallet may have broadcast before its response was lost. I reproduced Python cancelling an rpc entry when reopening SQLite after its writer exited. The advertised shared-journal compatibility requires preserving Veil’s accountType === 'local' condition; explicit release_reserved recovery needs the same consideration. Location

  4. [P1] discard() releases inputs after an uncertain broadcast. is_submitted becomes true only after a successful response. If submission times out, calling result.discard() still cancels the reservation, even though the transaction may already be accepted. I reproduced prepared → cancelled, with the held-input set becoming empty. The public documentation says discard has no effect after submission, so callers can reasonably use it during cleanup. Track whether broadcast was attempted and preserve uncertain reservations. Location

Beyond those bugs, the meaningful divergences from Veil are:

Area Python versus Veil Practical consequence
Private fees Python selects the fee record after proving the execution; Veil estimates and acquires execution plus fee inputs together. Python can spend proving time before discovering insufficient private fees, and introduces the crash-consistency issue above.
Write lifecycle Python exposes managed build/submit halves and discard(); Veil coordinates through execution progress callbacks. Useful for callers assembling transactions themselves, but requires stronger lifecycle protection.
Wallet support Python requires scanner plaintext; Veil also supports pinned wallet records and RPC accounts. This is a local-signer port, not full wallet-capability parity.
Recovery Python adds release on broadcast 4xx and explicit release_reserved; Veil retains more uncertain reservations. These are substantive safety-policy changes, not just liveness improvements. A final 4xx also needs care if an earlier submission attempt had an uncertain outcome.
Operational tooling Python ships JSON/SQLite stores in the SDK; Veil includes background CLI inventory maintenance. Python supports durable application workflows, but does not provide the same unattended maintenance tooling.

The planner, helper selection, confirmed-output matching, and bounded swap reselection are substantially aligned with Veil.

Validation: 144 focused SDK tests and 14 Shield Swap/example tests passed. Four additional probes reproduced the reported failures. Funded live tests were not rerun.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved reservation-lifecycle defects can release inputs needed by pending transactions, and managed local inventory proving conflicts with its own reservations.

9 open issues
What changed in this PR

Adds private-record inventory management to the Aleo SDK and connects it to Shield Swap, so trades and maintenance operations can share reservations and recover interrupted work.

Changes:

  • Adds inventory planning, join/split helpers, and memory, JSON, and SQLite reservation stores.
  • Integrates reservation checkpoints into facade writes and adds bounded record reselection for concurrent swaps.
  • Adds tests, generated API documentation, and a concurrent-swaps example with recovery instructions.
File Description
shield-swap-sdk/​tests/​test_inventory.py Tests reservation-aware selection and swap reselection.
shield-swap-sdk/​tests/​test_gen_context.py Raises the generated guide size limit.
shield-swap-sdk/​tests/​test_concurrent_swaps_example.py Checks example arguments and startup safeguards.
shield-swap-sdk/​tests/​integration/​test_record_inventory_live.py Exercises live inventory maintenance, concurrent swaps, and recovery.
shield-swap-sdk/​README.md Documents concurrent swaps and inventory integration.
shield-swap-sdk/​python/​aleo_shield_swap/​client.py Adds inventory methods and synchronous swap reselection.
shield-swap-sdk/​python/​aleo_shield_swap/​async_client.py Adds asynchronous inventory methods and reselection.
shield-swap-sdk/​python/​aleo_shield_swap/​AGENTS.md Includes generated inventory API documentation.
shield-swap-sdk/​python/​aleo_shield_swap/​_core.py Adds reservation filters and shared selection helpers.
shield-swap-sdk/​python/​aleo_shield_swap/​_calls.py Integrates managed submission and bounded reselection.
shield-swap-sdk/​examples/​concurrent-swaps/​README.md Explains batch execution and recovery.
shield-swap-sdk/​examples/​concurrent-swaps/​concurrent_swaps.py Demonstrates inventory preparation and concurrent trades.
shield-swap-sdk/​codegen/​gen_context.py Includes inventory methods in generated guides.
shield-swap-sdk/​AGENTS.md Publishes generated inventory API guidance.
shield-swap-sdk/​.gitignore Excludes package-local recovery journals.
sdk/​Readme.md Introduces facade inventory management.
sdk/​python/​tests/​test_inventory_store.py Tests the shared reservation-store contract.
sdk/​python/​tests/​test_inventory_planner.py Adds planner known-answer and validation tests.
sdk/​python/​tests/​test_inventory_assets.py Tests asset decoding and helper compatibility.
sdk/​python/​tests/​test_facade_inventory.py Tests managed writes and private-fee reservations.
sdk/​python/​tests/​fixtures/​main_aj_arc20_2_15.aleo Supplies a dynamic join-router fixture.
sdk/​python/​aleo/​inventory/​store.py Implements reservation stores and stale-writer recovery.
sdk/​python/​aleo/​inventory/​planner.py Plans deterministic joins and splits.
sdk/​python/​aleo/​inventory/​join_programs.py Registers and validates deployed join helpers.
sdk/​python/​aleo/​inventory/​interface.py Parses program interfaces for compatibility checks.
sdk/​python/​aleo/​inventory/​assets.py Resolves assets and decodes eligible records.
sdk/​python/​aleo/​inventory/​__init__.py Exports the inventory API.
sdk/​python/​aleo/​facade/​network.py Records synchronous broadcast outcomes.
sdk/​python/​aleo/​facade/​client.py Binds inventory configuration to the facade.
sdk/​python/​aleo/​facade/​call.py Adds managed builds, delegated writes, and discard support.
sdk/​python/​aleo/​facade/​async_client.py Integrates asynchronous managed writes and inventory.
sdk/​python/​aleo/​__init__.pyi Exposes inventory types in package stubs.
sdk/​python/​aleo/​__init__.py Exports inventory classes at package level.
README.md Documents planning, storage choices, and recovery.

Review effort: Balanced

Comment on lines +137 to +138
if self.checkpoint is not None:
self.checkpoint.discard()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in b5ff539 and c553c9c: SpendCheckpoint tracks is_broadcasting (set before the request leaves) and is_uncertain (set when an attempt ends without a definite refusal). discard() is inert in either state and after submitted; a discarded checkpoint refuses begin_broadcast. Tests: test_discard_keeps_the_reservation_after_a_broadcast_attempt_with_no_answer, test_discard_during_an_in_flight_broadcast_does_not_release_the_inputs (sync and async), test_a_discarded_result_is_refused_by_submit.

Comment on lines +438 to +441
checkpoint = managed.begin_spend(
self.program_id, self.function_name, self.args, account=account,
private_fee=private_fee or fee_record is not None, fee_record=_fee_record_text(fee_record),
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in b5ff539: both inventory builders prove through the unmanaged paths (_prove_transaction under local proving, delegate(broadcast=False) otherwise), keeping the outer checkpoint and its fee selector. Regression test: test_inventory_actions_prove_locally_on_a_managed_client_without_reserving_twice; the async builder shares the same code shape and the existing async engine test covers it.

Comment thread sdk/python/aleo/inventory/store.py Outdated
Comment on lines +194 to +201
self._thread_lock.acquire()
self._handle = open(self._path, "a+")
if sys.platform == "win32": # pragma: no cover - exercised on Windows only
import msvcrt
msvcrt.locking(self._handle.fileno(), msvcrt.LK_LOCK, 1) # type: ignore[attr-defined]
else:
import fcntl
fcntl.flock(self._handle, fcntl.LOCK_EX)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in c553c9c: _FileLock.__enter__ closes the handle and releases the thread lock when opening or locking fails. Covered by test_json_store_releases_its_locks_when_the_journal_cannot_be_read.

Comment thread sdk/python/aleo/inventory/store.py Outdated
Comment on lines +245 to +247
if entry.status == "reserved" and host == self._host and not _process_alive(pid):
entry.status = "cancelled"
self._store(entry)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in b5ff539 by adopting Veil's layout: the fee record is folded into the entry that holds the execution inputs (records + fee_record_id), so every checkpoint after selection is one store update on one row and recovery cannot separate them. Tests: test_selects_the_fee_record_and_journals_it_in_the_same_entry_as_the_inputs, test_the_fee_record_is_never_held_apart_from_the_inputs_once_the_transaction_is_saved.

Comment thread sdk/python/aleo/inventory/store.py Outdated
Comment on lines +313 to +314
text = store.path.read_text() or "[]"
store._cache = [dict(item) for item in json.loads(text)]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in c553c9c: the JSON guard releases the file and thread locks when the journal cannot be read or decoded, then re-raises. Test: test_json_store_releases_its_locks_when_the_journal_cannot_be_read (corrupt file raises, repaired file works without a restart).

*.pyc

# Live inventory suite and example journals (reservations, swap handles)
.shield-swap/

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in c553c9c: .shield-swap/ is now ignored at the repository root as well.

Comment thread sdk/python/aleo/inventory/planner.py Outdated
Comment on lines +195 to +198
outputs_needed = count - len(working) + 1 if repack else 2
usable = record.amount - deduction * (outputs_needed - 1)
amount = usable // outputs_needed
push("split", [record], [amount, record.amount - amount - deduction], amount)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Deliberately unchanged in this PR. The split loop is a line-for-line port of Veil's planner (packages/core/src/inventory/planner.ts, same usable // outputs_needed halving) and the planner tests here are Veil's known answers, so a capacity-aware split would diverge the two SDKs' plans for the same inventory. The [9, 3] / records=4 / min=3 case fails identically in Veil. Worth fixing in both planners together; I will open a follow-up issue referencing this thread.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed after all, in the follow-up commit on this branch (closes #83): when the two halves could no longer yield the lots still required, the split cuts a whole number of lots instead, which keeps every lot the record can fund. [9, 3] / records=4 / min=3 now plans 9 -> [3, 6], 6 -> [3, 3], and 20,003 microcredits plan [1, 10002], [1, 1] with two deductions. Halving still wins whenever it can finish, so every Veil known-answer plan is unchanged; only plans Veil rejects differ. Test: test_splits_whole_lots_when_halving_would_strand_value_below_the_minimum.

Comment thread sdk/python/aleo/inventory/store.py Outdated
Comment on lines +176 to +180
path.parent.mkdir(parents=True, exist_ok=True)
try:
path.parent.chmod(0o700)
except OSError: # pragma: no cover - not every filesystem honours modes
pass

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in c553c9c: _private_file only chmods a parent directory it creates; an existing directory keeps its mode. Test: test_durable_stores_keep_an_existing_directory_s_permissions.

Comment thread shield-swap-sdk/README.md
aleo = Aleo(Aleo.HTTPProvider("https://edge.provable.com/api", network="testnet"),
record_management=RecordManagementConfig(enabled=True))
aleo.default_account = account
dex = ShieldSwap(aleo)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in c553c9c: the snippet now attaches dex.journal = Journal(Path.home() / ".shield-swap" / "testnet" / <address> / "swaps.jsonl") before the batch, so each concurrent swap gets its own reserved blinded identity.

- Inventory actions prove through the facade's unmanaged paths
  (_prove_transaction, delegate(broadcast=False)) so a managed client
  under local proving does not reserve its inputs twice.
- The private fee record is folded into the same journal entry as the
  execution inputs (Veil's layout): a short-lived claim wins it
  atomically, then update(records, fee_record_id) moves it over. Every
  later checkpoint is one store update, so a crash cannot keep the
  proved transaction while releasing its fee record.
- Dead-process recovery and reconcile(release_reserved=True) cancel only
  entries with account_type "local"; a Veil wallet's rpc entry is kept.
- SpendCheckpoint tracks uncertain broadcast attempts: discard() is
  inert after any attempt, and a 4xx that follows an uncertain attempt
  keeps the reservation. The managed broadcast asks the raw client for
  single attempts (retry=False) and reports each one to the checkpoint
  before retrying with the usual backoff.
@iamalwaysuncomfortable

Copy link
Copy Markdown
Member Author

All four reproduce against 0fe8ddb; each is fixed in b5ff539 with a regression test.

  1. Nested managed build under local proving. RecordInventory._build / AsyncRecordInventory._build now prove through the facade's unmanaged paths: _prove_transaction for proving="local" and delegate(broadcast=False) for delegated proving, the Python form of Veil's withoutManagement. The engine's own reservation is the only one. Test: test_inventory_actions_prove_locally_on_a_managed_client_without_reserving_twice (split_record on a managed client with local proving; one journal entry, status submitted).

  2. Fee record and inputs in one entry. Adopted Veil's layout. The selector still wins the fee record atomically against other writers (the store only reserves through acquire), but it does so with a short-lived claim entry and then folds the record into the entry that holds the execution inputs (records + fee_record_id) and cancels the claim. From there prepared, submitted, confirmed, and discard are each one store update on one row, so the crash you induced leaves either a duplicate hold or nothing, never a retained transaction with a released fee record. RecordSpendPatch gained records and fee_record_id; the store protocol is still list / acquire / update. Tests: test_selects_the_fee_record_and_journals_it_in_the_same_entry_as_the_inputs, test_the_fee_record_is_never_held_apart_from_the_inputs_once_the_transaction_is_saved (asserts every update after fee selection targets the spend's own entry), plus the store contract test for the new patch keys.

  3. accountType in recovery. Dead-process recovery in both durable stores and reconcile_record_inventory(release_reserved=True) now cancel only reserved entries whose account_type is local; an rpc entry written by a Veil wallet is kept. Tests: test_durable_stores_release_reservations_of_dead_local_processes (adds a dead-pid rpc row that stays reserved and still blocks acquisition) and test_reconcile_keeps_a_wallet_clients_reservation_even_on_request.

  4. discard() after an uncertain broadcast, and a 4xx after one. SpendCheckpoint tracks is_uncertain: any broadcast attempt that does not end in a definite refusal sets it, discard() is then inert, and a later 4xx no longer releases. Because the raw client retries 5xx with backoff inside one submit_transaction call, the managed broadcast now asks it for a single attempt (retry=False) and runs the same backoff itself, reporting every failed attempt to the checkpoint before retrying. The engine passes the checkpoint on its Broadcast request so inventory actions get the same treatment. Docs for TransactionResult.discard, begin_spend, and both READMEs now say "never sent" rather than "not submitted". Tests: test_discard_keeps_the_reservation_after_a_broadcast_attempt_with_no_answer, test_a_refusal_after_a_retried_node_error_keeps_the_reservation (503 then 400 inside one submit: entry stays prepared, two single attempts observed), test_a_refusal_after_an_uncertain_broadcast_attempt_keeps_the_reservation (engine level; a first-attempt 400 still releases).

On the divergence table: fee selection after proving, the managed build/submit halves, the local-signer scope, and no CLI are deliberate calls recorded in the PR description; the first two are kept with the stronger lifecycle rules above, and the 4xx release and release_reserved are now limited to the cases where the transaction provably never left this process (first attempt, local entry).

Validation: sdk 1133 fast tests, pyright strict 0; shield-swap-sdk 541 tests, pyright at the 15 pre-existing errors, agent guide current. The funded live suite (tests/integration/test_record_inventory_live.py) was rerun on testnet after these changes: 4 passed in 7m20s, 28 transactions, six-way first-selection collision resolved through 15 reselections, journal reopened clean.

@iamalwaysuncomfortable iamalwaysuncomfortable left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Re-reviewed head b5ff5396a484f2bf60fac6d447aa530a6db94839. Three findings are fixed; the discard/broadcast finding still has one concurrency gap.

[P1] discard() can release inputs while broadcast is in flight.

The broadcast helpers update checkpoint state only after receiving a response or exception. While the request is pending, both is_submitted and is_uncertain remain false, so another thread or async task can call result.discard() and release the inputs.

I reproduced:

  1. Start broadcasting a built transaction; hold its HTTP response.
  2. Call discard()—the reservation becomes cancelled.
  3. Build another transaction using the same input successfully.
  4. Release the first response—the journal now contains a submitted and a prepared transaction sharing that input.

Mark the checkpoint as broadcasting before calling or awaiting submit_once(), and prevent discard during that state. Preserve the distinction between a definite first-attempt refusal and an uncertain outcome. Both sync and async helpers need this protection. Affected code

The other fixes look sound:

  • Local inventory proving bypasses duplicate reservation acquisition.
  • Execution inputs and private fees share one prepared checkpoint. Subprocess crash probes confirmed they stay held together.
  • Automatic and explicit recovery preserve Veil RPC-wallet reservations.
  • Completed timeouts and 5xx→4xx retries now retain reservations correctly.

Validation: 350 tests passed across inventory, facade/network, and Shield Swap coverage. No funded live tests rerun.

- SpendCheckpoint marks a broadcast attempt before the request leaves
  (begin_broadcast); discard() is inert until the attempt resolves, and
  a checkpoint that was already discarded refuses to broadcast, since
  its inputs may belong to another transaction by then. The engine and
  both facade broadcast helpers set the mark; a lock serialises the
  lifecycle flags against a discard from another thread.
- Durable stores no longer chmod an existing parent directory; only a
  directory they create becomes owner-only.
- The file lock and the JSON journal guard release what they took when
  acquisition or decoding fails, so a repaired journal works without a
  restart.
- Root .gitignore excludes .shield-swap/ (the examples write claim
  secrets there when run from the repository root), and the Shield Swap
  README's concurrent-swap snippet attaches the swap journal.
@iamalwaysuncomfortable

Copy link
Copy Markdown
Member Author

The in-flight gap is closed in c553c9c.

Discard during a pending broadcast. SpendCheckpoint.begin_broadcast() marks the attempt before the request is sent (is_broadcasting), and discard() is inert in that state as well as after submitted or an uncertain outcome. The mark is set by both broadcast helpers (sync and async) on every attempt and by the engine before it yields Broadcast, so drivers that perform the request themselves get the same protection. The lifecycle flags are serialised with a lock against a discard from another thread. The first-attempt refusal path is unchanged: broadcast_failed clears the in-flight mark and releases only when no earlier attempt was uncertain. A checkpoint that was discarded before any attempt now refuses begin_broadcast with InventoryError, because its inputs may already belong to another transaction, which is exactly the state your probe produced in step 3.

Tests: test_discard_during_an_in_flight_broadcast_does_not_release_the_inputs (sync and async; the fake node calls discard() while the request is pending, the entry ends submitted and the input stays held), test_a_discarded_result_is_refused_by_submit, and test_a_checkpoint_in_flight_ignores_discard_and_a_discarded_one_refuses_to_broadcast at the engine level.

The same commit takes the Copilot notes that were not already covered by b5ff539: the durable stores no longer chmod an existing parent directory, the file lock and the JSON guard release what they took when acquisition or decoding fails, .shield-swap/ is ignored at the repository root, and the Shield Swap README's concurrent-swap snippet attaches the swap journal. The planner note is Veil parity (same halving in planner.ts, Veil's known answers as tests) and is tracked in #83 for both SDKs. Each thread has a reply.

Validation: sdk 1139 fast tests, pyright strict 0; shield-swap-sdk 541 tests, pyright at the 15 pre-existing errors, agent guide current. The funded live suite was rerun on testnet after this commit: 4 passed in 9m06s (the preflight first rebalanced the ETH records left by an aborted run), 28 transactions, 15 reservation conflicts resolved by reselection, journal reopened clean. One earlier attempt aborted when the hosted prover refused all six ETH-direction proofs with its own "queued past the cap" error; the six reservations released cleanly before proving and the next run was unaffected.

The planner halved the largest record on every split, so a record small
relative to the minimum could reject a reachable target: [9, 3] towards
four lots of at least 3 went 9 -> [4, 5] -> [2, 3] and raised, and
20,003 microcredits could not fund three lots of 1 although two 10,000
deductions leave exactly enough. When the two halves could no longer
yield the lots still required, the split now cuts a whole number of
lots instead, which keeps every lot the record can fund. Halving still
wins whenever it can finish, so Veil's known-answer plans are unchanged;
only plans Veil rejects differ.

Closes #83.

This branch has not been deployed

No deployments
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.

2 participants