-
Notifications
You must be signed in to change notification settings - Fork 38
feat(contracts): expert cooldown, spending limits, vouchers, webhook events (#240-#243) #245
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
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 |
|---|---|---|
| @@ -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(()) | ||
| } | ||
|
|
||
| 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); | ||
| } | ||
| 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
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. 🧩 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 || trueRepository: 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 || trueRepository: LightForgeHub/SkillSphere-Dapp Length of output: 700 🌐 Web query:
💡 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 Citations:
Extend the cooldown key TTL when writing it.
🤖 Prompt for AI Agents |
||
| } | ||
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.
🧩 Analysis chain
🏁 Script executed:
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:
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:
Repository: LightForgeHub/SkillSphere-Dapp
Length of output: 10177
🏁 Script executed:
Repository: LightForgeHub/SkillSphere-Dapp
Length of output: 205
🏁 Script executed:
Repository: LightForgeHub/SkillSphere-Dapp
Length of output: 219
Return
InvalidVoucherSignatureinstead of trapping on bad signatures.contracts/src/crypto.rs::verify_voucher_signaturealways returnsOk(())and callsenv.crypto().ed25519_verify(...), which panics on verification failure in the Soroban SDK—sostart_session_with_voucherwill abort the invocation instead of returning the contract errorError::InvalidVoucherSignature(the error is defined incontracts/src/errors.rsbut never used/returned).🤖 Prompt for AI Agents