diff --git a/.changeset/new-islands-shave.md b/.changeset/new-islands-shave.md new file mode 100644 index 00000000000..27a47af37b6 --- /dev/null +++ b/.changeset/new-islands-shave.md @@ -0,0 +1,5 @@ +--- +"@thirdweb-dev/sdk": patch +--- + +Add fiat checkout to nft-drop, edition-drop and signature-drop diff --git a/packages/sdk/src/contracts/prebuilt-implementations/edition-drop.ts b/packages/sdk/src/contracts/prebuilt-implementations/edition-drop.ts index 8f8c1fbe516..7d302230253 100644 --- a/packages/sdk/src/contracts/prebuilt-implementations/edition-drop.ts +++ b/packages/sdk/src/contracts/prebuilt-implementations/edition-drop.ts @@ -19,6 +19,7 @@ import { TransactionResult, TransactionResultWithId, } from "../../core/types"; +import { PaperCheckout } from "../../integrations/paper-xyz"; import { EditionMetadata, EditionMetadataOwner } from "../../schema"; import { DropErc1155ContractSchema } from "../../schema/contracts/drop-erc1155"; import { SDKOptions } from "../../schema/sdk-options"; @@ -106,6 +107,13 @@ export class EditionDropImpl extends StandardErc1155 { * ``` */ public claimConditions: DropErc1155ClaimConditions; + + /** + * Checkout + * @remarks Create a FIAT currency checkout for your NFT drop. + */ + public checkout: PaperCheckout; + public history: DropErc1155History; public interceptor: ContractInterceptor; public erc1155: Erc1155; @@ -148,6 +156,7 @@ export class EditionDropImpl extends StandardErc1155 { this.platformFees = new ContractPlatformFee(this.contractWrapper); this.interceptor = new ContractInterceptor(this.contractWrapper); this.erc1155 = new Erc1155(this.contractWrapper, this.storage); + this.checkout = new PaperCheckout(this.contractWrapper); } /** diff --git a/packages/sdk/src/contracts/prebuilt-implementations/nft-drop.ts b/packages/sdk/src/contracts/prebuilt-implementations/nft-drop.ts index 34edb9fe680..596125b4d16 100644 --- a/packages/sdk/src/contracts/prebuilt-implementations/nft-drop.ts +++ b/packages/sdk/src/contracts/prebuilt-implementations/nft-drop.ts @@ -22,6 +22,7 @@ import { TransactionResult, TransactionResultWithId, } from "../../core/types"; +import { PaperCheckout } from "../../integrations/paper-xyz"; import { DropErc721ContractSchema } from "../../schema/contracts/drop-erc721"; import { SDKOptions } from "../../schema/sdk-options"; import { @@ -161,6 +162,13 @@ export class NFTDropImpl extends StandardErc721 { * ``` */ public revealer: DelayedReveal; + + /** + * Checkout + * @remarks Create a FIAT currency checkout for your NFT drop. + */ + public checkout: PaperCheckout; + public erc721: Erc721; constructor( @@ -206,6 +214,8 @@ export class NFTDropImpl extends StandardErc721 { () => this.erc721.nextTokenIdToMint(), ); this.interceptor = new ContractInterceptor(this.contractWrapper); + + this.checkout = new PaperCheckout(this.contractWrapper); } /** diff --git a/packages/sdk/src/contracts/prebuilt-implementations/signature-drop.ts b/packages/sdk/src/contracts/prebuilt-implementations/signature-drop.ts index 3bf4eadbded..8d099198b1d 100644 --- a/packages/sdk/src/contracts/prebuilt-implementations/signature-drop.ts +++ b/packages/sdk/src/contracts/prebuilt-implementations/signature-drop.ts @@ -21,6 +21,7 @@ import { TransactionResult, TransactionResultWithId, } from "../../core/types"; +import { PaperCheckout } from "../../integrations/paper-xyz"; import { DropErc721ContractSchema } from "../../schema/contracts/drop-erc721"; import { SDKOptions } from "../../schema/sdk-options"; import { @@ -161,6 +162,12 @@ export class SignatureDropImpl extends StandardErc721 { */ public signature: Erc721WithQuantitySignatureMintable; + /** + * Checkout + * @remarks Create a FIAT currency checkout for your NFT drop. + */ + public checkout: PaperCheckout; + constructor( network: NetworkOrSignerOrProvider, address: string, @@ -212,6 +219,8 @@ export class SignatureDropImpl extends StandardErc721 { this.contractWrapper, this.storage, ); + + this.checkout = new PaperCheckout(this.contractWrapper); } /** diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index fa19a385fb8..d6b8f9197d4 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -34,6 +34,9 @@ export * from "./common"; export * from "./constants"; export * from "./contracts"; +// export integration things +export * from "./integrations/paper-xyz"; + // explcitly export the *TYPES* of prebuilt contracts export type { EditionImpl } from "./contracts/prebuilt-implementations/edition"; export type { EditionDropImpl } from "./contracts/prebuilt-implementations/edition-drop"; diff --git a/packages/sdk/src/integrations/paper-xyz.ts b/packages/sdk/src/integrations/paper-xyz.ts new file mode 100644 index 00000000000..cdc50a9a944 --- /dev/null +++ b/packages/sdk/src/integrations/paper-xyz.ts @@ -0,0 +1,238 @@ +import { ChainId } from "../constants"; +import { ContractWrapper } from "../core/classes/contract-wrapper"; +import { SignedPayload721WithQuantitySignature } from "../schema/contracts/common/signature"; +import { DropERC721 } from "@thirdweb-dev/contracts-js/dist/declarations/src/DropERC721"; +import { DropERC1155 } from "@thirdweb-dev/contracts-js/dist/declarations/src/DropERC1155"; +import { SignatureDrop } from "@thirdweb-dev/contracts-js/dist/declarations/src/SignatureDrop"; +import fetch from "cross-fetch"; +import invariant from "tiny-invariant"; + +const PAPER_API_BASE = `https://paper.xyz/api` as const; +const PAPER_API_VERSION = `2022-08-12` as const; + +/** + * @internal + */ +export const PAPER_API_URL = + `${PAPER_API_BASE}/${PAPER_API_VERSION}/platform/thirdweb` as const; + +const PAPER_CHAIN_ID_MAP = { + [ChainId.Mainnet]: "Ethereum", + [ChainId.Rinkeby]: "Rinkeby", + [ChainId.Goerli]: "Goerli", + [ChainId.Polygon]: "Polygon", + [ChainId.Mumbai]: "Mumbai", + [ChainId.Avalanche]: "Avalanche", +} as const; + +/** + * @internal + */ +export function parseChainIdToPaperChain(chainId: number) { + invariant( + chainId in PAPER_CHAIN_ID_MAP, + `chainId not supported by paper: ${chainId}`, + ); + return PAPER_CHAIN_ID_MAP[chainId as keyof typeof PAPER_CHAIN_ID_MAP]; +} + +type RegisterContractSuccessResponse = { + result: { + id: string; + }; +}; + +/** + * + * @param contractAddress + * @param chainId + * @internal + * @returns the paper xyz contract id + * @throws if the contract is not registered on paper xyz + */ +export async function fetchRegisteredCheckoutId( + contractAddress: string, + chainId: number, +): Promise { + const paperChain = parseChainIdToPaperChain(chainId); + const res = await fetch( + `${PAPER_API_URL}/register-contract?contractAddress=${contractAddress}&chain=${paperChain}`, + ); + const json = (await res.json()) as RegisterContractSuccessResponse; + invariant(json.result.id, "Contract is not registered with paper"); + return json.result.id; +} + +/** + * The parameters for creating a paper.xyz checkout link. + * @public + */ +export type PaperCreateCheckoutLinkShardParams = { + /** + * The title of the checkout. + */ + title: string; + /** + * The number of NFTs that will be purchased through the checkout flow. + */ + quantity?: number; + /** + * The wallet address that the NFT will be sent to. + */ + walletAddress?: string; + /** + * The email address of the recipient. + */ + email?: string; + /** + * The description of the checkout. + */ + description?: string; + /** + * The image that will be displayed on the checkout page. + */ + imageUrl?: string; + /** + * Override the seller's Twitter handle for this checkout. + */ + twitterHandleOverride?: string; + /** + * The time in minutes that the intent will be valid for. + */ + expiresInMinutes?: number; + /** + * Determines whether the buyer or seller pays the network and service fees for this purchase. The seller will be billed if set to SELLER. (default: `BUYER`) + */ + feeBearer?: "BUYER" | "SELLER"; + /** + * Arbitrary data that will be included in webhooks and when viewing purchases in the paper.xyz dashboard. + */ + metadata?: Record; + /** + * If true, Paper will send buyers an email when their purchase is transferred to their wallet. (default: true) + */ + sendEmailOnSuccess?: boolean; + /** + * The URL to prompt the user to navigate after they complete their purchase. + */ + successCallbackUrl?: string; + /** + * The URL to redirect (or prompt the user to navigate) if the checkout link is expired or the buyer is not eligible to purchase (sold out, not allowlisted, sale not started, etc.). + */ + cancelCallbackurl?: string; + /** + * If true, the checkout flow will redirect the user to the successCallbackUrl immediately after successful payment and bypass the final receipt page. + */ + redirectAfterPayment?: boolean; + /** + * The maximum quantity the buyer is allowed to purchase in one transaction. + */ + limitPerTransaction?: number; +}; + +/** + * @internal + */ +export type PaperCreateCheckoutLinkIntentParams< + TContract extends DropERC721 | DropERC1155 | SignatureDrop, +> = PaperCreateCheckoutLinkShardParams & + (TContract extends DropERC1155 + ? { + contractArgs: { tokenId: string }; + } + : TContract extends SignatureDrop + ? { + contractArgs: SignedPayload721WithQuantitySignature; + } + : TContract extends DropERC721 + ? {} + : never); + +/** + * @internal + */ +export type PaperCreateCheckoutLinkIntentResult = { + checkoutLinkIntentUrl: string; +}; + +const DEFAULT_PARAMS: Partial = { + expiresInMinutes: 15, + feeBearer: "BUYER", + sendEmailOnSuccess: true, + redirectAfterPayment: false, +}; + +/** + * @internal + */ +export async function createCheckoutLinkIntent< + TContract extends DropERC721 | DropERC1155 | SignatureDrop, +>( + contractId: string, + params: PaperCreateCheckoutLinkIntentParams, +): Promise { + const res = await fetch(`${PAPER_API_URL}/checkout-link-intent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + contractId, + ...DEFAULT_PARAMS, + ...params, + metadata: { + ...params.metadata, + via_platform: "thirdweb", + }, + // overrides that are hard coded + hideNativeMint: true, + hidePaperWallet: !!params.walletAddress, + hideExternalWallet: true, + hidePayWithCrypto: true, + usePaperKey: false, + }), + }); + const json = (await res.json()) as PaperCreateCheckoutLinkIntentResult; + invariant( + json.checkoutLinkIntentUrl, + "Failed to create checkout link intent", + ); + return json.checkoutLinkIntentUrl; +} + +/** + * @internal + */ +export class PaperCheckout< + TContract extends DropERC721 | DropERC1155 | SignatureDrop, +> { + private contractWrapper; + + constructor(contractWrapper: ContractWrapper) { + this.contractWrapper = contractWrapper; + } + + private async getCheckoutId(): Promise { + return fetchRegisteredCheckoutId( + this.contractWrapper.readContract.address, + await this.contractWrapper.getChainID(), + ); + } + + public async isEnabled(): Promise { + try { + return !!(await this.getCheckoutId()); + } catch (err) { + return false; + } + } + + public async createLinkIntent( + params: PaperCreateCheckoutLinkIntentParams, + ): Promise { + return await createCheckoutLinkIntent( + await this.getCheckoutId(), + params, + ); + } +}