release_payment(orchestrator, task_id, asset, amount) increments task.spent every time it is called. The orchestrator's executor retries on transient RPC/network failures, and a retried release that actually succeeded on-chain the first time will pay the agent twice and double-spend the user's locked funds, up to plan_cost. There is no idempotency key tying a release to a specific plan step, so the contract cannot tell a retry from a legitimate second step. For a payments contract, exactly-once release is a correctness requirement, not a nice-to-have, and it is the kind of hardening that funded projects pay a premium for.
Goal
Make each release idempotent on a caller-supplied step_id so that repeating a release with the same (task_id, step_id) is a safe no-op that returns the original result, while distinct steps still accumulate normally.
Proposed surface (signatures only)
release_payment(env, orchestrator, task_id, step_id: u64 /* or BytesN<32> */, asset, amount) -> Result<bool, VaultError>
- Persist a per-task set/map of processed
step_ids (bounded; see constraints)
- New
VaultError::StepAlreadyReleased is not returned on replay; instead the call is an idempotent success. Reserve an error only for the conflicting case (same step_id, different amount).
Requirements and constraints
- Same
(task_id, step_id, amount) seen twice: second call performs no transfer, does not change spent, and returns success (idempotent).
- Same
(task_id, step_id) with a different amount: reject (ReleaseConflict), since it indicates a client bug.
- Storage growth must be bounded and TTL-managed; store step records under the task's key space and clean them up in
finalize_task.
- This changes the
release_payment signature (breaking). Update packages/orchestrator/src/agent-vault-client.ts and the executor to pass a stable step_id derived from the plan step, and document the migration in the PR.
- Preserve all existing checks (auth, asset match,
spent + amount <= plan_cost, pause).
Edge cases
- Retry after the task is completed (reject cleanly, not a double-pay).
- Two different steps with the same amount (must both apply).
- A step that would exceed
plan_cost on first sight (reject as today).
- Replay across a
force_complete_stale_task boundary.
Acceptance criteria
Pointers
contracts/agent-vault/src/lib.rs: release_payment, TaskInfo, finalize_task, DataKey, VaultError.
packages/orchestrator/src/agent-vault-client.ts, packages/orchestrator/src/executor.ts.
Notes for contributors
Comment with your storage layout for step records and your step_id derivation before implementing. See CONTRIBUTING.md.
release_payment(orchestrator, task_id, asset, amount)incrementstask.spentevery time it is called. The orchestrator's executor retries on transient RPC/network failures, and a retried release that actually succeeded on-chain the first time will pay the agent twice and double-spend the user's locked funds, up toplan_cost. There is no idempotency key tying a release to a specific plan step, so the contract cannot tell a retry from a legitimate second step. For a payments contract, exactly-once release is a correctness requirement, not a nice-to-have, and it is the kind of hardening that funded projects pay a premium for.Goal
Make each release idempotent on a caller-supplied
step_idso that repeating a release with the same(task_id, step_id)is a safe no-op that returns the original result, while distinct steps still accumulate normally.Proposed surface (signatures only)
release_payment(env, orchestrator, task_id, step_id: u64 /* or BytesN<32> */, asset, amount) -> Result<bool, VaultError>step_ids (bounded; see constraints)VaultError::StepAlreadyReleasedis not returned on replay; instead the call is an idempotent success. Reserve an error only for the conflicting case (samestep_id, differentamount).Requirements and constraints
(task_id, step_id, amount)seen twice: second call performs no transfer, does not changespent, and returns success (idempotent).(task_id, step_id)with a different amount: reject (ReleaseConflict), since it indicates a client bug.finalize_task.release_paymentsignature (breaking). Updatepackages/orchestrator/src/agent-vault-client.tsand the executor to pass a stablestep_idderived from the plan step, and document the migration in the PR.spent + amount <= plan_cost, pause).Edge cases
plan_coston first sight (reject as today).force_complete_stale_taskboundary.Acceptance criteria
(task_id, step_id, amount)transfers once and is a safe success on repeatsstep_idis rejectedplan_coststep_id; integration test simulates a duplicate release and asserts single paymentcargo test,cargo clippy --all-targets -- -D warnings, andnpm testpassPointers
contracts/agent-vault/src/lib.rs:release_payment,TaskInfo,finalize_task,DataKey,VaultError.packages/orchestrator/src/agent-vault-client.ts,packages/orchestrator/src/executor.ts.Notes for contributors
Comment with your storage layout for step records and your
step_idderivation before implementing. See CONTRIBUTING.md.