-
Notifications
You must be signed in to change notification settings - Fork 42
Issue 101 idempotent release payment #113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ec2722c
93e35e5
440f1b9
47afa56
cfd00b9
ae4b6f5
21a679b
0838809
3c73b01
3a565ef
6b5a673
cd25f2b
af71a9a
028e6de
876ab21
98a0fb9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ logs/ | |
|
|
||
| # Rust build artifacts | ||
| contracts/*/target/ | ||
| contracts/*/test_snapshots/ | ||
|
|
||
| # Source maps | ||
| *.js.map | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -155,6 +155,8 @@ pub enum VaultError { | |
| DisputeSplitMismatch = 21, | ||
| DisputeResolverNotSet = 22, | ||
| TaskNotDisputed = 23, | ||
| ReleaseConflict = 24, | ||
| TooManyStepReleases = 25, | ||
| } | ||
|
|
||
| // Storage keys | ||
|
|
@@ -190,6 +192,10 @@ pub enum DataKey { | |
| MaxActiveTasks, | ||
| /// The dedicated resolver authorized to settle raised disputes. | ||
| DisputeResolver, | ||
| /// Idempotency record for a released plan step under a task. | ||
| TaskStepRelease(u64, u64), | ||
| /// Enumerable list of released step IDs for cleanup on task finalization. | ||
| TaskStepIds(u64), | ||
| } | ||
|
|
||
| // Data structs | ||
|
|
@@ -270,6 +276,14 @@ pub struct TaskInfo { | |
| pub created_at: u64, | ||
| } | ||
|
|
||
| /// Per-step release idempotency record. Presence means this `(task_id, step_id)` | ||
| /// has already produced its transfer with the recorded amount. | ||
| #[contracttype] | ||
| #[derive(Clone)] | ||
| pub struct StepRelease { | ||
| pub amount: i128, | ||
| } | ||
|
|
||
| /// Authoritative lifecycle state for a task at the current ledger timestamp. | ||
| #[contracttype] | ||
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
|
|
@@ -294,6 +308,10 @@ const DEFAULT_MAX_ACTIVE_TASKS: u32 = 50; | |
| /// Maximum number of task records returned by `get_user_task_infos`. | ||
| const MAX_USER_TASK_INFOS_PAGE_SIZE: u32 = 50; | ||
|
|
||
| /// Bounds per-task idempotency storage. Normal plans are far smaller; the cap | ||
| /// prevents a hostile orchestrator from creating unbounded persistent keys. | ||
| const MAX_RELEASE_STEPS_PER_TASK: u32 = 256; | ||
|
|
||
| const PERSISTENT_TTL_THRESHOLD: u32 = 17_280; // ~1 day | ||
| const PERSISTENT_TTL_EXTEND_TO: u32 = 518_400; // ~30 days | ||
|
|
||
|
|
@@ -307,7 +325,7 @@ const INSTANCE_TTL_EXTEND_TO: u32 = 518_400; // ~30 days | |
| /// deployment before assuming a given function or storage layout | ||
| /// exists, especially important on Soroban where the same address | ||
| /// can be upgraded in place. | ||
| const CONTRACT_VERSION: u32 = 4; | ||
| const CONTRACT_VERSION: u32 = 5; | ||
|
|
||
| // Contract | ||
|
|
||
|
|
@@ -788,6 +806,7 @@ impl AgentVault { | |
| env: Env, | ||
| orchestrator: Address, | ||
| task_id: u64, | ||
| step_id: u64, | ||
| asset: Address, | ||
| amount: i128, | ||
| ) -> Result<bool, VaultError> { | ||
|
|
@@ -817,10 +836,23 @@ impl AgentVault { | |
| if task.asset != asset { | ||
| return Err(VaultError::AssetMismatch); | ||
| } | ||
|
|
||
| let step_key = DataKey::TaskStepRelease(task_id, step_id); | ||
| if let Some(record) = env.storage().persistent().get::<_, StepRelease>(&step_key) { | ||
| Self::extend_persistent_ttl(&env, &step_key); | ||
| Self::extend_task_step_ids_ttl(&env, task_id); | ||
| if record.amount == amount { | ||
| return Ok(true); | ||
| } | ||
| return Err(VaultError::ReleaseConflict); | ||
| } | ||
|
|
||
| if task.spent + amount > task.plan_cost { | ||
| return Err(VaultError::ExceedsPlanCost); | ||
| } | ||
|
|
||
| Self::record_step_release(&env, task_id, step_id, amount)?; | ||
|
|
||
| Self::extend_instance_ttl(&env); | ||
| let token_client = token::Client::new(&env, &asset); | ||
| token_client.transfer(&env.current_contract_address(), &orchestrator, &amount); | ||
|
|
@@ -1174,6 +1206,7 @@ impl AgentVault { | |
| task.completed = true; | ||
| env.storage().persistent().set(&task_key, &task); | ||
| Self::extend_persistent_ttl(env, &task_key); | ||
| Self::remove_task_step_releases(env, task_id); | ||
|
|
||
| if dispute_split.is_none() { | ||
| let refund = task.plan_cost - task.spent; | ||
|
|
@@ -1196,6 +1229,69 @@ impl AgentVault { | |
| Ok(()) | ||
| } | ||
|
|
||
| fn record_step_release( | ||
| env: &Env, | ||
| task_id: u64, | ||
| step_id: u64, | ||
| amount: i128, | ||
| ) -> Result<(), VaultError> { | ||
| let ids_key = DataKey::TaskStepIds(task_id); | ||
| let mut step_ids: Vec<u64> = env | ||
| .storage() | ||
| .persistent() | ||
| .get(&ids_key) | ||
| .unwrap_or(Vec::new(env)); | ||
|
|
||
| if !step_ids.iter().any(|id| id == step_id) { | ||
| if step_ids.len() >= MAX_RELEASE_STEPS_PER_TASK { | ||
| return Err(VaultError::TooManyStepReleases); | ||
| } | ||
| step_ids.push_back(step_id); | ||
| env.storage().persistent().set(&ids_key, &step_ids); | ||
| Self::extend_persistent_ttl(env, &ids_key); | ||
| } | ||
|
|
||
| let step_key = DataKey::TaskStepRelease(task_id, step_id); | ||
| env.storage() | ||
| .persistent() | ||
| .set(&step_key, &StepRelease { amount }); | ||
| Self::extend_persistent_ttl(env, &step_key); | ||
| Ok(()) | ||
| } | ||
|
|
||
| fn extend_task_step_ids_ttl(env: &Env, task_id: u64) { | ||
| let ids_key = DataKey::TaskStepIds(task_id); | ||
| let step_ids: Vec<u64> = match env.storage().persistent().get(&ids_key) { | ||
| Some(ids) => ids, | ||
| None => return, | ||
| }; | ||
| Self::extend_persistent_ttl(env, &ids_key); | ||
| for step_id in step_ids.iter() { | ||
| let step_key = DataKey::TaskStepRelease(task_id, step_id); | ||
| if env.storage().persistent().has(&step_key) { | ||
| Self::extend_persistent_ttl(env, &step_key); | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+1262
to
+1275
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- targeted symbols ---'
rg -n -C 4 \
'extend_task_step_ids_ttl|extend_persistent_ttl|MAX_RELEASE_STEPS_PER_TASK|TaskStepIds|TaskStepRelease|replay' \
contracts/agent-vault/src contracts/agent-vault/tests 2>/dev/null || true
printf '%s\n' '--- source outline ---'
ast-grep outline contracts/agent-vault/src/lib.rs 2>/dev/null | head -200 || true
printf '%s\n' '--- call-site range ---'
sed -n '800,875p' contracts/agent-vault/src/lib.rs
printf '%s\n' '--- helper range ---'
sed -n '1220,1295p' contracts/agent-vault/src/lib.rsRepository: clevercon-protocol/clevercon Length of output: 46876 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- replay tests ---'
sed -n '700,930p' contracts/agent-vault/src/tests.rs
printf '%s\n' '--- TTL tests ---'
sed -n '1580,1665p' contracts/agent-vault/src/tests.rs
printf '%s\n' '--- TTL implementation and dependency versions ---'
sed -n '1370,1410p' contracts/agent-vault/src/lib.rs
rg -n 'soroban-sdk|stellar-sdk|budget|cpu|ledger.*entry|extend_ttl' \
Cargo.toml Cargo.lock contracts 2>/dev/null | head -240Repository: clevercon-protocol/clevercon Length of output: 16837 🌐 Web query:
💡 Result: In the Soroban protocol, transaction resource limits and storage TTL extensions are managed through specific protocol parameters and transaction structures. ### Transaction Resource Limits Every Soroban transaction must include a SorobanTransactionData structure, which specifies the resource limits the transaction is permitted to consume [1][2]. These limits are enforced during execution [2]. The Citations:
🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
p = Path("contracts/agent-vault/src/lib.rs")
s = p.read_text()
const = re.search(r"const\s+MAX_RELEASE_STEPS_PER_TASK:\s*u32\s*=\s*(\d+)", s)
helper = re.search(
r"fn\s+extend_task_step_ids_ttl\s*\([^)]*\)\s*\{(?P<body>.*?)\n\s*\}\n\n\s*fn\s+remove_task_step_releases",
s,
re.S,
)
replay = re.search(
r"if\s+let\s+Some\(record\)\s*=\s*env\.storage\(\)\.persistent\(\)\.get::<_,\s*StepRelease>\(&step_key\)\)\s*\{(?P<body>.*?)\n\s*\}",
s,
re.S,
)
record = re.search(
r"fn\s+record_step_release\s*\([^)]*\)\s*->\s*Result<\(\),\s*VaultError>\s*\{(?P<body>.*?)\n\s*\}\n\n\s*fn\s+extend_task_step_ids_ttl",
s,
re.S,
)
assert const and helper and replay and record
n = int(const.group(1))
hb = helper.group("body")
rb = replay.group("body")
recb = record.group("body")
print(f"MAX_RELEASE_STEPS_PER_TASK={n}")
print("helper index gets: 1")
print("helper index TTL extensions: 1")
print(f"helper maximum step has calls: {n if 'persistent().has(&step_key)' in hb else 0}")
print(f"helper maximum step TTL extensions: {n if 'extend_persistent_ttl(env, &step_key)' in hb else 0}")
print(f"replay branch refreshes step key: {'extend_persistent_ttl(&env, &step_key)' in rb}")
print(f"new-record branch transfers after missing step record: {'record_step_release' in s and 'token_client.transfer' in s}")
print(f"record path refreshes new step key: {'extend_persistent_ttl(env, &step_key)' in recb}")
print(f"record path refreshes index on new ID: {'extend_persistent_ttl(env, &ids_key)' in recb}")
PYRepository: clevercon-protocol/clevercon Length of output: 255 🌐 Web query:
💡 Result: In Stellar Soroban, resource limits are mutable network settings managed by validators and can vary between networks [1]. As of August 2026, the maximum number of distinct ledger entries a single transaction may touch (the footprint limit) is 400 [1]. This limit encompasses the sum of all read-only and read-write entries accessed by the entire invocation tree of the transaction [2][1]. While the total footprint cap is 400, transactions are also subject to specific caps on disk-read and write operations [1]. For example, there are separate limits for the number of entries read from disk and the number of entries written to the ledger [1]. Because these limits are dynamic network parameters, you should verify the current values for your specific network (e.g., Mainnet, Testnet) using the Stellar Laboratory Network Limits page or the Stellar CLI command: stellar network settings --network [3][1] If a transaction's footprint exceeds these defined limits, or if it attempts to access ledger entries not included in the declared footprint, the transaction will fail [4][2][5]. The Citations:
🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
text = Path("contracts/agent-vault/src/lib.rs").read_text()
def body_after(signature):
start = text.index(signature)
open_brace = text.index("{", start)
depth = 0
for i in range(open_brace, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
return text[open_brace + 1:i]
raise AssertionError(signature)
n = int(re.search(
r"const\s+MAX_RELEASE_STEPS_PER_TASK:\s*u32\s*=\s*(\d+)",
text,
).group(1))
helper = body_after("fn extend_task_step_ids_ttl")
release = body_after("pub fn release_payment")
record = body_after("fn record_step_release")
print(f"MAX_RELEASE_STEPS_PER_TASK={n}")
print(f"helper step-key has calls per listed ID: {helper.count('persistent().has(&step_key)')}")
print(f"helper step-key TTL calls per listed ID: {helper.count('extend_persistent_ttl(env, &step_key)')}")
print(f"helper index TTL calls: {helper.count('extend_persistent_ttl(env, &ids_key)')}")
print(f"replay calls helper: {'Self::extend_task_step_ids_ttl(&env, task_id);' in release}")
print(f"replay directly refreshes step key: {'Self::extend_persistent_ttl(&env, &step_key);' in release}")
print(f"record refreshes index: {'Self::extend_persistent_ttl(env, &ids_key);' in record}")
print(f"record refreshes step key: {'Self::extend_persistent_ttl(env, &step_key);' in record}")
print(f"release transfers after record path: {release.find('Self::record_step_release') < release.find('token_client.transfer')}")
PYRepository: clevercon-protocol/clevercon Length of output: 479 🌐 Web query:
💡 Result: For Soroban smart contracts on the Stellar network, resource limits are categorized into per-transaction limits and per-ledger limits. These limits are network settings that can be adjusted by validator consensus [1]. Per-Transaction Limits The following limits apply to individual transactions [1]: - Footprint entries (read + write): 400 (The maximum number of distinct entries a single transaction may touch). - Disk-read entries: 200 (The maximum number of distinct disk-backed entries a transaction may read). - Disk-read bytes: 200,000 bytes. Per-Ledger Limits The following limits apply to the total consumption within a single ledger (with a target ledger close time of approximately 5 seconds) [1]: - Disk-read entries: 1,000 entries. - Disk-read bytes: 400,000 bytes. Important Considerations - Network Settings: Because these values are defined as network configuration settings, they are subject to change. You can verify current values for a specific network using tools like the Stellar Laboratory or by querying the network settings directly [1]. - Footprint Requirements: Every Soroban transaction must include a ledger footprint that explicitly declares the keys the transaction will read or write [2][3]. - Transaction Simulation: Since it can be complex to determine the required footprint, the Citations:
Bound replay TTL refresh work without breaking idempotency. At 256 recorded steps, this path reads up to 256 step entries and extends up to 256 step keys. The documented Soroban disk-read limit is 200 entries, so replay can exceed transaction resources. Do not refresh only the replayed step and index. An older indexed step can expire, and a later call can transfer funds again. Use a bounded storage and TTL design that preserves every step record required for idempotency. 🤖 Prompt for AI Agents |
||
|
|
||
| fn remove_task_step_releases(env: &Env, task_id: u64) { | ||
| let ids_key = DataKey::TaskStepIds(task_id); | ||
| let step_ids: Vec<u64> = env | ||
| .storage() | ||
| .persistent() | ||
| .get(&ids_key) | ||
| .unwrap_or(Vec::new(env)); | ||
| for step_id in step_ids.iter() { | ||
| let step_key = DataKey::TaskStepRelease(task_id, step_id); | ||
| if env.storage().persistent().has(&step_key) { | ||
| env.storage().persistent().remove(&step_key); | ||
| } | ||
| } | ||
| if env.storage().persistent().has(&ids_key) { | ||
| env.storage().persistent().remove(&ids_key); | ||
| } | ||
| } | ||
|
|
||
| /// Loads the user's asset account balance, or returns a zeroed struct if not found. | ||
| fn get_or_create_asset_account(env: &Env, user: &Address, asset: &Address) -> UserAssetAccount { | ||
| let key = DataKey::UserAsset(user.clone(), asset.clone()); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Guard plan cost with checked arithmetic and add the boundary test.
task.spent + amountuses uncheckedi128arithmetic on caller-supplied input. If overflow checks are disabled, the sum can wrap and bypass theplan_costlimit, allowing an invalid release. Replace the addition withchecked_add, map overflow toVaultError::ExceedsPlanCost, and reuse the checked sum for accounting. Add a test usingi128::MAXthat assertsVaultError::ExceedsPlanCost.📍 Affects 2 files
contracts/agent-vault/src/lib.rs#L850-L852(this comment)contracts/agent-vault/src/tests.rs#L846-L862🤖 Prompt for AI Agents