diff --git a/docs/README.md b/docs/README.md index e0cf1965..c1668bd3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1127,6 +1127,35 @@ donationRate = 10 ``` +## token-id + +> **The ‘tokenId’ parameter defines the eToken that will be used in the button or widget.** + +?> The token ID parameter is optional. It accepts a string containing the token ID. Default value is null. + + +**Example:** + + +#### **HTML** + +```html +token-id="c67bf5c2b6d91cfb46a5c1772582eff80d88686887be10aa63b0945479cf4ed4" +``` + +#### **JavaScript** + +```javascript +tokenId: "c67bf5c2b6d91cfb46a5c1772582eff80d88686887be10aa63b0945479cf4ed4" +``` + +#### **React** + +```react +tokenId = "c67bf5c2b6d91cfb46a5c1772582eff80d88686887be10aa63b0945479cf4ed4" +``` + + # Contribute PayButton is a community-driven open-source initiative. Contributions from the community are _crucial_ to the success of the project. diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 940e9fdb..814af78b 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -34,6 +34,8 @@ - [disable-sound](/?id=disable-sound) - [size](/?id=size) - [donation-rate](/?id=donation-rate) + - [token-id](/?id=token-id) + - [Contribute](/?id=contribute) - [Developer Quick Start](/?id=developer-quick-start) diff --git a/docs/zh-cn/README.md b/docs/zh-cn/README.md index 2e5addaa..fedca865 100644 --- a/docs/zh-cn/README.md +++ b/docs/zh-cn/README.md @@ -1126,6 +1126,36 @@ donationRate = 10 ``` +## token-id + +> **「tokenId」 参数用于定义在按钮或小组件中使用的 eToken。** + +?> tokenId 参数是可选的。它接受一个包含 token ID 的字符串。默认值为 null。 + + +**Example:** + + +#### **HTML** + +```html +token-id="c67bf5c2b6d91cfb46a5c1772582eff80d88686887be10aa63b0945479cf4ed4" +``` + +#### **JavaScript** + +```javascript +tokenId: "c67bf5c2b6d91cfb46a5c1772582eff80d88686887be10aa63b0945479cf4ed4" +``` + +#### **React** + +```react +tokenId = "c67bf5c2b6d91cfb46a5c1772582eff80d88686887be10aa63b0945479cf4ed4" +``` + + + # 贡献 diff --git a/docs/zh-cn/_sidebar.md b/docs/zh-cn/_sidebar.md index 6acc45ce..12556353 100644 --- a/docs/zh-cn/_sidebar.md +++ b/docs/zh-cn/_sidebar.md @@ -33,6 +33,8 @@ - [disable-sound](/zh-cn/?id=disable-sound) - [size](/zh-cn/?id=size) - [donation-rate](/zh-cn/?id=donation-rate) + - [token-id](/zh-cn/?id=token-id) + - [贡献](/zh-cn/?id=贡献) - [开发人员快速入门](/zh-cn/?id=开发人员快速入门) - [入门](/zh-cn/?id=入门) diff --git a/docs/zh-tw/README.md b/docs/zh-tw/README.md index c93271f1..e65e4af5 100644 --- a/docs/zh-tw/README.md +++ b/docs/zh-tw/README.md @@ -1122,6 +1122,37 @@ donationRate = 10 ``` +## token-id + +> **「tokenId」 參數用於定義在按鈕或小工具中使用的 eToken。** + +?> tokenId 參數為選填。它接受一個包含 token ID 的字串。預設值為 null。 + + +**Example:** + + +#### **HTML** + +```html +token-id="c67bf5c2b6d91cfb46a5c1772582eff80d88686887be10aa63b0945479cf4ed4" +``` + +#### **JavaScript** + +```javascript +tokenId: "c67bf5c2b6d91cfb46a5c1772582eff80d88686887be10aa63b0945479cf4ed4" +``` + +#### **React** + +```react +tokenId = "c67bf5c2b6d91cfb46a5c1772582eff80d88686887be10aa63b0945479cf4ed4" +``` + + + + # 貢獻 diff --git a/docs/zh-tw/_sidebar.md b/docs/zh-tw/_sidebar.md index 8bda09ed..39ceda84 100644 --- a/docs/zh-tw/_sidebar.md +++ b/docs/zh-tw/_sidebar.md @@ -32,6 +32,9 @@ - [auto-close](/zh-tw/?id=auto-close) - [disable-sound](/zh-tw/?id=disable-sound) - [size](/zh-tw/?id=size) + - [donation-rate](/zh-tw/?id=donation-rate) + - [token-id](/zh-tw/?id=token-id) + - [貢獻](/zh-tw/?id=貢獻) - [開發人員快速入門](/zh-tw/?id=開發人員快速入門) - [入門](/zh-tw/?id=入門) diff --git a/paybutton/src/index.tsx b/paybutton/src/index.tsx index d6d911bc..56a056c7 100644 --- a/paybutton/src/index.tsx +++ b/paybutton/src/index.tsx @@ -106,6 +106,7 @@ const allowedProps = [ 'transactionText', 'size', 'donationRate', + 'tokenId' ]; const requiredProps = [ diff --git a/react/lib/components/PayButton/PayButton.tsx b/react/lib/components/PayButton/PayButton.tsx index f8c83981..be7684f8 100644 --- a/react/lib/components/PayButton/PayButton.tsx +++ b/react/lib/components/PayButton/PayButton.tsx @@ -59,6 +59,7 @@ export interface PayButtonProps extends ButtonProps { sizeScaleAlreadyApplied?: boolean; donationAddress?: string; donationRate?: number; + tokenId?: string; } export const PayButton = ({ @@ -92,7 +93,8 @@ export const PayButton = ({ size = 'md', sizeScaleAlreadyApplied = false, donationRate = DEFAULT_DONATION_RATE, - donationAddress = config.donationAddress + donationAddress = config.donationAddress, + tokenId, }: PayButtonProps): React.ReactElement => { const [dialogOpen, setDialogOpen] = useState(false); const [disabled, setDisabled] = useState(false); @@ -310,7 +312,8 @@ export const PayButton = ({ expectedPaymentId: paymentId, currencyObj, donationRate - } + }, + tokenId, }) } if (altpaymentSocket === undefined && useAltpayment) { @@ -466,6 +469,7 @@ export const PayButton = ({ donationRate={donationRate} convertedCurrencyObj={convertedCurrencyObj} setConvertedCurrencyObj={setConvertedCurrencyObj} + tokenId={tokenId} /> {errorMsg && (

