diff --git a/e2e/tests/meta-tx.test.ts b/e2e/tests/meta-tx.test.ts index 664c1d543..5515bdf35 100644 --- a/e2e/tests/meta-tx.test.ts +++ b/e2e/tests/meta-tx.test.ts @@ -30,12 +30,13 @@ import { META_TX_API_ID_VOUCHER, createDisputeResolver, deployerWallet, - initSellerAndBuyerSDKs + initSellerAndBuyerSDKs, + buildFullOfferArgs } from "./utils"; import { CoreSDK, forwarder } from "../../packages/core-sdk/src"; import EvaluationMethod from "../../contracts/protocol-contracts/scripts/domain/EvaluationMethod"; import TokenType from "../../contracts/protocol-contracts/scripts/domain/TokenType"; -import { AuthTokenType, GatingType, OfferCreator } from "../../packages/common"; +import { AuthTokenType, GatingType, OfferCreator, FullOfferArgs } from "../../packages/common"; import { MSEC_PER_DAY, MSEC_PER_SEC @@ -924,6 +925,451 @@ describe("meta-tx", () => { }); }); + describe("#signMetaTxCreateOfferAndCommit()", () => { + const noCondition = { + method: EvaluationMethod.None, + tokenType: TokenType.MultiToken, + tokenAddress: constants.AddressZero, + gatingType: GatingType.PerAddress, + minTokenId: 0, + maxTokenId: 0, + threshold: 0, + maxCommits: 0 + }; + + test("native exchange token buyer-initiated offer", async () => { + const exchangeToken = constants.AddressZero; + // sellerDeposit and drFeeAmount are 0 so the seller (committer) doesn't + // need to deposit any funds prior to the commit + const sellerDeposit = "0"; + const drFeeAmount = "0"; + + // Create a dispute resolver with zero native fee + const { fundedWallet: drFundedWallet } = + await initCoreSDKWithFundedWallet(sellerWallet); + const drAddress = drFundedWallet.address.toLowerCase(); + const { disputeResolver } = await createDisputeResolver( + drFundedWallet, + deployerWallet, + { + assistant: drAddress, + admin: drAddress, + treasury: drAddress, + metadataUri: "", + escalationResponsePeriodInMS: 90 * MSEC_PER_DAY - 1 * MSEC_PER_SEC, + fees: [ + { + feeAmount: drFeeAmount, + tokenAddress: exchangeToken, + tokenName: "Native" + } + ], + sellerAllowList: [] + } + ); + + // Create fresh buyer/seller wallets + const { + sellerCoreSDK: sellerCoreSDKNew, + buyerCoreSDK: buyerCoreSDKNew, + sellerWallet: sellerFundedWallet, + buyerWallet: buyerFundedWallet + } = await initSellerAndBuyerSDKs(sellerWallet); + + // Create seller account (seller is committer for buyer-initiated offer) + const seller = await createSeller( + sellerCoreSDKNew, + sellerFundedWallet.address + ); + + // Build full offer args for buyer-initiated offer: + // buyer is offer creator, seller is committer + const fullOfferArgsUnsigned = await buildFullOfferArgs( + sellerCoreSDKNew, // seller calls createOfferAndCommit + buyerCoreSDKNew, // buyer signs the offer + noCondition, + { + committer: sellerFundedWallet.address, + offerCreator: buyerFundedWallet.address, + sellerId: seller.id, + sellerOfferParams: { + collectionIndex: 0, + mutualizerAddress: constants.AddressZero, + royaltyInfo: { recipients: [], bps: [] } + }, + useDepositedFunds: true, + creator: OfferCreator.Buyer, + feeLimit: parseEther("0.1") + }, + { + offerParams: { + disputeResolverId: disputeResolver.id, + sellerDeposit, + quantityAvailable: 1 // must be 1 for buyer-initiated offers + } + } + ); + + // Buyer (offer creator) signs the full offer + const { signature } = await buyerCoreSDKNew.signFullOffer({ + fullOfferArgsUnsigned + }); + const fullOfferArgs: FullOfferArgs = { ...fullOfferArgsUnsigned, signature }; + + const nonce = Date.now(); + + // Seller signs meta-tx for createOfferAndCommit + const { r, s, v, functionName, functionSignature } = + await sellerCoreSDKNew.signMetaTxCreateOfferAndCommit({ + createOfferAndCommitArgs: fullOfferArgs, + nonce + }); + + // Relayer executes meta-tx on behalf of seller + const metaTx = await sellerCoreSDKNew.relayMetaTransaction({ + functionName, + functionSignature, + nonce, + sigR: r, + sigS: s, + sigV: v + }); + const metaTxReceipt = await metaTx.wait(); + expect(metaTxReceipt.transactionHash).toBeTruthy(); + expect(BigNumber.from(metaTxReceipt.effectiveGasPrice).gt(0)).toBe(true); + }); + + test("non-native exchange token buyer-initiated offer", async () => { + const exchangeToken = MOCK_ERC20_ADDRESS; + // sellerDeposit and drFeeAmount are 0 so the seller (committer) doesn't + // need to deposit any ERC20 tokens prior to the commit + const sellerDeposit = "0"; + const drFeeAmount = "0"; + + // Create a dispute resolver with zero ERC20 fee + const { fundedWallet: drFundedWallet } = + await initCoreSDKWithFundedWallet(sellerWallet); + const drAddress = drFundedWallet.address.toLowerCase(); + const { disputeResolver } = await createDisputeResolver( + drFundedWallet, + deployerWallet, + { + assistant: drAddress, + admin: drAddress, + treasury: drAddress, + metadataUri: "", + escalationResponsePeriodInMS: 90 * MSEC_PER_DAY - 1 * MSEC_PER_SEC, + fees: [ + { + feeAmount: drFeeAmount, + tokenAddress: exchangeToken, + tokenName: "ERC20" + } + ], + sellerAllowList: [] + } + ); + + // Create fresh buyer/seller wallets + const { + sellerCoreSDK: sellerCoreSDKNew, + buyerCoreSDK: buyerCoreSDKNew, + sellerWallet: sellerFundedWallet, + buyerWallet: buyerFundedWallet + } = await initSellerAndBuyerSDKs(sellerWallet); + + // Mint ERC20 tokens to fresh wallets (they start with 0 ERC20 balance) + await ensureMintedAndAllowedTokens( + [buyerFundedWallet, sellerFundedWallet], + undefined, + false + ); + + // Create seller account (seller is committer for buyer-initiated offer) + const seller = await createSeller( + sellerCoreSDKNew, + sellerFundedWallet.address + ); + + // Build full offer args for buyer-initiated offer with ERC20 exchange token: + // buyer is offer creator (deposits price in ERC20), seller is committer (pays sellerDeposit=0) + const fullOfferArgsUnsigned = await buildFullOfferArgs( + sellerCoreSDKNew, // seller calls createOfferAndCommit + buyerCoreSDKNew, // buyer signs the offer + noCondition, + { + committer: sellerFundedWallet.address, + offerCreator: buyerFundedWallet.address, + sellerId: seller.id, + sellerOfferParams: { + collectionIndex: 0, + mutualizerAddress: constants.AddressZero, + royaltyInfo: { recipients: [], bps: [] } + }, + useDepositedFunds: true, + creator: OfferCreator.Buyer, + feeLimit: parseEther("0.1") + }, + { + offerParams: { + disputeResolverId: disputeResolver.id, + exchangeToken, + sellerDeposit, + quantityAvailable: 1 // must be 1 for buyer-initiated offers + } + } + ); + + // Buyer (offer creator) signs the full offer + const { signature } = await buyerCoreSDKNew.signFullOffer({ + fullOfferArgsUnsigned + }); + const fullOfferArgs: FullOfferArgs = { ...fullOfferArgsUnsigned, signature }; + + const nonce = Date.now(); + + // Seller signs meta-tx for createOfferAndCommit + const { r, s, v, functionName, functionSignature } = + await sellerCoreSDKNew.signMetaTxCreateOfferAndCommit({ + createOfferAndCommitArgs: fullOfferArgs, + nonce + }); + + // Relayer executes meta-tx on behalf of seller + const metaTx = await sellerCoreSDKNew.relayMetaTransaction({ + functionName, + functionSignature, + nonce, + sigR: r, + sigS: s, + sigV: v + }); + const metaTxReceipt = await metaTx.wait(); + expect(metaTxReceipt.transactionHash).toBeTruthy(); + expect(BigNumber.from(metaTxReceipt.effectiveGasPrice).gt(0)).toBe(true); + }); + + test("native exchange token seller-initiated offer", async () => { + const exchangeToken = constants.AddressZero; + // price, sellerDeposit, and drFeeAmount are 0 so neither party needs to + // forward ETH value through the meta-tx relayer + const price = "0"; + const sellerDeposit = "0"; + const drFeeAmount = "0"; + + // Create a dispute resolver with zero native fee + const { fundedWallet: drFundedWallet } = + await initCoreSDKWithFundedWallet(sellerWallet); + const drAddress = drFundedWallet.address.toLowerCase(); + const { disputeResolver } = await createDisputeResolver( + drFundedWallet, + deployerWallet, + { + assistant: drAddress, + admin: drAddress, + treasury: drAddress, + metadataUri: "", + escalationResponsePeriodInMS: 90 * MSEC_PER_DAY - 1 * MSEC_PER_SEC, + fees: [ + { + feeAmount: drFeeAmount, + tokenAddress: exchangeToken, + tokenName: "Native" + } + ], + sellerAllowList: [] + } + ); + + // Create fresh buyer/seller wallets + const { + sellerCoreSDK: sellerCoreSDKNew, + buyerCoreSDK: buyerCoreSDKNew, + sellerWallet: sellerFundedWallet, + buyerWallet: buyerFundedWallet + } = await initSellerAndBuyerSDKs(sellerWallet); + + // Create seller account (seller is offer creator for seller-initiated offer) + const seller = await createSeller( + sellerCoreSDKNew, + sellerFundedWallet.address + ); + + // Build full offer args for seller-initiated offer: + // seller is offer creator, buyer is committer (pays price=0) + const fullOfferArgsUnsigned = await buildFullOfferArgs( + buyerCoreSDKNew, // buyer calls createOfferAndCommit + sellerCoreSDKNew, // seller signs the offer + noCondition, + { + committer: buyerFundedWallet.address, + offerCreator: sellerFundedWallet.address, + sellerId: seller.id, + sellerOfferParams: { + collectionIndex: 0, + mutualizerAddress: constants.AddressZero, + royaltyInfo: { recipients: [], bps: [] } + }, + useDepositedFunds: true, + creator: OfferCreator.Seller, + feeLimit: parseEther("0.1") + }, + { + offerParams: { + disputeResolverId: disputeResolver.id, + price, + sellerDeposit, + buyerCancelPenalty: "0" // must be <= price (which is 0) + } + } + ); + + // Seller (offer creator) signs the full offer + const { signature } = await sellerCoreSDKNew.signFullOffer({ + fullOfferArgsUnsigned + }); + const fullOfferArgs: FullOfferArgs = { ...fullOfferArgsUnsigned, signature }; + + const nonce = Date.now(); + + // Buyer signs meta-tx for createOfferAndCommit + const { r, s, v, functionName, functionSignature } = + await buyerCoreSDKNew.signMetaTxCreateOfferAndCommit({ + createOfferAndCommitArgs: fullOfferArgs, + nonce + }); + + // Relayer executes meta-tx on behalf of buyer + const metaTx = await buyerCoreSDKNew.relayMetaTransaction({ + functionName, + functionSignature, + nonce, + sigR: r, + sigS: s, + sigV: v + }); + const metaTxReceipt = await metaTx.wait(); + expect(metaTxReceipt.transactionHash).toBeTruthy(); + expect(BigNumber.from(metaTxReceipt.effectiveGasPrice).gt(0)).toBe(true); + }); + + test("non-native exchange token seller-initiated offer", async () => { + const exchangeToken = MOCK_ERC20_ADDRESS; + // sellerDeposit and drFeeAmount are 0 so the seller doesn't need to deposit + // any funds prior to the commit + const sellerDeposit = "0"; + const drFeeAmount = "0"; + + // Create a dispute resolver with zero ERC20 fee + const { fundedWallet: drFundedWallet } = + await initCoreSDKWithFundedWallet(sellerWallet); + const drAddress = drFundedWallet.address.toLowerCase(); + const { disputeResolver } = await createDisputeResolver( + drFundedWallet, + deployerWallet, + { + assistant: drAddress, + admin: drAddress, + treasury: drAddress, + metadataUri: "", + escalationResponsePeriodInMS: 90 * MSEC_PER_DAY - 1 * MSEC_PER_SEC, + fees: [ + { + feeAmount: drFeeAmount, + tokenAddress: exchangeToken, + tokenName: "ERC20" + } + ], + sellerAllowList: [] + } + ); + + // Create fresh buyer/seller wallets + const { + sellerCoreSDK: sellerCoreSDKNew, + buyerCoreSDK: buyerCoreSDKNew, + sellerWallet: sellerFundedWallet, + buyerWallet: buyerFundedWallet + } = await initSellerAndBuyerSDKs(sellerWallet); + + // Mint ERC20 tokens to fresh wallets (they start with 0 ERC20 balance) + await ensureMintedAndAllowedTokens( + [buyerFundedWallet, sellerFundedWallet], + undefined, + false + ); + + // Create seller account (seller is offer creator for seller-initiated offer) + const seller = await createSeller( + sellerCoreSDKNew, + sellerFundedWallet.address + ); + + // Build full offer args for seller-initiated offer with ERC20 exchange token: + // seller is offer creator (deposits sellerDeposit=0), buyer is committer (pays price in ERC20) + const fullOfferArgsUnsigned = await buildFullOfferArgs( + buyerCoreSDKNew, // buyer calls createOfferAndCommit + sellerCoreSDKNew, // seller signs the offer + noCondition, + { + committer: buyerFundedWallet.address, + offerCreator: sellerFundedWallet.address, + sellerId: seller.id, + sellerOfferParams: { + collectionIndex: 0, + mutualizerAddress: constants.AddressZero, + royaltyInfo: { recipients: [], bps: [] } + }, + useDepositedFunds: true, + creator: OfferCreator.Seller, + feeLimit: parseEther("0.1") + }, + { + offerParams: { + disputeResolverId: disputeResolver.id, + exchangeToken, + sellerDeposit + } + } + ); + + // Seller (offer creator) signs the full offer + const { signature } = await sellerCoreSDKNew.signFullOffer({ + fullOfferArgsUnsigned + }); + const fullOfferArgs: FullOfferArgs = { ...fullOfferArgsUnsigned, signature }; + + // Buyer (committer) pre-approves ERC20 token for price amount + await approveErc20Token( + buyerCoreSDKNew, + exchangeToken, + fullOfferArgsUnsigned.price + ); + + const nonce = Date.now(); + + // Buyer signs meta-tx for createOfferAndCommit + const { r, s, v, functionName, functionSignature } = + await buyerCoreSDKNew.signMetaTxCreateOfferAndCommit({ + createOfferAndCommitArgs: fullOfferArgs, + nonce + }); + + // Relayer executes meta-tx on behalf of buyer + const metaTx = await buyerCoreSDKNew.relayMetaTransaction({ + functionName, + functionSignature, + nonce, + sigR: r, + sigS: s, + sigV: v + }); + const metaTxReceipt = await metaTx.wait(); + expect(metaTxReceipt.transactionHash).toBeTruthy(); + expect(BigNumber.from(metaTxReceipt.effectiveGasPrice).gt(0)).toBe(true); + }); + }); + describe("#signMetaTxRedeemVoucher()", () => { test("non-native exchange token offer", async () => { const commitTx = await buyerCoreSDK.commitToOffer(offerToCommit.id); diff --git a/e2e/tests/utils.ts b/e2e/tests/utils.ts index a811d9451..39c34a8fb 100644 --- a/e2e/tests/utils.ts +++ b/e2e/tests/utils.ts @@ -640,13 +640,15 @@ export async function buildFullOfferArgs( ) ).wait(); } - await ( - await offerCreatorCoreSDK.depositFunds( - creatorId, - creatorDepositFunds, - offerArgs.exchangeToken - ) - ).wait(); + if (BigNumber.from(creatorDepositFunds).gt(0)) { + await ( + await offerCreatorCoreSDK.depositFunds( + creatorId, + creatorDepositFunds, + offerArgs.exchangeToken + ) + ).wait(); + } return { condition, diff --git a/packages/core-sdk/src/meta-tx/handler.ts b/packages/core-sdk/src/meta-tx/handler.ts index 74868df69..1770525f3 100644 --- a/packages/core-sdk/src/meta-tx/handler.ts +++ b/packages/core-sdk/src/meta-tx/handler.ts @@ -12,7 +12,8 @@ import { OptInToSellerUpdateArgs, envConfigs, abis, - SellerOfferArgs + SellerOfferArgs, + FullOfferArgs } from "@bosonprotocol/common"; import { storeMetadataOnTheGraph } from "../offers/storage"; import { BigNumber, BigNumberish } from "@ethersproject/bignumber"; @@ -26,7 +27,8 @@ import { import { bosonExchangeCommitHandlerIface, bosonExchangeHandlerIface, - encodeCommitToBuyerOffer + encodeCommitToBuyerOffer, + encodeCreateOfferAndCommit } from "../exchanges/interface"; import { bosonOfferHandlerIface, @@ -962,6 +964,37 @@ export async function signMetaTxCommitToBuyerOffer( }); } +export async function signMetaTxCreateOfferAndCommit( + args: BaseMetaTxArgs & { + createOfferAndCommitArgs: FullOfferArgs; + metadataStorage?: MetadataStorage; + theGraphStorage?: MetadataStorage; + } +): Promise { + utils.validation.createOfferAndCommitArgsSchema.validateSync( + args.createOfferAndCommitArgs, + { abortEarly: false } + ); + + await storeMetadataOnTheGraph({ + metadataUriOrHash: args.createOfferAndCommitArgs.metadataUri, + metadataStorage: args.metadataStorage, + theGraphStorage: args.theGraphStorage + }); + + await storeMetadataItems({ + ...args, + createOffersArgs: [args.createOfferAndCommitArgs] + }); + + return signMetaTx({ + ...args, + functionName: + "createOfferAndCommit(((uint256,uint256,uint256,uint256,uint256,uint256,address,uint8,uint8,string,string,bool,uint256,(address[],uint256[])[],uint256),(uint256,uint256,uint256,uint256),(uint256,uint256,uint256),(uint256,address),(uint8,uint8,address,uint8,uint256,uint256,uint256,uint256),uint256,uint256,bool),address,address,bytes,uint256,(uint256,(address[],uint256[]),address))", + functionSignature: encodeCreateOfferAndCommit(args.createOfferAndCommitArgs) + }); +} + export async function signMetaTxCancelVoucher( args: BaseMetaTxArgs & { exchangeId: BigNumberish; diff --git a/packages/core-sdk/src/meta-tx/mixin.ts b/packages/core-sdk/src/meta-tx/mixin.ts index 3464de6a9..bb6f47417 100644 --- a/packages/core-sdk/src/meta-tx/mixin.ts +++ b/packages/core-sdk/src/meta-tx/mixin.ts @@ -653,6 +653,27 @@ export class MetaTxMixin extends BaseCoreSDK { }); } + /** + * Encodes and signs a meta transaction for `createOfferAndCommit` that can be relayed. + * @param args - Meta transaction args. + * @returns Signature. + */ + public async signMetaTxCreateOfferAndCommit( + args: Omit< + Parameters[0], + "web3Lib" | "metaTxHandlerAddress" | "chainId" + > + ) { + return handler.signMetaTxCreateOfferAndCommit({ + web3Lib: this._web3Lib, + theGraphStorage: this._theGraphStorage, + metadataStorage: this._metadataStorage, + metaTxHandlerAddress: this._protocolDiamond, + chainId: this._chainId, + ...args + }); + } + /** * Encodes and signs a meta transaction for `cancelVoucher` that can be relayed. * @param args - Meta transaction args.