Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contracts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ soroban-sdk = "21.0.0"
[dev-dependencies]
soroban-sdk = { version = "21.0.0", features = ["testutils"] }
proptest = { version = "1.4.0", default-features = false, features = ["std"] }
ed25519-dalek = { version = "2.1.1", features = ["rand_core"] }

[[test]]
name = "fuzz_claimable"
Expand Down
67 changes: 67 additions & 0 deletions contracts/src/crypto.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//! Off-chain signed session invitations — Issue #242.
//!
//! Experts pre-sign voucher payloads off-chain so seekers can open sessions
//! without a separate on-chain expert confirmation transaction.

use soroban_sdk::{contracttype, xdr::ToXdr, Address, Bytes, BytesN, Env};

use crate::{DataKey, Error};

/// Signed session invitation issued by an expert off-chain.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SessionVoucher {
pub expert: Address,
pub rate_per_second: i128,
pub max_duration: u64,
pub expiry: u64,
pub nonce: u64,
}

/// Canonical byte sequence signed by the expert wallet.
pub fn voucher_message(env: &Env, voucher: &SessionVoucher) -> Bytes {
let mut message = Bytes::new(env);
message.append(&voucher.expert.to_xdr(env));
message.append(&voucher.rate_per_second.to_xdr(env));
message.append(&voucher.max_duration.to_xdr(env));
message.append(&voucher.expiry.to_xdr(env));
message.append(&voucher.nonce.to_xdr(env));
message
}

