diff --git a/src/acpClient.ts b/src/acpClient.ts index e86d6f8..f9125f2 100644 --- a/src/acpClient.ts +++ b/src/acpClient.ts @@ -118,6 +118,40 @@ class AcpClient { return this.acpContractClient.walletAddress; } + private _hydrateJob(data: IAcpJob["data"]): AcpJob { + const memos = data.memos.map((memo) => + new AcpMemo( + this.contractClientByAddress(data.contractAddress), + memo.id, + memo.memoType, + memo.content, + memo.nextPhase, + memo.status, + memo.senderAddress, + memo.signedReason, + memo.expiry ? new Date(parseInt(memo.expiry) * 1000) : undefined, + memo.payableDetails, + memo.txHash, + memo.signedTxHash, + ) + ); + + return new AcpJob( + this, + data.id, + data.clientAddress, + data.providerAddress, + data.evaluatorAddress, + data.price, + data.priceTokenAddress, + memos, + data.phase, + data.context, + data.contractAddress, + data.deliverable, + ); + } + async init(skipSocketConnection: boolean = false) { if (skipSocketConnection) { return; @@ -146,39 +180,15 @@ class AcpClient { callback(true); if (this.onEvaluate) { - const job = new AcpJob( - this, - data.id, - data.clientAddress, - data.providerAddress, - data.evaluatorAddress, - data.price, - data.priceTokenAddress, - data.memos.map((memo) => { - return new AcpMemo( - this.contractClientByAddress(data.contractAddress), - memo.id, - memo.memoType, - memo.content, - memo.nextPhase, - memo.status, - memo.senderAddress, - memo.signedReason, - memo.expiry - ? new Date(parseInt(memo.expiry) * 1000) - : undefined, - memo.payableDetails, - memo.txHash, - memo.signedTxHash, - ); - }), - data.phase, - data.context, - data.contractAddress, - data.netPayableAmount, - ); - - this.onEvaluate(job); + try { + const job = this._hydrateJob(data); + this.onEvaluate(job); + } catch (err) { + console.error( + `Failed to hydrate job ${data.id} in ON_EVALUATE:`, + err + ); + } } }, ); @@ -189,42 +199,18 @@ class AcpClient { callback(true); if (this.onNewTask) { - const job = new AcpJob( - this, - data.id, - data.clientAddress, - data.providerAddress, - data.evaluatorAddress, - data.price, - data.priceTokenAddress, - data.memos.map((memo) => { - return new AcpMemo( - this.contractClientByAddress(data.contractAddress), - memo.id, - memo.memoType, - memo.content, - memo.nextPhase, - memo.status, - memo.senderAddress, - memo.signedReason, - memo.expiry - ? new Date(parseInt(memo.expiry) * 1000) - : undefined, - memo.payableDetails, - memo.txHash, - memo.signedTxHash, - ); - }), - data.phase, - data.context, - data.contractAddress, - data.netPayableAmount, - ); - - this.onNewTask( - job, - job.memos.find((m) => m.id == data.memoToSign), - ); + try { + const job = this._hydrateJob(data); + this.onNewTask( + job, + job.memos.find((m) => m.id == data.memoToSign), + ); + } catch (err) { + console.error( + `Failed to hydrate job ${data.id} in ON_NEW_TASK:`, + err + ); + } } }, ); @@ -499,39 +485,7 @@ class AcpClient { for (const job of rawJobs) { try { - const memos = job.memos.map((memo) => - new AcpMemo( - this.contractClientByAddress(job.contractAddress), - memo.id, - memo.memoType, - memo.content, - memo.nextPhase, - memo.status, - memo.senderAddress, - memo.signedReason, - memo.expiry ? new Date(parseInt(memo.expiry) * 1000) : undefined, - memo.payableDetails, - memo.txHash, - memo.signedTxHash, - ) - ); - - jobs.push( - new AcpJob( - this, - job.id, - job.clientAddress, - job.providerAddress, - job.evaluatorAddress, - job.price, - job.priceTokenAddress, - memos, - job.phase, - job.context, - job.contractAddress, - job.netPayableAmount, - ) - ); + jobs.push(this._hydrateJob(job)); } catch (err) { errors.push({ jobId: job.id, error: err as Error }); } @@ -582,38 +536,7 @@ class AcpClient { } try { - const memos = job.memos.map( - (memo) => - new AcpMemo( - this.contractClientByAddress(job.contractAddress), - memo.id, - memo.memoType, - memo.content, - memo.nextPhase, - memo.status, - memo.senderAddress, - memo.signedReason, - memo.expiry ? new Date(parseInt(memo.expiry) * 1000) : undefined, - memo.payableDetails, - memo.txHash, - memo.signedTxHash, - ) - ); - - return new AcpJob( - this, - job.id, - job.clientAddress, - job.providerAddress, - job.evaluatorAddress, - job.price, - job.priceTokenAddress, - memos, - job.phase, - job.context, - job.contractAddress, - job.netPayableAmount, - ); + return this._hydrateJob(job); } catch (err) { throw new AcpError(`Failed to hydrate job ${jobId}`, err); } diff --git a/src/acpFare.ts b/src/acpFare.ts index 60e971b..801a5df 100644 --- a/src/acpFare.ts +++ b/src/acpFare.ts @@ -13,7 +13,18 @@ class Fare { constructor(public contractAddress: Address, public decimals: number) {} formatAmount(amount: number) { - return parseUnits(amount.toString(), this.decimals); + if (!Number.isFinite(amount) || amount < 0) { + throw new AcpError( + `Invalid amount: ${amount}. Amount must be a finite, non-negative number.` + ); + } + + const numStr = amount.toString(); + const amountStr = numStr.includes('e') + ? amount.toFixed(this.decimals) + : numStr; + + return parseUnits(amountStr, this.decimals); } static async fromContractAddress( diff --git a/src/acpJob.ts b/src/acpJob.ts index e963001..6999207 100644 --- a/src/acpJob.ts +++ b/src/acpJob.ts @@ -32,7 +32,7 @@ class AcpJob { public phase: AcpJobPhases, public context: Record, public contractAddress: Address, - public netPayableAmount?: number + public deliverable?: DeliverablePayload ) { const content = this.memos.find( (m) => m.nextPhase === AcpJobPhases.NEGOTIATION @@ -85,20 +85,15 @@ class AcpJob { return this.acpContractClient.config.baseFare; } - public get deliverable() { - return this.memos.find((m) => m.nextPhase === AcpJobPhases.COMPLETED) - ?.content; - } - public get rejectionReason() { - const requestMemo = this.memos.find( + const rejectedMemo = this.memos.find( (m) => - m.nextPhase === AcpJobPhases.NEGOTIATION && + (m.nextPhase === AcpJobPhases.NEGOTIATION || m.nextPhase === AcpJobPhases.COMPLETED) && m.status === AcpMemoStatus.REJECTED ); - if (requestMemo) { - return requestMemo.signedReason; + if (rejectedMemo) { + return rejectedMemo.signedReason; } return this.memos.find((m) => m.nextPhase === AcpJobPhases.REJECTED) @@ -125,6 +120,59 @@ class AcpJob { return this.memos[this.memos.length - 1]; } + public get netPayableAmount(): number | undefined { + const payableMemo = this.memos.find( + (m) => + m.nextPhase === AcpJobPhases.TRANSACTION && + m.type === MemoType.PAYABLE_REQUEST && + m.payableDetails + ); + + if (!payableMemo || !payableMemo.payableDetails) { + return undefined; + } + + const decimals = + payableMemo.payableDetails.token.toLowerCase() === + this.baseFare.contractAddress.toLowerCase() + ? this.baseFare.decimals + : 18; + + const grossAmount = payableMemo.payableDetails.amount; + + let netAmount: bigint; + if (this.priceType === PriceType.PERCENTAGE) { + const feeBasisPoints = BigInt(Math.round(this.priceValue * 10000)); + let feeAmount = (grossAmount * feeBasisPoints) / BigInt(10000); + + if (feeBasisPoints > BigInt(0) && feeAmount === BigInt(0)) { + feeAmount = BigInt(1); + } + + netAmount = grossAmount - feeAmount; + } else { + netAmount = grossAmount; + } + + const formattedAmount = formatUnits(netAmount, decimals); + const amountNumber = parseFloat(formattedAmount); + + if (!Number.isFinite(amountNumber)) { + throw new AcpError( + `Net payable amount overflow: ${formattedAmount} exceeds safe number range` + ); + } + + if (amountNumber > Number.MAX_SAFE_INTEGER) { + throw new AcpError( + `Net payable amount ${formattedAmount} exceeds MAX_SAFE_INTEGER (${Number.MAX_SAFE_INTEGER}). Precision may be lost.` + ); + } + + const factor = Math.pow(10, decimals); + return Math.floor(amountNumber * factor) / factor; + } + async createRequirement(content: string) { const operations: OperationPayload[] = []; diff --git a/src/interfaces.ts b/src/interfaces.ts index 2904914..0876668 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -75,13 +75,12 @@ export interface IAcpJob { evaluatorAddress: Address; price: number; priceTokenAddress: Address; - deliverable: DeliverablePayload | null; memos: IAcpMemoData[]; context: Record; createdAt: string; contractAddress: Address; + deliverable?: DeliverablePayload; memoToSign?: number; - netPayableAmount?: number; }; error?: Error; }