{ const [success, setSuccess] = useState(false); const [internalDisabled, setInternalDisabled] = useState(false); @@ -259,6 +261,7 @@ export const PaymentDialog = ({ donationRate={donationRate} convertedCurrencyObj={convertedCurrencyObj} setConvertedCurrencyObj={setConvertedCurrencyObj} + tokenId={tokenId} foot={success && ( = props => { donationRate = DEFAULT_DONATION_RATE, setConvertedCurrencyObj = () => {}, setPaymentId, + tokenId, } = props; const [loading, setLoading] = useState(true); const [draftAmount, setDraftAmount] = useState("") @@ -331,6 +334,7 @@ export const Widget: React.FunctionComponent = props => { const [thisAmount, setThisAmount] = useState(props.amount) const [thisCurrencyObject, setThisCurrencyObject] = useState(props.currencyObject) + const [tokenName, setTokenName] = useState(null) const blurCSS = isPropsTrue(disabled) ? { filter: 'blur(5px)' } : {} // inject keyframes once (replacement for @global in makeStyles) @@ -500,6 +504,10 @@ export const Widget: React.FunctionComponent = props => { )}' stroke='%23fff' stroke-width='.6'/%3E%3Cpath d='m7.2979 14.697-2.6964-2.6966 0.89292-0.8934c0.49111-0.49137 0.90364-0.88958 0.91675-0.88491 0.013104 0.0047 0.71923 0.69866 1.5692 1.5422 0.84994 0.84354 1.6548 1.6397 1.7886 1.7692l0.24322 0.23547 7.5834-7.5832 1.8033 1.8033-9.4045 9.4045z' fill='%23fff' stroke-width='.033708'/%3E%3C/svg%3E%0A` }, [theme]) + const getTokenIconUrl = useCallback((tokenId: string): string => { + return `https://icons.etokens.cash/128/${tokenId}.png` + }, []) + useEffect(() => { if (thisCurrencyObject?.string !== undefined) { const raw = stripFormatting(thisCurrencyObject.string); @@ -541,6 +549,7 @@ export const Widget: React.FunctionComponent = props => { wsBaseUrl, setTxsSocket: setThisTxsSocket, setNewTxs: setThisNewTxs, + tokenId, }) if (thisUseAltpayment) { await setupAltpaymentSocket({ @@ -589,6 +598,26 @@ export const Widget: React.FunctionComponent = props => { })() }, [thisNewTxs, to, apiBaseUrl]) + useEffect(() => { + ;(async (): Promise => { + if (tokenId && tokenId !== '') { + try { + const tokenInfo = await getTokenInfo(tokenId, to) + const name = tokenInfo.genesisInfo.tokenTicker ?? null + setTokenName(name) + } catch (err) { + console.error('Failed to fetch token info:', err) + setTokenName(null) + setErrorMsg('Unable to load token information') + } finally { + setLoading(false) + } + return + } + setLoading(false) + })() + }, [tokenId, to]) + useEffect(() => { if ( isChild || @@ -805,7 +834,7 @@ export const Widget: React.FunctionComponent = props => { setText( `Send ${amountToDisplay} ${thisCurrencyObject.currency} = ${convertedAmountToDisplay} ${thisAddressType}`, ) - const url = resolveUrl(thisAddressType, convertedObj.float) + const url = resolveUrl(thisAddressType, convertedObj.float, tokenId) setUrl(url ?? "") } } else { @@ -830,16 +859,16 @@ export const Widget: React.FunctionComponent = props => { amountToDisplay = amountWithDonationObj.string } - setText(`Send ${amountToDisplay} ${cur}`) + setText(`Send ${amountToDisplay} ${tokenId ? tokenName : cur}`) // Pass base amount (without donation) to resolveUrl - nextUrl = resolveUrl(cur, baseAmount) + nextUrl = resolveUrl(cur, baseAmount, tokenId) } else { - setText(`Send any amount of ${thisAddressType}`) - nextUrl = resolveUrl(thisAddressType) + setText(`Send any amount of ${tokenId ? tokenName : thisAddressType}`) + nextUrl = resolveUrl(thisAddressType, undefined, tokenId) } setUrl(nextUrl ?? '') } - }, [to, thisCurrencyObject, price, thisAmount, opReturn, hasPrice, isCashtabAvailable, userDonationRate, donationEnabled, disabled, donationAddress, currency, randomSatoshis, thisAddressType, shouldApplyDonation]) + }, [to, thisCurrencyObject, price, thisAmount, opReturn, hasPrice, isCashtabAvailable, userDonationRate, donationEnabled, disabled, donationAddress, currency, randomSatoshis, thisAddressType, shouldApplyDonation, tokenName, tokenId]) useEffect(() => { try { @@ -961,7 +990,7 @@ export const Widget: React.FunctionComponent = props => { setRecentlyCopied(true) }, [disabled, to, url, setCopied, setRecentlyCopied, qrLoading]) - const resolveUrl = useCallback((currency: string, amount?: number) => { + const resolveUrl = useCallback((currency: string, amount?: number, tokenId?: string) => { if (disabled || !to) return; const prefix = CURRENCY_PREFIXES_MAP[currency.toLowerCase() as typeof CRYPTO_CURRENCIES[number]]; @@ -978,11 +1007,18 @@ export const Widget: React.FunctionComponent = props => { const donationPercent = userDonationRate / 100 // Calculate donation amount from base amount const thisDonationAmount = amount * donationPercent - - thisUrl += `?amount=${amount}` - thisUrl += `&addr=${donationAddress}&amount=${thisDonationAmount.toFixed(decimals)}`; + if (tokenId) { + thisUrl += `?token_decimalized_qty=${amount}` + } else { + thisUrl += `?amount=${amount}` + thisUrl += `&addr=${donationAddress}&amount=${thisDonationAmount.toFixed(decimals)}`; + } } else { - thisUrl += `?amount=${amount}` + if (tokenId) { + thisUrl += `?token_decimalized_qty=${amount}` + } else { + thisUrl += `?amount=${amount}` + } } } @@ -991,9 +1027,14 @@ export const Widget: React.FunctionComponent = props => { thisUrl += `${separator}op_return_raw=${opReturn}`; } + if (tokenId) { + const separator = thisUrl.includes('?') ? '&' : '?'; + thisUrl += `${separator}token_id=${tokenId}`; + } + return thisUrl; }, - [disabled, to, opReturn, userDonationRate, donationAddress, donationEnabled, shouldApplyDonation] + [disabled, to, opReturn, userDonationRate, donationAddress, donationEnabled, shouldApplyDonation, tokenId] ) const stripFormatting = (s: string) => { return s.replace(/,/g, '').replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, ''); @@ -1033,6 +1074,7 @@ export const Widget: React.FunctionComponent = props => { } } + const qrCode = ( = props => { bgColor={isDarkMode ? '#1a1a1a' : '#ffffff'} fgColor={theme.palette.tertiary as unknown as string} imageSettings={{ - src: success ? checkSvg : isValidCashAddress(to) ? bchSvg : xecSvg, + src: success ? checkSvg : tokenId ? getTokenIconUrl(tokenId) : isValidCashAddress(to) ? bchSvg : xecSvg, excavate: false, height: 112, width: 112, diff --git a/react/lib/util/chronik.ts b/react/lib/util/chronik.ts index 7714d257..8ae51662 100644 --- a/react/lib/util/chronik.ts +++ b/react/lib/util/chronik.ts @@ -113,6 +113,27 @@ export async function satoshisToUnit(satoshis: bigint, networkFormat: string): P throw new Error('[CHRONIK]: Invalid address') } +const getTokenAmount = async (transaction: Tx, tokenId: string, address: string, networkSlug: string): Promise => { + let totalTokenOutput = BigInt(0); + const tokenInfo = await getTokenInfo(tokenId, address); + const decimals = tokenInfo.genesisInfo.decimals; + const divisor = 10 ** decimals; + + for (const output of transaction.outputs) { + if (output.token?.tokenId === tokenId) { + const outputAddress = outputScriptToAddress(networkSlug, output.outputScript) + + if(outputAddress === address) { + const atoms = BigInt(output.token.atoms); + + totalTokenOutput += atoms / BigInt(divisor); + } + } + } + + return totalTokenOutput.toString(); +} + const getTransactionAmountAndData = async (transaction: Tx, addressString: string): Promise<{amount: string, opReturn: string}> => { let totalOutput = BigInt(0); let totalInput = BigInt(0); @@ -145,14 +166,15 @@ const getTransactionAmountAndData = async (transaction: Tx, addressString: stri } } -const getTransactionFromChronikTransaction = async (transaction: Tx, address: string): Promise => { +const getTransactionFromChronikTransaction = async (transaction: Tx, address: string, tokenId?: string): Promise => { const { amount, opReturn } = await getTransactionAmountAndData(transaction, address) const parsedOpReturn = resolveOpReturn(opReturn) const networkSlug = getAddressPrefix(address) const inputAddresses = getSortedInputAddresses(networkSlug, transaction) + const tokenAmount = tokenId ? await getTokenAmount(transaction, tokenId, address, networkSlug) : undefined; return { hash: transaction.txid, - amount, + amount: tokenId ? tokenAmount! : amount, address, timestamp: transaction.block !== undefined ? transaction.block.timestamp : transaction.timeFirstSeen, confirmed: transaction.block !== undefined, @@ -236,12 +258,25 @@ function getSortedInputAddresses (networkSlug: string, transaction: Tx): string[ return sortedInputAddresses } +export const getTokenInfo = async (tokenId: string, address:string) => { + const networkSlug = getAddressPrefix(address) + const blockchainUrls = config.networkBlockchainURLs[networkSlug]; + + const chronik = await ChronikClient.useStrategy( + ConnectionStrategy.AsOrdered, + blockchainUrls, + ); + const tokenInfo = await chronik.token(tokenId); + + return tokenInfo; +} export const parseWebsocketMessage = async ( wsMsg: any, setNewTx: Function, chronik: ChronikClient, - address: string + address: string, + tokenId?: string ): Promise => { const { type } = wsMsg; if (type === 'Error') { @@ -252,8 +287,7 @@ export const parseWebsocketMessage = async ( case 'TX_ADDED_TO_MEMPOOL': { const rawTransaction = await chronik.tx(wsMsg.txid); - const transaction = await getTransactionFromChronikTransaction(rawTransaction, address ?? '') - + const transaction = await getTransactionFromChronikTransaction(rawTransaction, address ?? '', tokenId) setNewTx([transaction]); break; } @@ -264,7 +298,8 @@ export const parseWebsocketMessage = async ( export const initializeChronikWebsocket = async ( address: string, - setNewTx: Function + setNewTx: Function, + tokenId?: string ): Promise => { const networkSlug = getAddressPrefix(address) const blockchainUrls = config.networkBlockchainURLs[networkSlug]; @@ -279,7 +314,8 @@ export const initializeChronikWebsocket = async ( msg, setNewTx, chronik, - address + address, + tokenId ); }, }); diff --git a/react/lib/util/socket.ts b/react/lib/util/socket.ts index 2961ff3b..7ebf4886 100644 --- a/react/lib/util/socket.ts +++ b/react/lib/util/socket.ts @@ -122,6 +122,7 @@ interface SetupTxsSocketParams { setNewTxs: Function setDialogOpen?: Function checkSuccessInfo?: CheckSuccessInfo + tokenId?: string } export const setupTxsSocket = async (params: SetupTxsSocketParams): Promise => { @@ -145,9 +146,13 @@ export const setupChronikWebSocket = async (params: SetupTxsSocketParams): Promi params.setTxsSocket(undefined); } - const newChronikSocket = await initializeChronikWebsocket(params.address, (transactions: Transaction[]) => { - params.setNewTxs(transactions); - }); + const newChronikSocket = await initializeChronikWebsocket( + params.address, + (transactions: Transaction[]) => { + params.setNewTxs(transactions); + }, + params.tokenId + ); params.setTxsSocket(newChronikSocket); }