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
35 changes: 35 additions & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Code Coverage

on:
push:
branches:
- dev
pull_request:
branches:
- dev

jobs:
coverage:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

- name: Install cargo-tarpaulin
uses: taiki-e/install-action@cargo-tarpaulin

- name: Run code coverage
run: cargo tarpaulin --out Lcov Xml

- name: Upload coverage reports
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: |
lcov.info
cobertura.xml
2 changes: 2 additions & 0 deletions contracts/invoice-escrow/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,6 @@ pub enum Error {
SignatureExpired = 22,
/// Funding amount does not meet the required milestone threshold.
InvalidMilestoneAmount = 23,
/// Cannot cancel because escrow is not in the correct state.
CancelNotAllowed = 24,
}
57 changes: 55 additions & 2 deletions contracts/invoice-escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ impl InvoiceEscrow {
Ok(())
}

/// Cancel an escrow in Created state, refunding any partial funds to the funders.
/// Only the seller may cancel, and only while status is Created.
/// Cancel an unfunded escrow. Only the seller may cancel, and only while status is Created
/// AND no investor has contributed any funds yet.
///
Expand All @@ -188,7 +190,7 @@ impl InvoiceEscrow {
/// investor's already-transferred funds (cancellation has no refund path), so any nonzero
/// `funded_amt` blocks cancellation regardless of status.
///
/// Emits `escrow_cancelled` with `(invoice_id, seller)`.
/// Emits `escrow_refunded` (if partial funds existed) and `escrow_cancelled`.
pub fn cancel_escrow(env: Env, invoice_id: Symbol, seller: Address) -> Result<(), Error> {
seller.require_auth();
let config = storage::get_config(&env).ok_or(Error::NotInit)?;
Expand All @@ -198,8 +200,59 @@ impl InvoiceEscrow {
if data.seller != seller {
return Err(Error::Unauthorized);
}
if data.status == EscrowStatus::Cancelled {
return Err(Error::EscrowCancelled);
}
if data.status != EscrowStatus::Created {
return Err(Error::EscrowFunded);
return Err(Error::CancelNotAllowed);
}

if data.funded_amt > 0 {
let amount_to_refund = data.funded_amt;
let token = token::Client::new(&env, &data.token);
let contract = env.current_contract_address();
let funder_opt = data.funder.clone();

if let Some(distributor) = config.payment_distributor.as_ref() {
token.transfer(&contract, distributor, &amount_to_refund);
env.invoke_contract::<()>(
distributor,
&Symbol::new(&env, DISTRIBUTE_REFUND_FN),
soroban_sdk::vec![
&env,
contract.to_val(),
invoice_id.clone().into_val(&env),
soroban_sdk::vec![
&env,
<Address as IntoVal<Env, soroban_sdk::Val>>::into_val(
&data.token,
&env
),
<Option<Address> as IntoVal<Env, soroban_sdk::Val>>::into_val(
&funder_opt,
&env,
)
]
.into_val(&env),
soroban_sdk::vec![&env, amount_to_refund].into_val(&env),
(EscrowStatus::Cancelled as u32).into_val(&env)
],
);
} else {
if let Some(funder) = &funder_opt {
let funder_amt = storage::get_funder_amount(&env, invoice_id.clone(), funder);
if funder_amt > 0 {
token.transfer(&contract, funder, &funder_amt);
}
}
}

env.invoke_contract::<()>(
&data.inv_token,
&Symbol::new(&env, "set_transfer_locked"),
soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)],
);
events::escrow_refunded(&env, invoice_id.clone(), amount_to_refund);
}
if data.funded_amt > 0 {
return Err(Error::EscrowPartiallyFunded);
Expand Down
22 changes: 20 additions & 2 deletions contracts/invoice-escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2371,14 +2371,15 @@ fn test_cancel_escrow_already_funded_rejected() {
);
client.fund_escrow(&invoice_id, &buyer, &1000);

// Cannot cancel once funded
// Cannot cancel once fully funded (status is Funded)
let res = client.try_cancel_escrow(&invoice_id, &seller);
assert_eq!(res, Err(Ok(Error::EscrowFunded)));
assert_eq!(res, Err(Ok(Error::CancelNotAllowed)));

let _ = pt_client;
}

#[test]
fn test_cancel_escrow_partially_funded_refunds() {
fn test_cancel_escrow_partially_funded_rejected() {
let env = Env::default();
env.mock_all_auths();
Expand All @@ -2391,11 +2392,13 @@ fn test_cancel_escrow_partially_funded_rejected() {
let pt_admin = Address::generate(&env);
let pt_id = env.register_stellar_asset_contract_v2(pt_admin.clone());
let pt_asset = AssetClient::new(&env, &pt_id.address());
let pt_client = TokenClient::new(&env, &pt_id.address());

client.initialize(&admin, &0);

let seller = Address::generate(&env);
let buyer = Address::generate(&env);
let invoice_id = Symbol::new(&env, "INV_PART");
let invoice_id = Symbol::new(&env, "INV_PFUND");

pt_asset.mint(&buyer, &1000);
Expand All @@ -2410,6 +2413,21 @@ fn test_cancel_escrow_partially_funded_rejected() {
&pt_id.address(),
&inv_token_id,
&test_commitment(&env, "test_invoice_data"),
&Some(500),
);
client.fund_escrow(&invoice_id, &buyer, &500);

assert_eq!(pt_client.balance(&buyer), 500);
assert_eq!(pt_client.balance(&escrow_id), 500);

// Cancel while partially funded
client.cancel_escrow(&invoice_id, &seller);

assert_eq!(client.get_escrow_status(&invoice_id), EscrowStatus::Cancelled);

// Funds should be returned to buyer
assert_eq!(pt_client.balance(&escrow_id), 0);
assert_eq!(pt_client.balance(&buyer), 1000);
);

// Partial funding: status stays Created, but funds have already moved into escrow.
Expand Down
1 change: 1 addition & 0 deletions contracts/invoice-escrow/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pub enum EscrowStatus {
Settled = 2,
/// Refunded to investor after due date.
Refunded = 3,
/// Cancelled by seller while in Created state (refunds partial funders if any).
/// Cancelled by seller while still in Created state and never funded
/// (locked out once any investor contribution has been received).
Cancelled = 4,
Expand Down
91 changes: 65 additions & 26 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@

set -euo pipefail

DRY_RUN=false

while [[ $# -gt 0 ]]; do
case $1 in
--dry-run)
DRY_RUN=true
shift
;;
*)
shift
;;
esac
done

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -110,6 +124,15 @@ deploy_contract() {
fi

info "Deploying ${label} from ${wasm} …"

if [[ "${DRY_RUN}" == "true" ]]; then
contract_id="SIMULATED_${label^^}_ID"
contract_id=$(echo "$contract_id" | tr - _ | tr -d - | tr '[:lower:]' '[:upper:]')
success "[DRY-RUN] ${label} deployed → ${contract_id}"
echo "${contract_id}"
return
fi

local contract_id
contract_id=$(soroban contract deploy \
"${SOROBAN_FLAGS[@]}" \
Expand Down Expand Up @@ -175,16 +198,20 @@ info " decimals = ${INVOICE_TOKEN_DECIMALS}"
info " invoice_id = ${INVOICE_TOKEN_INVOICE_ID}"
info " minter = ${INVOICE_ESCROW_ID} (escrow contract)"

soroban contract invoke \
"${SOROBAN_FLAGS[@]}" \
--id "${INVOICE_TOKEN_ID}" \
-- initialize \
--admin "${ADMIN_PUBLIC_KEY}" \
--name "${INVOICE_TOKEN_NAME}" \
--symbol "${INVOICE_TOKEN_SYMBOL}" \
--decimals "${INVOICE_TOKEN_DECIMALS}" \
--invoice_id "${INVOICE_TOKEN_INVOICE_ID}" \
--minter "${INVOICE_ESCROW_ID}"
if [[ "${DRY_RUN}" == "true" ]]; then
success "[DRY-RUN] invoice-token initialised"
else
soroban contract invoke \
"${SOROBAN_FLAGS[@]}" \
--id "${INVOICE_TOKEN_ID}" \
-- initialize \
--admin "${ADMIN_PUBLIC_KEY}" \
--name "${INVOICE_TOKEN_NAME}" \
--symbol "${INVOICE_TOKEN_SYMBOL}" \
--decimals "${INVOICE_TOKEN_DECIMALS}" \
--invoice_id "${INVOICE_TOKEN_INVOICE_ID}" \
--minter "${INVOICE_ESCROW_ID}"
fi

success "invoice-token initialised"

Expand All @@ -193,35 +220,47 @@ info "Initialising invoice-escrow …"
info " admin = ${ADMIN_PUBLIC_KEY}"
info " platform_fee_bps = ${PLATFORM_FEE_BPS}"

soroban contract invoke \
"${SOROBAN_FLAGS[@]}" \
--id "${INVOICE_ESCROW_ID}" \
-- initialize \
--admin "${ADMIN_PUBLIC_KEY}" \
--platform_fee_bps "${PLATFORM_FEE_BPS}"
if [[ "${DRY_RUN}" == "true" ]]; then
success "[DRY-RUN] invoice-escrow initialised"
else
soroban contract invoke \
"${SOROBAN_FLAGS[@]}" \
--id "${INVOICE_ESCROW_ID}" \
-- initialize \
--admin "${ADMIN_PUBLIC_KEY}" \
--platform_fee_bps "${PLATFORM_FEE_BPS}"
fi

success "invoice-escrow initialised"

# --- payment-distributor.initialize ---
info "Initialising payment-distributor …"
info " admin = ${ADMIN_PUBLIC_KEY}"

soroban contract invoke \
"${SOROBAN_FLAGS[@]}" \
--id "${PAYMENT_DISTRIBUTOR_ID}" \
-- initialize \
--admin "${ADMIN_PUBLIC_KEY}"
if [[ "${DRY_RUN}" == "true" ]]; then
success "[DRY-RUN] payment-distributor initialised"
else
soroban contract invoke \
"${SOROBAN_FLAGS[@]}" \
--id "${PAYMENT_DISTRIBUTOR_ID}" \
-- initialize \
--admin "${ADMIN_PUBLIC_KEY}"
fi

success "payment-distributor initialised"

# --- invoice-escrow.set_payment_distributor ---
info "Wiring invoice-escrow to payment-distributor …"

soroban contract invoke \
"${SOROBAN_FLAGS[@]}" \
--id "${INVOICE_ESCROW_ID}" \
-- set_payment_distributor \
--payment_distributor "${PAYMENT_DISTRIBUTOR_ID}"
if [[ "${DRY_RUN}" == "true" ]]; then
success "[DRY-RUN] invoice-escrow wired to payment-distributor"
else
soroban contract invoke \
"${SOROBAN_FLAGS[@]}" \
--id "${INVOICE_ESCROW_ID}" \
-- set_payment_distributor \
--payment_distributor "${PAYMENT_DISTRIBUTOR_ID}"
fi

success "invoice-escrow wired to payment-distributor"

Expand Down
Loading