/// Verify an ed25519 signature over the canonical voucher message.
pub fn verify_voucher_signature(
env: &Env,
voucher: &SessionVoucher,
public_key: &BytesN<32>,
signature: &BytesN<64>,
) -> Result<(), Error> {
let message = voucher_message(env, voucher);
env.crypto()
.ed25519_verify(public_key, &message, signature);
Ok(())
Comment on lines +33 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== List relevant files =="
ls -R contracts/src | sed -n '1,200p'

echo "== Show contracts/src/crypto.rs around lines 1-120 =="
nl -ba contracts/src/crypto.rs | sed -n '1,140p'

echo "== Locate verify_voucher_signature =="
rg -n "fn verify_voucher_signature" -S contracts/src

echo "== Show exact function body =="
python3 - <<'PY'
import re, pathlib
p = pathlib.Path("contracts/src/crypto.rs")
txt = p.read_text()
m = re.search(r"pub fn verify_voucher_signature[^{]*\{", txt)
if not m:
    print("Not found")
    raise SystemExit(1)
start = m.start()
# naive brace matching
i = m.end()
depth = 1
while i < len(txt) and depth:
    if txt[i] == '{': depth += 1
    elif txt[i] == '}': depth -= 1
    i += 1
body = txt[m.start():i]
print(body)
PY

echo "== Locate Error::InvalidVoucherSignature =="
rg -n "InvalidVoucherSignature" -S contracts/src

echo "== Show Error enum definition area =="
rg -n "enum Error|struct Error|pub enum Error" -S contracts/src
# print any files likely containing Error (heuristic: match "Error" and "InvalidVoucherSignature")
for f in $(rg -l "InvalidVoucherSignature" contracts/src); do
  echo "---- $f (context) ----"
  nl -ba "$f" | sed -n '1,220p'
done

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 336


🌐 Web query:

soroban_sdk Crypto ed25519_verify return type and behavior on failure

💡 Result:

In soroban_sdk, Crypto::ed25519_verify returns no value (it’s a unit function): pub fn ed25519_verify(&self, public_key: &BytesN<32>, message: &Bytes, signature: &BytesN<64>) [1]. On failure (i.e., if the signature verification fails), it panics; the docs explicitly list “Panics: If the signature verification fails.” [2][1]. Behaviorally, successful verification means the function returns normally and execution continues; the example usage shows that after calling env.crypto.ed25519_verify(...), execution proceeds only if the signature is valid. [1] Note: Soroban’s host function details are handled by the environment/crypto implementation; there have been discussions about using stricter Ed25519 verification in the host to avoid malleability issues, but that’s separate from the SDK-level API behavior (panic-on-failure). [3]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Show contracts/src/crypto.rs (first 220 lines) =="
cat -n contracts/src/crypto.rs | sed -n '1,220p'

echo "== Search for InvalidVoucherSignature in repo =="
rg -n "InvalidVoucherSignature" -S contracts/src

echo "== Show contracts/src/errors.rs =="
cat -n contracts/src/errors.rs | sed -n '1,260p'

echo "== Show call sites of verify_voucher_signature =="
rg -n "verify_voucher_signature" -S contracts/src

echo "== Show contracts/src/lib.rs around voucher handling =="
rg -n "Voucher|SessionVoucher|voucher" -S contracts/src/lib.rs
cat -n contracts/src/lib.rs | sed -n '1,260p'

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 20977


🌐 Web query:

soroban_sdk try_ed25519_verify non-panicking ed25519_verify returns Result or Option

💡 Result:

soroban_sdk::EnvCrypto::try_ed25519_verify does not return Result/Option; it’s a “non-panicking” verification API whose success/failure is communicated via its return type. However, in the Soroban SDK’s current documented API, the non-panicking behavior question you asked is answered by comparing with env.crypto.ed25519_verify: - soroban_sdk::crypto::ed25519_verify panics if the signature verification fails (so it is not non-panicking). [1][2] From the authoritative docs we found in this research, the exact return type of try_ed25519_verify (whether it’s a Result, Option, or something else) is not shown in the sources we pulled; we only have explicit evidence that ed25519_verify panics on failure. [1][2] If you want, paste the try_ed25519_verify signature (or a link to its docs page) and I’ll confirm whether it returns Result or Option from the exact declaration.

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Show start_session_with_voucher implementation (around 2035-2125) =="
sed -n '2020,2130p' contracts/src/lib.rs | cat -n

echo "== Show test_start_session_with_voucher (around 6570-6690) =="
sed -n '6560,6700p' contracts/src/lib.rs | cat -n

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 10177


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Search for InvalidVoucherSignature usage (entire contracts/src) =="
rg -n "InvalidVoucherSignature" contracts/src

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 205


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Search for ed25519_verify or try_ed25519_verify usage =="
rg -n "ed25519_verify|try_ed25519_verify" contracts/src

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 219


Return InvalidVoucherSignature instead of trapping on bad signatures.

contracts/src/crypto.rs::verify_voucher_signature always returns Ok(()) and calls env.crypto().ed25519_verify(...), which panics on verification failure in the Soroban SDK—so start_session_with_voucher will abort the invocation instead of returning the contract error Error::InvalidVoucherSignature (the error is defined in contracts/src/errors.rs but never used/returned).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/crypto.rs` around lines 33 - 42, The verify_voucher_signature
function currently calls env.crypto().ed25519_verify(...) and always returns
Ok(()), which causes a trap on bad signatures; change verify_voucher_signature
to check the verification result and return
Err(Error::InvalidVoucherSignature.into()) on failure instead of allowing a
panic—specifically, in verify_voucher_signature (taking Env, SessionVoucher,
BytesN<32>, BytesN<64>) call env.crypto().ed25519_verify(public_key, &message,
signature), if it indicates failure return the contract error
Error::InvalidVoucherSignature, otherwise return Ok(()) so
start_session_with_voucher receives a proper contract error rather than
aborting.

}

pub fn voucher_pubkey(env: &Env, expert: &Address) -> Option<BytesN<32>> {
env.storage()
.persistent()
.get(&DataKey::ExpertVoucherPubkey(expert.clone()))
}

pub fn set_voucher_pubkey(env: &Env, expert: &Address, public_key: BytesN<32>) {
env.storage()
.persistent()
.set(&DataKey::ExpertVoucherPubkey(expert.clone()), &public_key);
}

pub fn is_nonce_consumed(env: &Env, expert: &Address, nonce: u64) -> bool {
env.storage()
.persistent()
.has(&DataKey::VoucherNonceConsumed(expert.clone(), nonce))
}

pub fn consume_nonce(env: &Env, expert: &Address, nonce: u64) {
env.storage()
.persistent()
.set(&DataKey::VoucherNonceConsumed(expert.clone(), nonce), &true);
}
136 changes: 51 additions & 85 deletions contracts/src/disputes.rs
Original file line number Diff line number Diff line change
@@ -1,98 +1,64 @@
//! Expert-initiated session cancellation with partial refund (#238).
//! Expert cooldown after dispute loss — Issue #240.
//!
//! When arbitration awards more to the seeker than the expert, the expert
//! enters a temporary cooldown during which they cannot accept new sessions.
//! Cooldown expiry is tracked by ledger sequence in temporary storage.

use soroban_sdk::{token, Address, Env, String};
use soroban_sdk::{Address, Env};

use crate::{
events, DataKey, Error, SessionStatus, SkillSphereContract, MIN_SESSION_ESCROW,
};
use crate::DataKey;

/// Cancels an active or paused session on behalf of the expert.
///
/// Accrued (claimable) earnings are paid to the expert; the remaining
/// escrow balance is refunded to the seeker. The cancellation reason
/// CID is stored for transparency and the session status becomes
/// `CancelledByExpert`.
pub fn cancel_session_by_expert(
env: &Env,
expert: Address,
session_id: u64,
reason_cid: String,
) -> Result<(i128, i128), Error> {
SkillSphereContract::assert_not_locked(env)?;
SkillSphereContract::set_reentrancy_lock(env, true);

expert.require_auth();

if !SkillSphereContract::is_valid_ipfs_cid(&reason_cid) {
SkillSphereContract::set_reentrancy_lock(env, false);
return Err(Error::InvalidCid);
}
/// Stellar closes a ledger roughly every 5 seconds; seven days ≈ 120_960 ledgers.
pub const DEFAULT_EXPERT_COOLDOWN_LEDGERS: u32 = 7 * 24 * 60 * 12;

let mut session = SkillSphereContract::get_session_or_error(env, session_id)?;
/// Returns the configured cooldown length in ledgers (admin-set, default 7 days).
pub fn cooldown_ledgers(env: &Env) -> u32 {
env.storage()
.instance()
.get(&DataKey::ExpertCooldownLedgers)
.unwrap_or(DEFAULT_EXPERT_COOLDOWN_LEDGERS)
}

if expert != session.expert {
SkillSphereContract::set_reentrancy_lock(env, false);
return Err(Error::Unauthorized);
}
/// Admin-only setter invoked from `lib.rs`.
pub fn set_cooldown_ledgers(env: &Env, ledgers: u32) {
env.storage()
.instance()
.set(&DataKey::ExpertCooldownLedgers, &ledgers);
}

if !matches!(
session.status,
SessionStatus::Active | SessionStatus::Paused
) {
SkillSphereContract::set_reentrancy_lock(env, false);
return Err(Error::InvalidSessionState);
/// True when the expert still has an active post-loss cooldown.
pub fn is_expert_on_cooldown(env: &Env, expert: &Address) -> bool {
if let Some(until_ledger) = env
.storage()
.temporary()
.get::<DataKey, u32>(&DataKey::ExpertCooldownUntil(expert.clone()))
{
return env.ledger().sequence() < until_ledger;
}
false
}

let now = env.ledger().timestamp();
let effective_time = SkillSphereContract::bounded_time(&session, now);
let claimable = SkillSphereContract::claimable_amount_for_session(&session, effective_time);
let remaining = session.balance.saturating_sub(claimable);

session.balance = 0;
session.accrued_amount = 0;
session.last_settlement_timestamp = effective_time as u32;
session.status = SessionStatus::CancelledByExpert;
SkillSphereContract::save_session(env, &session);

/// Returns the ledger sequence after which the expert may accept sessions again.
pub fn expert_cooldown_until(env: &Env, expert: &Address) -> Option<u32> {
env.storage()
.persistent()
.set(&DataKey::SessionCancelReason(session_id), &reason_cid);

let token_client = token::Client::new(env, &session.token);

let mut expert_payout = claimable;
let mut seeker_refund = remaining;
if expert_payout < MIN_SESSION_ESCROW {
expert_payout = 0;
}
if seeker_refund < MIN_SESSION_ESCROW {
seeker_refund = 0;
}
.temporary()
.get(&DataKey::ExpertCooldownUntil(expert.clone()))
}

if expert_payout > 0 {
token_client.transfer(
&env.current_contract_address(),
&session.expert,
&expert_payout,
);
}
if seeker_refund > 0 {
token_client.transfer(
&env.current_contract_address(),
&session.seeker,
&seeker_refund,
);
/// Apply cooldown when the seeker receives a strictly larger award than the expert.
pub fn apply_cooldown_if_expert_lost(
env: &Env,
expert: &Address,
seeker_award_bps: u32,
expert_award_bps: u32,
) {
if seeker_award_bps <= expert_award_bps {
return;
}

events::publish_expert_cancel(
env,
session_id,
expert,
expert_payout,
seeker_refund,
reason_cid,
);

SkillSphereContract::set_reentrancy_lock(env, false);
Ok((expert_payout, seeker_refund))
let ledgers = cooldown_ledgers(env);
let until = env.ledger().sequence().saturating_add(ledgers);
env.storage()
.temporary()
.set(&DataKey::ExpertCooldownUntil(expert.clone()), &until);
Comment on lines +59 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Inspect the exact code around the reported lines
sed -n '1,120p' contracts/src/disputes.rs | nl -ba | sed -n '40,90p'

# 2) Locate the function(s) that write the cooldown key and see if extend_ttl is used anywhere
rg -n "ExpertCooldownUntil|temporary\\(\\)\\.set\\(|extend_ttl\\(" contracts/src/disputes.rs contracts/src -S || true

# 3) Find any usage of extend_ttl across the contracts module
rg -n "extend_ttl\\(" contracts/src -S || true

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 117


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the exact region around the reported lines (59-63)
awk 'NR>=45 && NR<=80 {printf "%d:%s\n", NR, $0}' contracts/src/disputes.rs

# Confirm whether extend_ttl is used anywhere in the file/module
rg -n "extend_ttl\\(" contracts/src/disputes.rs contracts/src || true

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 700


🌐 Web query:

soroban_sdk temporary().set ttl default extend_ttl min_temp_entry_ttl

💡 Result:

In the Soroban SDK, temporary storage entries are ephemeral and automatically deleted when their time-to-live (TTL) reaches zero [1][2]. Unlike persistent or instance storage, temporary entries are not archived [1][2]. The relevant methods for managing TTL in the Soroban SDK are provided by the env.storage().temporary() interface: 1. set(&self, key: &K, val: &V): This method stores a value under a given key [3]. Temporary entries have a default TTL upon creation, which is governed by the network's configuration rather than a direct parameter in the set method [1]. 2. extend_ttl(&self, key: &K, threshold: u32, extend_to: u32): This method allows you to extend the TTL of an existing temporary storage entry [3][4]. It functions as a conditional update: - The extension is performed only if the current TTL is below the specified threshold (in ledgers) [3][4]. - If the condition is met, the TTL is extended to the specified extend_to value (in ledgers) [3][4]. Developers should note that while temporary entries can have their TTLs extended, they are designed for ephemeral data [1][2]. Relying on TTL extensions for long-term storage is generally discouraged, as anyone can submit a TTL extension operation on any entry without authorization, and temporary entries do not provide the safety or permanence of persistent storage [5]. In test environments, developers can verify these behaviors using test utilities such as get_ttl and simulating ledger advancement [4][6].

Citations:


Extend the cooldown key TTL when writing it.

apply_cooldown_if_expert_lost stores DataKey::ExpertCooldownUntil(expert) with env.storage().temporary().set(...) and never calls env.storage().temporary().extend_ttl(...), so the entry’s lifetime is limited to the network-configured default temp TTL and may expire before until, re-enabling the expert early. Use extend_ttl to ensure the temp entry’s TTL covers the intended ledger sequence. https://developers.stellar.org/docs/build/guides/archival/test-ttl-extension

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/disputes.rs` around lines 59 - 63, The cooldown entry written
by apply_cooldown_if_expert_lost uses env.storage().temporary().set(...) but
never extends its TTL, so compute the intended expiry (you already compute
`until` via `env.ledger().sequence().saturating_add(ledgers)`) and call the
temporary storage TTL extension API immediately after setting the key: invoke
`env.storage().temporary().extend_ttl(&DataKey::ExpertCooldownUntil(expert.clone()),
ledgers_or_calculated_ttl)` (using the same `ledgers`/computed duration that
`cooldown_ledgers` returned, or convert the `until` sequence into the
appropriate TTL units expected by `extend_ttl`) to ensure the temp entry lives
at least until the intended ledger sequence.

}
13 changes: 8 additions & 5 deletions contracts/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,13 @@ pub enum Error {
SessionFrozen = 48,
SwapFailed = 49,

// #236 / #239 / #237 / #238
RateLimitExceeded = 50,
TokenNotWhitelisted = 51,
TokenAlreadyWhitelisted = 52,
TokenNotInWhitelist = 53,
// #240 / #241 / #242
ExpertOnCooldown = 50,
SpendingLimitExceeded = 51,
VoucherExpired = 52,
VoucherNonceUsed = 53,
InvalidVoucherSignature = 54,
VoucherPubkeyNotSet = 55,
InvalidVoucher = 56,

}
Loading
Loading