diff --git a/packages/core-sdk/src/resources/license.ts b/packages/core-sdk/src/resources/license.ts index f34972c1..0873e992 100644 --- a/packages/core-sdk/src/resources/license.ts +++ b/packages/core-sdk/src/resources/license.ts @@ -1,6 +1,7 @@ -import { PublicClient, zeroAddress } from "viem"; +import { Address, PublicClient, zeroAddress } from "viem"; import { + Erc20TokenClient, IpAssetRegistryClient, LicenseRegistryEventClient, LicenseRegistryReadOnlyClient, @@ -10,6 +11,7 @@ import { LicensingModulePredictMintingLicenseFeeResponse, LicensingModuleSetLicensingConfigRequest, ModuleRegistryReadOnlyClient, + Multicall3Client, PiLicenseTemplateClient, PiLicenseTemplateGetLicenseTermsResponse, PiLicenseTemplateReadOnlyClient, @@ -42,6 +44,8 @@ import { } from "../utils/licenseTermsHelper"; import { chain, getAddress } from "../utils/utils"; import { SupportedChainIds } from "../types/config"; +import { calculateLicenseWipMintFee, contractCallWithWipFees } from "../utils/wipFeeUtils"; +import { WipSpender } from "../types/utils/wip"; export class LicenseClient { public licenseRegistryClient: LicenseRegistryEventClient; @@ -51,9 +55,12 @@ export class LicenseClient { public licenseTemplateClient: PiLicenseTemplateClient; public licenseRegistryReadOnlyClient: LicenseRegistryReadOnlyClient; public moduleRegistryReadOnlyClient: ModuleRegistryReadOnlyClient; + public multicall3Client: Multicall3Client; + public wipClient: Erc20TokenClient; private readonly rpcClient: PublicClient; private readonly wallet: SimpleWalletClient; private readonly chainId: SupportedChainIds; + private readonly walletAddress: Address; constructor(rpcClient: PublicClient, wallet: SimpleWalletClient, chainId: SupportedChainIds) { this.licensingModuleClient = new LicensingModuleClient(rpcClient, wallet); @@ -63,9 +70,12 @@ export class LicenseClient { this.licenseRegistryReadOnlyClient = new LicenseRegistryReadOnlyClient(rpcClient); this.ipAssetRegistryClient = new IpAssetRegistryClient(rpcClient, wallet); this.moduleRegistryReadOnlyClient = new ModuleRegistryReadOnlyClient(rpcClient); + this.multicall3Client = new Multicall3Client(rpcClient, wallet); + this.wipClient = new Erc20TokenClient(rpcClient, wallet); this.rpcClient = rpcClient; this.wallet = wallet; this.chainId = chainId; + this.walletAddress = wallet.account!.address; } /** * Registers new license terms and return the ID of the newly registered license terms. @@ -364,6 +374,9 @@ export class LicenseClient { request: MintLicenseTokensRequest, ): Promise { try { + const receiver = + (request.receiver && getAddress(request.receiver, "request.receiver")) || + this.walletAddress; const req: LicensingModuleMintLicenseTokensRequest = { licensorIpId: getAddress(request.licensorIpId, "request.licensorIpId"), licenseTemplate: @@ -372,9 +385,7 @@ export class LicenseClient { this.licenseTemplateClient.address, licenseTermsId: BigInt(request.licenseTermsId), amount: BigInt(request.amount || 1), - receiver: - (request.receiver && getAddress(request.receiver, "request.receiver")) || - this.wallet.account!.address, + receiver, royaltyContext: zeroAddress, maxMintingFee: BigInt(request.maxMintingFee), maxRevenueShare: getRevenueShare(request.maxRevenueShare), @@ -408,26 +419,54 @@ export class LicenseClient { `License terms id ${request.licenseTermsId} is not attached to the IP with id ${request.licensorIpId}.`, ); } + const encodedTxData = this.licensingModuleClient.mintLicenseTokensEncode(req); if (request.txOptions?.encodedTxDataOnly) { - return { encodedTxData: this.licensingModuleClient.mintLicenseTokensEncode(req) }; - } else { - const txHash = await this.licensingModuleClient.mintLicenseTokens(req); - if (request.txOptions?.waitForTransaction) { - const txReceipt = await this.rpcClient.waitForTransactionReceipt({ - ...request.txOptions, - hash: txHash, - }); - const targetLogs = this.licensingModuleClient.parseTxLicenseTokensMintedEvent(txReceipt); - const startLicenseTokenId = targetLogs[0].startLicenseTokenId; - const licenseTokenIds = []; - for (let i = 0; i < req.amount; i++) { - licenseTokenIds.push(startLicenseTokenId + BigInt(i)); - } - return { txHash: txHash, licenseTokenIds: licenseTokenIds }; - } else { - return { txHash: txHash }; - } + return { encodedTxData }; + } + + // get license token minting fee + const licenseMintingFee = await calculateLicenseWipMintFee({ + multicall3Client: this.multicall3Client, + licenseTemplateClient: this.licenseTemplateClient, + licensingModuleClient: this.licensingModuleClient, + parentIpId: req.licensorIpId, + licenseTermsId: req.licenseTermsId, + receiver, + amount: req.amount, + }); + + const wipSpenders: WipSpender[] = []; + if (licenseMintingFee > 0n) { + wipSpenders.push({ + address: this.licensingModuleClient.address, + amount: licenseMintingFee, + }); + } + const { txHash, receipt } = await contractCallWithWipFees({ + totalFees: licenseMintingFee, + wipOptions: request.wipOptions, + multicall3Client: this.multicall3Client, + rpcClient: this.rpcClient, + wipClient: this.wipClient, + wipSpenders, + contractCall: () => { + return this.licensingModuleClient.mintLicenseTokens(req); + }, + wallet: this.wallet, + sender: this.walletAddress, + txOptions: request.txOptions, + encodedTxs: [encodedTxData], + }); + if (!receipt) { + return { txHash }; + } + const targetLogs = this.licensingModuleClient.parseTxLicenseTokensMintedEvent(receipt); + const startLicenseTokenId = targetLogs[0].startLicenseTokenId; + const licenseTokenIds = []; + for (let i = 0; i < req.amount; i++) { + licenseTokenIds.push(startLicenseTokenId + BigInt(i)); } + return { txHash, licenseTokenIds: licenseTokenIds, receipt }; } catch (error) { handleError(error, "Failed to mint license tokens"); } diff --git a/packages/core-sdk/src/resources/royalty.ts b/packages/core-sdk/src/resources/royalty.ts index f2fcadeb..d97b0636 100644 --- a/packages/core-sdk/src/resources/royalty.ts +++ b/packages/core-sdk/src/resources/royalty.ts @@ -1,21 +1,44 @@ -import { Address, Hex, PublicClient, zeroAddress } from "viem"; +import { + Address, + decodeEventLog, + encodeFunctionData, + erc20Abi, + Hex, + PublicClient, + TransactionReceipt, + zeroAddress, +} from "viem"; import { handleError } from "../utils/errors"; import { ClaimableRevenueRequest, ClaimableRevenueResponse, + ClaimAllRevenueRequest, + ClaimAllRevenueResponse, + ClaimedToken, PayRoyaltyOnBehalfRequest, PayRoyaltyOnBehalfResponse, + TransferClaimedTokensFromIpToWalletParams, } from "../types/resources/royalty"; import { + Erc20TokenClient, + IpAccountImplClient, IpAssetRegistryClient, + ipRoyaltyVaultImplAbi, IpRoyaltyVaultImplEventClient, IpRoyaltyVaultImplReadOnlyClient, + Multicall3Client, RoyaltyModuleClient, + royaltyWorkflowsAbi, + royaltyWorkflowsAddress, SimpleWalletClient, } from "../abi/generated"; import { IPAccountClient } from "./ipAccount"; -import { getAddress } from "../utils/utils"; +import { getAddress, validateAddress, validateAddresses } from "../utils/utils"; +import { WIP_TOKEN_ADDRESS } from "../constants/common"; +import { contractCallWithWipFees } from "../utils/wipFeeUtils"; +import { WipSpender } from "../types/utils/wip"; +import { simulateAndWriteContract } from "../utils/contract"; export class RoyaltyClient { public royaltyModuleClient: RoyaltyModuleClient; @@ -23,8 +46,11 @@ export class RoyaltyClient { public ipAccountClient: IPAccountClient; public ipRoyaltyVaultImplReadOnlyClient: IpRoyaltyVaultImplReadOnlyClient; public ipRoyaltyVaultImplEventClient: IpRoyaltyVaultImplEventClient; + public multicall3Client: Multicall3Client; + public wipClient: Erc20TokenClient; private readonly rpcClient: PublicClient; private readonly wallet: SimpleWalletClient; + private readonly walletAddress: Address; constructor(rpcClient: PublicClient, wallet: SimpleWalletClient) { this.royaltyModuleClient = new RoyaltyModuleClient(rpcClient, wallet); @@ -32,8 +58,81 @@ export class RoyaltyClient { this.ipRoyaltyVaultImplReadOnlyClient = new IpRoyaltyVaultImplReadOnlyClient(rpcClient); this.ipRoyaltyVaultImplEventClient = new IpRoyaltyVaultImplEventClient(rpcClient); this.ipAccountClient = new IPAccountClient(rpcClient, wallet); + this.multicall3Client = new Multicall3Client(rpcClient, wallet); + this.wipClient = new Erc20TokenClient(rpcClient, wallet); this.rpcClient = rpcClient; this.wallet = wallet; + this.walletAddress = wallet.account!.address; + } + + public async claimAllRevenue(req: ClaimAllRevenueRequest): Promise { + try { + const ancestorIpId = validateAddress(req.ancestorIpId); + const claimer = validateAddress(req.claimer); + const childIpIds = validateAddresses(req.childIpIds); + const royaltyPolicies = validateAddresses(req.royaltyPolicies); + const currencyTokens = validateAddresses(req.currencyTokens); + + // todo: use generated code when aeneid explorer is available + const { txHash, receipt } = await simulateAndWriteContract({ + rpcClient: this.rpcClient, + wallet: this.wallet, + waitForTransaction: true, + data: { + abi: royaltyWorkflowsAbi, + address: royaltyWorkflowsAddress[1315], + functionName: "claimAllRevenue", + args: [ancestorIpId, claimer, childIpIds, royaltyPolicies, currencyTokens], + }, + }); + const txHashes: Hex[] = []; + txHashes.push(txHash); + + // determine if the claimer is an IP owned by the wallet + const isClaimerIp = await this.ipAssetRegistryClient.isRegistered({ + id: claimer, + }); + const ipAccount = new IpAccountImplClient(this.rpcClient, this.wallet, claimer); + let ownsClaimer = claimer === this.walletAddress; + if (isClaimerIp) { + const ipOwner = await ipAccount.owner(); + ownsClaimer = ipOwner === this.walletAddress; + } + + // if wallet does not own the claimer then we cannot auto claim or unwrap + if (!ownsClaimer) { + return { receipt, txHashes }; + } + + const claimedTokens = this.getClaimedTokensFromReceipt(receipt!); + const skipTransfer = req.claimOptions?.autoTransferAllClaimedTokensFromIp === false; + const skipUnwrapIp = req.claimOptions?.autoUnwrapIpTokens === false; + + // transfer claimed tokens from IP to wallet if wallet owns IP + if (!skipTransfer && isClaimerIp && ownsClaimer) { + const hashes = await this.transferClaimedTokensFromIpToWallet({ + ipAccount, + skipUnwrapIp, + claimedTokens, + }); + txHashes.push(...hashes); + } else if (!skipUnwrapIp && this.walletAddress === claimer) { + // if the claimer is the wallet, then we can unwrap any claimed WIP tokens + for (const { token, amount } of claimedTokens) { + if (token !== WIP_TOKEN_ADDRESS) { + continue; + } + const hash = await this.wipClient.withdraw({ + value: amount, + }); + txHashes.push(hash); + await this.rpcClient.waitForTransactionReceipt({ hash }); + } + } + return { receipt, claimedTokens, txHashes }; + } catch (error) { + handleError(error, "Failed to claim all revenue"); + } } /** @@ -50,7 +149,12 @@ export class RoyaltyClient { request: PayRoyaltyOnBehalfRequest, ): Promise { try { - const { receiverIpId, payerIpId, token, amount } = request; + const { receiverIpId, payerIpId, token, amount, wipOptions, txOptions } = request; + const sender = this.wallet.account!.address; + const payAmount = BigInt(amount); + if (payAmount <= 0n) { + throw new Error("The amount to pay must be number greater than 0."); + } const isReceiverRegistered = await this.ipAssetRegistryClient.isRegistered({ id: getAddress(receiverIpId, "request.receiverIpId"), }); @@ -71,19 +175,39 @@ export class RoyaltyClient { token: getAddress(token, "request.token"), amount: BigInt(amount), }; + + const encodedTxData = this.royaltyModuleClient.payRoyaltyOnBehalfEncode(req); if (request.txOptions?.encodedTxDataOnly) { - return { encodedTxData: this.royaltyModuleClient.payRoyaltyOnBehalfEncode(req) }; + return { encodedTxData }; + } + const contractCall = () => { + return this.royaltyModuleClient.payRoyaltyOnBehalf(req); + }; + + // auto wrap wallet's IP to WIP if paying WIP + if (token === WIP_TOKEN_ADDRESS) { + const wipSpenders: WipSpender[] = [ + { + address: this.royaltyModuleClient.address, + amount: payAmount, + }, + ]; + return contractCallWithWipFees({ + totalFees: payAmount, + wipOptions, + multicall3Client: this.multicall3Client, + rpcClient: this.rpcClient, + wipClient: this.wipClient, + wipSpenders, + contractCall, + sender, + wallet: this.wallet, + txOptions, + encodedTxs: [encodedTxData], + }); } else { - const txHash = await this.royaltyModuleClient.payRoyaltyOnBehalf(req); - if (request.txOptions?.waitForTransaction) { - await this.rpcClient.waitForTransactionReceipt({ - ...request.txOptions, - hash: txHash, - }); - return { txHash }; - } else { - return { txHash }; - } + const txHash = await contractCall(); + return { txHash }; } } catch (error) { handleError(error, "Failed to pay royalty on behalf"); @@ -129,4 +253,66 @@ export class RoyaltyClient { } return await this.royaltyModuleClient.ipRoyaltyVaults({ ipId: royaltyVaultIpId }); } + + private getClaimedTokensFromReceipt(receipt: TransactionReceipt): ClaimedToken[] { + const eventName = "RevenueTokenClaimed"; + const claimedTokens: ClaimedToken[] = []; + for (const log of receipt.logs) { + try { + const event = decodeEventLog({ + abi: ipRoyaltyVaultImplAbi, + eventName: eventName, + data: log.data, + topics: log.topics, + }); + if (event.eventName === eventName) { + claimedTokens.push({ + token: event.args.token, + amount: event.args.amount, + }); + } + } catch (e) { + /* empty */ + } + } + return claimedTokens; + } + + private async transferClaimedTokensFromIpToWallet({ + ipAccount, + skipUnwrapIp, + claimedTokens, + }: TransferClaimedTokensFromIpToWalletParams) { + const txHashes: Hex[] = []; + const transferToken = async (token: Address, amount: bigint) => { + if (amount <= 0) { + return; + } + const hash = await ipAccount.execute({ + to: token, + value: BigInt(0), + operation: 0, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "transfer", + args: [this.walletAddress, amount], + }), + }); + await this.rpcClient.waitForTransactionReceipt({ hash }); + txHashes.push(hash); + + // auto unwrap WIP tokens once they are transferred + if (token === WIP_TOKEN_ADDRESS && !skipUnwrapIp) { + const withdrawalHash = await this.wipClient.withdraw({ + value: amount, + }); + txHashes.push(withdrawalHash); + await this.rpcClient.waitForTransactionReceipt({ hash: withdrawalHash }); + } + }; + for (const { token, amount } of claimedTokens) { + await transferToken(token, amount); + } + return txHashes; + } } diff --git a/packages/core-sdk/src/types/resources/license.ts b/packages/core-sdk/src/types/resources/license.ts index 0fa3e880..7c40c83c 100644 --- a/packages/core-sdk/src/types/resources/license.ts +++ b/packages/core-sdk/src/types/resources/license.ts @@ -1,8 +1,9 @@ -import { Address } from "viem"; +import { Address, TransactionReceipt } from "viem"; -import { TxOptions } from "../options"; +import { WithTxOptions, TxOptions } from "../options"; import { EncodedTxData } from "../../abi/generated"; import { LicensingConfig } from "../common"; +import { WithWipOptions } from "../utils/wip"; export type LicenseApiResponse = { data: License; @@ -39,7 +40,7 @@ export type LicenseTerms = { commercializerChecker: Address; /*The data to be passed to the commercializer checker contract.*/ commercializerCheckerData: Address; - /*Percentage of revenue that must be shared with the licensor.*/ + /**Percentage of revenue that must be shared with the licensor. Must be from 0-100.*/ commercialRevShare: number; /*The maximum revenue that can be generated from the commercial use of the work.*/ commercialRevCeiling: bigint; @@ -112,11 +113,12 @@ export type MintLicenseTokensRequest = { maxRevenueShare: number | string; amount?: number | string | bigint; receiver?: Address; - txOptions?: TxOptions; -}; +} & WithTxOptions & + WithWipOptions; export type MintLicenseTokensResponse = { licenseTokenIds?: bigint[]; + receipt?: TransactionReceipt; txHash?: string; encodedTxData?: EncodedTxData; }; diff --git a/packages/core-sdk/src/types/resources/royalty.ts b/packages/core-sdk/src/types/resources/royalty.ts index aaebebf3..4be34337 100644 --- a/packages/core-sdk/src/types/resources/royalty.ts +++ b/packages/core-sdk/src/types/resources/royalty.ts @@ -1,7 +1,9 @@ -import { Address } from "viem"; +import { Address, Hash, TransactionReceipt } from "viem"; -import { TxOptions } from "../options"; -import { EncodedTxData } from "../../abi/generated"; +import { TxOptions, WithTxOptions } from "../options"; +import { EncodedTxData, IpAccountImplClient } from "../../abi/generated"; +import { WithWipOptions } from "../utils/wip"; +import { TokenAmountInput } from "../common"; export type RoyaltyPolicyApiResponse = { data: RoyaltyPolicy; @@ -42,12 +44,13 @@ export type PayRoyaltyOnBehalfRequest = { receiverIpId: Address; payerIpId: Address; token: Address; - amount: string | number | bigint; - txOptions?: TxOptions; -}; + amount: TokenAmountInput; +} & WithTxOptions & + WithWipOptions; export type PayRoyaltyOnBehalfResponse = { txHash?: string; + receipt?: TransactionReceipt; encodedTxData?: EncodedTxData; }; @@ -131,3 +134,68 @@ export type SnapshotAndClaimBySnapshotBatchResponse = { snapshotId?: bigint; amountsClaimed?: bigint; }; + +/** + * Claims all revenue from the child IPs of an ancestor IP, then transfer + * all claimed tokens to the wallet if the wallet owns the IP or is the claimer. + * If claimed token is WIP, it will also be converted back to IP. + */ +export type ClaimAllRevenueRequest = { + /** The address of the ancestor IP from which the revenue is being claimed. */ + ancestorIpId: Address; + /** + * The address of the claimer of the currency (revenue) tokens. + * + * This is normally the ipId of the ancestor IP if the IP has all royalty tokens. + * Otherwise, this would be the address that is holding the ancestor IP royalty tokens. + */ + claimer: Address; + /** The addresses of the child IPs from which royalties are derived. */ + childIpIds: Address[]; + /** + * The addresses of the royalty policies, where + * royaltyPolicies[i] governs the royalty flow for childIpIds[i]. + */ + royaltyPolicies: Address[]; + /** The addresses of the currency tokens in which royalties will be claimed */ + currencyTokens: Address[]; + + claimOptions?: { + /** + * When enabled, all claimed tokens on the claimer are transferred to the + * wallet address if the wallet owns the IP. If the wallet is the claimer + * or if the claimer is not an IP owned by the wallet, then the tokens + * will not be transferred. + * Set to false to disable auto transferring claimed tokens from the claimer. + * + * @default true + */ + autoTransferAllClaimedTokensFromIp?: boolean; + + /** + * By default all claimed WIP tokens are converted back to IP after + * they are transferred. + * Set this to false to disable this behavior. + * + * @default false + */ + autoUnwrapIpTokens?: boolean; + }; +}; + +export type ClaimedToken = { + token: Address; + amount: bigint; +}; + +export type ClaimAllRevenueResponse = { + txHashes: Hash[]; + receipt?: TransactionReceipt; + claimedTokens?: ClaimedToken[]; +}; + +export type TransferClaimedTokensFromIpToWalletParams = { + ipAccount: IpAccountImplClient; + skipUnwrapIp: boolean; + claimedTokens: ClaimedToken[]; +}; diff --git a/packages/core-sdk/test/integration/license.test.ts b/packages/core-sdk/test/integration/license.test.ts index 329ea934..198aa23b 100644 --- a/packages/core-sdk/test/integration/license.test.ts +++ b/packages/core-sdk/test/integration/license.test.ts @@ -2,13 +2,21 @@ import chai from "chai"; import { StoryClient } from "../../src"; import { Hex, zeroAddress } from "viem"; import chaiAsPromised from "chai-as-promised"; -import { mockERC721, getStoryClient, getTokenId, aeneid } from "./utils/util"; +import { + mockERC721, + getStoryClient, + getTokenId, + aeneid, + getExpectedBalance, + TEST_WALLET_ADDRESS, +} from "./utils/util"; import { MockERC20 } from "./utils/mockERC20"; import { licensingModuleAddress, mockErc20Address, piLicenseTemplateAddress, } from "../../src/abi/generated"; +import { WIP_TOKEN_ADDRESS } from "../../src/constants/common"; chai.use(chaiAsPromised); const expect = chai.expect; @@ -80,6 +88,7 @@ describe("License Functions", () => { describe("attach License Terms and mint license tokens", async () => { let ipId: Hex; let licenseId: bigint; + let paidLicenseId: bigint; // license with 0.01IP minting fee let tokenId; before(async () => { tokenId = await getTokenId(); @@ -102,6 +111,14 @@ describe("License Functions", () => { }, }); licenseId = registerLicenseResult.licenseTermsId!; + + const paidLicenseResult = await client.license.registerCommercialRemixPIL({ + defaultMintingFee: 100n, + commercialRevShare: 10, + currency: WIP_TOKEN_ADDRESS, + txOptions: { waitForTransaction: true }, + }); + paidLicenseId = paidLicenseResult.licenseTermsId!; }); it("should attach License Terms", async () => { @@ -115,7 +132,20 @@ describe("License Functions", () => { expect(result.txHash).to.be.a("string").and.not.empty; }); + it("should be able to attach another license terms", async () => { + const result = await client.license.attachLicenseTerms({ + ipId: ipId, + licenseTermsId: paidLicenseId, + txOptions: { + waitForTransaction: true, + }, + }); + expect(result.txHash).to.be.a("string").and.not.empty; + }); + it("should mint license tokens", async () => { + const address = TEST_WALLET_ADDRESS; + const balanceBefore = await client.rpcClient.getBalance({ address }); const result = await client.license.mintLicenseTokens({ licenseTermsId: licenseId, licensorIpId: ipId, @@ -127,6 +157,33 @@ describe("License Functions", () => { }); expect(result.txHash).to.be.a("string").and.not.empty; expect(result.licenseTokenIds).to.be.a("array").and.not.empty; + const balanceAfter = await client.rpcClient.getBalance({ address }); + const expectedBalance = getExpectedBalance({ + balanceBefore, + receipt: result.receipt!, + cost: 0n, + }); + expect(balanceAfter).to.equal(expectedBalance); + }); + + it("should mint license tokens with fee and pay with IP", async () => { + const address = TEST_WALLET_ADDRESS; + const balanceBefore = await client.rpcClient.getBalance({ address }); + const result = await client.license.mintLicenseTokens({ + licenseTermsId: paidLicenseId, + licensorIpId: ipId, + maxMintingFee: 0n, + maxRevenueShare: 50, + txOptions: { waitForTransaction: true }, + }); + expect(result.txHash).to.be.a("string").and.not.empty; + const balanceAfter = await client.rpcClient.getBalance({ address }); + const expectedBalance = getExpectedBalance({ + balanceBefore, + receipt: result.receipt!, + cost: 100n, + }); + expect(balanceAfter).to.equal(expectedBalance); }); it("should get license terms", async () => { diff --git a/packages/core-sdk/test/integration/royalty.test.ts b/packages/core-sdk/test/integration/royalty.test.ts index e1c32b23..bd3ae007 100644 --- a/packages/core-sdk/test/integration/royalty.test.ts +++ b/packages/core-sdk/test/integration/royalty.test.ts @@ -1,10 +1,18 @@ import chai from "chai"; import { StoryClient } from "../../src"; -import { Address, Hex, encodeFunctionData } from "viem"; +import { Address, Hex, encodeFunctionData, zeroAddress } from "viem"; import chaiAsPromised from "chai-as-promised"; -import { mockERC721, getTokenId, getStoryClient, aeneid } from "./utils/util"; +import { + mockERC721, + getTokenId, + getStoryClient, + aeneid, + TEST_WALLET_ADDRESS, + getExpectedBalance, +} from "./utils/util"; import { MockERC20 } from "./utils/mockERC20"; -import { mockErc20Address } from "../../src/abi/generated"; +import { mockErc20Address, royaltyPolicyLapAddress } from "../../src/abi/generated"; +import { MAX_ROYALTY_TOKEN, WIP_TOKEN_ADDRESS } from "../../src/constants/common"; chai.use(chaiAsPromised); const expect = chai.expect; @@ -115,6 +123,25 @@ describe("Royalty Functions", () => { expect(response.txHash).to.be.a("string").and.not.empty; }); + it("should auto convert IP to WIP when paying WIP on behalf", async () => { + const balanceBefore = await client.getBalance(TEST_WALLET_ADDRESS); + const response = await client.royalty.payRoyaltyOnBehalf({ + receiverIpId: parentIpId, + payerIpId: childIpId, + token: WIP_TOKEN_ADDRESS, + amount: 100n, + txOptions: { waitForTransaction: true }, + }); + expect(response.txHash).to.be.a("string"); + const balanceAfter = await client.getBalance(TEST_WALLET_ADDRESS); + const expectedBalance = getExpectedBalance({ + balanceBefore, + cost: 100n, + receipt: response.receipt!, + }); + expect(balanceAfter).to.equal(expectedBalance); + }); + it("should return encoded transaction data for payRoyaltyOnBehalf", async () => { const response = await client.royalty.payRoyaltyOnBehalf({ receiverIpId: parentIpId, @@ -190,4 +217,125 @@ describe("Royalty Functions", () => { expect(response).to.equal(0n); }); }); + + describe("ClaimAllRevenue With WIP", () => { + let ipA: Address; + let ipB: Address; + let ipC: Address; + let ipD: Address; + let spgNftContract: Address; + let licenseTermsId: bigint; + + before(async () => { + // set up + // minting Fee: 100, 10% LAP rev share, A expect to get 120 WIP + // A -> B -> C -> D + const txData = await client.nftClient.createNFTCollection({ + name: "free-collection", + symbol: "FREE", + maxSupply: 100, + isPublicMinting: true, + mintOpen: true, + contractURI: "test-uri", + mintFeeRecipient: zeroAddress, + txOptions: { waitForTransaction: true }, + }); + spgNftContract = txData.spgNftContract!; + + const retA = await client.ipAsset.mintAndRegisterIpAssetWithPilTerms({ + spgNftContract, + allowDuplicates: true, + licenseTermsData: [ + { + terms: { + transferable: true, + royaltyPolicy: royaltyPolicyLapAddress[aeneid], + defaultMintingFee: 100n, + expiration: 0n, + commercialUse: true, + commercialAttribution: false, + commercializerChecker: zeroAddress, + commercializerCheckerData: zeroAddress, + commercialRevShare: 10, + commercialRevCeiling: 0n, + derivativesAllowed: true, + derivativesAttribution: true, + derivativesApproval: false, + derivativesReciprocal: true, + derivativeRevCeiling: 0n, + currency: WIP_TOKEN_ADDRESS, + uri: "", + }, + licensingConfig: { + isSet: false, + mintingFee: 100n, + licensingHook: zeroAddress, + hookData: zeroAddress, + commercialRevShare: 0, + disabled: false, + expectMinimumGroupRewardShare: 0, + expectGroupRewardPool: zeroAddress, + }, + }, + ], + txOptions: { waitForTransaction: true }, + }); + ipA = retA.ipId!; + licenseTermsId = retA.licenseTermsIds![0]; + + const retB = await client.ipAsset.mintAndRegisterIpAndMakeDerivative({ + spgNftContract, + allowDuplicates: true, + derivData: { + parentIpIds: [ipA!], + licenseTermsIds: [licenseTermsId!], + maxMintingFee: 0n, + maxRts: MAX_ROYALTY_TOKEN, + maxRevenueShare: 100, + }, + txOptions: { waitForTransaction: true }, + }); + ipB = retB.ipId!; + + const retC = await client.ipAsset.mintAndRegisterIpAndMakeDerivative({ + spgNftContract, + allowDuplicates: true, + derivData: { + parentIpIds: [ipB!], + licenseTermsIds: [licenseTermsId!], + maxMintingFee: 0n, + maxRts: MAX_ROYALTY_TOKEN, + maxRevenueShare: 100, + }, + txOptions: { waitForTransaction: true }, + }); + ipC = retC.ipId!; + + const retD = await client.ipAsset.mintAndRegisterIpAndMakeDerivative({ + spgNftContract, + allowDuplicates: true, + derivData: { + parentIpIds: [ipC!], + licenseTermsIds: [licenseTermsId!], + maxMintingFee: 0n, + maxRts: MAX_ROYALTY_TOKEN, + maxRevenueShare: 100, + }, + txOptions: { waitForTransaction: true }, + }); + ipD = retD.ipId!; + }); + + it("should claim all revenue and convert WIP back to IP", async () => { + const ret = await client.royalty.claimAllRevenue({ + ancestorIpId: ipA, + claimer: ipA, + childIpIds: [ipB, ipC], + royaltyPolicies: [royaltyPolicyLapAddress[aeneid], royaltyPolicyLapAddress[aeneid]], + currencyTokens: [WIP_TOKEN_ADDRESS, WIP_TOKEN_ADDRESS], + }); + expect(ret.txHashes).to.be.an("array").and.not.empty; + expect(ret.claimedTokens![0].amount).to.equal(120n); + }); + }); }); diff --git a/packages/core-sdk/test/unit/resources/license.test.ts b/packages/core-sdk/test/unit/resources/license.test.ts index 8f5ff972..5fa8f822 100644 --- a/packages/core-sdk/test/unit/resources/license.test.ts +++ b/packages/core-sdk/test/unit/resources/license.test.ts @@ -1,12 +1,12 @@ import chai from "chai"; -import { createMock } from "../testUtils"; +import { createMock, generateRandomAddress, generateRandomHash } from "../testUtils"; import * as sinon from "sinon"; import { LicenseClient } from "../../../src"; import { PublicClient, WalletClient, Account, zeroAddress, Hex } from "viem"; import chaiAsPromised from "chai-as-promised"; import { PiLicenseTemplateGetLicenseTermsResponse } from "../../../src/abi/generated"; import { LicenseTerms } from "../../../src/types/resources/license"; -import { MockERC20 } from "../../integration/utils/mockERC20"; +import { WIP_TOKEN_ADDRESS } from "../../../src/constants/common"; const { RoyaltyModuleReadOnlyClient } = require("../../../src/abi/generated"); chai.use(chaiAsPromised); @@ -17,6 +17,7 @@ describe("Test LicenseClient", () => { let licenseClient: LicenseClient; let rpcMock: PublicClient; let walletMock: WalletClient; + let predictMintingLicenseFeeStub: sinon.SinonStub; beforeEach(() => { rpcMock = createMock(); @@ -25,6 +26,14 @@ describe("Test LicenseClient", () => { accountMock.address = "0x73fcb515cee99e4991465ef586cfe2b072ebb512"; walletMock.account = accountMock; licenseClient = new LicenseClient(rpcMock, walletMock, "1315"); + (licenseClient.licenseTemplateClient as any).address = generateRandomAddress(); + (licenseClient.licensingModuleClient as any).address = generateRandomAddress(); + predictMintingLicenseFeeStub = sinon + .stub(licenseClient.licensingModuleClient, "predictMintingLicenseFee") + .resolves({ + currencyToken: WIP_TOKEN_ADDRESS, + tokenAmount: 0n, + }); }); afterEach(() => { @@ -818,6 +827,82 @@ describe("Test LicenseClient", () => { data: "0x1daAE3197Bc469Cb97B917aa460a12dD95c6627c", }); }); + + describe("With Minting Fees", () => { + let mintLicenseTokensStub: sinon.SinonStub; + let wipBalanceOfStub: sinon.SinonStub; + let balanceStub: sinon.SinonStub; + let approveStub: sinon.SinonStub; + let simulateContractStub: sinon.SinonStub; + + beforeEach(() => { + predictMintingLicenseFeeStub.resolves({ + currencyToken: WIP_TOKEN_ADDRESS, + tokenAmount: 100n, + }); + approveStub = sinon.stub(licenseClient.wipClient, "approve").resolves(txHash); + sinon.stub(licenseClient.wipClient, "allowance").resolves({ + result: 50n, + }); + wipBalanceOfStub = sinon.stub(licenseClient.wipClient, "balanceOf").resolves({ + result: 0n, + }); + balanceStub = sinon.stub().resolves(200n); + rpcMock.getBalance = balanceStub; + simulateContractStub = sinon.stub().resolves(generateRandomHash()); + rpcMock.simulateContract = simulateContractStub; + walletMock.writeContract = sinon.stub().resolves(generateRandomHash()); + mintLicenseTokensStub = sinon + .stub(licenseClient.licensingModuleClient, "mintLicenseTokens") + .resolves(txHash); + sinon.stub(licenseClient.ipAssetRegistryClient, "isRegistered").resolves(true); + sinon.stub(licenseClient.piLicenseTemplateReadOnlyClient, "exists").resolves(true); + sinon + .stub(licenseClient.licenseRegistryReadOnlyClient, "hasIpAttachedLicenseTerms") + .resolves(true); + }); + + it("should auto convert IP to WIP", async () => { + const result = await licenseClient.mintLicenseTokens({ + licensorIpId: zeroAddress, + licenseTermsId: "1", + maxMintingFee: 1, + maxRevenueShare: 1, + txOptions: { waitForTransaction: false }, + wipOptions: { useMulticallWhenPossible: false }, + }); + expect(result.txHash).to.equal(txHash); + expect(result.receipt).to.be.undefined; + expect(approveStub.calledOnce).to.be.true; + expect(mintLicenseTokensStub.calledOnce).to.be.true; + expect(mintLicenseTokensStub.firstCall.args[0].receiver).to.equal( + walletMock.account!.address, + ); + expect(simulateContractStub.callCount).to.equal(1); + expect(simulateContractStub.firstCall.args[0].functionName).to.equal("deposit"); + }); + + it("should support multicall when converting IP to WIP", async () => { + const mockLicenseTokenIds = [{ startLicenseTokenId: 1n }]; + sinon + .stub(licenseClient.licensingModuleClient, "parseTxLicenseTokensMintedEvent") + .returns(mockLicenseTokenIds as any); + const { txHash, receipt, licenseTokenIds } = await licenseClient.mintLicenseTokens({ + licensorIpId: zeroAddress, + licenseTermsId: "1", + maxMintingFee: 1, + maxRevenueShare: 1, + txOptions: { waitForTransaction: true }, + }); + expect(licenseTokenIds![0]).to.equal(mockLicenseTokenIds[0].startLicenseTokenId); + expect(txHash).not.to.be.undefined; + expect(receipt).not.to.be.undefined; + expect(mintLicenseTokensStub.notCalled).to.be.true; + expect(simulateContractStub.calledOnce).to.be.true; + const calls = simulateContractStub.firstCall.args[0].args[0]; + expect(calls.length).to.equal(3); + }); + }); }); describe("Test licenseClient.getLicenseTerms", async () => { @@ -902,7 +987,7 @@ describe("Test LicenseClient", () => { it("should return currency token and token amount when call predictMintingLicenseFee given licenseTemplate and receiver", async () => { sinon.stub(licenseClient.ipAssetRegistryClient, "isRegistered").resolves(true); sinon.stub(licenseClient.piLicenseTemplateReadOnlyClient, "exists").resolves(true); - sinon.stub(licenseClient.licensingModuleClient, "predictMintingLicenseFee").resolves({ + predictMintingLicenseFeeStub.resolves({ currencyToken: zeroAddress, tokenAmount: 1n, }); @@ -922,7 +1007,7 @@ describe("Test LicenseClient", () => { it("should return currency token and token amount when call predictMintingLicenseFee given correct args ", async () => { sinon.stub(licenseClient.ipAssetRegistryClient, "isRegistered").resolves(true); sinon.stub(licenseClient.piLicenseTemplateReadOnlyClient, "exists").resolves(true); - sinon.stub(licenseClient.licensingModuleClient, "predictMintingLicenseFee").resolves({ + predictMintingLicenseFeeStub.resolves({ currencyToken: zeroAddress, tokenAmount: 1n, }); diff --git a/packages/core-sdk/test/unit/resources/royalty.test.ts b/packages/core-sdk/test/unit/resources/royalty.test.ts index 3d4bb7bb..3f65b8c0 100644 --- a/packages/core-sdk/test/unit/resources/royalty.test.ts +++ b/packages/core-sdk/test/unit/resources/royalty.test.ts @@ -4,6 +4,7 @@ import * as sinon from "sinon"; import { PublicClient, WalletClient, Account } from "viem"; import chaiAsPromised from "chai-as-promised"; import { RoyaltyClient } from "../../../src/resources/royalty"; +import { WIP_TOKEN_ADDRESS } from "../../../src/constants/common"; const { IpRoyaltyVaultImplReadOnlyClient } = require("../../../src/abi/generated"); chai.use(chaiAsPromised); const expect = chai.expect; @@ -82,6 +83,27 @@ describe("Test RoyaltyClient", () => { expect(result.txHash).equals(txHash); }); + it("should convert IP to WIP when paying WIP via payRoyaltyOnBehalf", async () => { + sinon.stub(royaltyClient.ipAssetRegistryClient, "isRegistered").resolves(true); + sinon.stub(royaltyClient.wipClient, "balanceOf").resolves({ result: 0n }); + sinon.stub(royaltyClient.wipClient, "allowance").resolves({ result: 200n }); + rpcMock.getBalance = sinon.stub().resolves(150n); + const simulateContractStub = sinon.stub().resolves({ request: {} }); + rpcMock.simulateContract = simulateContractStub; + walletMock.writeContract = sinon.stub().resolves(txHash); + const result = await royaltyClient.payRoyaltyOnBehalf({ + receiverIpId: "0x73fcb515cee99e4991465ef586cfe2b072ebb512", + payerIpId: "0x73fcb515cee99e4991465ef586cfe2b072ebb512", + token: WIP_TOKEN_ADDRESS, + amount: 100n, + txOptions: { waitForTransaction: true }, + }); + expect(result.txHash).to.be.a("string").and.not.empty; + expect(simulateContractStub.calledOnce).to.be.true; + const calls = simulateContractStub.firstCall.args[0].args[0]; + expect(calls.length).to.equal(2); // deposit and payRoyaltyOnBehalf + }); + it("should return txHash when call payRoyaltyOnBehalf given given correct args and waitForTransaction is true", async () => { sinon.stub(royaltyClient.ipAssetRegistryClient, "isRegistered").resolves(true); sinon.stub(royaltyClient.royaltyModuleClient, "payRoyaltyOnBehalf").resolves(txHash);