diff --git a/backend/src/payments/soroban/escrow-contract.client.ts b/backend/src/payments/soroban/escrow-contract.client.ts index dec84e7b..442fdf4e 100644 --- a/backend/src/payments/soroban/escrow-contract.client.ts +++ b/backend/src/payments/soroban/escrow-contract.client.ts @@ -45,6 +45,7 @@ export enum ContractError { InvalidAmount = 3, AlreadyExists = 4, ArithmeticOverflow = 5, + InvalidParties = 22, } // --------------------------------------------------------------------------- diff --git a/contracts/README.md b/contracts/README.md index f64e0308..b34d68e1 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -52,6 +52,7 @@ backend maps that error to `NOT_FOUND`. | `5` | `ArithmeticOverflow` | Checked arithmetic failed | | `20` | `AmountExceedsEscrow` | A partial release would exceed the locked amount | | `21` | `NothingToRefund` | Nothing remains to refund | +| `22` | `InvalidParties` | Payer and beneficiary cannot be the same address | ### Amount Units and Token Decimals @@ -91,6 +92,18 @@ The backend derives escrow IDs as the **SHA-256 hash** of the `Payment` entity U passed to the contract as `BytesN<32>`. See `escrow-submission.processor.ts` for the exact derivation. +### Parties Validation and ID Uniqueness Rules + +1. **Parties Validation**: + - `create` strictly forbids identical `payer` and `beneficiary` addresses. + - Calling `create` where `payer == beneficiary` is rejected with `Error::InvalidParties` (`#22`). + +2. **Single-Use Escrow IDs & Storage Retention**: + - Escrow IDs (`BytesN<32>`) are single-use for the entire lifetime of the contract. + - Once an escrow is created, its entry in `persistent()` storage is permanently retained through all subsequent lifecycle states (`Locked` → `Released` / `Refunded` / `Completed`). + - Re-attempting `create` with an existing ID—even after full release or refund—is rejected with `Error::AlreadyExists` (`#4`). + - This prevents state corruption, replay attacks, and unauthorized re-creation. + ## Arithmetic Safety (CT-73) Amount-handling paths must use checked arithmetic (`checked_add` / `checked_sub`) rather than raw `+` / `-`. `revenue_distribution` already does this for its basis-point sum. `payment_escrow` stores a single amount per escrow and performs no arithmetic on it today; when partial releases or batch totals are added they must use the same checked pattern inline. diff --git a/contracts/payment_escrow/src/lib.rs b/contracts/payment_escrow/src/lib.rs index f3f120d3..75262755 100644 --- a/contracts/payment_escrow/src/lib.rs +++ b/contracts/payment_escrow/src/lib.rs @@ -74,6 +74,8 @@ pub enum Error { AmountExceedsEscrow = 20, /// Nothing remains to refund. NothingToRefund = 21, + /// Payer and beneficiary cannot be the same address. + InvalidParties = 22, } #[contract] @@ -184,6 +186,10 @@ impl PaymentEscrowContract { return Err(Error::InvalidAmount); } + if payer == beneficiary { + return Err(Error::InvalidParties); + } + if env .storage() .persistent() diff --git a/contracts/payment_escrow/tests/edge_cases.rs b/contracts/payment_escrow/tests/edge_cases.rs index 21aa142c..a467d3d6 100644 --- a/contracts/payment_escrow/tests/edge_cases.rs +++ b/contracts/payment_escrow/tests/edge_cases.rs @@ -115,3 +115,28 @@ fn unauthorized_refund_is_rejected() { }]); assert!(c.try_refund(&id).is_err()); } + +#[test] +fn identical_payer_and_beneficiary_is_rejected() { + let f = fixture(); + let c = client(&f); + let id = escrow_id(&f, 7); + assert_eq!( + c.try_create(&id, &f.payer, &f.payer, &100, &f.token), + Err(Ok(Error::InvalidParties)) + ); +} + +#[test] +fn id_reuse_after_release_is_rejected() { + let f = fixture(); + let c = client(&f); + let id = escrow_id(&f, 8); + c.create(&id, &f.payer, &f.beneficiary, &100, &f.token); + c.release(&id); + // Attempting to recreate an escrow with the same id must fail with AlreadyExists + assert_eq!( + c.try_create(&id, &f.payer, &f.beneficiary, &100, &f.token), + Err(Ok(Error::AlreadyExists)) + ); +} diff --git a/contracts/shared/error-codes.ts b/contracts/shared/error-codes.ts index 473c72a8..bc458ca8 100644 --- a/contracts/shared/error-codes.ts +++ b/contracts/shared/error-codes.ts @@ -11,6 +11,7 @@ export enum ContractError { InvalidAmount = 3, Unauthorized = 4, InvalidState = 5, + InvalidParties = 22, } export const CONTRACT_ERROR_NAMES: Readonly> = @@ -20,6 +21,7 @@ export const CONTRACT_ERROR_NAMES: Readonly> = [ContractError.InvalidAmount]: "InvalidAmount", [ContractError.Unauthorized]: "Unauthorized", [ContractError.InvalidState]: "InvalidState", + [ContractError.InvalidParties]: "InvalidParties", }); /** Human-readable name for a contract error code, if known. */