Harden propose_admin_transfer against unauthorized caller and illegal source state (#346) - #365
Conversation
|
@d3vobed Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
|
FIX FAILED CI @d3vobed |
…thm (Goldii-locks#292) Define refund distribution pathways for split-refund claims arising from cancel_escrow initiations. The new function is a pure calculator that computes how the escrowed balance is split between client and freelancer using caller-supplied BPS ratios, following the exact same pattern as multisig_split_refund and emergency_pause_split_refund. Production changes (lib.rs): - Add CancelSplitRefundCalculatedEvent struct (mirrors SplitRefundCalculatedEvent) Fields: client_refund, freelancer_payout, client_refund_bps, freelancer_payout_bps - Add pub fn cancel_escrow_split_refund(env, total_amount, client_refund_bps, freelancer_payout_bps) -> Result<RefundAllocation, Error> * Guard: total_amount <= 0 -> Error::InvalidAmount * Guard: client_refund_bps + freelancer_payout_bps != BPS_SCALE -> Error::InvalidRatio * Guard: u32 overflow on BPS addition -> Error::InvalidRatio * Uses split_round_nearest: client share is round-nearest, freelancer receives the exact remainder so first + second == total_amount always * Emits symbol_short!("cxlspref") with CancelSplitRefundCalculatedEvent * Pure calculator — no storage reads/writes, no auth required Tests added (test.rs) — 17 new tests (396 -> 413): Correct percentage calculation: - test_cancel_escrow_split_refund_full_client_refund - test_cancel_escrow_split_refund_full_freelancer_payout - test_cancel_escrow_split_refund_equal_split - test_cancel_escrow_split_refund_70_30 - test_cancel_escrow_split_refund_tiny_client_share No-value-lost rounding guarantee: - test_cancel_escrow_split_refund_odd_total_rounds_nearest_no_value_lost - test_cancel_escrow_split_refund_large_prime_total_preserved - test_cancel_escrow_split_refund_single_stroop_total Validation guards: - test_cancel_escrow_split_refund_zero_amount_fails - test_cancel_escrow_split_refund_negative_amount_fails - test_cancel_escrow_split_refund_bps_not_summing_to_scale_fails - test_cancel_escrow_split_refund_bps_overflow_fails - test_cancel_escrow_split_refund_both_bps_zero_fails Event emission: - test_cancel_escrow_split_refund_emits_event - test_cancel_escrow_split_refund_event_payload_correct BPS echoing: - test_cancel_escrow_split_refund_bps_echoed_in_allocation Standalone calculator: - test_cancel_escrow_split_refund_works_without_initialization All 413 tests pass.
…es (Goldii-locks#295) Require both client and freelancer to approve before cancel_escrow sets CancelLock. A single-signature call records approval only and returns immediately — the escrow remains fully operational until the second party signs. Production changes (lib.rs): - Add DataKey::CancelApproval — persistent u32 bitmask (bit 0 = client, bit 1 = freelancer) tracking which parties have signed - Add CancelApprovalRecordedEvent — emitted on first/partial approval with the current mask (symbol: cxlappr) - Add CancelApprovalRevokedEvent — emitted when a party withdraws their approval (symbol: cancelrev) - Rewrite cancel_escrow two-phase logic: * Zero-address guard unchanged * Single call records caller_bit in CancelApproval mask, emits cxlappr, returns Ok — lock NOT set * Duplicate call from same party returns Error::InvalidStatus * Second call (mask == 3) clears CancelApproval, sets CancelLock, emits final cancel event * Call while lock already active returns Error::EscrowLocked - Add revoke_cancel_approval(env, caller) -> Result<(), Error> * Party can withdraw their approval before lock fires * Returns InvalidStatus if no approval recorded or lock already active * Returns Unauthorized for non-parties * Emits cancelrev event with updated mask cancel_escrow_test.rs — updated two existing tests: - test_cancel_escrow_sets_lock_and_emits_event: updated to call both parties and assert lock fires only after second signature - test_cancel_escrow_by_freelancer_succeeds: updated to use two-phase sequence (freelancer first, then client) New tests (test.rs) — 19 new tests (396 -> 415): Single-signature reverts: - test_cancel_escrow_single_client_signature_does_not_lock - test_cancel_escrow_single_freelancer_signature_does_not_lock - test_cancel_escrow_duplicate_same_party_reverts - test_cancel_escrow_duplicate_freelancer_reverts Two-party approval completes cancel: - test_cancel_escrow_client_then_freelancer_locks - test_cancel_escrow_freelancer_then_client_locks - test_cancel_escrow_third_call_after_lock_fails Approval bitmask and events: - test_cancel_escrow_first_signature_emits_approval_event - test_cancel_escrow_freelancer_first_emits_mask_2 - test_cancel_escrow_second_signature_emits_cancel_event - test_cancel_escrow_approval_key_cleared_after_lock revoke_cancel_approval: - test_revoke_cancel_approval_client_before_second_sig - test_revoke_then_reapprove_completes_cancel - test_revoke_cancel_approval_without_prior_approval_fails - test_revoke_cancel_approval_after_lock_fails - test_revoke_cancel_approval_non_party_unauthorized - test_revoke_cancel_approval_emits_event Unauthorized callers: - test_cancel_escrow_arbiter_cannot_sign - test_cancel_escrow_admin_cannot_sign All 415 tests pass.
…ocks#298) Audited tax_withholding_deductions's existing validation against this issue's requirement ("assert that bad setups are rejected immediately with descriptive error types") and found the existing coverage already extensive: NotInitialized, NotFunded, InvalidMilestone, InvalidRatio, InvalidAmount (zero balance, overflow), and InvalidStatus for Released/Refunded milestones are all implemented and tested (test.rs, tax_withholding_tests.rs). One real gap: the status check only excluded Released/Refunded, not Disputed — unlike raise_dispute_inner and resolve_dispute elsewhere in this file, which both treat Disputed as its own case via an exhaustive match. A disputed milestone's funds are meant to be frozen pending resolve_dispute; tax_withholding_deductions could still compute and persist a TaxWithholdingRecord for one, moving money around that freeze. No existing test combined raise_dispute with tax_withholding_deductions, so this was uncovered on both the implementation and test side. Fixes the gap by converting the two equality checks to an exhaustive match over MilestoneStatus (so a future new variant fails to compile here instead of silently falling through as allowed), matching the established pattern elsewhere in this file, and adds a test exercising it via the existing dual-signature test fixture. Not touched: admin_tax_withholding_deductions, a separately-implemented sibling function (per its own doc comment, "arrived from a separate PR under the same name") that has no status check at all — a larger gap, but outside this issue's named scope (Module/Component: tax_withholding_deductions).
The merge of main into issue-346 (734b99e) removed the require_initialized function definition while keeping its call site in propose_admin_transfer, which broke compilation (E0599) and failed CI on Goldii-locks#365. Re-add the definition next to require_admin. No logic change to the hardened behavior. Fixes Goldii-locks#365
88a47d2 to
b2ad18a
Compare
|
Heads-up for reviewers: the 2 failing CI checks (
This PR's own |
…oldii-locks#346) - Add require_initialized() guard checked before any other ledger access. - propose_admin_transfer now rejects unauthorised callers (Unauthorized) and illegal source states (AdminTransferPending / InvalidAddress / NotInitialized) and performs no ledger mutation on rejected paths. - Add comprehensive tests asserting the typed errors and no-mutation guarantee for each rejected path plus the happy path.
- make test modules cfg(test) so testutils and crate::test are available only in test builds (fixes main-wide compile break where admin_override etc. were unconditional) - make setup_funded_escrow pub(crate) and add missing imports (crate::test::setup_funded_escrow and testutils::Address) to 6 test modules - fixes 16 compile errors (E0425/E0599/E0432) that blocked CI on main and PR Goldii-locks#365
…arbitration_split
…arbitration_split
feat(Goldii-locks#398): record previous pause state in EmergencyPauseAdminOverrideEvent Add to EmergencyPauseAdminOverrideEvent so the event emitted by emergency_pause_admin_override captures the full pause-state transition. Fields now reconcile exactly with persisted state: = new DataKey::EmergencyPaused, = the value it replaced; the lock is always left cleared. Still emitted only on the success path. Tests (emergency_pause_test.rs): - happy-path test asserts for both directions - event_reconciles_with_persisted_state: event fields vs is_emergency_paused() and direct DataKey::EmergencyPaused / EmergencyPauseLock reads - no_event_on_unauthorized / no_event_on_invalid_state: emoverrid count is 0 Includes regenerated test snapshots.
feat(Goldii-locks#396): emit PlatformFeeAllocationOverrideEvent from pf_alloc_admin_override Publish a typed event at the end of pf_alloc_admin_override carrying the acting admin and the resulting allocation (client/freelancer/treasury bps + unlocked flag) so indexers get an immutable record of the override. Fields reconcile with DataKey::PlatformFeeAllocation; emitted only on the success path. Tests (test.rs): - event_reconciles_with_persisted_state: fields vs get_platform_fee_allocation() - no_event_on_unauthorized / no_event_on_invalid_state / no_event_on_invalid_ratio Also drops the dangling so cargo test compiles. Includes regenerated test snapshots.
The branch's own copy of admin_pause_escrow does not parse. A line from
the previous version was left dangling after the new writes:
.set(&DataKey::EmergencyPauseLock, &true);
env.storage().instance().set(&DataKey::Paused, &true);
.set(&DataKey::EpLk, &true); <- orphaned continuation
That produced 90 errors, all downstream of "expected expression, found
`.`". DataKey::EmergencyPauseLock is also not a variant -- the enum
calls it EpLk -- in lib.rs and twice in the new test suite.
Rebuilt the function around the two guards this PR is actually for:
assert_emergency_pause_not_locked before any write, and an early return
when the escrow is already paused so a redundant call mutates nothing.
Because that early return now handles the repeat case, the inner
`if !already_paused` that main used to gate the event is redundant, and
the publish is unconditional inside the lock.
Two of the new tests then failed on event tallies. They read
pause_event_count after is_paused / is_lock_held, and those helpers go
through env.as_contract -- env.events().all() reports the most recent
invocation, not a running total. Reordered so the tally is read first.
The idempotency case now asserts the second call emits nothing at all,
which is what "no-op" means here. Same env behaviour as Goldii-locks#418, Goldii-locks#428
and Goldii-locks#438.
576 tests passing / WASM release build OK
…349-admin-pause-escrow-guards feat: harden caller authorization and precondition guards in admin_pause_escrow (closes Goldii-locks#349)
|
Thanks @d3vobed — the hardening here is right, but the branch doesn't compile, and one of the problems isn't something I can fix for you without inventing your test. 1. A test is truncated and spliced into the next one. fn test_propose_admin_transfer_unauthorized_no_mutation() {
...
let token = env.register_stellar_asset_contract_v2(admin.clone()).address();
#[test]
fn test_set_platform_fee_allocation_emits_structured_event() {The two
2. #[cfg(test)]
pub(crate) mod test;
mod test;The second line is left over from before. Only the 3. The doc checklist is duplicated. The actual change is sound and worth landing. Suggested path: rebase on |
…om pf_alloc_admin_override Resolve the merge against main, which had already landed a narrower version of this event under the `pf_ovr` topic. The branch declared a second `PlatformFeeAllocationOverrideEvent` struct (same name, plus `contract_id` and `locked`) and published it a second time under a `pfovrride` topic after the allocation lock was released -- two identically named types will not compile, and the two publishes are the same event emitted twice. Unified to one struct carrying the branch's richer payload and one publish, kept inside the lock guard where main emits it. Retained main's already-published `pf_ovr` topic rather than renaming a topic that is live on the default branch. Updated main's existing assertion for the two new fields and repointed the branch's four new tests at `pf_ovr`. 580 tests pass; wasm32 release build is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(Goldii-locks#396): emit PlatformFeeAllocationOverrideEvent from pf_alloc_admin_override
…pf_alloc_admin_override Resolve the merge against main. Three fixes were needed on top of the branch: * The branch predates Goldii-locks#434, so taking its rewritten pf_alloc_admin_override body verbatim would have silently dropped the PlatformFeeAllocationOverrideEvent that call now emits. Kept the single-write optimisation and the event. * The branch deletes `mod admin_override_cancel_tests;` -- stale-branch damage that would drop a whole test module from the build. Kept ours. * Its new test referenced `DataKey::EmergencyPauseLock`, which does not exist; the variant is the short key `EpLk`. Also folded the branch's `setup_pf_alloc_escrow` helper together with Goldii-locks#434's `setup_locked_pf_alloc` instead of landing two near-identical copies. 591 tests pass; wasm32 release build is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
perf(Goldii-locks#397): reduce ledger storage footprint of pf_alloc_admin_override
…time extensions
Main had already migrated MilestoneTimeExtension writes to temporary
storage, so the branch's remaining substance is the read fallback --
`load_time_extension` now checks temporary first and falls back to the
persistent key. Without it, any escrow whose extension was written before
the migration reads back 0 and auto-releases on the unextended deadline.
Fixes on top of the branch:
* Both new i128-extreme tests wrote storage from outside a contract
context, which panics in soroban-sdk 22 ("not accessible outside of a
contract"). Wrapped the seeding writes in `env.as_contract`.
* The branch reorders `mod admin_override_cancel_tests;` above `mod test;`
and drops the `#[cfg(test)]` attributes main added; kept ours.
593 tests pass; wasm32 release build is clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…etic-footprint fix-checked-arithmetic-footprint
…min paths Three footprint reductions, all consistent with the pattern main already applies elsewhere: * admin_tax_withholding_deductions no longer sets and immediately removes TaxWithholdingExecutionLock. Main had already hoisted every guard above the lock, leaving a set/remove pair that protected nothing. * admin_override_tax_release no longer writes the MilestoneReleased temporary flag. Nothing reads it (is_milestone_released_flag is dead_code) and the persistent status already carries Released -- the same rationale main documents for two other override paths under Goldii-locks#383. * multisig_admin_override_refund removes MultisigLocked instead of writing `false`, so no stale entry is left on the ledger. Two fixes on top of the branch: it re-added guards main had already hoisted (kept ours), and its new test read storage through env.as_contract before checking the event tally -- env.events().all() only reflects the most recent invocation, so the buffer was already cleared. Reordered the assertions. 596 tests pass; wasm32 release build is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Minor Admin Changes
…row is paused admin_override_refund and multisig_admin_override_refund now reject their illegal source state (paused / not multisig-locked) before touching any job or milestone storage, so a rejected call writes nothing. Resolved against main: unioned the test module lists, kept main's crate-scoped `setup_funded_escrow`, and dropped the branch's duplicate imports. Replaced the branch's inlined Paused read with the existing `assert_not_paused` helper rather than adding a third hand-rolled copy. 600 tests pass; wasm32 release build is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rden-admin-override-refund-guards Harden caller authorization and precondition guards in admin_override…
…AdminOverrideEvent EmergencyPauseAdminOverrideEvent gains a `previous` field, so an indexer can see which transition an override actually performed rather than only its result. The branch predates Goldii-locks#428, which removed the re-entrancy guard from emergency_pause_admin_override, so it reinstated the lock write and published the event a second time. Kept main's single publish and added `previous: current` to it. Its new tests referenced `DataKey::EmergencyPaused` and `DataKey::EmergencyPauseLock`; neither variant exists (the short keys are `Ep` and `EpLk`). The lock assertion also expected `Some(false)` -- after Goldii-locks#428 the guard key is never written at all, so it now asserts `None`. 603 tests pass; wasm32 release build is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(Goldii-locks#398): structured event for emergency_pause_admin_override outcome
…#400) The same author's Goldii-locks#432 branch already carried this work -- the ArbitrationSplitAppliedEvent struct, its publish at the end of apply_dispute_arbitration_split, and arbitration_split_event_tests.rs -- so it landed with that merge. The only remaining difference here was a set of stale test snapshots; taking main's regenerated copies leaves the tree identical to main. Merging so the commits are recorded against this PR rather than closing it unmerged. 603 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…i-locks#351, Goldii-locks#345, Goldii-locks#336, Goldii-locks#350) Four entrypoints now reject unauthorised callers and illegal source states before touching the ledger: * transfer_admin rejects a zero address (InvalidAddress) and refuses to rotate the admin while a multisig proposal is pending (AdminTransferPending). * emergency_unpause rejects NotPaused before taking the transition lock. * admin_resume_escrow rejects NotPaused instead of silently succeeding, so a mistaken call no longer reads as a completed recovery. * multisig_admin_override_release requires MultisigLocked to be active before any JobMeta or milestone read. lib.rs merged cleanly (main's DataKey renames Ep/EpLk applied to the branch's guards). test.rs needed hand-resolution: a three-way apply anchored the four multisig_lock insertions onto unrelated admin_override_release / admin_override_refund tests, which would have rewritten those tests into copies of the multisig ones. Reverted and placed each insertion on its intended test. 613 tests pass; wasm32 release build is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Harden auth and precondition guards (Goldii-locks#351, Goldii-locks#345, Goldii-locks#336, Goldii-locks#350)
…_escrow locks cancel_escrow no longer lets one party freeze the escrow on its own. The first signature records a bit in a CancelApproval mask and emits CancelApprovalRecordedEvent; the CancelLock fires only when the counter-party signs. revoke_cancel_approval lets a party withdraw its signature before the lock. Resolved against main: * The branch deletes the public cancel_escrow_split_refund entrypoint, which is unrelated to this change. Kept it. * It reverts admin_override_tax_release to the two-equality status check that Goldii-locks#411 replaced with an exhaustive match rejecting Disputed. Kept ours. * The textual merge spliced main's enriched CancelEscrowInitiatedEvent fields into the branch's new CancelApprovalRecordedEvent (which does not declare them) while leaving the real CancelEscrowInitiatedEvent publish with only two. Swapped them back. * Dropped a duplicate CancelLock read the branch added below the one main already performs. Removing the standalone `admin.require_auth()` from admin_override_tax_release is kept: require_admin already calls require_auth, so it was a duplicated auth requirement. Nineteen existing tests asserted the old one-signature lock -- they were written after the branch diverged, so it could not have adapted them. Updated each to supply the counter-party signature, and reinterpreted the two duplicate-call tests: a repeat from the same party is now InvalidStatus, and EscrowLocked applies once both have signed. revoke_cancel_approval arrived with no tests (the branch carries snapshot files for tests that were never committed). Added six covering the event payload, mask clearing, revoke-then-reapprove, revoking after the lock, revoking without a prior approval, and a non-party caller. 619 tests pass; wasm32 release build is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ow-multi-party-auth feat(cancel_escrow): enforce two-party authentication before lock fir…
…nsent for yield/time/streaming Adds TimeExtExecutionLock and PaymentStreamingExecutionLock re-entrancy guards across the settlement entrypoints, a time_extensions_consent dual-signature path, and an interest_yield_split_refund allocation entrypoint. Resolved against main: * Error discriminant 32 is already taken by main's EmptyBalance, so the branch's TimeExtInProgress / PaymentStreamingInProgress moved to 33 and 34. Leaving them at 32/33 would have renumbered a live error code. * The branch reintroduces DataKey::EmergencyPauseLock, which main shortened to EpLk; dropped its copy and kept its two genuinely new lock keys. * Both sides added a helper in the same place -- kept main's assert_nonzero_balance alongside the branch's two lock assertions. * test.rs is purely additive on the branch, so took main's file and reapplied the module declaration and the Goldii-locks#255 test block rather than resolving the interleaved hunks. 651 tests pass; wasm32 release build is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-refunds feat: locks, split-refund, and dual consent for yield/time/streaming
…split-refund fees, shorter time-extension key Closes Goldii-locks#306, Goldii-locks#308, Goldii-locks#315, Goldii-locks#322. * emergency_pause now takes the client and freelancer instead of the admin and requires both signatures (Goldii-locks#322). The admin's unilateral path remains emergency_pause_admin_override. * validate_fee_allocation enforces MAX_TREASURY_FEE_BPS (2000) and MAX_CLIENT_FEE_BPS (5000) with a new FeeTooHigh error (Goldii-locks#306). * split_refund_net_distribution applies the platform fee to the freelancer's payout only, leaving the client refund fee-exempt (Goldii-locks#308). * DataKey::MilestoneTimeExtension becomes TimeExt and its value narrows from u64 to u32, matching the EpLk/Ep shortening already in the enum (Goldii-locks#315). Resolved against main: * The branch inserts FeeTooHigh at 29 and shifts AlreadyPaused, NotPaused and InvalidAllocationWeights up by one. Those codes are live and main has since added 32-34, so FeeTooHigh took 35 and every existing discriminant kept its value. * Its time-extension read/write reverted main's move to temporary storage and Goldii-locks#426's persistent fallback. Kept both, under the new TimeExt key. * split_refund_net_distribution called multisig_split_refund with four arguments; that function takes five and additionally requires the admin key and an active multisig lock. Repointed at cancel_escrow_split_refund, the pure allocator with the same shape. * Moved the cap check below the sum check, so a malformed ratio still reports InvalidRatio rather than FeeTooHigh -- structural validity first, policy bound second. * Updated thirteen emergency_pause call sites the branch could not see. Three of them (from Goldii-locks#378) used emergency_pause as the probe for "the admin key is unchanged"; that no longer tests admin authority, so they now probe emergency_unpause, which still distinguishes the rightful admin (NotPaused) from anyone else (Unauthorized). * Two fee tests configured an even-thirds split, now above the treasury cap; moved to 40/40/20, which still floors every share to zero. 654 tests pass; wasm32 release build is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…entication feat: enhance escrow security and optimize state storage
…rithm (Goldii-locks#308) Replaces main's earlier cancel_escrow_split_refund with this branch's version, which is strictly better in three ways: it rejects a zero total (main accepted it and returned a 0/0 allocation), it derives the freelancer leg with an explicit checked_sub, and it publishes a CancelSplitRefundCalculatedEvent so a cancellation split can be audited without querying storage. Resolved against main: the branch reverts admin_override_tax_release to the two-equality status check that Goldii-locks#411 replaced with an exhaustive match rejecting Disputed -- kept ours. Both definitions of the function survived the merge textually; removed main's. The branch ships seventeen test snapshots for tests that were never committed, so the new behaviour arrived unverified. Wrote the seventeen tests those snapshots name: the distribution pathways (equal, 70/30, full client, full freelancer), the rounding cases (odd total, single stroop, sub-basis-point client share, large prime total), the rejected inputs (zero, negative, both shares zero, shares not summing to BPS_SCALE, overflowing shares), the echoed basis points, the event and its payload, and that the calculator answers on an uninitialised contract. Every distribution case asserts client_refund + freelancer_payout == total_amount, which is the property the round-nearest-then-subtract arithmetic exists to guarantee. 671 tests pass; wasm32 release build is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ow-refund-allocation feat(cancel_escrow): add cancel_escrow_split_refund allocation algori…
…gal source state (Goldii-locks#346) propose_admin_transfer now checks the initialisation marker (DataKey::Version, set only by initialize) before require_admin, so an uninitialised contract is turned away before any auth is required rather than after. The resulting error is unchanged -- load_admin already reported NotInitialized -- but the check no longer sits behind a require_auth. The branch itself did not apply cleanly: it was a damaged patch rather than a stale one. Every one of its five new tests had its opening lines spliced into the head of an unrelated existing test and its body dropped into a later one, so test_set_platform_fee_allocation_emits_structured_event, test_pf_alloc_admin_override_emits_structured_event, test_set_platform_fee_allocation_fails_does_not_emit_event and test_platform_fee_split_rounds_to_zero each ended up with a second initialize argument list and a foreign assertion block. It also carried duplicate import lines in six files, a duplicated `let (...)` binding, a duplicated doc checklist, and both `pub(crate) mod test;` and `mod test;`. The file did not balance its braces. Took main's sources and reapplied only the substance: the require_initialized helper, its call site, and the five tests reconstructed from the fragments the diff carried -- unauthorized caller, already-pending proposal, uninitialised contract, zero address, and the happy path. Each rejection asserts the pending-transfer entry is still absent, and the pending-state case asserts the first proposal survives intact. Folded their shared setup into one helper instead of repeating it five times. 676 tests pass; wasm32 release build is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
Closes #346
Hardens
propose_admin_transferso it rejects unauthorised callers and illegal source states with specific typed errors, performing no ledger mutation on any rejected path.require_initialized()precondition, checked before any other ledger access (the contract's init marker isDataKey::Version, set byinitialize).require_admin(auth + stored-admin match →Unauthorized) and theAdminTransferPending/InvalidAddresspreconditions; the new init check sits first so an uninitialised contract is rejected up-front.Unauthorized,AdminTransferPending,InvalidAddress,NotInitialized) and verifying the pending-admin-transfer ledger entry is never written on a rejected call.Verification
cargo testincontracts/milestone-escrow— all tests pass (incl. the 5 new ones).