@b3dotfun/sdk 0.1.65-alpha.2 → 0.1.65-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ALL_CHAINS, getAvailableChainIds, isSameChainAndToken } from "../../../anyspend/index.js";
2
+ import { ALL_CHAINS, getAvailableChainIds, getPaymentUrl, isSameChainAndToken, ZERO_ADDRESS, } from "../../../anyspend/index.js";
3
3
  import { Button, toast } from "../../../global-account/react/index.js";
4
4
  import { cn } from "../../../shared/utils/cn.js";
5
5
  import { TokenSelector } from "@relayprotocol/relay-kit-ui";
@@ -128,6 +128,8 @@ export function QRDeposit({ mode = "modal", recipientAddress, sourceToken: sourc
128
128
  useOnOrderSuccess({ orderData: oat, orderId, onSuccess });
129
129
  // For pure transfers, always use recipient address; for orders, use global address
130
130
  const displayAddress = isPureTransfer ? recipientAddress : globalAddress || recipientAddress;
131
+ // Generate EIP-681 payment URI for the QR code so wallets know which chain/token to use
132
+ const qrValue = getPaymentUrl(displayAddress, undefined, sourceToken.address === ZERO_ADDRESS ? "ETH" : sourceToken.address, sourceChainId, sourceToken.decimals);
131
133
  const handleCopyAddress = async () => {
132
134
  if (displayAddress) {
133
135
  await navigator.clipboard.writeText(displayAddress);
@@ -160,7 +162,7 @@ export function QRDeposit({ mode = "modal", recipientAddress, sourceToken: sourc
160
162
  }
161
163
  return (_jsx("div", { className: classes?.container ||
162
164
  cn("anyspend-container anyspend-qr-deposit font-inter bg-as-surface-primary mx-auto w-full max-w-[460px] p-6", mode === "page" && "border-as-border-secondary overflow-hidden rounded-2xl border shadow-xl"), children: _jsxs("div", { className: classes?.content || "anyspend-qr-deposit-content flex flex-col gap-4", children: [_jsxs("div", { className: classes?.header || "anyspend-qr-header flex items-center justify-between", children: [_jsx("button", { onClick: handleBack, className: classes?.backButton || "anyspend-qr-back-button text-as-secondary hover:text-as-primary", children: _jsx("svg", { className: "h-5 w-5", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M15 19l-7-7 7-7" }) }) }), _jsx("h2", { className: classes?.title || "anyspend-qr-title text-as-primary text-base font-semibold", children: "Deposit" }), onClose ? (_jsx("button", { onClick: handleClose, className: classes?.closeButton || "anyspend-qr-close-button text-as-secondary hover:text-as-primary", children: _jsx("svg", { className: "h-5 w-5", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) })) : (_jsx("div", { className: "w-5" }))] }), _jsxs("div", { className: classes?.tokenSelectorContainer || "anyspend-qr-token-selector flex flex-col gap-1.5", children: [_jsx("label", { className: classes?.tokenSelectorLabel || "anyspend-qr-token-label text-as-secondary text-sm", children: "Send" }), _jsx(TokenSelector, { chainIdsFilter: getAvailableChainIds("from"), context: "from", fromChainWalletVMSupported: true, isValidAddress: true, lockedChainIds: getAvailableChainIds("from"), multiWalletSupportEnabled: true, onAnalyticEvent: undefined, setToken: handleTokenSelect, supportedWalletVMs: ["evm"], token: undefined, trigger: _jsxs(Button, { variant: "outline", role: "combobox", className: classes?.tokenSelectorTrigger ||
163
- "anyspend-qr-token-trigger border-as-stroke bg-as-surface-secondary flex h-auto w-full items-center justify-between gap-2 rounded-xl border px-3 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [sourceToken.metadata?.logoURI ? (_jsx(ChainTokenIcon, { chainUrl: ALL_CHAINS[sourceChainId]?.logoUrl, tokenUrl: sourceToken.metadata.logoURI, className: "h-8 min-h-8 w-8 min-w-8" })) : (_jsx("div", { className: "h-8 w-8 rounded-full bg-gray-700" })), _jsxs("div", { className: "flex flex-col items-start gap-0", children: [_jsx("div", { className: "text-as-primary font-semibold", children: sourceToken.symbol }), _jsx("div", { className: "text-as-primary/70 text-xs", children: ALL_CHAINS[sourceChainId]?.name ?? "Unknown" })] })] }), _jsx(ChevronsUpDown, { className: "h-4 w-4 shrink-0 opacity-70" })] }) })] }), _jsxs("div", { className: classes?.qrContent || "anyspend-qr-content border-as-stroke flex items-start gap-4 rounded-xl border p-4", children: [_jsxs("div", { className: classes?.qrCodeContainer || "anyspend-qr-code-container flex flex-col items-center gap-2", children: [_jsx("div", { className: classes?.qrCode || "anyspend-qr-code rounded-lg bg-white p-2", children: _jsx(QRCodeSVG, { value: displayAddress, size: 120, level: "M", marginSize: 0 }) }), _jsxs("span", { className: classes?.qrScanHint || "anyspend-qr-scan-hint text-as-secondary text-xs", children: ["SCAN WITH ", _jsx("span", { className: "inline-block", children: "\uD83E\uDD8A" })] })] }), _jsxs("div", { className: classes?.addressContainer || "anyspend-qr-address-container flex flex-1 flex-col gap-1", children: [_jsx("span", { className: classes?.addressLabel || "anyspend-qr-address-label text-as-secondary text-sm", children: "Deposit address:" }), _jsxs("div", { className: classes?.addressRow || "anyspend-qr-address-row flex items-start gap-1", children: [_jsx("span", { className: classes?.address || "anyspend-qr-address text-as-primary break-all font-mono text-sm leading-relaxed", children: displayAddress }), _jsx("button", { onClick: handleCopyAddress, className: classes?.addressCopyIcon ||
165
+ "anyspend-qr-token-trigger border-as-stroke bg-as-surface-secondary flex h-auto w-full items-center justify-between gap-2 rounded-xl border px-3 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [sourceToken.metadata?.logoURI ? (_jsx(ChainTokenIcon, { chainUrl: ALL_CHAINS[sourceChainId]?.logoUrl, tokenUrl: sourceToken.metadata.logoURI, className: "h-8 min-h-8 w-8 min-w-8" })) : (_jsx("div", { className: "h-8 w-8 rounded-full bg-gray-700" })), _jsxs("div", { className: "flex flex-col items-start gap-0", children: [_jsx("div", { className: "text-as-primary font-semibold", children: sourceToken.symbol }), _jsx("div", { className: "text-as-primary/70 text-xs", children: ALL_CHAINS[sourceChainId]?.name ?? "Unknown" })] })] }), _jsx(ChevronsUpDown, { className: "h-4 w-4 shrink-0 opacity-70" })] }) })] }), _jsxs("div", { className: classes?.qrContent || "anyspend-qr-content border-as-stroke flex items-start gap-4 rounded-xl border p-4", children: [_jsxs("div", { className: classes?.qrCodeContainer || "anyspend-qr-code-container flex flex-col items-center gap-2", children: [_jsx("div", { className: classes?.qrCode || "anyspend-qr-code rounded-lg bg-white p-2", children: _jsx(QRCodeSVG, { value: qrValue, size: 120, level: "M", marginSize: 0 }) }), _jsxs("span", { className: classes?.qrScanHint || "anyspend-qr-scan-hint text-as-secondary text-xs", children: ["SCAN WITH ", _jsx("span", { className: "inline-block", children: "\uD83E\uDD8A" })] })] }), _jsxs("div", { className: classes?.addressContainer || "anyspend-qr-address-container flex flex-1 flex-col gap-1", children: [_jsx("span", { className: classes?.addressLabel || "anyspend-qr-address-label text-as-secondary text-sm", children: "Deposit address:" }), _jsxs("div", { className: classes?.addressRow || "anyspend-qr-address-row flex items-start gap-1", children: [_jsx("span", { className: classes?.address || "anyspend-qr-address text-as-primary break-all font-mono text-sm leading-relaxed", children: displayAddress }), _jsx("button", { onClick: handleCopyAddress, className: classes?.addressCopyIcon ||
164
166
  "anyspend-qr-copy-icon text-as-secondary hover:text-as-primary mt-0.5 shrink-0", children: copied ? _jsx(Check, { className: "h-4 w-4" }) : _jsx(Copy, { className: "h-4 w-4" }) })] })] })] }), _jsx(ChainWarningText, { chainId: destinationChainId }), _jsxs(WarningText, { children: ["Only send ", sourceToken.symbol, " on ", ALL_CHAINS[sourceChainId]?.name ?? "the specified chain", ". Other tokens will not be converted."] }), isPureTransfer && isWatchingTransfer && (_jsxs("div", { className: classes?.watchingIndicator ||
165
167
  "anyspend-qr-watching flex items-center justify-center gap-2 rounded-lg bg-blue-500/10 p-3", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin text-blue-500" }), _jsx("span", { className: "text-sm text-blue-500", children: "Watching for incoming transfer..." })] })), _jsx("button", { onClick: handleCopyAddress, className: classes?.copyButton ||
166
168
  "anyspend-qr-copy-button flex w-full items-center justify-center gap-2 rounded-xl bg-blue-500 py-3.5 font-medium text-white transition-all hover:bg-blue-600", children: "Copy deposit address" })] }) }));
@@ -82,7 +82,7 @@ export declare function isTestnet(chainId: number): boolean;
82
82
  export declare function getDefaultToken(chainId: number): components["schemas"]["Token"];
83
83
  export declare function getChainName(chainId: number): string;
84
84
  export declare function getCoingeckoName(chainId: number): string | null;
85
- export declare function getPaymentUrl(address: string, amount: bigint, currency: string, chainId: number, decimals?: number): string;
85
+ export declare function getPaymentUrl(address: string, amount: bigint | undefined, currency: string, chainId: number, decimals?: number): string;
86
86
  export declare function getExplorerTxUrl(chainId: number, txHash: string): string;
87
87
  export declare function getExplorerAddressUrl(chainId: number, address: string): string;
88
88
  export declare function getMulticall3Address(chainId: number): string;
@@ -362,8 +362,8 @@ export function getPaymentUrl(address, amount, currency, chainId, decimals) {
362
362
  // For EVM chains, follow EIP-681 format
363
363
  // Format: ethereum:[address]@[chainId]?value=[amount]&symbol=[symbol]
364
364
  const params = new URLSearchParams();
365
- // Add value for native token transfers
366
- if (currency === chain.nativeToken.symbol) {
365
+ // Add value for native token transfers (skip if amount not provided, e.g. deposit_first)
366
+ if (currency === chain.nativeToken.symbol && amount !== undefined) {
367
367
  params.append("value", amount.toString());
368
368
  }
369
369
  // Handle token transfers differently from native transfers
@@ -378,28 +378,31 @@ export function getPaymentUrl(address, amount, currency, chainId, decimals) {
378
378
  }
379
379
  // For ERC20 tokens, convert from smallest unit to display units using decimals
380
380
  // For example: 2400623 (raw) with 6 decimals becomes "2.400623"
381
- let displayAmount;
382
- if (decimals !== undefined && currency !== chain.nativeToken.symbol) {
383
- // Convert from smallest unit to display unit for ERC20 tokens
384
- const divisor = BigInt(10 ** decimals);
385
- const wholePart = amount / divisor;
386
- const fractionalPart = amount % divisor;
387
- if (fractionalPart === BigInt(0)) {
388
- displayAmount = wholePart.toString();
381
+ // Skip amount if not provided (e.g. deposit_first orders)
382
+ if (amount !== undefined) {
383
+ let displayAmount;
384
+ if (decimals !== undefined && currency !== chain.nativeToken.symbol) {
385
+ // Convert from smallest unit to display unit for ERC20 tokens
386
+ const divisor = BigInt(10 ** decimals);
387
+ const wholePart = amount / divisor;
388
+ const fractionalPart = amount % divisor;
389
+ if (fractionalPart === BigInt(0)) {
390
+ displayAmount = wholePart.toString();
391
+ }
392
+ else {
393
+ // Format fractional part with leading zeros if needed
394
+ const fractionalStr = fractionalPart.toString().padStart(decimals, "0");
395
+ // Remove trailing zeros
396
+ const trimmedFractional = fractionalStr.replace(/0+$/, "");
397
+ displayAmount = trimmedFractional ? `${wholePart}.${trimmedFractional}` : wholePart.toString();
398
+ }
389
399
  }
390
400
  else {
391
- // Format fractional part with leading zeros if needed
392
- const fractionalStr = fractionalPart.toString().padStart(decimals, "0");
393
- // Remove trailing zeros
394
- const trimmedFractional = fractionalStr.replace(/0+$/, "");
395
- displayAmount = trimmedFractional ? `${wholePart}.${trimmedFractional}` : wholePart.toString();
401
+ // For native tokens or when decimals not provided, use raw amount
402
+ displayAmount = amount.toString();
396
403
  }
404
+ tokenParams.append("amount", displayAmount);
397
405
  }
398
- else {
399
- // For native tokens or when decimals not provided, use raw amount
400
- displayAmount = amount.toString();
401
- }
402
- tokenParams.append("amount", displayAmount);
403
406
  tokenParams.append("address", address); // recipient address
404
407
  // For Arbitrum and other L2s, try a more explicit format
405
408
  if (chainId !== mainnet.id) {
@@ -419,7 +422,9 @@ export function getPaymentUrl(address, amount, currency, chainId, decimals) {
419
422
  // to make sure wallets recognize the correct chain
420
423
  const nativeParams = new URLSearchParams();
421
424
  nativeParams.append("chainId", chainId.toString());
422
- nativeParams.append("value", amount.toString());
425
+ if (amount !== undefined) {
426
+ nativeParams.append("value", amount.toString());
427
+ }
423
428
  const url = `ethereum:${address}@${chainId}?${nativeParams.toString()}`;
424
429
  return url;
425
430
  }
@@ -438,60 +443,65 @@ export function getPaymentUrl(address, amount, currency, chainId, decimals) {
438
443
  const isNativeSOL = currency === chain.nativeToken.symbol || currency === "SOL" || currency === "11111111111111111111111111111111";
439
444
  if (isNativeSOL) {
440
445
  // Native SOL transfers - convert from lamports to SOL
441
- let displayAmount;
442
- if (decimals !== undefined) {
443
- const divisor = BigInt(10 ** decimals);
444
- const wholePart = amount / divisor;
445
- const fractionalPart = amount % divisor;
446
- if (fractionalPart === BigInt(0)) {
447
- displayAmount = wholePart.toString();
448
- }
449
- else {
450
- const fractionalStr = fractionalPart.toString().padStart(decimals, "0");
451
- const trimmedFractional = fractionalStr.replace(/0+$/, "");
452
- displayAmount = trimmedFractional ? `${wholePart}.${trimmedFractional}` : wholePart.toString();
453
- }
454
- }
455
- else {
456
- // Fallback: assume SOL has 9 decimals
457
- const divisor = BigInt(1000000000); // 1e9
458
- const wholePart = amount / divisor;
459
- const fractionalPart = amount % divisor;
460
- if (fractionalPart === BigInt(0)) {
461
- displayAmount = wholePart.toString();
446
+ if (amount !== undefined) {
447
+ let displayAmount;
448
+ if (decimals !== undefined) {
449
+ const divisor = BigInt(10 ** decimals);
450
+ const wholePart = amount / divisor;
451
+ const fractionalPart = amount % divisor;
452
+ if (fractionalPart === BigInt(0)) {
453
+ displayAmount = wholePart.toString();
454
+ }
455
+ else {
456
+ const fractionalStr = fractionalPart.toString().padStart(decimals, "0");
457
+ const trimmedFractional = fractionalStr.replace(/0+$/, "");
458
+ displayAmount = trimmedFractional ? `${wholePart}.${trimmedFractional}` : wholePart.toString();
459
+ }
462
460
  }
463
461
  else {
464
- const fractionalStr = fractionalPart.toString().padStart(9, "0");
465
- const trimmedFractional = fractionalStr.replace(/0+$/, "");
466
- displayAmount = trimmedFractional ? `${wholePart}.${trimmedFractional}` : wholePart.toString();
462
+ // Fallback: assume SOL has 9 decimals
463
+ const divisor = BigInt(1000000000); // 1e9
464
+ const wholePart = amount / divisor;
465
+ const fractionalPart = amount % divisor;
466
+ if (fractionalPart === BigInt(0)) {
467
+ displayAmount = wholePart.toString();
468
+ }
469
+ else {
470
+ const fractionalStr = fractionalPart.toString().padStart(9, "0");
471
+ const trimmedFractional = fractionalStr.replace(/0+$/, "");
472
+ displayAmount = trimmedFractional ? `${wholePart}.${trimmedFractional}` : wholePart.toString();
473
+ }
467
474
  }
475
+ // For native SOL, use simple format without spl-token parameter
476
+ params.append("amount", displayAmount);
468
477
  }
469
- // For native SOL, use simple format without spl-token parameter
470
- params.append("amount", displayAmount);
471
478
  }
472
479
  else {
473
480
  // SPL token transfers
474
- let displayAmount;
475
- if (decimals !== undefined) {
476
- const divisor = BigInt(10 ** decimals);
477
- const wholePart = amount / divisor;
478
- const fractionalPart = amount % divisor;
479
- if (fractionalPart === BigInt(0)) {
480
- displayAmount = wholePart.toString();
481
+ if (amount !== undefined) {
482
+ let displayAmount;
483
+ if (decimals !== undefined) {
484
+ const divisor = BigInt(10 ** decimals);
485
+ const wholePart = amount / divisor;
486
+ const fractionalPart = amount % divisor;
487
+ if (fractionalPart === BigInt(0)) {
488
+ displayAmount = wholePart.toString();
489
+ }
490
+ else {
491
+ const fractionalStr = fractionalPart.toString().padStart(decimals, "0");
492
+ const trimmedFractional = fractionalStr.replace(/0+$/, "");
493
+ displayAmount = trimmedFractional ? `${wholePart}.${trimmedFractional}` : wholePart.toString();
494
+ }
481
495
  }
482
496
  else {
483
- const fractionalStr = fractionalPart.toString().padStart(decimals, "0");
484
- const trimmedFractional = fractionalStr.replace(/0+$/, "");
485
- displayAmount = trimmedFractional ? `${wholePart}.${trimmedFractional}` : wholePart.toString();
497
+ displayAmount = amount.toString();
486
498
  }
499
+ params.append("amount", displayAmount);
487
500
  }
488
- else {
489
- displayAmount = amount.toString();
490
- }
491
- params.append("amount", displayAmount);
492
501
  params.append("spl-token", currency); // token mint address
493
502
  }
494
- const url = `solana:${address}?${params.toString()}`;
503
+ const queryString = params.toString();
504
+ const url = queryString ? `solana:${address}?${queryString}` : `solana:${address}`;
495
505
  console.log("Solana URL (isNativeSOL:", isNativeSOL, "):", url);
496
506
  return url;
497
507
  }
@@ -470,6 +470,8 @@ export interface AnySpendCollectorClubPurchaseProps extends BaseModalProps {
470
470
  forceFiatPayment?: boolean;
471
471
  /** Staging environment support */
472
472
  isStaging?: boolean;
473
+ /** Optional discount code to apply to the purchase */
474
+ discountCode?: string;
473
475
  }
474
476
  /**
475
477
  * Props for the AnySpend Deposit modal
@@ -69,5 +69,10 @@ export interface AnySpendCollectorClubPurchaseProps {
69
69
  * Force fiat payment
70
70
  */
71
71
  forceFiatPayment?: boolean;
72
+ /**
73
+ * Optional discount code to apply to the purchase.
74
+ * When provided, validates on-chain and adjusts the price accordingly.
75
+ */
76
+ discountCode?: string;
72
77
  }
73
- export declare function AnySpendCollectorClubPurchase({ loadOrder, mode, activeTab, packId, packAmount, pricePerPack, paymentToken, recipientAddress, spenderAddress, isStaging, onSuccess, header, showRecipient, vendingMachineId, packType, forceFiatPayment, }: AnySpendCollectorClubPurchaseProps): import("react/jsx-runtime").JSX.Element;
78
+ export declare function AnySpendCollectorClubPurchase({ loadOrder, mode, activeTab, packId, packAmount, pricePerPack, paymentToken, recipientAddress, spenderAddress, isStaging, onSuccess, header, showRecipient, vendingMachineId, packType, forceFiatPayment, discountCode, }: AnySpendCollectorClubPurchaseProps): import("react/jsx-runtime").JSX.Element;
@@ -82,7 +82,7 @@ export declare function isTestnet(chainId: number): boolean;
82
82
  export declare function getDefaultToken(chainId: number): components["schemas"]["Token"];
83
83
  export declare function getChainName(chainId: number): string;
84
84
  export declare function getCoingeckoName(chainId: number): string | null;
85
- export declare function getPaymentUrl(address: string, amount: bigint, currency: string, chainId: number, decimals?: number): string;
85
+ export declare function getPaymentUrl(address: string, amount: bigint | undefined, currency: string, chainId: number, decimals?: number): string;
86
86
  export declare function getExplorerTxUrl(chainId: number, txHash: string): string;
87
87
  export declare function getExplorerAddressUrl(chainId: number, address: string): string;
88
88
  export declare function getMulticall3Address(chainId: number): string;
@@ -470,6 +470,8 @@ export interface AnySpendCollectorClubPurchaseProps extends BaseModalProps {
470
470
  forceFiatPayment?: boolean;
471
471
  /** Staging environment support */
472
472
  isStaging?: boolean;
473
+ /** Optional discount code to apply to the purchase */
474
+ discountCode?: string;
473
475
  }
474
476
  /**
475
477
  * Props for the AnySpend Deposit modal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@b3dotfun/sdk",
3
- "version": "0.1.65-alpha.2",
3
+ "version": "0.1.65-alpha.4",
4
4
  "source": "src/index.ts",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "react-native": "./dist/cjs/index.native.js",
@@ -27,9 +27,11 @@
27
27
  import { USDC_BASE } from "@b3dotfun/sdk/anyspend/constants";
28
28
  import { components } from "@b3dotfun/sdk/anyspend/types/api";
29
29
  import { GetQuoteResponse } from "@b3dotfun/sdk/anyspend/types/api_req_res";
30
+ import { PUBLIC_BASE_RPC_URL } from "@b3dotfun/sdk/shared/constants";
30
31
  import { formatUnits } from "@b3dotfun/sdk/shared/utils/number";
31
- import React, { useMemo } from "react";
32
- import { encodeFunctionData } from "viem";
32
+ import React, { useEffect, useMemo, useState } from "react";
33
+ import { createPublicClient, encodeFunctionData, http } from "viem";
34
+ import { base } from "viem/chains";
33
35
  import { AnySpendCustom } from "./AnySpendCustom";
34
36
 
35
37
  // Collector Club Shop contract addresses on Base
@@ -50,6 +52,37 @@ const BUY_PACKS_FOR_ABI = {
50
52
  type: "function",
51
53
  } as const;
52
54
 
55
+ // ABI for buyPacksForWithDiscount function (with discount code)
56
+ const BUY_PACKS_FOR_WITH_DISCOUNT_ABI = {
57
+ inputs: [
58
+ { internalType: "address", name: "user", type: "address" },
59
+ { internalType: "uint256", name: "packId", type: "uint256" },
60
+ { internalType: "uint256", name: "amount", type: "uint256" },
61
+ { internalType: "string", name: "discountCode", type: "string" },
62
+ ],
63
+ name: "buyPacksForWithDiscount",
64
+ outputs: [],
65
+ stateMutability: "nonpayable",
66
+ type: "function",
67
+ } as const;
68
+
69
+ // ABI for isDiscountCodeValid view function
70
+ const IS_DISCOUNT_CODE_VALID_ABI = {
71
+ inputs: [{ internalType: "string", name: "code", type: "string" }],
72
+ name: "isDiscountCodeValid",
73
+ outputs: [
74
+ { internalType: "bool", name: "isValid", type: "bool" },
75
+ { internalType: "uint256", name: "discountAmount", type: "uint256" },
76
+ ],
77
+ stateMutability: "view",
78
+ type: "function",
79
+ } as const;
80
+
81
+ const basePublicClient = createPublicClient({
82
+ chain: base,
83
+ transport: http(PUBLIC_BASE_RPC_URL),
84
+ });
85
+
53
86
  export interface AnySpendCollectorClubPurchaseProps {
54
87
  /**
55
88
  * Optional order ID to load existing order
@@ -118,6 +151,11 @@ export interface AnySpendCollectorClubPurchaseProps {
118
151
  * Force fiat payment
119
152
  */
120
153
  forceFiatPayment?: boolean;
154
+ /**
155
+ * Optional discount code to apply to the purchase.
156
+ * When provided, validates on-chain and adjusts the price accordingly.
157
+ */
158
+ discountCode?: string;
121
159
  }
122
160
 
123
161
  export function AnySpendCollectorClubPurchase({
@@ -137,6 +175,7 @@ export function AnySpendCollectorClubPurchase({
137
175
  vendingMachineId,
138
176
  packType,
139
177
  forceFiatPayment,
178
+ discountCode,
140
179
  }: AnySpendCollectorClubPurchaseProps) {
141
180
  const ccShopAddress = isStaging ? CC_SHOP_ADDRESS_STAGING : CC_SHOP_ADDRESS;
142
181
 
@@ -150,25 +189,117 @@ export function AnySpendCollectorClubPurchase({
150
189
  }
151
190
  }, [pricePerPack, packAmount]);
152
191
 
153
- // Calculate fiat amount (totalAmount in USD, assuming USDC with 6 decimals)
192
+ // Discount code validation state
193
+ const [discountInfo, setDiscountInfo] = useState<{
194
+ isValid: boolean;
195
+ discountAmount: bigint;
196
+ isLoading: boolean;
197
+ error: string | null;
198
+ }>({
199
+ isValid: false,
200
+ discountAmount: BigInt(0),
201
+ isLoading: false,
202
+ error: null,
203
+ });
204
+
205
+ // Validate discount code on-chain when provided
206
+ useEffect(() => {
207
+ if (!discountCode) {
208
+ setDiscountInfo({ isValid: false, discountAmount: BigInt(0), isLoading: false, error: null });
209
+ return;
210
+ }
211
+
212
+ let cancelled = false;
213
+
214
+ const validateDiscount = async () => {
215
+ setDiscountInfo(prev => ({ ...prev, isLoading: true, error: null }));
216
+
217
+ try {
218
+ const result = await basePublicClient.readContract({
219
+ address: ccShopAddress as `0x${string}`,
220
+ abi: [IS_DISCOUNT_CODE_VALID_ABI],
221
+ functionName: "isDiscountCodeValid",
222
+ args: [discountCode],
223
+ });
224
+
225
+ if (cancelled) return;
226
+
227
+ const [isValid, discountAmount] = result;
228
+
229
+ if (!isValid) {
230
+ setDiscountInfo({
231
+ isValid: false,
232
+ discountAmount: BigInt(0),
233
+ isLoading: false,
234
+ error: "Invalid or expired discount code",
235
+ });
236
+ return;
237
+ }
238
+
239
+ setDiscountInfo({ isValid: true, discountAmount, isLoading: false, error: null });
240
+ } catch (error) {
241
+ if (cancelled) return;
242
+ console.error("Failed to validate discount code", { discountCode, error });
243
+ setDiscountInfo({
244
+ isValid: false,
245
+ discountAmount: BigInt(0),
246
+ isLoading: false,
247
+ error: "Failed to validate discount code",
248
+ });
249
+ }
250
+ };
251
+
252
+ validateDiscount();
253
+
254
+ return () => {
255
+ cancelled = true;
256
+ };
257
+ }, [discountCode, ccShopAddress]);
258
+
259
+ // Calculate effective dstAmount after discount
260
+ const effectiveDstAmount = useMemo(() => {
261
+ if (!discountCode || !discountInfo.isValid || discountInfo.discountAmount === BigInt(0)) {
262
+ return totalAmount;
263
+ }
264
+
265
+ const total = BigInt(totalAmount);
266
+ const discount = discountInfo.discountAmount;
267
+
268
+ if (discount >= total) {
269
+ console.error("Discount exceeds total price", { totalAmount, discountAmount: discount.toString() });
270
+ return "0";
271
+ }
272
+
273
+ return (total - discount).toString();
274
+ }, [totalAmount, discountCode, discountInfo.isValid, discountInfo.discountAmount]);
275
+
276
+ // Calculate fiat amount (effectiveDstAmount in USD, assuming USDC with 6 decimals)
154
277
  const srcFiatAmount = useMemo(() => {
155
- if (!totalAmount || totalAmount === "0") return "0";
156
- return formatUnits(totalAmount, USDC_BASE.decimals);
157
- }, [totalAmount]);
278
+ if (!effectiveDstAmount || effectiveDstAmount === "0") return "0";
279
+ return formatUnits(effectiveDstAmount, USDC_BASE.decimals);
280
+ }, [effectiveDstAmount]);
158
281
 
159
- // Encode the buyPacksFor function call
282
+ // Encode the contract function call (with or without discount)
160
283
  const encodedData = useMemo(() => {
161
284
  try {
285
+ if (discountCode && discountInfo.isValid) {
286
+ return encodeFunctionData({
287
+ abi: [BUY_PACKS_FOR_WITH_DISCOUNT_ABI],
288
+ functionName: "buyPacksForWithDiscount",
289
+ args: [recipientAddress as `0x${string}`, BigInt(packId), BigInt(packAmount), discountCode],
290
+ });
291
+ }
292
+
162
293
  return encodeFunctionData({
163
294
  abi: [BUY_PACKS_FOR_ABI],
164
295
  functionName: "buyPacksFor",
165
296
  args: [recipientAddress as `0x${string}`, BigInt(packId), BigInt(packAmount)],
166
297
  });
167
298
  } catch (error) {
168
- console.error("Failed to encode function data", { recipientAddress, packId, packAmount, error });
299
+ console.error("Failed to encode function data", { recipientAddress, packId, packAmount, discountCode, error });
169
300
  return "0x";
170
301
  }
171
- }, [recipientAddress, packId, packAmount]);
302
+ }, [recipientAddress, packId, packAmount, discountCode, discountInfo.isValid]);
172
303
 
173
304
  // Default header if not provided
174
305
  const defaultHeader = () => (
@@ -182,6 +313,31 @@ export function AnySpendCollectorClubPurchase({
182
313
  </div>
183
314
  );
184
315
 
316
+ // Don't render AnySpendCustom while discount is being validated (avoids showing wrong price)
317
+ if (discountCode && discountInfo.isLoading) {
318
+ return (
319
+ <div className="mb-4 flex flex-col items-center gap-3 text-center">
320
+ <p className="text-as-secondary text-sm">Validating discount code...</p>
321
+ </div>
322
+ );
323
+ }
324
+
325
+ if (discountCode && discountInfo.error) {
326
+ return (
327
+ <div className="mb-4 flex flex-col items-center gap-3 text-center">
328
+ <p className="text-sm text-red-500">{discountInfo.error}</p>
329
+ </div>
330
+ );
331
+ }
332
+
333
+ if (discountCode && discountInfo.isValid && effectiveDstAmount === "0") {
334
+ return (
335
+ <div className="mb-4 flex flex-col items-center gap-3 text-center">
336
+ <p className="text-sm text-red-500">Discount exceeds total price</p>
337
+ </div>
338
+ );
339
+ }
340
+
185
341
  return (
186
342
  <AnySpendCustom
187
343
  loadOrder={loadOrder}
@@ -192,7 +348,7 @@ export function AnySpendCollectorClubPurchase({
192
348
  orderType="custom"
193
349
  dstChainId={BASE_CHAIN_ID}
194
350
  dstToken={paymentToken}
195
- dstAmount={totalAmount}
351
+ dstAmount={effectiveDstAmount}
196
352
  contractAddress={ccShopAddress}
197
353
  encodedData={encodedData}
198
354
  metadata={{
@@ -201,6 +357,9 @@ export function AnySpendCollectorClubPurchase({
201
357
  pricePerPack,
202
358
  vendingMachineId,
203
359
  packType,
360
+ ...(discountCode && discountInfo.isValid
361
+ ? { discountCode, discountAmount: discountInfo.discountAmount.toString() }
362
+ : {}),
204
363
  }}
205
364
  header={header || defaultHeader}
206
365
  onSuccess={onSuccess}
@@ -1,4 +1,10 @@
1
- import { ALL_CHAINS, getAvailableChainIds, isSameChainAndToken } from "@b3dotfun/sdk/anyspend";
1
+ import {
2
+ ALL_CHAINS,
3
+ getAvailableChainIds,
4
+ getPaymentUrl,
5
+ isSameChainAndToken,
6
+ ZERO_ADDRESS,
7
+ } from "@b3dotfun/sdk/anyspend";
2
8
  import { components } from "@b3dotfun/sdk/anyspend/types/api";
3
9
  import { Button, toast } from "@b3dotfun/sdk/global-account/react";
4
10
  import { cn } from "@b3dotfun/sdk/shared/utils/cn";
@@ -194,6 +200,15 @@ export function QRDeposit({
194
200
  // For pure transfers, always use recipient address; for orders, use global address
195
201
  const displayAddress = isPureTransfer ? recipientAddress : globalAddress || recipientAddress;
196
202
 
203
+ // Generate EIP-681 payment URI for the QR code so wallets know which chain/token to use
204
+ const qrValue = getPaymentUrl(
205
+ displayAddress,
206
+ undefined,
207
+ sourceToken.address === ZERO_ADDRESS ? "ETH" : sourceToken.address,
208
+ sourceChainId,
209
+ sourceToken.decimals,
210
+ );
211
+
197
212
  const handleCopyAddress = async () => {
198
213
  if (displayAddress) {
199
214
  await navigator.clipboard.writeText(displayAddress);
@@ -376,7 +391,7 @@ export function QRDeposit({
376
391
  {/* QR Code */}
377
392
  <div className={classes?.qrCodeContainer || "anyspend-qr-code-container flex flex-col items-center gap-2"}>
378
393
  <div className={classes?.qrCode || "anyspend-qr-code rounded-lg bg-white p-2"}>
379
- <QRCodeSVG value={displayAddress} size={120} level="M" marginSize={0} />
394
+ <QRCodeSVG value={qrValue} size={120} level="M" marginSize={0} />
380
395
  </div>
381
396
  <span className={classes?.qrScanHint || "anyspend-qr-scan-hint text-as-secondary text-xs"}>
382
397
  SCAN WITH <span className="inline-block">🦊</span>