diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index 1664ec9..86361f1 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -11,28 +11,28 @@ datasource db { } model Organization { - id String @id - name String - admin String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id // Unique identifier for the organization (Symbol on-chain) + name String // Display name of the organization + admin String // Primary administrator wallet address + createdAt DateTime @default(now()) // When the organization was first indexed + updatedAt DateTime @updatedAt // Last time the metadata was updated @@index([createdAt]) } model Transaction { - id String @default(cuid()) - txHash String // Blockchain transaction hash - eventIndex Int // Index of the event within the transaction - walletAddress String - volumeUSD Decimal - createdAt DateTime // Explicitly set to ledger closure time for partitioning - type String // e.g., "PAYOUT_ALLOCATED", "ORG_FUNDED", "PAYOUT_CLAIMED" + id String @default(cuid()) // Internal tracking ID + txHash String // Stellar blockchain transaction hash + eventIndex Int // Position of the event within the transaction + walletAddress String // Actor associated with the transaction + volumeUSD Decimal // Value of the transaction in USD at the time of ledger closure + createdAt DateTime // Exact timestamp of ledger closure (Partition Key) + type String // Type of event (e.g., "PAYOUT_ALLOCATED") ledger Int // Ledger sequence number - rawData String? // JSON-encoded raw event data for debugging + rawData String? // Original JSON payload from the Soroban RPC @@id([id, createdAt]) - @@unique([txHash, eventIndex, createdAt]) // Required for partitioning + @@unique([txHash, eventIndex, createdAt]) // Ensures no duplicate events are indexed @@index([createdAt]) @@index([walletAddress]) @@index([txHash]) @@ -40,14 +40,14 @@ model Transaction { } model PayoutEvent { - id String @default(cuid()) - orgId String - maintainer String - amountStroops BigInt - amountXlm Decimal - ledger Int - txHash String - createdAt DateTime // Partition key + id String @default(cuid()) // Internal tracking ID + orgId String // Associated organization ID + maintainer String // Recipient maintainer address + amountStroops BigInt // Payout amount in stroops (10^-7 XLM) + amountXlm Decimal // Payout amount converted to XLM + ledger Int // Ledger sequence number + txHash String // Stellar transaction hash + createdAt DateTime // Timestamp of the payout (Partition Key) @@id([id, createdAt]) @@index([orgId]) diff --git a/packages/backend/src/services/WebhookService.ts b/packages/backend/src/services/WebhookService.ts index ec37f9b..122ed86 100644 --- a/packages/backend/src/services/WebhookService.ts +++ b/packages/backend/src/services/WebhookService.ts @@ -4,12 +4,19 @@ import { Queue } from "bullmq"; import { redis } from "./cache.js"; export interface WebhookJobData { + /** The unique ID of the organization to notify. */ organizationId: string; + /** The name of the event being dispatched (e.g., 'payout_claimed'). */ event: string; + /** The JSON-serializable payload data for the webhook. */ data: any; } +/** + * Service for managing webhook configurations and dispatching events via BullMQ. + */ export class WebhookService { + /** The BullMQ queue for background webhook delivery. */ private webhookQueue: Queue; constructor() { @@ -27,14 +34,29 @@ export class WebhookService { }); } + /** + * Generates a cryptographically secure random secret for webhook signing. + * @returns A 64-character hex string. + */ private generateWebhookSecret(): string { return randomBytes(32).toString('hex'); } + /** + * Calculates a SHA-256 HMAC signature for a webhook payload. + * @param payload The raw stringified JSON payload. + * @param secret The organization's webhook secret. + * @returns The hex-encoded signature. + */ calculateSignature(payload: string, secret: string): string { return createHash('sha256').update(payload).update(secret).digest('hex'); } + /** + * Ensures an organization has a webhook secret, generating one if necessary. + * @param organizationId The organization to generate a secret for. + * @returns The organization's secret. + */ async generateSecretForOrganization(organizationId: string): Promise { const existingConfig = await webhookRepository.getConfig(organizationId); @@ -47,10 +69,19 @@ export class WebhookService { return newSecret; } + /** + * Retrieves the current webhook configuration for an organization. + * @param organizationId The ID of the organization. + */ async getConfig(organizationId: string) { return webhookRepository.getConfig(organizationId); } + /** + * Updates or creates a webhook URL configuration for an organization. + * @param organizationId The ID of the organization. + * @param url The external HTTP POST endpoint. + */ async updateConfig(organizationId: string, url: string) { const secret = await this.generateSecretForOrganization(organizationId); return webhookRepository.upsertConfig(organizationId, url, secret); @@ -58,6 +89,9 @@ export class WebhookService { /** * Dispatches a webhook asynchronously using BullMQ. + * @param organizationId The organization to notify. + * @param event The event name. + * @param data The payload data. */ async queueWebhook(organizationId: string, event: string, data: any) { const config = await webhookRepository.getConfig(organizationId); @@ -73,7 +107,12 @@ export class WebhookService { } /** - * Specifically handles PayoutClaimed webhooks. + * Specifically handles PayoutClaimed webhooks by queuing a background job. + * @param organizationId The ID of the organization. + * @param maintainer The address of the maintainer who claimed the payout. + * @param amountStroops The payout amount in stroops. + * @param txHash The transaction hash on the Stellar network. + * @param ledger The ledger sequence number. */ async dispatchPayoutClaimed( organizationId: string, diff --git a/packages/contracts/src/lib.rs b/packages/contracts/src/lib.rs index d5d1073..8eed495 100644 --- a/packages/contracts/src/lib.rs +++ b/packages/contracts/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, token, Address, Bytes, BytesN, Env, FromVal, IntoVal, String, Symbol, Vec, + contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, token, Address, Bytes, BytesN, Env, FromVal, IntoVal, String, Symbol, Vec, }; // ───────────────────────────────────────────────────────────────────────────── @@ -53,6 +53,64 @@ pub struct MultisigAdmin { pub threshold: u32, } +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum PrincessError { + /// The contract has already been initialized and cannot be re-configured. + AlreadyInitialized = 1, + /// The provided list of administrators for initialization is empty. + EmptyAdminList = 2, + /// The multisig threshold must be greater than zero and less than or equal to the number of admins. + InvalidThreshold = 3, + /// Attempted to call a function that requires the contract to be initialized. + ContractNotInitialized = 4, + /// The protocol is currently paused by the global administrators. + ProtocolPaused = 5, + /// The number of valid administrator signatures does not meet the required threshold. + InsufficientMultisigAuth = 6, + /// An organization with this ID (or derived from this admin/name) already exists. + OrgAlreadyRegistered = 7, + /// The requested organization could not be found in storage. + OrgNotFound = 8, + /// The caller does not have the necessary permissions for this operation. + NotAuthorized = 9, + /// The amount provided (funding or payout) must be a positive value. + InvalidAmount = 10, + /// The organization's total budget would exceed the maximum representable value. + BudgetOverflow = 11, + /// The organization does not have enough remaining budget to cover the payout. + InsufficientBudget = 12, + /// An organization cannot have more than 10 administrators. + MaxAdminLimitReached = 13, + /// The address is already registered as an administrator for this organization. + AdminAlreadyExists = 14, + /// Cannot remove the last administrator; an organization must have at least one. + CannotRemoveLastAdmin = 15, + /// The address is not currently an administrator of the specified organization. + NotAnAdmin = 16, + /// This maintainer is already associated with an organization. + MaintainerAlreadyRegistered = 17, + /// This maintainer is not registered in the system. + MaintainerNotRegistered = 18, + /// The maintainer is registered but belongs to a different organization. + MaintainerOrgMismatch = 19, + /// The maintainer's total claimable balance would exceed the maximum representable value. + PayoutOverflow = 20, + /// A batch payout operation cannot exceed 100 entries to prevent timeout. + BatchSizeExceeded = 21, + /// The provided list of payouts for a batch operation is empty. + EmptyBatch = 22, + /// The maintainer has no funds available to claim. + NoClaimableBalance = 23, + /// The payout is currently within its mandatory lock/vesting period. + PayoutLocked = 24, + /// There is no pending administrator proposal to accept. + NoPendingAdmin = 25, + /// The caller is not the address currently proposed as a new administrator. + NotPendingAdmin = 26, +} + #[contracttype] pub enum DataKey { /// The global Stellar Asset Contract address configured during initialization. @@ -103,15 +161,15 @@ impl PayoutRegistry { pub fn init(env: Env, token: Address, admins: Vec
, threshold: u32) { if env.storage().persistent().has(&DataKey::Token) { - panic!("already initialized"); + panic_with_error!(&env, PrincessError::AlreadyInitialized); } if admins.is_empty() { - panic!("admins list cannot be empty"); + panic_with_error!(&env, PrincessError::EmptyAdminList); } if threshold == 0 || threshold > admins.len() as u32 { - panic!("invalid threshold"); + panic_with_error!(&env, PrincessError::InvalidThreshold); } env.storage().persistent().set(&DataKey::Token, &token); @@ -139,7 +197,7 @@ impl PayoutRegistry { env.storage() .persistent() .get(&DataKey::Token) - .expect("contract not initialized") + .unwrap_or_else(|| panic_with_error!(&env, PrincessError::ContractNotInitialized)) } /// Retrieve the multisig admin configuration. @@ -153,7 +211,7 @@ impl PayoutRegistry { env.storage() .persistent() .get(&DataKey::MultisigAdmin) - .expect("contract not initialized") + .unwrap_or_else(|| panic_with_error!(&env, PrincessError::ContractNotInitialized)) } /// Retrieve the current protocol state. @@ -167,7 +225,7 @@ impl PayoutRegistry { env.storage() .persistent() .get(&DataKey::ProtocolState) - .expect("contract not initialized") + .unwrap_or_else(|| panic_with_error!(&env, PrincessError::ContractNotInitialized)) } /// Assert that the protocol is currently active. @@ -178,7 +236,7 @@ impl PayoutRegistry { let state = Self::get_protocol_state(env.clone()); match state { ProtocolState::Active => {}, // Continue normally - ProtocolState::Paused => panic!("protocol is paused"), + ProtocolState::Paused => panic_with_error!(env, PrincessError::ProtocolPaused), } } @@ -205,7 +263,7 @@ impl PayoutRegistry { // Verify we meet the threshold if auth_count < multisig_admin.threshold { - panic!("insufficient multisig signatures: {} < {}", auth_count, multisig_admin.threshold); + panic_with_error!(env, PrincessError::InsufficientMultisigAuth); } } @@ -229,7 +287,7 @@ impl PayoutRegistry { let org_key = DataKey::Organization(id.clone()); if env.storage().persistent().has(&org_key) { - panic!("organization already registered"); + panic_with_error!(&env, PrincessError::OrgAlreadyRegistered); } let mut admins = Vec::new(&env); @@ -281,7 +339,7 @@ impl PayoutRegistry { env.storage() .persistent() .get(&DataKey::Organization(id)) - .expect("organization not found") + .unwrap_or_else(|| panic_with_error!(&env, PrincessError::OrgNotFound)) } /// Update the IPFS CID for an organization's metadata (Logo/Description). @@ -290,7 +348,7 @@ impl PayoutRegistry { let org_key = DataKey::Organization(id.clone()); let mut org: Organization = env.storage().persistent() .get(&org_key) - .expect("organization not found"); + .unwrap_or_else(|| panic_with_error!(&env, PrincessError::OrgNotFound)); // Authorization: Check if the caller is one of the registered admins let mut is_authorized = false; @@ -303,7 +361,7 @@ impl PayoutRegistry { } if !is_authorized { - panic!("not authorized to update metadata"); + panic_with_error!(&env, PrincessError::NotAuthorized); } org.metadata_cid = Some(metadata_cid.clone()); @@ -323,7 +381,7 @@ impl PayoutRegistry { from.require_auth_for_args((org_id.clone(), from.clone(), amount).into_val(&env)); if amount <= 0 { - panic!("amount must be positive"); + panic_with_error!(&env, PrincessError::InvalidAmount); } if !env @@ -331,13 +389,13 @@ impl PayoutRegistry { .persistent() .has(&DataKey::Organization(org_id.clone())) { - panic!("organization not found"); + panic_with_error!(&env, PrincessError::OrgNotFound); } // Effects: Update the Persistent Storage first (CEI) let budget_key = DataKey::OrgBudget(org_id.clone()); let current_budget: i128 = env.storage().persistent().get(&budget_key).unwrap_or(0); - let new_budget = current_budget.checked_add(amount).expect("budget overflow"); + let new_budget = current_budget.checked_add(amount).unwrap_or_else(|| panic_with_error!(&env, PrincessError::BudgetOverflow)); env.storage() .persistent() .set(&budget_key, &new_budget); @@ -372,16 +430,16 @@ impl PayoutRegistry { } if !is_authorized { - panic!("not authorized to add admin"); + panic_with_error!(&env, PrincessError::NotAuthorized); } if org.admins.len() >= 10 { - panic!("max admin limit reached"); + panic_with_error!(&env, PrincessError::MaxAdminLimitReached); } for i in 0..org.admins.len() { if org.admins.get(i).unwrap() == new_admin { - panic!("address is already an admin"); + panic_with_error!(&env, PrincessError::AdminAlreadyExists); } } @@ -410,11 +468,11 @@ impl PayoutRegistry { } if !is_authorized { - panic!("not authorized to remove admin"); + panic_with_error!(&env, PrincessError::NotAuthorized); } if org.admins.len() <= 1 { - panic!("cannot remove the last admin"); + panic_with_error!(&env, PrincessError::CannotRemoveLastAdmin); } let mut index = None; @@ -431,7 +489,7 @@ impl PayoutRegistry { env.storage().persistent().set(&DataKey::Organization(org_id.clone()), &org); env.storage().persistent().extend_ttl(&DataKey::Organization(org_id.clone()), PERSISTENT_LIFETIME_THRESHOLD, PERSISTENT_BUMP_AMOUNT); }, - None => panic!("address is not an admin"), + None => panic_with_error!(&env, PrincessError::NotAnAdmin), } env.events().publish( @@ -459,7 +517,7 @@ impl PayoutRegistry { .storage() .persistent() .get(&DataKey::OrgAdmin(org_id.clone())) - .expect("organization not found"); + .unwrap_or_else(|| panic_with_error!(&env, PrincessError::OrgNotFound)); admin.require_auth(); if env @@ -467,7 +525,7 @@ impl PayoutRegistry { .persistent() .has(&DataKey::MaintainerOrg(maintainer.clone())) { - panic!("maintainer already registered"); + panic_with_error!(&env, PrincessError::MaintainerAlreadyRegistered); } env.storage() @@ -512,7 +570,7 @@ impl PayoutRegistry { .storage() .persistent() .get(&DataKey::MaintainerOrg(address.clone())) - .expect("maintainer not registered"); + .unwrap_or_else(|| panic_with_error!(&env, PrincessError::MaintainerNotRegistered)); Maintainer { address, org_id } } @@ -547,26 +605,26 @@ impl PayoutRegistry { } if !is_authorized { - panic!("not authorized: caller is not an organization admin"); + panic_with_error!(&env, PrincessError::NotAuthorized); } if amount <= 0 { - panic!("payout amount must be positive"); + panic_with_error!(&env, PrincessError::InvalidAmount); } let maintainer_org: Symbol = env .storage() .persistent() .get(&DataKey::MaintainerOrg(maintainer.clone())) - .expect("maintainer not registered"); + .unwrap_or_else(|| panic_with_error!(&env, PrincessError::MaintainerNotRegistered)); if maintainer_org != org_id { - panic!("maintainer does not belong to this organization"); + panic_with_error!(&env, PrincessError::MaintainerOrgMismatch); } let budget_key = DataKey::OrgBudget(org_id.clone()); let current_budget: i128 = env.storage().persistent().get(&budget_key).unwrap_or(0); if current_budget < amount { - panic!("insufficient organization budget"); + panic_with_error!(&env, PrincessError::InsufficientBudget); } env.storage() @@ -582,7 +640,7 @@ impl PayoutRegistry { .persistent() .get(&balance_key) .unwrap_or(MaintainerPayout { amount: 0, unlock_timestamp: 0 }); - current_payout.amount = current_payout.amount.checked_add(amount).expect("payout amount overflow"); + current_payout.amount = current_payout.amount.checked_add(amount).unwrap_or_else(|| panic_with_error!(&env, PrincessError::PayoutOverflow)); current_payout.unlock_timestamp = unlock_timestamp; env.storage().persistent().set(&balance_key, ¤t_payout); env.storage().persistent().extend_ttl(&balance_key, PERSISTENT_LIFETIME_THRESHOLD, PERSISTENT_BUMP_AMOUNT); @@ -617,16 +675,16 @@ impl PayoutRegistry { } } if !is_authorized { - panic!("caller is not an organization admin"); + panic_with_error!(&env, PrincessError::NotAuthorized); } // Enforce batch size limit to prevent out-of-gas errors if payouts.len() > 100 { - panic!("batch size exceeds maximum of 100"); + panic_with_error!(&env, PrincessError::BatchSizeExceeded); } if payouts.is_empty() { - panic!("payouts list must not be empty"); + panic_with_error!(&env, PrincessError::EmptyBatch); } // Compute total payout sum and validate each entry before touching storage @@ -634,24 +692,24 @@ impl PayoutRegistry { for i in 0..payouts.len() { let entry = payouts.get(i).unwrap(); if entry.amount <= 0 { - panic!("payout amount must be positive"); + panic_with_error!(&env, PrincessError::InvalidAmount); } let maintainer_org: Symbol = env .storage() .persistent() .get(&DataKey::MaintainerOrg(entry.maintainer.clone())) - .expect("maintainer not registered"); + .unwrap_or_else(|| panic_with_error!(&env, PrincessError::MaintainerNotRegistered)); if maintainer_org != org_id { - panic!("maintainer does not belong to this organization"); + panic_with_error!(&env, PrincessError::MaintainerOrgMismatch); } - total = total.checked_add(entry.amount).expect("total overflow"); + total = total.checked_add(entry.amount).unwrap_or_else(|| panic_with_error!(&env, PrincessError::PayoutOverflow)); } // Verify the org has enough budget to cover the entire batch let budget_key = DataKey::OrgBudget(org_id.clone()); let current_budget: i128 = env.storage().persistent().get(&budget_key).unwrap_or(0); if current_budget < total { - panic!("insufficient organization budget for batch"); + panic_with_error!(&env, PrincessError::InsufficientBudget); } // Deduct total from org budget in one write @@ -711,11 +769,11 @@ impl PayoutRegistry { .unwrap_or(MaintainerPayout { amount: 0, unlock_timestamp: 0 }); if payout.amount == 0 { - panic!("no claimable balance"); + panic_with_error!(&env, PrincessError::NoClaimableBalance); } if env.ledger().timestamp() < payout.unlock_timestamp { - panic!("payout is still locked"); + panic_with_error!(&env, PrincessError::PayoutLocked); } let amount_to_claim = payout.amount; @@ -824,9 +882,9 @@ impl PayoutRegistry { .storage() .persistent() .get(&DataKey::PendingAdmin) - .expect("no pending admin proposal"); + .unwrap_or_else(|| panic_with_error!(&env, PrincessError::NoPendingAdmin)); if pending != new_admin { - panic!("caller is not the pending admin"); + panic_with_error!(&env, PrincessError::NotPendingAdmin); } // Build a new single-member multisig with threshold 1 let mut admins = Vec::new(&env); diff --git a/packages/frontend/src/app/error.tsx b/packages/frontend/src/app/error.tsx index b40c537..1fea42b 100644 --- a/packages/frontend/src/app/error.tsx +++ b/packages/frontend/src/app/error.tsx @@ -14,6 +14,7 @@ import { useEffect } from 'react'; import { Button } from '@/components/ui/Button'; import { toast } from 'sonner'; +import { PrincessErrorMessage } from "@very-princess/types"; // ── Error Types ────────────────────────────────────────────────────────────── @@ -25,10 +26,18 @@ interface ErrorBoundaryProps { // ── Error Message Translation ─────────────────────────────────────────────────── function translateBlockchainError(error: Error): string { - const errorMessage = error.message.toLowerCase(); + const errorMessage = error.message; + + // Check if the error message is one of our custom PrincessError messages + const customMessages = Object.values(PrincessErrorMessage); + if (customMessages.includes(errorMessage)) { + return errorMessage; + } + + const lowerMessage = errorMessage.toLowerCase(); // Common Stellar/Soroban error patterns - if (errorMessage.includes('outofgas') || errorMessage.includes('out of gas')) { + if (lowerMessage.includes('outofgas') || lowerMessage.includes('out of gas')) { return 'Transaction ran out of gas. Please try again with a higher gas limit.'; } diff --git a/packages/frontend/src/lib/sorobanClient.ts b/packages/frontend/src/lib/sorobanClient.ts index f842f65..f0d4d88 100644 --- a/packages/frontend/src/lib/sorobanClient.ts +++ b/packages/frontend/src/lib/sorobanClient.ts @@ -1,20 +1,6 @@ /** * @file sorobanClient.ts * @description Browser-side service for interacting with the Soroban RPC and Horizon. - * - * This service centralizes all Stellar network interactions for the frontend. - * It provides methods for both read-only contract calls (simulations) and - * for building write transactions that can be signed by a wallet (e.g., Freighter). - * - * It encapsulates the `stellar-sdk`'s `SorobanRpc.Server` and `Horizon.Server` - * instances, ensuring they are configured and used consistently. - * - * ## Adding New Operations - * - * 1. Identify the contract function name (e.g. `get_org`). - * 2. Build the argument list using `nativeToScVal`. - * 3. Call `simulateContractCall(functionName, args)` and convert the return - * value with `scValToNative`. */ "use client"; @@ -31,6 +17,7 @@ import { Horizon, } from "@stellar/stellar-sdk"; import type { MaintainerBalance, Organization } from "./contractTypes"; +import { PrincessError, PrincessErrorMessage } from "@very-princess/types"; // ─── Network Configuration ──────────────────────────────────────────────────── @@ -61,11 +48,48 @@ class SorobanClient { }); } + // ─── Error Handling ─────────────────────────────────────────────────────────── + + /** + * Decodes a Soroban error from a failed transaction result or simulation. + */ + private _parseSorobanError(errorResponse: any): string { + try { + // Handle simulation error + if (errorResponse.error) { + return `Simulation failed: ${errorResponse.error}`; + } + + // Handle transaction result error + const returnValue = errorResponse.returnValue; + if (returnValue) { + // @ts-ignore - _arm is internal to ScVal objects in stellar-sdk + if (returnValue._arm === "error") { + // @ts-ignore + const errorVal = returnValue._value; + // @ts-ignore + if (errorVal._arm === "contract") { + // @ts-ignore + const errorCode = errorVal._value as number; + const message = PrincessErrorMessage[errorCode as PrincessError]; + if (message) return message; + return `Contract Error: ${errorCode}`; + } + } + } + + return "Transaction failed on-chain. Please check your inputs and balance."; + } catch (err) { + console.error("Failed to parse Soroban error:", err); + return "Transaction failed. Error details could not be parsed."; + } + } + // ─── Simulation Helper ──────────────────────────────────────────────────────── private async _simulateContractCall( functionName: string, - args: Parameters[0][] + args: any[] ): Promise> { if (!CONTRACT_ID) { throw new Error("NEXT_PUBLIC_CONTRACT_ID is not set. Deploy the contract first."); @@ -81,12 +105,11 @@ class SorobanClient { }; const tx = new TransactionBuilder( - // @ts-ignore — minimal account duck-typing is sufficient for simulation + // @ts-ignore fakeAccount, { fee: BASE_FEE, networkPassphrase: NETWORK_PASSPHRASE } ) .addOperation( - // @ts-ignore — call() accepts string args contract.call(functionName, ...args.map((a) => nativeToScVal(a))) ) .setTimeout(30) @@ -95,10 +118,10 @@ class SorobanClient { const simResult = await this.rpcServer.simulateTransaction(tx); if (SorobanRpc.Api.isSimulationError(simResult)) { - throw new Error(`Contract simulation failed: ${simResult.error}`); + throw new Error(this._parseSorobanError(simResult)); } - // @ts-ignore — returnVal present on success result + // @ts-ignore return scValToNative(simResult.result?.retval); } @@ -111,23 +134,9 @@ class SorobanClient { id: String(map["id"]), name: String(map["name"]), admin: String(map["admin"]), + metadataCid: map["metadata_cid"] ? String(map["metadata_cid"]) : undefined, }; } -/** - * Read a registered organization from the PayoutRegistry. - * - * @param orgId — Short Symbol ID of the organization (max 9 chars). - */ -export async function readOrganization(orgId: string): Promise { - const raw = await simulateContractCall("get_org", [orgId]); - const map = raw as Record; - return { - id: String(map["id"]), - name: String(map["name"]), - admin: String(map["admin"]), - metadataCid: map["metadata_cid"] ? String(map["metadata_cid"]) : undefined, - }; -} public async readMaintainers(orgId: string): Promise { const raw = await this._simulateContractCall("get_maintainers", [orgId]); @@ -183,7 +192,6 @@ export async function readOrganization(orgId: string): Promise { networkPassphrase: NETWORK_PASSPHRASE, }) .addOperation( - // @ts-ignore contract.call( "fund_org", nativeToScVal(orgId), @@ -196,7 +204,7 @@ export async function readOrganization(orgId: string): Promise { const simResult = await this.rpcServer.simulateTransaction(tx); if (SorobanRpc.Api.isSimulationError(simResult)) { - throw new Error(`Simulation failed: ${simResult.error}`); + throw new Error(this._parseSorobanError(simResult)); } const preparedTx = SorobanRpc.assembleTransaction(tx, simResult).build(); @@ -212,7 +220,6 @@ export async function readOrganization(orgId: string): Promise { networkPassphrase: NETWORK_PASSPHRASE, }) .addOperation( - // @ts-ignore contract.call("claim_payout", nativeToScVal(userAddress)) ) .setTimeout(60) @@ -220,107 +227,83 @@ export async function readOrganization(orgId: string): Promise { const simResult = await this.rpcServer.simulateTransaction(tx); if (SorobanRpc.Api.isSimulationError(simResult)) { - throw new Error(`Simulation failed: ${simResult.error}`); + throw new Error(this._parseSorobanError(simResult)); } const preparedTx = SorobanRpc.assembleTransaction(tx, simResult).build(); return preparedTx.toXDR(); } - public async submitSignedTransaction(signedXdr: string): Promise { - const tx = TransactionBuilder.fromXDR(signedXdr, NETWORK_PASSPHRASE); + public async buildAllocatePayoutTransaction( + adminAddress: string, + orgId: string, + maintainerAddress: string, + amountStroops: bigint + ): Promise { + const account = await this._loadAccount(adminAddress); + const contract = new Contract(CONTRACT_ID); - const sendResult = await this.rpcServer.sendTransaction(tx as any); - if (sendResult.status === "ERROR") { - throw new Error(`Send error: ${JSON.stringify(sendResult)}`); - } -/** - * Build, simulate, and prepare an unsigned XDR for `allocate_payout`. - */ -export async function buildAllocatePayoutTransaction( - adminAddress: string, - orgId: string, - maintainerAddress: string, - amountStroops: bigint -): Promise { - const account = await loadAccount(adminAddress); - const contract = new Contract(CONTRACT_ID); - - const tx = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, - }) - .addOperation( - // @ts-ignore - contract.call("allocate_payout", - nativeToScVal(orgId, { type: "symbol" }), - nativeToScVal(maintainerAddress, { type: "address" }), - nativeToScVal(amountStroops, { type: "i128" }), - nativeToScVal(0, { type: "u64" }) + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation( + contract.call("allocate_payout", + nativeToScVal(orgId, { type: "symbol" }), + nativeToScVal(maintainerAddress, { type: "address" }), + nativeToScVal(amountStroops, { type: "i128" }), + nativeToScVal(0, { type: "u64" }) + ) ) - ) - .setTimeout(60) - .build(); + .setTimeout(60) + .build(); - const simResult = await rpcServer.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationError(simResult)) { - throw new Error(`Simulation failed: ${simResult.error}`); + const simResult = await this.rpcServer.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simResult)) { + throw new Error(this._parseSorobanError(simResult)); + } + + const preparedTx = SorobanRpc.assembleTransaction(tx, simResult).build(); + return preparedTx.toXDR(); } - const preparedTx = SorobanRpc.assembleTransaction(tx, simResult).build(); - return preparedTx.toXDR(); -} + public async buildUpdateOrgMetadataTransaction( + adminAddress: string, + orgId: string, + metadataCid: string + ): Promise { + const account = await this._loadAccount(adminAddress); + const contract = new Contract(CONTRACT_ID); -/** - * Build, simulate, and prepare an unsigned XDR for `update_org_metadata`. - * - * @param adminAddress - The admin's public key. - * @param orgId - Organization ID. - * @param metadataCid - The new IPFS CID. - */ -export async function buildUpdateOrgMetadataTransaction( - adminAddress: string, - orgId: string, - metadataCid: string -): Promise { - const account = await loadAccount(adminAddress); - const contract = new Contract(CONTRACT_ID); - - const tx = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, - }) - .addOperation( - // @ts-ignore - contract.call("update_org_metadata", - nativeToScVal(orgId, { type: "symbol" }), - nativeToScVal(metadataCid, { type: "string" }) + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation( + contract.call("update_org_metadata", + nativeToScVal(orgId, { type: "symbol" }), + nativeToScVal(metadataCid, { type: "string" }) + ) ) - ) - .setTimeout(60) - .build(); + .setTimeout(60) + .build(); - const simResult = await rpcServer.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationError(simResult)) { - throw new Error(`Simulation failed: ${simResult.error}`); + const simResult = await this.rpcServer.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simResult)) { + throw new Error(this._parseSorobanError(simResult)); + } + + const preparedTx = SorobanRpc.assembleTransaction(tx, simResult).build(); + return preparedTx.toXDR(); } - const preparedTx = SorobanRpc.assembleTransaction(tx, simResult).build(); - return preparedTx.toXDR(); -} + public async submitSignedTransaction(signedXdr: string): Promise { + const tx = TransactionBuilder.fromXDR(signedXdr, NETWORK_PASSPHRASE); -/** - * Submit a signed transaction to Soroban RPC and wait for confirmation. - * @param signedXdr — Base64 string from Freighter. - */ -export async function submitSignedTransaction(signedXdr: string): Promise { - const tx = TransactionBuilder.fromXDR(signedXdr, NETWORK_PASSPHRASE); - - // Submit the transaction - const sendResult = await rpcServer.sendTransaction(tx as any); - if (sendResult.status === "ERROR") { - throw new Error(`Send error: ${JSON.stringify(sendResult)}`); - } + const sendResult = await this.rpcServer.sendTransaction(tx as any); + if (sendResult.status === "ERROR") { + throw new Error(`Send error: ${JSON.stringify(sendResult)}`); + } return new Promise((resolve, reject) => { let attempts = 0; @@ -338,7 +321,7 @@ export async function submitSignedTransaction(signedXdr: string): Promise = { + [PrincessError.AlreadyInitialized]: "The contract is already initialized.", + [PrincessError.EmptyAdminList]: "The admin list cannot be empty.", + [PrincessError.InvalidThreshold]: "The multisig threshold is invalid.", + [PrincessError.ContractNotInitialized]: "The contract has not been initialized yet.", + [PrincessError.ProtocolPaused]: "The protocol is currently paused for maintenance.", + [PrincessError.InsufficientMultisigAuth]: "Insufficient signatures provided for this multisig action.", + [PrincessError.OrgAlreadyRegistered]: "This organization is already registered.", + [PrincessError.OrgNotFound]: "Organization not found.", + [PrincessError.NotAuthorized]: "You are not authorized to perform this action.", + [PrincessError.InvalidAmount]: "The provided amount must be positive.", + [PrincessError.BudgetOverflow]: "Organization budget overflow.", + [PrincessError.InsufficientBudget]: "Insufficient organization budget.", + [PrincessError.MaxAdminLimitReached]: "Maximum number of admins (10) reached.", + [PrincessError.AdminAlreadyExists]: "This address is already an admin.", + [PrincessError.CannotRemoveLastAdmin]: "Cannot remove the last administrator.", + [PrincessError.NotAnAdmin]: "This address is not an administrator.", + [PrincessError.MaintainerAlreadyRegistered]: "This maintainer is already registered.", + [PrincessError.MaintainerNotRegistered]: "Maintainer not found in the registry.", + [PrincessError.MaintainerOrgMismatch]: "Maintainer does not belong to this organization.", + [PrincessError.PayoutOverflow]: "Payout amount overflow.", + [PrincessError.BatchSizeExceeded]: "Batch size exceeds the limit of 100 entries.", + [PrincessError.EmptyBatch]: "The batch payout list cannot be empty.", + [PrincessError.NoClaimableBalance]: "You have no claimable balance at this time.", + [PrincessError.PayoutLocked]: "This payout is still in its unlock period.", + [PrincessError.NoPendingAdmin]: "No pending admin proposal found.", + [PrincessError.NotPendingAdmin]: "You are not the proposed pending administrator.", +};