From d092378e7d8c2e7fba941b3ccd063c8ccf5bd862 Mon Sep 17 00:00:00 2001 From: gorrettivibes-coder Date: Tue, 20 Oct 2026 05:54:28 +0100 Subject: [PATCH 1/2] Add permissionless escrow queries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- __tests__/soroban/sdk.test.ts | 93 +++++++++- contracts/contracts/escrow/src/lib.rs | 226 +++++++++++++++++++++++++ contracts/contracts/escrow/src/test.rs | 33 ++++ docs/soroban-escrow-deployment.md | 95 +++++++++++ lib/soroban/sdk/args.ts | 8 +- lib/soroban/sdk/client.ts | 177 ++++++++++++++++++- lib/soroban/sdk/index.ts | 3 + lib/soroban/sdk/types.ts | 33 +++- 8 files changed, 662 insertions(+), 6 deletions(-) diff --git a/__tests__/soroban/sdk.test.ts b/__tests__/soroban/sdk.test.ts index 8f8fbe4..adee0a6 100644 --- a/__tests__/soroban/sdk.test.ts +++ b/__tests__/soroban/sdk.test.ts @@ -27,6 +27,18 @@ function diagnostic(event: xdr.ContractEvent): xdr.DiagnosticEvent { return new xdr.DiagnosticEvent({ inSuccessfulContractCall: true, event }) } +function contractStruct(fields: Record): xdr.ScVal { + return xdr.ScVal.scvMap( + Object.entries(fields).map( + ([key, val]) => + new xdr.ScMapEntry({ + key: nativeToScVal(key, { type: 'symbol' }), + val, + }) + ) + ) +} + function invoked(tx: Transaction): { method: string; argCount: number } { const operation = tx.operations[0] as { func: { value(): { functionName(): { toString(): string }; args(): xdr.ScVal[] } } @@ -39,6 +51,7 @@ function client(overrides: { simulate?: (tx: Transaction) => Promise> sign?: (xdr: string) => Promise getTransaction?: () => Promise> + withSigner?: boolean } = {}) { const sign = vi.fn(overrides.sign ?? (async (xdr: string) => xdr)) const seen: Transaction[] = [] @@ -46,7 +59,7 @@ function client(overrides: { { contractId: CONTRACT_ID, sourcePublicKey: SOURCE, - signTransaction: sign, + signTransaction: overrides.withSigner === false ? undefined : sign, parties: { admin: SOURCE, client: SOURCE, @@ -158,4 +171,82 @@ describe('soroban contract sdk', () => { sdk.createEscrow('project-1', 100, [{ id: 1, amount: 40 }]) ).rejects.toBeInstanceOf(SorobanSdkError) }) + + it('reads escrow state through simulation without a signer', async () => { + const { sdk, sign, seen } = client({ + withSigner: false, + simulate: async () => ({ + result: { + retval: contractStruct({ + client: nativeToScVal(SOURCE, { type: 'address' }), + freelancer: nativeToScVal(SOURCE, { type: 'address' }), + token: nativeToScVal(CONTRACT_ID, { type: 'address' }), + funded: nativeToScVal(true), + allocated: nativeToScVal(BigInt(1000), { type: 'i128' }), + released: nativeToScVal(BigInt(250), { type: 'i128' }), + remaining: nativeToScVal(BigInt(750), { type: 'i128' }), + token_balance: nativeToScVal(BigInt(750), { type: 'i128' }), + status: nativeToScVal(1, { type: 'u32' }), + }), + }, + }), + }) + + await expect(sdk.getEscrowState(CONTRACT_ID)).resolves.toEqual({ + client: SOURCE, + freelancer: SOURCE, + token: CONTRACT_ID, + funded: true, + allocated: '1000', + released: '250', + remaining: '750', + tokenBalance: '750', + status: 'active', + }) + expect(invoked(seen[0])).toEqual({ method: 'get_escrow_state', argCount: 0 }) + expect(sign).not.toHaveBeenCalled() + }) + + it('returns milestone status and completion details from a read-only query', async () => { + const { sdk, seen } = client({ + simulate: async () => ({ + result: { + retval: contractStruct({ + id: nativeToScVal(3, { type: 'u32' }), + amount: nativeToScVal(BigInt(1000), { type: 'i128' }), + released: nativeToScVal(BigInt(1000), { type: 'i128' }), + remaining: nativeToScVal(BigInt(0), { type: 'i128' }), + deadline: nativeToScVal(BigInt(1234), { type: 'u64' }), + description: nativeToScVal('Delivery'), + status: nativeToScVal(5, { type: 'u32' }), + client_approved: nativeToScVal(true), + freelancer_approved: nativeToScVal(false), + completed: nativeToScVal(true), + }), + }, + }), + }) + + await expect(sdk.getMilestone(CONTRACT_ID, '3')).resolves.toMatchObject({ + id: 3, + amount: '1000', + deadline: '1234', + released: '1000', + remaining: '0', + status: 5, + statusName: 'released', + completed: true, + }) + expect(invoked(seen[0])).toEqual({ method: 'get_milestone', argCount: 1 }) + }) + + it('surfaces a missing escrow query as a standardized contract error', async () => { + const { sdk } = client({ + simulate: async () => ({ error: 'HostError: Error(Contract, #2)' }), + }) + await expect(sdk.getEscrowState(CONTRACT_ID)).rejects.toMatchObject({ + code: 'ERR_NOT_INITIALIZED', + contractCode: 2, + }) + }) }) diff --git a/contracts/contracts/escrow/src/lib.rs b/contracts/contracts/escrow/src/lib.rs index 27eb5c9..5b23a3b 100644 --- a/contracts/contracts/escrow/src/lib.rs +++ b/contracts/contracts/escrow/src/lib.rs @@ -43,6 +43,46 @@ pub enum DataKey { Milestone(u32), MilestoneIds, EscrowBalance, + EscrowReleased, + ResolvedToFreelancer(u32), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[contracttype] +pub enum EscrowStatus { + Unfunded = 0, + Active = 1, + Disputed = 2, + Settled = 3, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[contracttype] +pub struct EscrowState { + pub client: Address, + pub freelancer: Address, + pub token: Address, + pub funded: bool, + pub allocated: i128, + pub released: i128, + pub remaining: i128, + pub token_balance: i128, + pub status: EscrowStatus, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[contracttype] +pub struct MilestoneState { + pub id: u32, + pub deadline: u64, + pub amount: i128, + pub released: i128, + pub remaining: i128, + pub status: MilestoneStatus, + pub description: String, + pub client_approved: bool, + pub freelancer_approved: bool, + pub completed: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -75,6 +115,76 @@ pub enum Error { AlreadyExpired = 12, } +fn previously_released(env: &Env) -> i128 { + if let Some(released) = env + .storage() + .instance() + .get::(&DataKey::EscrowReleased) + { + return released; + } + + let ids: Vec = env + .storage() + .instance() + .get(&DataKey::MilestoneIds) + .unwrap_or(Vec::new(env)); + let mut released = 0; + for i in 0..ids.len() { + if let Some(milestone) = env + .storage() + .instance() + .get::(&DataKey::Milestone(ids.get(i).unwrap())) + { + if milestone.status == MilestoneStatus::Released { + released += milestone.amount; + } + } + } + released +} + +fn milestone_state(env: &Env, milestone: Milestone) -> MilestoneState { + let completed = milestone.status == MilestoneStatus::Released + || milestone.status == MilestoneStatus::Refunded + || milestone.status == MilestoneStatus::Resolved + || milestone.status == MilestoneStatus::AutoExpired; + let released = if milestone.status == MilestoneStatus::Released + || (milestone.status == MilestoneStatus::Resolved + && env + .storage() + .instance() + .get::(&DataKey::ResolvedToFreelancer(milestone.id)) + .unwrap_or(false)) + { + milestone.amount + } else { + 0 + }; + let funded: bool = env + .storage() + .instance() + .get(&DataKey::IsFunded) + .unwrap_or(false); + + MilestoneState { + id: milestone.id, + deadline: milestone.deadline, + amount: milestone.amount, + released, + remaining: if funded && !completed { + milestone.amount + } else { + 0 + }, + status: milestone.status, + description: milestone.description, + client_approved: milestone.client_approved, + freelancer_approved: milestone.freelancer_approved, + completed, + } +} + /// Standardized event schemas. Topics are indexed by Soroban RPC consumers; /// non-topic fields are emitted as the event data payload. #[contractevent] @@ -244,6 +354,7 @@ impl EscrowContract { env.storage().instance().set(&DataKey::Token, &token); env.storage().instance().set(&DataKey::MilestoneIds, &ids); env.storage().instance().set(&DataKey::IsFunded, &false); + env.storage().instance().set(&DataKey::EscrowReleased, &0i128); EscrowCreated { contract_id: env.current_contract_address(), @@ -438,8 +549,10 @@ impl EscrowContract { } let transfer_amount = milestone.amount; + let released = previously_released(&env) + transfer_amount; milestone.status = MilestoneStatus::Released; env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); + env.storage().instance().set(&DataKey::EscrowReleased, &released); let mut balance: i128 = env.storage().instance().get(&DataKey::EscrowBalance).unwrap_or(0); if balance < transfer_amount { @@ -554,8 +667,17 @@ impl EscrowContract { } let transfer_amount = milestone.amount; + let released = if release_to_freelancer { + previously_released(&env) + transfer_amount + } else { + previously_released(&env) + }; + env.storage() + .instance() + .set(&DataKey::ResolvedToFreelancer(milestone_id), &release_to_freelancer); milestone.status = MilestoneStatus::Resolved; env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); + env.storage().instance().set(&DataKey::EscrowReleased, &released); let mut balance: i128 = env.storage().instance().get(&DataKey::EscrowBalance).unwrap_or(0); if balance >= transfer_amount { @@ -647,6 +769,110 @@ impl EscrowContract { // --- State Getters --- + pub fn get_escrow_state(env: Env) -> Result { + let client: Address = env + .storage() + .instance() + .get(&DataKey::Client) + .ok_or(Error::NotInitialized)?; + let freelancer: Address = env + .storage() + .instance() + .get(&DataKey::Freelancer) + .ok_or(Error::NotInitialized)?; + let token_address: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .ok_or(Error::NotInitialized)?; + let funded: bool = env + .storage() + .instance() + .get(&DataKey::IsFunded) + .unwrap_or(false); + let ids: Vec = env + .storage() + .instance() + .get(&DataKey::MilestoneIds) + .ok_or(Error::NotInitialized)?; + + let mut allocated = 0i128; + let mut any_disputed = false; + let mut all_settled = true; + for i in 0..ids.len() { + let milestone: Milestone = env + .storage() + .instance() + .get(&DataKey::Milestone(ids.get(i).unwrap())) + .ok_or(Error::MilestoneNotFound)?; + allocated += milestone.amount; + if milestone.status == MilestoneStatus::Disputed { + any_disputed = true; + } + if milestone.status != MilestoneStatus::Released + && milestone.status != MilestoneStatus::Refunded + && milestone.status != MilestoneStatus::Resolved + && milestone.status != MilestoneStatus::AutoExpired + { + all_settled = false; + } + } + + let status = if !funded { + EscrowStatus::Unfunded + } else if any_disputed { + EscrowStatus::Disputed + } else if all_settled { + EscrowStatus::Settled + } else { + EscrowStatus::Active + }; + let token_client = token::Client::new(&env, &token_address); + + Ok(EscrowState { + client, + freelancer, + token: token_address, + funded, + allocated, + released: previously_released(&env), + remaining: env + .storage() + .instance() + .get(&DataKey::EscrowBalance) + .unwrap_or(0), + token_balance: token_client.balance(&env.current_contract_address()), + status, + }) + } + + pub fn get_milestone(env: Env, milestone_id: u32) -> Result { + let milestone: Milestone = env.storage() + .instance() + .get(&DataKey::Milestone(milestone_id)) + .ok_or(Error::MilestoneNotFound)?; + Ok(milestone_state(&env, milestone)) + } + + pub fn get_milestones_state(env: Env) -> Result, Error> { + let ids: Vec = env + .storage() + .instance() + .get(&DataKey::MilestoneIds) + .ok_or(Error::NotInitialized)?; + let mut milestones = Vec::new(&env); + for i in 0..ids.len() { + let id = ids.get(i).unwrap(); + let milestone: Milestone = env + .storage() + .instance() + .get(&DataKey::Milestone(id)) + .ok_or(Error::MilestoneNotFound)?; + milestones.push_back(milestone_state(&env, milestone)); + } + Ok(milestones) + } + pub fn get_milestones(env: Env) -> Result, Error> { let ids: Vec = env.storage().instance().get(&DataKey::MilestoneIds).ok_or(Error::NotInitialized)?; let mut milestones = Vec::new(&env); diff --git a/contracts/contracts/escrow/src/test.rs b/contracts/contracts/escrow/src/test.rs index 12a68d0..bd5f566 100644 --- a/contracts/contracts/escrow/src/test.rs +++ b/contracts/contracts/escrow/src/test.rs @@ -119,6 +119,11 @@ fn test_happy_path() { assert_eq!(escrow.get_arbiter(), setup.arbiter); assert_eq!(escrow.get_token(), setup.token_address); assert_eq!(escrow.is_funded(), false); + let initial_state = escrow.get_escrow_state(); + assert_eq!(initial_state.allocated, 300); + assert_eq!(initial_state.released, 0); + assert_eq!(initial_state.remaining, 0); + assert_eq!(initial_state.status, EscrowStatus::Unfunded); let fetched_milestones = escrow.get_milestones(); assert_eq!(fetched_milestones.len(), 2); @@ -135,6 +140,10 @@ fn test_happy_path() { let updated_milestones = escrow.get_milestones(); assert_eq!(updated_milestones.get(0).unwrap().status, MilestoneStatus::Funded); + let funded_state = escrow.get_escrow_state(); + assert_eq!(funded_state.remaining, 300); + assert_eq!(funded_state.token_balance, 300); + assert_eq!(funded_state.status, EscrowStatus::Active); // Submit Milestone 1 escrow.start_milestone(&1); @@ -154,6 +163,15 @@ fn test_happy_path() { assert_eq!(token_client.balance(&setup.freelancer), 100); assert_eq!(token_client.balance(&escrow.address), 200); assert_eq!(escrow.get_escrow_balance(), 200); + let released_state = escrow.get_escrow_state(); + assert_eq!(released_state.released, 100); + assert_eq!(released_state.remaining, 200); + assert_eq!(released_state.token_balance, 200); + let milestone_state = escrow.get_milestone(&1); + assert_eq!(milestone_state.amount, 100); + assert_eq!(milestone_state.released, 100); + assert_eq!(milestone_state.remaining, 0); + assert_eq!(milestone_state.completed, true); } #[test] @@ -187,6 +205,15 @@ fn test_voluntary_refund() { // Client balance is restored (750 + 250 = 1000) assert_eq!(token_client.balance(&setup.client), 1000); assert_eq!(token_client.balance(&escrow.address), 0); + let refunded_state = escrow.get_escrow_state(); + assert_eq!(refunded_state.released, 0); + assert_eq!(refunded_state.remaining, 0); + assert_eq!(refunded_state.token_balance, 0); + assert_eq!(refunded_state.status, EscrowStatus::Settled); + let milestone_state = escrow.get_milestone(&1); + assert_eq!(milestone_state.released, 0); + assert_eq!(milestone_state.remaining, 0); + assert_eq!(milestone_state.completed, true); } #[test] @@ -224,6 +251,9 @@ fn test_dispute_and_resolve_to_freelancer() { assert_eq!(token_client.balance(&setup.freelancer), 400); assert_eq!(token_client.balance(&setup.client), 600); assert_eq!(escrow.get_milestones().get(0).unwrap().status, MilestoneStatus::Resolved); + let state = escrow.get_milestone(&1); + assert_eq!(state.released, 400); + assert_eq!(state.remaining, 0); } #[test] @@ -260,6 +290,9 @@ fn test_dispute_and_resolve_to_client() { assert_eq!(token_client.balance(&setup.client), 1000); assert_eq!(token_client.balance(&setup.freelancer), 0); assert_eq!(escrow.get_milestones().get(0).unwrap().status, MilestoneStatus::Resolved); + let state = escrow.get_milestone(&1); + assert_eq!(state.released, 0); + assert_eq!(state.remaining, 0); } #[test] diff --git a/docs/soroban-escrow-deployment.md b/docs/soroban-escrow-deployment.md index 22261fd..23e30cf 100644 --- a/docs/soroban-escrow-deployment.md +++ b/docs/soroban-escrow-deployment.md @@ -53,6 +53,101 @@ Integration notes - The contract expects an SPL-like token address (`token`) passed at initialization. The contract uses the standard `token::Client` interface for transfers. - Constructing the initialization `milestones` vector via CLI can be complex; for integration we recommend using the `@stellar/stellar-sdk` / Soroban client in an application script to upload WASM and call `initialize` with typed arguments. +## Permissionless state queries + +`get_escrow_state()`, `get_milestone(milestone_id)`, and +`get_milestones_state()` are read-only contract methods. They require no +participant authorization and do not submit a transaction. Each query is +executed through Soroban RPC simulation, so it reads the state at the ledger +used for that simulation. + +The application SDK exposes these methods without requiring a wallet signer: + +```ts +import { Networks } from '@stellar/stellar-sdk' +import { SorobanContractClient } from '@/lib/soroban/sdk' + +const escrow = new SorobanContractClient({ + contractId: escrowId, + sourcePublicKey: publicKey, + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Networks.TESTNET, +}) + +const state = await escrow.getEscrowState(escrowId) +const milestones = await escrow.getMilestones(escrowId) +``` + +`sourcePublicKey` is only used as the RPC simulation transaction source; it +does not have to belong to an escrow participant. A functioning network account +and access to Soroban RPC are still required. + +The SDK returns a stable camelCase response: + +```json +{ + "client": "G...", + "freelancer": "G...", + "token": "C...", + "funded": true, + "allocated": "10000000", + "released": "2500000", + "remaining": "7500000", + "tokenBalance": "7500000", + "status": "active" +} +``` + +Amounts and deadlines are decimal strings in token base units and ledger +seconds, respectively, to avoid JavaScript integer precision loss. `allocated` +is the sum of milestone allocations. `released` is the total paid to the +freelancer, including dispute resolutions in their favor. `remaining` is the +contract's recorded escrow balance; `tokenBalance` is the token contract's +actual balance for the escrow contract and can be compared with `remaining` to +identify a discrepancy. + +Escrow `status` values are `unfunded` (not funded), `active` (funded with +unfinished milestones), `disputed` (at least one open dispute), and `settled` +(all milestones are released, refunded, resolved, or auto-expired). + +Milestone responses include `id`, `amount` (the allocation), `released` +(amount paid to the freelancer), `remaining` (amount still held by the escrow, +zero before funding and after settlement), `deadline`, `description`, numeric +`status`, `statusName`, `clientApproved`, `freelancerApproved`, and `completed`. +Milestone status numbers/names are: `0 created`, `1 funded`, `2 in_progress`, +`3 submitted`, `4 approved`, `5 released`, `6 refunded`, `7 disputed`, +`8 resolved`, and `9 auto_expired`. `completed` is true for released, refunded, +resolved, and auto-expired milestones. + +Example milestone returned by `getMilestone()`: + +```json +{ + "id": 1, + "amount": "2500000", + "released": "2500000", + "remaining": "0", + "deadline": "0", + "description": "Design", + "status": 5, + "statusName": "released", + "clientApproved": true, + "freelancerApproved": true, + "completed": true +} +``` + +Invalid contract addresses and milestone identifiers fail locally with +`INVALID_ESCROW_ID` and `INVALID_MILESTONE_ID`. A missing/uninitialized escrow +returns the contract's `ERR_NOT_INITIALIZED` error; an unknown milestone +returns `ERR_MILESTONE_NOT_FOUND`. Query failures are thrown as +`SorobanSdkError` with a stable `code`, and contract errors also include the +numeric `contractCode` for diagnostics. Frontends should render these as +query failures rather than treating them as empty escrow state; backend +consumers should retain the ledger returned by their RPC layer when audit +reproducibility is needed. Application databases may cache these responses, +but the contract response is the source for independently verifiable state. + Replacing the JS stub - The repository contains a JS stub at `lib/soroban/deploy.ts`. Replace that stub with an implementation that uploads the compiled WASM, sends the install/create contract transaction, and returns the deployed contract ID and tx hash. See notes in that file for steps. diff --git a/lib/soroban/sdk/args.ts b/lib/soroban/sdk/args.ts index f040659..ae353d2 100644 --- a/lib/soroban/sdk/args.ts +++ b/lib/soroban/sdk/args.ts @@ -64,11 +64,15 @@ export function assertSafeAmount(value: number, label: string): void { export function assertMilestoneId(milestoneId: string): number { if (!/^\d+$/.test(milestoneId)) { - throw new SorobanSdkError('milestoneId must be an unsigned integer') + throw new SorobanSdkError('milestoneId must be an unsigned integer', { + code: 'INVALID_MILESTONE_ID', + }) } const id = Number(milestoneId) if (!Number.isSafeInteger(id) || id > 0xffffffff) { - throw new SorobanSdkError('milestoneId must be an unsigned integer') + throw new SorobanSdkError('milestoneId must be an unsigned integer', { + code: 'INVALID_MILESTONE_ID', + }) } return id } diff --git a/lib/soroban/sdk/client.ts b/lib/soroban/sdk/client.ts index d7941f7..0495764 100644 --- a/lib/soroban/sdk/client.ts +++ b/lib/soroban/sdk/client.ts @@ -16,6 +16,8 @@ import { parseContractError, parseContractEvents, parseDiagnosticEvents } from ' import { SorobanSdkError, type EscrowParties, + type EscrowMilestone, + type EscrowState, type Milestone, type ParsedContractEvent, type SorobanSdkConfig, @@ -48,7 +50,7 @@ export class SorobanContractClient { private readonly networkPassphrase: string private readonly contractId: string private readonly sourcePublicKey: string - private readonly signTransaction: SorobanSdkConfig['signTransaction'] + private readonly signTransaction?: SorobanSdkConfig['signTransaction'] private readonly parties?: EscrowParties private readonly timeoutSeconds: number private readonly confirmationAttempts: number @@ -73,6 +75,30 @@ export class SorobanContractClient { this.assemble = deps?.assemble ?? ((tx, simulation) => rpc.assembleTransaction(tx, simulation)) } + async getEscrowState(escrowId: string): Promise { + assertQueryContractId(escrowId) + return parseEscrowState(await this.query(escrowId, 'get_escrow_state', [])) + } + + async getMilestone(escrowId: string, milestoneId: string): Promise { + assertQueryContractId(escrowId) + const id = assertMilestoneId(milestoneId) + return parseEscrowMilestone( + await this.query(escrowId, 'get_milestone', [encodeU32(id)]) + ) + } + + async getMilestones(escrowId: string): Promise { + assertQueryContractId(escrowId) + const value = await this.query(escrowId, 'get_milestones_state', []) + if (!Array.isArray(value)) { + throw new SorobanSdkError('Contract returned an invalid milestone list', { + code: 'INVALID_QUERY_RESPONSE', + }) + } + return value.map(parseEscrowMilestone) + } + async createEscrow(projectId: string, budget: number, milestones?: Milestone[]): Promise { if (!projectId.trim()) throw new SorobanSdkError('projectId is required') if (!this.parties) throw new SorobanSdkError('Escrow parties are required to create an escrow') @@ -134,6 +160,11 @@ export class SorobanContractClient { options: { projectId?: string; expectedEvent?: string; expectedAmount?: number } = {} ): Promise { try { + if (!this.signTransaction) { + throw new SorobanSdkError('A transaction signer is required for contract writes', { + code: 'SIGNER_REQUIRED', + }) + } const account = await this.server.getAccount(this.sourcePublicKey) const tx = new TransactionBuilder(account, { fee: BASE_FEE, @@ -217,6 +248,45 @@ export class SorobanContractClient { } } + private async query(contractId: string, method: string, args: xdr.ScVal[]): Promise { + try { + const account = await this.server.getAccount(this.sourcePublicKey) + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(new Contract(contractId).call(method, ...args)) + .setTimeout(this.timeoutSeconds) + .build() + const simulation = await this.server.simulateTransaction(tx) + + if (rpc.Api.isSimulationError(simulation)) { + const error = parseContractError(simulation.error) + throw new SorobanSdkError(error.message, { + code: error.code ?? 'CONTRACT_QUERY_FAILED', + contractCode: error.contractCode, + }) + } + if (rpc.Api.isSimulationRestore(simulation)) { + throw new SorobanSdkError('Contract state has expired and cannot be read until restored', { + code: 'CONTRACT_STATE_EXPIRED', + }) + } + if (!simulation.result?.retval) { + throw new SorobanSdkError('Contract query returned no value', { + code: 'EMPTY_QUERY_RESPONSE', + }) + } + return scValToNative(simulation.result.retval) + } catch (err) { + if (err instanceof SorobanSdkError) throw err + const message = err instanceof Error ? err.message : 'Unknown Soroban query failure' + throw new SorobanSdkError(`Unable to query ${method}: ${message}`, { + code: 'QUERY_FAILED', + }) + } + } + private async submit(tx: Transaction | FeeBumpTransaction): Promise { let latest: rpc.Api.SendTransactionResponse | undefined for (let attempt = 0; attempt < 3; attempt++) { @@ -312,6 +382,14 @@ function assertContractId(value: string, label: string): void { if (!StrKey.isValidContract(value)) throw new SorobanSdkError(`${label} is not a contract address`) } +function assertQueryContractId(value: string): void { + if (!StrKey.isValidContract(value)) { + throw new SorobanSdkError('escrowId is not a contract address', { + code: 'INVALID_ESCROW_ID', + }) + } +} + function assertAddress(value: string, label: string): void { if (!StrKey.isValidEd25519PublicKey(value) && !StrKey.isValidContract(value)) { throw new SorobanSdkError(`${label} is not a Stellar address`) @@ -327,3 +405,100 @@ function assertParties(parties: EscrowParties): void { function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } + +function parseEscrowState(value: unknown): EscrowState { + const state = asRecord(value, 'escrow state') + const statusValue = readNumber(state.status, 'status') + const statuses = ['unfunded', 'active', 'disputed', 'settled'] as const + const status = statuses[statusValue] + if (!status) { + throw new SorobanSdkError('Contract returned an unknown escrow status', { + code: 'INVALID_QUERY_RESPONSE', + }) + } + return { + client: readAddress(state.client, 'client'), + freelancer: readAddress(state.freelancer, 'freelancer'), + token: readAddress(state.token, 'token'), + funded: state.funded === true, + allocated: readAmount(state.allocated, 'allocated'), + released: readAmount(state.released, 'released'), + remaining: readAmount(state.remaining, 'remaining'), + tokenBalance: readAmount(state.token_balance, 'token balance'), + status, + } +} + +function parseEscrowMilestone(value: unknown): EscrowMilestone { + const milestone = asRecord(value, 'milestone') + const status = readNumber(milestone.status, 'milestone status') + const statusNames = [ + 'created', + 'funded', + 'in_progress', + 'submitted', + 'approved', + 'released', + 'refunded', + 'disputed', + 'resolved', + 'auto_expired', + ] + const statusName = statusNames[status] + if (!statusName) { + throw new SorobanSdkError('Contract returned an unknown milestone status', { + code: 'INVALID_QUERY_RESPONSE', + }) + } + return { + id: readNumber(milestone.id, 'milestone id'), + amount: readAmount(milestone.amount, 'milestone amount'), + deadline: readAmount(milestone.deadline, 'milestone deadline'), + released: readAmount(milestone.released, 'milestone released amount'), + remaining: readAmount(milestone.remaining, 'milestone remaining amount'), + description: typeof milestone.description === 'string' ? milestone.description : '', + status, + statusName, + clientApproved: milestone.client_approved === true, + freelancerApproved: milestone.freelancer_approved === true, + completed: milestone.completed === true, + } +} + +function asRecord(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new SorobanSdkError(`Contract returned an invalid ${label}`, { + code: 'INVALID_QUERY_RESPONSE', + }) + } + return value as Record +} + +function readAddress(value: unknown, label: string): string { + if (typeof value === 'string') return value + if (value && typeof value === 'object' && 'toString' in value) { + return String(value) + } + throw new SorobanSdkError(`Contract returned an invalid ${label} address`, { + code: 'INVALID_QUERY_RESPONSE', + }) +} + +function readAmount(value: unknown, label: string): string { + if (typeof value === 'bigint' || typeof value === 'number' || typeof value === 'string') { + return value.toString() + } + throw new SorobanSdkError(`Contract returned an invalid ${label}`, { + code: 'INVALID_QUERY_RESPONSE', + }) +} + +function readNumber(value: unknown, label: string): number { + const parsed = typeof value === 'bigint' ? Number(value) : typeof value === 'number' ? value : NaN + if (!Number.isSafeInteger(parsed)) { + throw new SorobanSdkError(`Contract returned an invalid ${label}`, { + code: 'INVALID_QUERY_RESPONSE', + }) + } + return parsed +} diff --git a/lib/soroban/sdk/index.ts b/lib/soroban/sdk/index.ts index 9a47b1d..84937cd 100644 --- a/lib/soroban/sdk/index.ts +++ b/lib/soroban/sdk/index.ts @@ -4,6 +4,9 @@ export { ESCROW_CONTRACT_ERRORS, parseContractError, parseContractEvent } from ' export { SorobanSdkError } from './types' export type { EscrowParties, + EscrowMilestone, + EscrowState, + EscrowStateStatus, Milestone, ParsedContractEvent, SorobanSdkConfig, diff --git a/lib/soroban/sdk/types.ts b/lib/soroban/sdk/types.ts index 2189045..785b71c 100644 --- a/lib/soroban/sdk/types.ts +++ b/lib/soroban/sdk/types.ts @@ -11,6 +11,30 @@ export interface Milestone { freelancerApproved?: boolean } +export type EscrowStateStatus = 'unfunded' | 'active' | 'disputed' | 'settled' + +export interface EscrowState { + client: string + freelancer: string + token: string + funded: boolean + allocated: string + released: string + remaining: string + tokenBalance: string + status: EscrowStateStatus +} + +export interface EscrowMilestone extends Omit { + amount: string + deadline: string + released: string + remaining: string + status: number + statusName: string + completed: boolean +} + /** Addresses required by the escrow `initialize` method. */ export interface EscrowParties { admin: string @@ -26,7 +50,7 @@ export interface SorobanSdkConfig { /** Account that signs and is passed as `caller` where the contract requires it. */ sourcePublicKey: string /** Wallet callback. Receives unsigned transaction XDR and returns signed XDR. */ - signTransaction: (xdr: string) => Promise + signTransaction?: (xdr: string) => Promise rpcUrl?: string networkPassphrase?: string parties?: EscrowParties @@ -63,8 +87,13 @@ export interface TxResult { } export class SorobanSdkError extends Error { - constructor(message: string) { + readonly code?: string + readonly contractCode?: number + + constructor(message: string, options: { code?: string; contractCode?: number } = {}) { super(message) this.name = 'SorobanSdkError' + this.code = options.code + this.contractCode = options.contractCode } } From cfaeb9117dc7fcad7117c270da885a693f2c163c Mon Sep 17 00:00:00 2001 From: gorrettivibes-coder Date: Tue, 20 Oct 2026 05:54:29 +0100 Subject: [PATCH 2/2] Add proposal comparison and shortlist Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/api/proposals/[id]/review/route.ts | 76 ++++++++ components/proposals/proposal-card.tsx | 23 ++- components/proposals/proposal-filters.tsx | 1 + components/proposals/proposal-list.tsx | 166 ++++++++++++++++-- .../proposals/proposal-status-badge.tsx | 1 + .../migrations/011_proposal_shortlisting.sql | 6 + lib/proposals/service.ts | 20 ++- lib/proposals/types.ts | 2 +- 8 files changed, 270 insertions(+), 25 deletions(-) create mode 100644 app/api/proposals/[id]/review/route.ts create mode 100644 lib/db/migrations/011_proposal_shortlisting.sql diff --git a/app/api/proposals/[id]/review/route.ts b/app/api/proposals/[id]/review/route.ts new file mode 100644 index 0000000..7a8c2a3 --- /dev/null +++ b/app/api/proposals/[id]/review/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from "next/server"; +import { readAccessToken, verifyAccessToken } from "@/lib/auth/session"; +import { UUIDSchema } from "@/lib/proposals"; +import { db as sql } from "@/lib/proposals/db"; +import { updateProposalStatus } from "@/lib/proposals/service"; +import type { ProposalStatus } from "@/lib/proposals/types"; + +type RouteContext = { params: Promise<{ id: string }> }; +const REVIEW_STATUSES = ["pending", "shortlisted", "accepted", "rejected"] as const; + +export async function PATCH(request: NextRequest, context: RouteContext) { + const token = readAccessToken(request); + const verified = token ? verifyAccessToken(token) : null; + if (!verified) { + return NextResponse.json({ error: "Authentication required" }, { status: 401 }); + } + + const { id } = await context.params; + if (!UUIDSchema.safeParse(id).success) { + return NextResponse.json({ error: "Invalid proposal ID" }, { status: 400 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 }); + } + if ( + !body || + typeof body !== "object" || + !("status" in body) || + typeof body.status !== "string" || + !REVIEW_STATUSES.includes(body.status as (typeof REVIEW_STATUSES)[number]) + ) { + return NextResponse.json({ error: "A valid review status is required" }, { status: 422 }); + } + + try { + const users = (await sql` + SELECT id, role FROM users + WHERE wallet_address = ${verified.walletAddress} + LIMIT 1 + `) as { id: string; role: string }[]; + const user = users[0]; + if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 }); + if (user.role !== "client" && user.role !== "admin") { + return NextResponse.json({ error: "Only clients can review proposals" }, { status: 403 }); + } + + const proposal = await updateProposalStatus( + id, + user.id, + body.status as Extract + ); + return NextResponse.json({ proposal }); + } catch (error) { + if (error instanceof Error && error.message === "NOT_FOUND") { + return NextResponse.json({ error: "Proposal not found" }, { status: 404 }); + } + if (error instanceof Error && error.message === "INVALID_TRANSITION") { + return NextResponse.json( + { error: "Only pending or shortlisted proposals can be changed" }, + { status: 409 } + ); + } + if (error instanceof Error && error.message === "PROPOSAL_CHANGED") { + return NextResponse.json( + { error: "Proposal changed concurrently; reload and retry" }, + { status: 409 } + ); + } + console.error(`[PATCH /api/proposals/${id}/review]`, error); + return NextResponse.json({ error: "Failed to update proposal" }, { status: 500 }); + } +} diff --git a/components/proposals/proposal-card.tsx b/components/proposals/proposal-card.tsx index 52d8120..7335e28 100644 --- a/components/proposals/proposal-card.tsx +++ b/components/proposals/proposal-card.tsx @@ -10,6 +10,9 @@ interface Props { readOnly?: boolean; onAccept?: (p: Proposal) => void; onReject?: (p: Proposal) => void; + onShortlist?: (p: Proposal) => void; + onCompare?: (p: Proposal) => void; + comparing?: boolean; onViewProfile?: (p: Proposal) => void; } @@ -18,6 +21,9 @@ export function ProposalCard({ readOnly, onAccept, onReject, + onShortlist, + onCompare, + comparing = false, onViewProfile, }: Props) { const ISO = /^[A-Z]{3}$/; @@ -82,8 +88,23 @@ export function ProposalCard({ > View profile - {!readOnly && proposal.status === "pending" && ( + {!readOnly && (proposal.status === "pending" || proposal.status === "shortlisted") && ( <> + + diff --git a/components/proposals/proposal-filters.tsx b/components/proposals/proposal-filters.tsx index f682f3d..f3dcb30 100644 --- a/components/proposals/proposal-filters.tsx +++ b/components/proposals/proposal-filters.tsx @@ -34,6 +34,7 @@ export function ProposalFiltersBar({ value, onChange }: Props) { All Pending + Shortlisted Accepted Rejected Updated diff --git a/components/proposals/proposal-list.tsx b/components/proposals/proposal-list.tsx index 54fdac9..620df2b 100644 --- a/components/proposals/proposal-list.tsx +++ b/components/proposals/proposal-list.tsx @@ -8,6 +8,8 @@ import { ProposalConfirmDialog } from "./proposal-confirm-dialog"; import { ProposalSkeleton } from "./proposal-skeleton"; import { ProposalEmptyState } from "./proposal-empty-state"; import type { Proposal, ProposalFilters } from "@/lib/proposals/types"; +import { Button } from "@/components/ui/button"; +import { AlertCircle, RefreshCw, X } from "lucide-react"; interface Props { projectId: string; @@ -28,22 +30,36 @@ export function ProposalList({ projectId, readOnly }: Props) { action: "accept" | "reject"; } | null>(null); const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [comparing, setComparing] = useState([]); + const [reloadKey, setReloadKey] = useState(0); useEffect(() => { let cancelled = false; setLoading(true); + setError(null); const params = new URLSearchParams({ projectId }); if (filters.status && filters.status !== "all") params.set("status", filters.status); if (filters.sortBy) params.set("sortBy", filters.sortBy); if (filters.sortDir) params.set("sortDir", filters.sortDir); if (filters.search) params.set("search", filters.search); - fetch(`/api/proposals?${params}`) - .then((r) => r.json()) + fetch(`/api/proposals?${params}`, { credentials: "include" }) + .then(async (r) => { + if (!r.ok) throw new Error(`Request failed (${r.status})`); + return r.json(); + }) .then((data) => { - if (!cancelled) setProposals(data.proposals ?? []); + if (!cancelled) { + setProposals(data.proposals ?? []); + setComparing((current) => current.filter((id) => (data.proposals ?? []).some( + (proposal: Proposal) => proposal.id === id + ))); + } + }) + .catch(() => { + if (!cancelled) setError("We couldn’t load proposals. Check your connection and try again."); }) - .catch(() => toast.error("Failed to load proposals")) .finally(() => { if (!cancelled) setLoading(false); }); @@ -51,33 +67,59 @@ export function ProposalList({ projectId, readOnly }: Props) { return () => { cancelled = true; }; - }, [projectId, filters]); + }, [projectId, filters, reloadKey]); - async function confirmAction() { - if (!pendingAction) return; + async function updateStatus(proposal: Proposal, status: "pending" | "shortlisted" | "accepted" | "rejected") { setSubmitting(true); try { - const res = await fetch(`/api/proposals/${pendingAction.proposal.id}`, { + const res = await fetch(`/api/proposals/${proposal.id}/review`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: pendingAction.action === "accept" ? "accepted" : "rejected", - }), + credentials: "include", + body: JSON.stringify({ status }), }); - if (!res.ok) throw new Error(); - const { proposal } = await res.json(); - setProposals((prev) => - prev.map((p) => (p.id === proposal.id ? { ...p, ...proposal } : p)) + const result = await res.json(); + if (!res.ok) throw new Error(result.error ?? "Proposal update failed"); + setProposals((prev) => prev.map((p) => (p.id === proposal.id ? { ...p, ...result.proposal } : p))); + toast.success( + status === "shortlisted" + ? "Proposal added to shortlist" + : status === "pending" + ? "Proposal removed from shortlist" + : `Proposal ${status}` ); - toast.success(`Proposal ${pendingAction.action}ed`); setPendingAction(null); - } catch { - toast.error("Action failed"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Action failed"); } finally { setSubmitting(false); } } + async function confirmAction() { + if (!pendingAction) return; + await updateStatus( + pendingAction.proposal, + pendingAction.action === "accept" ? "accepted" : "rejected" + ); + } + + function toggleCompare(proposalId: string) { + setComparing((current) => + current.includes(proposalId) + ? current.filter((id) => id !== proposalId) + : [...current, proposalId] + ); + } + + const comparison = proposals.filter((proposal) => comparing.includes(proposal.id)); + const currencyFormat = (proposal: Proposal) => { + const currency = proposal.currency.toUpperCase(); + return /^[A-Z]{3}$/.test(currency) + ? new Intl.NumberFormat("en-US", { style: "currency", currency }).format(proposal.proposedBudget) + : `${proposal.proposedBudget.toLocaleString()} ${currency}`; + }; + return (
@@ -88,6 +130,15 @@ export function ProposalList({ projectId, readOnly }: Props) {
+ ) : error ? ( +
+ +

{error}

+ +
) : proposals.length === 0 ? ( toggleCompare(prop.id)} + onShortlist={(prop) => + void updateStatus(prop, prop.status === "shortlisted" ? "pending" : "shortlisted") + } onAccept={(prop) => setPendingAction({ proposal: prop, action: "accept" }) } @@ -113,6 +169,57 @@ export function ProposalList({ projectId, readOnly }: Props) { )} + {!loading && !error && comparison.length > 0 && ( +
+
+
+

+ Proposal comparison +

+

+ Compare budget, delivery, experience, skills, and proposal details. +

+
+ +
+
+ + + + + {comparison.map((proposal) => ( + + ))} + + + + p.status} /> + + p.estimatedDuration || "Not specified"} /> + `${p.freelancerRating.toFixed(1)} / 5`} /> + `${p.freelancerPastContracts} completed jobs`} /> + p.freelancerSkills.join(", ") || "Not specified"} /> + p.coverLetter} /> + +
Details +
+ {proposal.freelancerName} + +
+
+
+
+ )} + ); +} + +function ComparisonRow({ + label, + proposals, + value, +}: { + label: string; + proposals: Proposal[]; + value: (proposal: Proposal) => string; +}) { + return ( + + {label} + {proposals.map((proposal) => ( + + {value(proposal)} + + ))} + + ); } \ No newline at end of file diff --git a/components/proposals/proposal-status-badge.tsx b/components/proposals/proposal-status-badge.tsx index 28e4f87..a947e0b 100644 --- a/components/proposals/proposal-status-badge.tsx +++ b/components/proposals/proposal-status-badge.tsx @@ -3,6 +3,7 @@ import type { ProposalStatus } from "@/lib/proposals/types"; const STYLES: Record = { pending: "bg-yellow-100 text-yellow-800 border-yellow-200", + shortlisted: "bg-blue-100 text-blue-800 border-blue-200", accepted: "bg-green-100 text-green-800 border-green-200", rejected: "bg-red-100 text-red-800 border-red-200", updated: "bg-blue-100 text-blue-800 border-blue-200", diff --git a/lib/db/migrations/011_proposal_shortlisting.sql b/lib/db/migrations/011_proposal_shortlisting.sql new file mode 100644 index 0000000..42db1bd --- /dev/null +++ b/lib/db/migrations/011_proposal_shortlisting.sql @@ -0,0 +1,6 @@ +ALTER TABLE proposals + DROP CONSTRAINT IF EXISTS proposals_status_check; + +ALTER TABLE proposals + ADD CONSTRAINT proposals_status_check + CHECK (status IN ('pending', 'shortlisted', 'accepted', 'rejected', 'updated')); diff --git a/lib/proposals/service.ts b/lib/proposals/service.ts index 66fcc05..2ecdda0 100644 --- a/lib/proposals/service.ts +++ b/lib/proposals/service.ts @@ -110,7 +110,7 @@ export async function listProposalsForFreelancer( export async function updateProposalStatus( proposalId: string, clientId: string, - next: Extract + next: Extract ): Promise { const check = (await sql` SELECT status, client_id FROM proposals WHERE id = ${proposalId} @@ -119,16 +119,28 @@ export async function updateProposalStatus( if (!check[0]) throw new Error("NOT_FOUND"); if (check[0].client_id !== clientId) throw new Error("NOT_FOUND"); const previous = check[0].status; - if (previous === next) throw new Error("NO_CHANGE"); + const allowed: Record = { + pending: ["shortlisted", "accepted", "rejected"], + shortlisted: ["pending", "accepted", "rejected"], + }; + if (!allowed[previous]?.includes(next)) throw new Error("INVALID_TRANSITION"); - await sql`UPDATE proposals SET status = ${next}, updated_at = NOW() WHERE id = ${proposalId}`; + const updated = (await sql` + UPDATE proposals + SET status = ${next}, updated_at = NOW() + WHERE id = ${proposalId} + AND client_id = ${clientId} + AND status = ${previous} + RETURNING id + `) as unknown as { id: string }[]; + if (!updated.length) throw new Error("PROPOSAL_CHANGED"); await sql` INSERT INTO proposal_audit_logs (proposal_id, actor_id, action, previous_status, new_status) VALUES ( ${proposalId}, ${clientId}, - ${next === "accepted" ? "accept" : "reject"}, + ${next === "accepted" ? "accept" : next === "rejected" ? "reject" : "update"}, ${previous}, ${next} ) `; diff --git a/lib/proposals/types.ts b/lib/proposals/types.ts index 84a8388..acc7858 100644 --- a/lib/proposals/types.ts +++ b/lib/proposals/types.ts @@ -1,4 +1,4 @@ -export type ProposalStatus = "pending" | "accepted" | "rejected" | "updated"; +export type ProposalStatus = "pending" | "shortlisted" | "accepted" | "rejected" | "updated"; export interface Proposal { id: string;