diff --git a/packages/mesh-contract/src/marketplace/offchain.ts b/packages/mesh-contract/src/marketplace/offchain.ts index f7c5c4b39..77c9f742d 100644 --- a/packages/mesh-contract/src/marketplace/offchain.ts +++ b/packages/mesh-contract/src/marketplace/offchain.ts @@ -24,6 +24,7 @@ import { import { applyParamsToScript } from "@meshsdk/core-cst"; import { MeshTxInitiator, MeshTxInitiatorInput } from "../common"; +import { computeMarketplacePurchasePayouts } from "./purchase-fee-math"; import blueprintV1 from "./aiken-workspace-v1/plutus.json"; import blueprintV2 from "./aiken-workspace-v2/plutus.json"; @@ -178,27 +179,24 @@ export class MeshMarketplaceContract extends MeshTxInitiator { ) .selectUtxosFrom(utxos); - let ownerToReceiveLovelace = - ((inputDatum.fields[1].int as number) * this.feePercentageBasisPoint) / - 10000; - if (this.feePercentageBasisPoint > 0 && ownerToReceiveLovelace < 1000000) { - ownerToReceiveLovelace = 1000000; - } + const { ownerToReceiveLovelace, sellerToReceiveLovelace } = + computeMarketplacePurchasePayouts( + inputDatum.fields[1].int, + this.feePercentageBasisPoint, + inputLovelace, + ); - if (ownerToReceiveLovelace > 0) { + if (ownerToReceiveLovelace > 0n) { const ownerToReceive = [ { unit: "lovelace", - quantity: Math.ceil(ownerToReceiveLovelace).toString(), + quantity: ownerToReceiveLovelace.toString(), }, ]; tx.txOut(this.ownerAddress, ownerToReceive); } - const sellerToReceiveLovelace = - (inputDatum.fields[1].int as number) + Number(inputLovelace); - - if (sellerToReceiveLovelace > 0) { + if (sellerToReceiveLovelace > 0n) { const sellerAddress = serializeAddressObj( inputDatum.fields[0], this.networkId, diff --git a/packages/mesh-contract/src/marketplace/purchase-fee-math.node.test.mjs b/packages/mesh-contract/src/marketplace/purchase-fee-math.node.test.mjs new file mode 100644 index 000000000..1b5fde61b --- /dev/null +++ b/packages/mesh-contract/src/marketplace/purchase-fee-math.node.test.mjs @@ -0,0 +1,54 @@ +/** + * Zero-dep regression harness for marketplace purchaseAsset BigInt fee math. + * Run: node --test packages/mesh-contract/src/marketplace/purchase-fee-math.node.test.mjs + * Full helper assertions: packages/mesh-contract/src/marketplace/purchase-fee-math.test.ts (jest) + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** Prior buggy formula from purchaseAsset. */ +function buggyLegacyMix(priceInt, feePercentageBasisPoint, inputLovelace) { + let ownerToReceiveLovelace = + (priceInt * feePercentageBasisPoint) / 10000; + if (feePercentageBasisPoint > 0 && ownerToReceiveLovelace < 1000000) { + ownerToReceiveLovelace = 1000000; + } + const sellerToReceiveLovelace = priceInt + Number(inputLovelace); + return { ownerToReceiveLovelace, sellerToReceiveLovelace }; +} + +describe("purchaseAsset fee math BigInt regression", () => { + it("legacy Number-mix throws TypeError when price is BigInt", () => { + assert.throws( + () => buggyLegacyMix(100_000_000n, 250, "2000000"), + (err) => + err instanceof TypeError && + /Cannot mix BigInt and other types/.test(err.message), + ); + }); + + it("offchain.ts purchaseAsset uses computeMarketplacePurchasePayouts", () => { + const src = readFileSync(join(__dirname, "offchain.ts"), "utf8"); + assert.match(src, /computeMarketplacePurchasePayouts/); + assert.doesNotMatch( + src, + /\(inputDatum\.fields\[1\]\.int as number\) \* this\.feePercentageBasisPoint/, + ); + assert.doesNotMatch( + src, + /\(inputDatum\.fields\[1\]\.int as number\) \+ Number\(inputLovelace\)/, + ); + }); + + it("purchase-fee-math.ts uses BigInt arithmetic", () => { + const src = readFileSync(join(__dirname, "purchase-fee-math.ts"), "utf8"); + assert.match(src, /BigInt\(priceInt\)/); + assert.match(src, /10000n/); + assert.doesNotMatch(src, /as number\) \* feePercentageBasisPoint/); + }); +}); diff --git a/packages/mesh-contract/src/marketplace/purchase-fee-math.test.ts b/packages/mesh-contract/src/marketplace/purchase-fee-math.test.ts new file mode 100644 index 000000000..1c71558b1 --- /dev/null +++ b/packages/mesh-contract/src/marketplace/purchase-fee-math.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "@jest/globals"; +import { computeMarketplacePurchasePayouts } from "./purchase-fee-math"; + +describe("computeMarketplacePurchasePayouts", () => { + it("does not throw TypeError when datum price int is BigInt", () => { + expect(() => + computeMarketplacePurchasePayouts(100_000_000n, 250, "2000000"), + ).not.toThrow(); + }); + + it("computes owner fee (2.5%) and seller payout with BigInt price", () => { + // 100 ADA price, 2.5% fee => 2.5 ADA (above 1 ADA minimum) + const { ownerToReceiveLovelace, sellerToReceiveLovelace } = + computeMarketplacePurchasePayouts(100_000_000n, 250, "2000000"); + expect(ownerToReceiveLovelace).toBe(2_500_000n); + expect(sellerToReceiveLovelace).toBe(102_000_000n); + }); + + it("enforces minimum 1 ADA owner fee when basis points > 0", () => { + const { ownerToReceiveLovelace } = computeMarketplacePurchasePayouts( + 1_000n, + 250, + "0", + ); + expect(ownerToReceiveLovelace).toBe(1_000_000n); + }); + + it("still works when price int is a Number", () => { + const { ownerToReceiveLovelace, sellerToReceiveLovelace } = + computeMarketplacePurchasePayouts(100_000_000, 250, "2000000"); + expect(ownerToReceiveLovelace).toBe(2_500_000n); + expect(sellerToReceiveLovelace).toBe(102_000_000n); + }); +}); diff --git a/packages/mesh-contract/src/marketplace/purchase-fee-math.ts b/packages/mesh-contract/src/marketplace/purchase-fee-math.ts new file mode 100644 index 000000000..66e8ed7aa --- /dev/null +++ b/packages/mesh-contract/src/marketplace/purchase-fee-math.ts @@ -0,0 +1,24 @@ +/** + * BigInt-safe fee / payout math for MeshMarketplaceContract.purchaseAsset. + * On-chain datum ints may deserialize as bigint; mixing with Number throws + * TypeError: Cannot mix BigInt and other types. + */ +export function computeMarketplacePurchasePayouts( + priceInt: number | bigint, + feePercentageBasisPoint: number, + inputLovelace: string | number | bigint, +): { + ownerToReceiveLovelace: bigint; + sellerToReceiveLovelace: bigint; +} { + const price = BigInt(priceInt); + const feeBp = BigInt(feePercentageBasisPoint); + // Match prior Math.ceil(float) semantics for positive amounts. + let ownerToReceiveLovelace = + feeBp === 0n ? 0n : (price * feeBp + 9999n) / 10000n; + if (feePercentageBasisPoint > 0 && ownerToReceiveLovelace < 1000000n) { + ownerToReceiveLovelace = 1000000n; + } + const sellerToReceiveLovelace = price + BigInt(inputLovelace); + return { ownerToReceiveLovelace, sellerToReceiveLovelace }; +}