UNPKG

morphcredit-merchant-sdk

Version:

MorphCredit Merchant SDK for BNPL integration

804 lines (770 loc) 27.3 kB
import { ethers } from 'ethers'; import { useState, useEffect, useCallback } from 'react'; import { jsxs, Fragment, jsx } from 'react/jsx-runtime'; // src/index.ts // ../../apps/config/addresses.json var addresses_default = { scoreOracle: "0xd9Dc385246308FfBEBdEAc210F4c6B2E26Eb096d", creditRegistry: "0x62179b92bD09Bfc6699646F3394A6595c1E12BB2", lendingPool: "0x22D194Bb22f66731421C5F93163a7DFC05D2Ed5f", bnplFactory: "0x50e43053510E8f25280d335F5c7F30b15CF13965"}; var MorphCreditButton = ({ amount, userAddress, onSuccess, onError, onOffersLoaded, onWalletConnect, disabled = false, className = "", style, children = "Pay with MorphCredit", variant = "primary", size = "md", loading = false, showOffers = false }) => { const [sdk] = useState(() => new MorphCreditSDK({}, { enableLogging: true })); const [isConnecting, setIsConnecting] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [offers, setOffers] = useState([]); const [, setSelectedOffer] = useState(null); const [showOfferSelector, setShowOfferSelector] = useState(false); const [walletAddress, setWalletAddress] = useState(userAddress || null); useEffect(() => { sdk.onOffersLoaded((loadedOffers) => { setOffers(loadedOffers); onOffersLoaded?.(loadedOffers); }); sdk.onAgreementCreated((result) => { setIsProcessing(false); onSuccess?.(result); }); sdk.setErrorHandler((error) => { setIsConnecting(false); setIsProcessing(false); onError?.(error); }); return () => { sdk.disconnectWallet(); }; }, [sdk, onOffersLoaded, onSuccess, onError]); useEffect(() => { setWalletAddress(userAddress || null); }, [userAddress]); const connectWallet = useCallback(async () => { try { setIsConnecting(true); const address = await sdk.connectWallet(); setWalletAddress(address); onWalletConnect?.(address); } catch (error) { console.error("Failed to connect wallet:", error); onError?.(error); } finally { setIsConnecting(false); } }, [sdk, onWalletConnect, onError]); const handleClick = useCallback(async () => { if (disabled || loading || isConnecting || isProcessing) { return; } try { if (!walletAddress) { await connectWallet(); return; } const userOffers = await sdk.getOffers({ address: walletAddress, amount, includeFeatures: true }); if (userOffers.length === 0) { throw new Error("No offers available for this amount"); } if (showOffers && userOffers.length > 1) { setOffers(userOffers); setShowOfferSelector(true); } else { const firstOffer = userOffers[0]; await createAgreement(firstOffer.id); } } catch (error) { console.error("Error in button click:", error); onError?.(error); } }, [ disabled, loading, isConnecting, isProcessing, walletAddress, amount, showOffers, sdk, connectWallet, onError ]); const createAgreement = useCallback(async (offerId) => { try { setIsProcessing(true); setShowOfferSelector(false); await sdk.createAgreement(offerId); } catch (error) { console.error("Failed to create agreement:", error); onError?.(error); } }, [sdk, onError]); const handleOfferSelect = useCallback((offer) => { setSelectedOffer(offer); createAgreement(offer.id); }, [createAgreement]); const getButtonClasses = () => { const baseClasses = "morphcredit-button"; const variantClasses = { primary: "morphcredit-button--primary", secondary: "morphcredit-button--secondary", outline: "morphcredit-button--outline" }; const sizeClasses = { sm: "morphcredit-button--sm", md: "morphcredit-button--md", lg: "morphcredit-button--lg" }; const stateClasses = { disabled: disabled || loading || isConnecting || isProcessing ? "morphcredit-button--disabled" : "", loading: loading || isConnecting || isProcessing ? "morphcredit-button--loading" : "" }; return [ baseClasses, variantClasses[variant], sizeClasses[size], stateClasses.disabled, stateClasses.loading, className ].filter(Boolean).join(" "); }; const getButtonContent = () => { if (loading || isConnecting) { return /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx("span", { className: "morphcredit-button__spinner" }), isConnecting ? "Connecting..." : "Loading..." ] }); } if (isProcessing) { return /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx("span", { className: "morphcredit-button__spinner" }), "Processing..." ] }); } return children; }; return /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx( "button", { className: getButtonClasses(), style, onClick: handleClick, disabled: disabled || loading || isConnecting || isProcessing, type: "button", children: getButtonContent() } ), showOfferSelector && offers.length > 0 && /* @__PURE__ */ jsxs("div", { className: "morphcredit-offer-selector", children: [ /* @__PURE__ */ jsx("div", { className: "morphcredit-offer-selector__overlay", onClick: () => setShowOfferSelector(false) }), /* @__PURE__ */ jsxs("div", { className: "morphcredit-offer-selector__modal", children: [ /* @__PURE__ */ jsxs("div", { className: "morphcredit-offer-selector__header", children: [ /* @__PURE__ */ jsx("h3", { children: "Choose Your Payment Plan" }), /* @__PURE__ */ jsx( "button", { className: "morphcredit-offer-selector__close", onClick: () => setShowOfferSelector(false), children: "\xD7" } ) ] }), /* @__PURE__ */ jsx("div", { className: "morphcredit-offer-selector__content", children: offers.map((offer) => /* @__PURE__ */ jsxs( "div", { className: "morphcredit-offer-option", onClick: () => handleOfferSelect(offer), children: [ /* @__PURE__ */ jsxs("div", { className: "morphcredit-offer-option__header", children: [ /* @__PURE__ */ jsxs("h4", { children: [ "Tier ", offer.tier, " Plan" ] }), /* @__PURE__ */ jsxs("span", { className: "morphcredit-offer-option__apr", children: [ (offer.apr * 100).toFixed(1), "% APR" ] }) ] }), /* @__PURE__ */ jsxs("div", { className: "morphcredit-offer-option__details", children: [ /* @__PURE__ */ jsxs("p", { children: [ offer.installments, " payments of $", (Number(offer.installmentAmount) / 1e6).toFixed(2) ] }), /* @__PURE__ */ jsxs("p", { className: "morphcredit-offer-option__total", children: [ "Total: $", (Number(offer.totalCost) / 1e6).toFixed(2) ] }) ] }), offer.features && /* @__PURE__ */ jsxs("div", { className: "morphcredit-offer-option__features", children: [ offer.features.noLateFees && /* @__PURE__ */ jsx("span", { className: "morphcredit-offer-option__feature", children: "No Late Fees" }), offer.features.earlyPayoff && /* @__PURE__ */ jsx("span", { className: "morphcredit-offer-option__feature", children: "Early Payoff" }), offer.features.autoRepay && /* @__PURE__ */ jsx("span", { className: "morphcredit-offer-option__feature", children: "Auto Repay" }) ] }) ] }, offer.id )) }) ] }) ] }), /* @__PURE__ */ jsx("style", { children: ` .morphcredit-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: none; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-weight: 600; cursor: pointer; transition: all 0.2s ease; text-decoration: none; position: relative; overflow: hidden; } .morphcredit-button:disabled { cursor: not-allowed; opacity: 0.6; } .morphcredit-button--primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); } .morphcredit-button--primary:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6); } .morphcredit-button--secondary { background: #f8f9fa; color: #495057; border: 2px solid #dee2e6; } .morphcredit-button--secondary:hover:not(:disabled) { background: #e9ecef; border-color: #adb5bd; } .morphcredit-button--outline { background: transparent; color: #667eea; border: 2px solid #667eea; } .morphcredit-button--outline:hover:not(:disabled) { background: #667eea; color: white; } .morphcredit-button--sm { padding: 8px 16px; font-size: 14px; min-height: 36px; } .morphcredit-button--md { padding: 12px 24px; font-size: 16px; min-height: 44px; } .morphcredit-button--lg { padding: 16px 32px; font-size: 18px; min-height: 52px; } .morphcredit-button__spinner { width: 16px; height: 16px; border: 2px solid transparent; border-top: 2px solid currentColor; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .morphcredit-offer-selector { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; } .morphcredit-offer-selector__overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); } .morphcredit-offer-selector__modal { position: relative; background: white; border-radius: 12px; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto; } .morphcredit-offer-selector__header { display: flex; align-items: center; justify-content: space-between; padding: 24px; border-bottom: 1px solid #e9ecef; } .morphcredit-offer-selector__header h3 { margin: 0; font-size: 20px; font-weight: 600; color: #212529; } .morphcredit-offer-selector__close { background: none; border: none; font-size: 24px; cursor: pointer; color: #6c757d; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: background-color 0.2s; } .morphcredit-offer-selector__close:hover { background: #f8f9fa; } .morphcredit-offer-selector__content { padding: 24px; } .morphcredit-offer-option { border: 2px solid #e9ecef; border-radius: 8px; padding: 20px; margin-bottom: 16px; cursor: pointer; transition: all 0.2s ease; } .morphcredit-offer-option:hover { border-color: #667eea; box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1); } .morphcredit-offer-option__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .morphcredit-offer-option__header h4 { margin: 0; font-size: 18px; font-weight: 600; color: #212529; } .morphcredit-offer-option__apr { background: #e3f2fd; color: #1976d2; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; } .morphcredit-offer-option__details { margin-bottom: 12px; } .morphcredit-offer-option__details p { margin: 4px 0; color: #495057; } .morphcredit-offer-option__total { font-weight: 600; color: #212529 !important; } .morphcredit-offer-option__features { display: flex; gap: 8px; flex-wrap: wrap; } .morphcredit-offer-option__feature { background: #f8f9fa; color: #6c757d; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; } ` }) ] }); }; var button_default = MorphCreditButton; // src/index.ts var ErrorCodes = /* @__PURE__ */ ((ErrorCodes2) => { ErrorCodes2["WALLET_NOT_CONNECTED"] = "WALLET_NOT_CONNECTED"; ErrorCodes2["WALLET_CONNECTION_FAILED"] = "WALLET_CONNECTION_FAILED"; ErrorCodes2["WRONG_NETWORK"] = "WRONG_NETWORK"; ErrorCodes2["INSUFFICIENT_CREDIT"] = "INSUFFICIENT_CREDIT"; ErrorCodes2["SCORE_NOT_FOUND"] = "SCORE_NOT_FOUND"; ErrorCodes2["SCORE_EXPIRED"] = "SCORE_EXPIRED"; ErrorCodes2["AGREEMENT_FAILED"] = "AGREEMENT_FAILED"; ErrorCodes2["AGREEMENT_NOT_FOUND"] = "AGREEMENT_NOT_FOUND"; ErrorCodes2["AGREEMENT_EXPIRED"] = "AGREEMENT_EXPIRED"; ErrorCodes2["NETWORK_ERROR"] = "NETWORK_ERROR"; ErrorCodes2["RPC_ERROR"] = "RPC_ERROR"; ErrorCodes2["TIMEOUT"] = "TIMEOUT"; ErrorCodes2["USER_REJECTED"] = "USER_REJECTED"; ErrorCodes2["INSUFFICIENT_BALANCE"] = "INSUFFICIENT_BALANCE"; ErrorCodes2["INVALID_ADDRESS"] = "INVALID_ADDRESS"; ErrorCodes2["INVALID_AMOUNT"] = "INVALID_AMOUNT"; ErrorCodes2["INVALID_OFFER"] = "INVALID_OFFER"; return ErrorCodes2; })(ErrorCodes || {}); var MorphCreditError = class extends Error { constructor(code, message, details, retryable = false) { super(message); this.code = code; this.details = details; this.retryable = retryable; this.name = "MorphCreditError"; } }; var _MorphCreditSDK = class _MorphCreditSDK { constructor(config = {}, options = {}) { this.provider = null; this.signer = null; this.eventCallbacks = /* @__PURE__ */ new Map(); this.offersCache = /* @__PURE__ */ new Map(); const runtimeScoring = typeof globalThis !== "undefined" && globalThis.__MORPHCREDIT_SCORING_URL__ || typeof process !== "undefined" && process.env && process.env.MORPHCREDIT_SCORING_URL || "https://morphcredit.onrender.com"; this.config = { rpcUrl: "https://rpc-holesky.morphl2.io", contracts: { scoreOracle: addresses_default.scoreOracle, creditRegistry: addresses_default.creditRegistry, lendingPool: addresses_default.lendingPool, bnplFactory: addresses_default.bnplFactory }, scoringService: runtimeScoring, networkId: 2810, gasLimit: 8e5, confirmations: 1, ...config }; this.options = { autoConnect: false, enableLogging: false, retryAttempts: 3, retryDelay: 1e3, ...options }; this.initializeProviders(); } async getSigner() { if (!this.provider) throw new MorphCreditError("WALLET_CONNECTION_FAILED" /* WALLET_CONNECTION_FAILED */, "Provider not initialized"); if (!this.signer) { this.signer = await this.provider.getSigner(); } return this.signer; } initializeProviders() { try { if (typeof window !== "undefined" && window.ethereum) { this.provider = new ethers.BrowserProvider(window.ethereum); } if (this.options.enableLogging) { console.log("MorphCreditSDK initialized with config:", this.config); } } catch (error) { console.error("Failed to initialize providers:", error); } } async connectWallet() { try { if (!this.provider) { throw new MorphCreditError( "WALLET_CONNECTION_FAILED" /* WALLET_CONNECTION_FAILED */, "Provider not initialized" ); } this.signer = await this.provider.getSigner(); const address = await this.signer.getAddress(); if (this.options.enableLogging) { console.log("Wallet connected:", address); } this.triggerEvent("walletConnected", address); return address; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; throw new MorphCreditError( "WALLET_CONNECTION_FAILED" /* WALLET_CONNECTION_FAILED */, `Failed to connect wallet: ${errorMessage}`, error ); } } disconnectWallet() { this.signer = null; this.triggerEvent("walletDisconnected"); } getWalletAddress() { return this.signer ? this.signer.address : null; } isWalletConnected() { return this.signer !== null; } async getOffers(request) { try { if (!request.address) { throw new MorphCreditError( "INVALID_ADDRESS" /* INVALID_ADDRESS */, "Address is required" ); } if (!request.amount || request.amount <= 0) { throw new MorphCreditError( "INVALID_AMOUNT" /* INVALID_AMOUNT */, "Amount must be greater than 0" ); } const amountInWei = BigInt(Math.floor(request.amount * 1e6)); const res = await fetch(`${this.config.scoringService.replace(/\/$/, "")}/score`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ address: request.address }) }); if (!res.ok) { throw new MorphCreditError("NETWORK_ERROR" /* NETWORK_ERROR */, `Scoring service error: ${res.status}`); } const json = await res.json(); if (!json?.success || !json?.data?.scoring) { throw new MorphCreditError("SCORE_NOT_FOUND" /* SCORE_NOT_FOUND */, "Score response invalid"); } const tier = json.data.scoring.tier; const tierAprMap = { A: [0.1, 0.14], B: [0.14, 0.2], C: [0.2, 0.28], D: [0.28, 0.36], E: [0.36, 0.5] }; const [aprLow, aprHigh] = tierAprMap[tier] || [0.25, 0.35]; const aprChoices = [aprLow, (aprLow + aprHigh) / 2, aprHigh]; const now = Math.floor(Date.now() / 1e3); const biweekly = 14 * 24 * 60 * 60; const installments = 4; const offers = aprChoices.map((apr, idx) => { const totalCost = BigInt(Math.floor(Number(amountInWei) * (1 + apr))); const installmentAmount = BigInt(Number(totalCost) / installments); const id = `offer_${request.address}_${amountInWei}_${tier}_${idx}`; const offer = { id, principal: amountInWei, installments, installmentAmount, totalCost, apr, dueDates: [now + biweekly, now + biweekly * 2, now + biweekly * 3, now + biweekly * 4], merchant: request.address, status: "available", tier, features: { earlyPayoff: true, autoRepay: true } }; this.offersCache.set(id, offer); return offer; }); if (this.options.enableLogging) { console.log("Generated offers:", offers); } this.triggerEvent("offersLoaded", offers); return offers; } catch (error) { if (error instanceof MorphCreditError) { throw error; } throw new MorphCreditError( "NETWORK_ERROR" /* NETWORK_ERROR */, `Failed to get offers: ${error instanceof Error ? error.message : "Unknown error"}`, error ); } } // Removed mock offer generation async createAgreement(offerId, borrowerAddress) { try { const signer = await this.getSigner(); const offer = this.offersCache.get(offerId); if (!offer) throw new MorphCreditError("INVALID_OFFER" /* INVALID_OFFER */, "Offer not found in cache"); const borrower = borrowerAddress || await signer.getAddress(); const merchant = await signer.getAddress(); const fac = new ethers.Contract(this.config.contracts.bnplFactory, _MorphCreditSDK.BNPL_FACTORY_ABI, signer); if (!this.options.skipRoleCheck) { try { const FACTORY_ROLE = ethers.id("FACTORY_ROLE"); const caller = await signer.getAddress(); const hasRole = await fac.hasRole(FACTORY_ROLE, caller); if (!hasRole) { throw new MorphCreditError("AGREEMENT_FAILED" /* AGREEMENT_FAILED */, "Merchant is not authorized (FACTORY_ROLE missing)"); } } catch (e) { if (this.options.enableLogging) console.warn("Role check skipped due to error; set skipRoleCheck to true to silence", e); if (!this.options.skipRoleCheck) throw e; } } const aprBps = Math.floor(offer.apr * 1e4); const tx = await fac.createAgreement(borrower, merchant, offer.principal, offer.installments, aprBps, { gasLimit: this.config.gasLimit }); const receipt = await tx.wait(this.config.confirmations); let agreementAddress = tx.hash; try { const iface = new ethers.Interface(_MorphCreditSDK.BNPL_FACTORY_ABI); const factoryAddr = this.config.contracts.bnplFactory.toLowerCase(); const logs = (receipt?.logs ?? []).filter((l) => l?.address?.toLowerCase() === factoryAddr); for (const log of logs) { try { const parsed = iface.parseLog({ topics: log.topics, data: log.data }); if (parsed?.name === "AgreementCreated") { const args = parsed.args; agreementAddress = args.agreement ?? args[2]; break; } } catch { } } } catch { } const txResult = { success: receipt?.status === 1, txHash: tx.hash, agreementId: agreementAddress, blockNumber: receipt?.blockNumber ?? 0, gasUsed: Number(receipt?.gasUsed ?? 0n), gasPrice: tx.gasPrice ?? 0n, receipt }; this.triggerEvent("agreementCreated", txResult); return txResult; } catch (error) { if (error instanceof MorphCreditError) { throw error; } throw new MorphCreditError( "AGREEMENT_FAILED" /* AGREEMENT_FAILED */, `Failed to create agreement: ${error instanceof Error ? error.message : "Unknown error"}`, error ); } } async getAgreementStatus(agreementAddress) { try { if (!this.provider) throw new MorphCreditError("WALLET_CONNECTION_FAILED" /* WALLET_CONNECTION_FAILED */, "Provider not initialized"); const ag = new ethers.Contract(agreementAddress, _MorphCreditSDK.BNPL_AGREEMENT_ABI, this.provider); const a = await ag.getAgreement(); const installments = await ag.getAllInstallments(); const next = installments.find((i) => !i.isPaid); const remaining = Number(a.installments) - Number(a.paidInstallments); const statusNum = Number(a.status); const status = statusNum === 2 ? "completed" : statusNum === 3 ? "defaulted" : statusNum === 4 ? "written_off" : "active"; return { id: agreementAddress, status, paidInstallments: Number(a.paidInstallments), totalInstallments: Number(a.installments), nextDueDate: next ? Number(next.dueDate) : 0, nextAmount: next ? BigInt(next.amount) : BigInt(0), remainingBalance: BigInt(remaining) * BigInt(a.installmentAmount), lastPaymentDate: Number(a.lastPaymentDate), delinquencyDays: 0 }; } catch (error) { throw new MorphCreditError( "AGREEMENT_NOT_FOUND" /* AGREEMENT_NOT_FOUND */, `Failed to get agreement status: ${error instanceof Error ? error.message : "Unknown error"}`, error ); } } // Event handling onAgreementCreated(callback) { this.addEventCallback("agreementCreated", callback); } onOffersLoaded(callback) { this.addEventCallback("offersLoaded", callback); } onWalletConnected(callback) { this.addEventCallback("walletConnected", callback); } onWalletDisconnected(callback) { this.addEventCallback("walletDisconnected", callback); } addEventCallback(event, callback) { if (!this.eventCallbacks.has(event)) { this.eventCallbacks.set(event, []); } this.eventCallbacks.get(event).push(callback); } triggerEvent(event, data) { const callbacks = this.eventCallbacks.get(event); if (callbacks) { callbacks.forEach((callback) => { try { callback(data); } catch (error) { console.error(`Error in event callback for ${event}:`, error); } }); } } // Configuration methods updateConfig(config) { this.config = { ...this.config, ...config }; this.initializeProviders(); } updateOptions(options) { this.options = { ...this.options, ...options }; } getConfig() { return { ...this.config }; } setErrorHandler(handler) { this.addEventCallback("error", handler); } }; // Minimal ABIs _MorphCreditSDK.BNPL_FACTORY_ABI = [ "function createAgreement(address borrower,address merchant,uint256 principal,uint256 installments,uint256 apr) returns (address)", "function hasRole(bytes32 role, address account) view returns (bool)", "event AgreementCreated(address indexed borrower, address indexed merchant, address agreement, uint256 principal)" ]; _MorphCreditSDK.BNPL_AGREEMENT_ABI = [ "function getAgreement() view returns (tuple(uint256 principal,address borrower,address merchant,uint256 installments,uint256 installmentAmount,uint256 apr,uint256 penaltyRate,uint256[] dueDates,uint8 status,uint256 paidInstallments,uint256 lastPaymentDate,uint256 gracePeriod,uint256 writeOffPeriod))", "function getAllInstallments() view returns (tuple(uint256 id,uint256 amount,uint256 dueDate,bool isPaid,uint256 paidAt,uint256 penaltyAccrued)[])" ]; var MorphCreditSDK = _MorphCreditSDK; var index_default = MorphCreditSDK; export { ErrorCodes, button_default as MorphCreditButton, MorphCreditError, MorphCreditSDK, index_default as default }; //# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map