anza-xyz / solana-pay

A new standard for decentralized payments.
https://solanapay.com
Apache License 2.0
1.29k stars 450 forks source link

Bug Report: Unable to Verify Payments Made in USDC #239

Open cSarcasme opened 1 month ago

cSarcasme commented 1 month ago

Description: When using Solana Pay to process payments in USDC, although the payment is successfully completed and the transaction appears on the blockchain, it is impossible to verify the transaction using the validateTransfer method. Verification works perfectly for payments made in SOL.

Affected Components: Client component that handles QR code generation and transaction verification API route that generates the QR code and initiates the payment API route that verifies the transaction Steps to Reproduce:

Select USDC as the payment currency. Generate the QR code and make a payment using the generated QR code. Attempt to verify the payment using the "Verify Transaction" button. Expected Behavior: The payment in USDC should be verified just like payments made in SOL.

Actual Behavior: The verification process fails for USDC payments, showing a "Transaction not found" error.

Client Code:

'use client';

import Image from 'next/image';
import { useState, useEffect } from 'react';
import { useWallet } from '@solana/wallet-adapter-react';
import { createQR } from '@solana/pay';
import {
    createRTECurrency,
    getPriceForProductConvertion,
} from '@/app/product/components/ProductApi';
import Loading from '@/app/(protected)/loading';

interface Props {
    totalPrice: number;
}

export default function ProductPaymentWalletClient({ totalPrice }: Props) {
    const [qrCode, setQrCode] = useState<string | null>(null);
    const [reference, setReference] = useState<string | null>(null);
    const [selectedCurrency, setSelectedCurrency] = useState<string | null>(null);
    const [price, setPrice] = useState<number | null>(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        async function fetchPrice() {
            if (selectedCurrency && selectedCurrency !== 'Select the currency you need!') {
                const price = await getPriceForProductConvertion(
                    totalPrice,
                    selectedCurrency.toLowerCase()
                );
                setPrice(price);
            }
        }
        fetchPrice();
        setLoading(false);
    }, [selectedCurrency, totalPrice]);

    async function generateQRCode() {
        const res = await fetch('/api/productrtepay', { method: 'POST' });
        const { url, ref } = await res.json();
        console.log(url, 'ref', ref);

        const qr = createQR(url);
        const qrBlob = await qr.getRawData('png');
        if (!qrBlob) return;

        const reader = new FileReader();
        reader.onload = (event) => {
            if (typeof event.target?.result === 'string') {
                setQrCode(event.target.result);
            }
        };
        reader.readAsDataURL(qrBlob);

        setReference(ref);
        console.log('reference', ref);
    }

    const handleVerifyClick = async () => {
        const alertMessageNotFound = document.getElementById('alertMessageProductRTE');
        const alertTextNotFound = document.getElementById('alertTextProductRTE');

        if (!reference) {
            if (alertMessageNotFound && alertTextNotFound) {
                alertMessageNotFound.style.display = 'block';
                alertTextNotFound.innerText = 'Please generate a payment order first!';
                alertMessageNotFound.classList.add('alert-warning');

                setTimeout(() => {
                    alertMessageNotFound.style.display = 'none';
                    alertMessageNotFound.classList.remove('alert-warning');
                }, 5000);
            }
            return;
        }

        const res = await fetch(`/api/productrteverifypayment?reference=${reference}`);
        const { message, status } = await res.json();
        console.log('status', status);

        const alertMessageVerified = document.getElementById(
            'alertMessagetransactionRTEValidate'
        );
        const alertTextVerified = document.getElementById(
            'alertTexttransactionRTEValidate'
        );
        if (status === 'verified') {
            window.location.reload();
        } else {
            if (alertMessageNotFound && alertTextNotFound) {
                alertMessageNotFound.style.display = 'block';
                alertTextNotFound.innerText = 'Transaction not found';
                alertMessageNotFound.classList.add('alert-danger');

                setTimeout(() => {
                    alertMessageNotFound.style.display = 'none';
                    alertMessageNotFound.classList.remove('alert-danger');
                }, 5000);
            }
        }
    };

    const handleCurrencyChange = async (
        event: React.ChangeEvent<HTMLSelectElement>
    ) => {
        const selectedCurrency = event.target.value;
        setSelectedCurrency(selectedCurrency);

        if (selectedCurrency === 'Select the currency you need!') {
            setQrCode(null);
            setReference(null);
            setPrice(null);
            return;
        }

        await createRTECurrency(selectedCurrency);

        await generateQRCode();
    };

    if (loading) {
        return <Loading />;
    }

    return (
        <>
            <article
                id='productSelectCurrency'
                className=''
            >
                <div className='row'>
                    <div className='col-12 mt-3 mb-3'>
                        <form
                            className='p-3 bg-purchase'
                            id='buyProductRTE'
                        >
                            <>
                                <div
                                    id='ProductPaymentConnexion'
                                    className='fw-light'
                                >
                                    <span className='text-muted'>Select the currency:</span>
                                    <select
                                        className='form-select form-select-sm mt-1'
                                        value={selectedCurrency || ''}
                                        onChange={handleCurrencyChange}
                                    >
                                        <option>Select the currency you need!</option>
                                        <option value='solana'>SOL</option>
                                        <option value='USDC'>USDC</option>
                                    </select>
                                </div>
                                {price !== null && (
                                    <p className='mt-3 fw-light text-muted small'>
                                        Price: {price}
                                        <span className='ms-2'>
                                            {selectedCurrency === 'solana' ? 'SOL' : selectedCurrency}
                                        </span>
                                    </p>
                                )}
                            </>
                        </form>
                    </div>
                </div>
            </article>
            {qrCode && (
                <>
                    <hr className='text-info' />
                    <article id='ProductRTEPay mt-3'>
                        <p className='text-center text-muted fw-light small my-3 px-3'>
                            Scan the QR code with your wallet and send the required payment. Your
                            product task will be added to our dashboard once the payment is
                            received.
                        </p>
                        <hr className='text-info' />
                        <div className='row mt-4'>
                            <div className='col-12'>
                                <div
                                    className='alert  alert-dismissible fade show'
                                    id='alertMessageProductRTE'
                                    role='alert'
                                    style={{ display: 'none' }}
                                >
                                    <span id='alertTextProductRTE'></span>
                                    <button
                                        type='button'
                                        className='btn-close'
                                        data-bs-dismiss='alert'
                                    ></button>
                                </div>
                            </div>
                            <div className='col-12 col-md-6 text-center d-flex align-items-center justify-content-center'>
                                <div>
                                    {qrCode && (
                                        <Image
                                            src={qrCode}
                                            alt='QR Code'
                                            width={150}
                                            height={150}
                                            priority
                                        />
                                    )}
                                </div>
                            </div>
                            <div className='col-12 col-md-6 text-center d-flex flex-column align-items-center justify-content-center'>
                                {reference && (
                                    <button
                                        className='btn btn-info fw-light ms-3'
                                        onClick={handleVerifyClick}
                                    >
                                        Verify Transaction
                                    </button>
                                )}
                            </div>
                        </div>
                    </article>
                </>
            )}

            <div className='mt-3 text-center'></div>
        </>
    );
}

API Route for Generating QR Code and Payment:

import { NextRequest, NextResponse } from 'next/server';
import { Keypair, PublicKey } from '@solana/web3.js';
import { encodeURL } from '@solana/pay';
import BigNumber from 'bignumber.js';
import { cookies } from 'next/headers';
import {
    getPriceForProductConvertion,
    createPaymentRequestsCookie,
} from '@/app/product/components/ProductApi';

const myWallet = process.env.RECIPIENT_WALLET!;
const recipient = new PublicKey(myWallet as string);
let amount = new BigNumber(0);
const label = 'OPALES SOCIALS HUB ';
let memo = '';
const usdcTokenAdress = new PublicKey(process.env.TOKEN_ADRESS_USDC!);
let isSPLToken: boolean = false;

async function initializeAmount() {
    const cookieStore = cookies();
    const productRTE = cookieStore.get('productRTE');
    const productRTECookie = productRTE?.value;
    const productRTECurrency = cookieStore.get('productRTECurrency');
    const productRTECurrencyCookie = productRTECurrency?.value;
    let selectedProduct: string = 'retweets' || 'citation';
    let productPrice: number;

    const productPrices: { [key: string]: number } = {
        retweets: 3,
        citation: 17,
    };

    const optionPrices: { [key: string]: number } = {
        like: 1,
        comment: 2,
    };

    if (productRTECookie === 'retweets') {
        selectedProduct = 'retweets';
        productPrice = productPrices[selectedProduct];
        const price = await getPriceForProductConvertion(
            productPrice,
            productRTECurrencyCookie!
        );
        amount = new BigNumber(price);
        memo = 'retweets';
        console.log('amount', amount);
    } else if (productRTECookie === 'retweetsLike') {
        selectedProduct = 'retweets';
        productPrice = productPrices[selectedProduct] + optionPrices.like;
        const price = await getPriceForProductConvertion(
            productPrices[selectedProduct],
            productRTECurrencyCookie!
        );
        amount = new BigNumber(price);
        memo = 'retweets with likes';
        console.log('amount', amount);
    } else if (productRTECookie === 'retweetsComments') {
        selectedProduct = 'retweets';
        productPrice = productPrices[selectedProduct] + optionPrices.comment;
        const price = await getPriceForProductConvertion(
            productPrice,
            productRTECurrencyCookie!
        );
        amount = new BigNumber(price);
        memo = 'retweets with comments';
        console.log('amount', amount);
    } else if (productRTECookie === 'retweetsAll') {
        selectedProduct = 'retweets';
        productPrice =
            productPrices[selectedProduct] + optionPrices.like + optionPrices.comment;

        const price = await getPriceForProductConvertion(
            productPrice,
            productRTECurrencyCookie!
        );
        amount = new BigNumber(price);
        memo = 'retweets with likes and comments';
        console.log('amount', amount);
    } else if (productRTECookie === 'citation') {
        selectedProduct = 'citation';
        productPrice = productPrices[selectedProduct];

        const price = await getPriceForProductConvertion(
            productPrice,
            productRTECurrencyCookie!
        );
        amount = new BigNumber(price);
        memo = 'citations';

        console.log('amount', amount);
    }

    if (productRTECurrencyCookie === 'USDC') {
        isSPLToken = true;
    } else if (productRTECurrencyCookie === 'solana') {
        isSPLToken = false;
    } else {
        isSPLToken = false;
    }
}

async function generateUrl(
    recipient: PublicKey,
    amount: BigNumber,
    reference: PublicKey,
    label: string,
    message: string,
    memo: string,
    isSPLToken: boolean,
    splToken?: PublicKey
) {
    const tokenAddress = isSPLToken ? splToken || usdcTokenAdress : undefined;

    const url: URL = encodeURL({
        recipient,
        amount,
        reference,
        label,
        message,
        memo,
        splToken: tokenAddress,
    });

    return { url };
}

export async function POST(req: NextRequest) {
    try {
        await initializeAmount();
        const reference = new Keypair().publicKey;
        console.log('reference-post', reference);
        console.log('reference-post-type', typeof reference);
        const message = `Opales- Order ID #${Math.floor(Math.random() * 999999) + 1}`;

        let splToken: PublicKey | undefined = undefined;
        if (isSPLToken) {
            splToken = usdcTokenAdress;
        }

        const paymentRequests = {
            reference,
            recipient,
            amount,
            memo,
            message,
            isSPLToken,
        };
        await createPaymentRequestsCookie(paymentRequests);

        const urlData = await generateUrl(
            recipient,
            amount,
            reference,
            label,
            message,
            memo,
            isSPLToken,
            splToken
        );
        const ref = reference.toBase58();
        console.log('paymentReqquests', paymentRequests);
        console.log('ref', ref);
        const { url } = urlData;
        return NextResponse.json({ url: url.toString(), ref });
    } catch (error) {
        console.error('Error:', error);
        return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
    }
}

API Route for Verifying Transaction:

import { findReference, validateTransfer, FindReferenceError } from '@solana/pay';
import { Connection, PublicKey } from '@solana/web3.js';
import { NextRequest, NextResponse } from 'next/server';
import BigNumber from 'bignumber.js';
import { cookies } from 'next/headers';
import bs58 from 'bs58';
import { createPaymentRTEVerified } from '../../product/components/ProductApi';

const quicknodeEndpoint = process.env.SOLANA_ENDPOINT!;
const usdcTokenAdress = new PublicKey(process.env.TOKEN_ADRESS_USDC!);

interface PaymentData {
    reference: string;
    recipient: string;
    amount: string;
    isSPLToken: string;
    memo: string;
    message: string;
}

function stringToPublicKey(reference: string) {
    const decoded = bs58.decode(reference);
    if (decoded.length !== 32) {
        throw new Error('Invalid reference length');
    }
    return new PublicKey(decoded);
}

function stringToBigNumber(str: string) {
    return new BigNumber(str);
}

export async function GET(req: NextRequest) {
    const cookieStore = cookies();
    const paymentRequestsCookie = cookieStore.get('paymentRequests');

    if (!paymentRequestsCookie) {
        return NextResponse.json(
            { error: 'Payment request not found in cookies' },
            { status: 400 }
        );
    }
    const paymentRequestsValue = paymentRequestsCookie?.value;

    const paymentRequestsObject = JSON.parse(paymentRequestsValue);
    const paymentRequests = paymentRequestsObject as PaymentData;

    console.log('paymentRequests', paymentRequests);

    const { searchParams } = new URL(req.url);
    const reference = searchParams.get('reference');

    console.log('reference', reference, typeof reference);
    if (!reference) {
        return NextResponse.json(
            { error: 'Missing reference query parameter' },
            { status: 400 }
        );
    }

    try {
        if (paymentRequests.reference !== reference) {
            throw new Error('Payment request not found');
        }

        const recipient = stringToPublicKey(paymentRequests.recipient);
        const amount = stringToBigNumber(paymentRequests.amount);
        const isSPLToken = paymentRequests.isSPLToken === 'true' ? true : false;
        const memo = paymentRequests.memo;
        const message = paymentRequests.message;

        const connection = new Connection(quicknodeEndpoint, 'confirmed');

        const referencePublicKey = stringToPublicKey(reference);
        console.log('referencePublicKey', referencePublicKey, typeof referencePublicKey);

        if (!(referencePublicKey instanceof PublicKey)) {
            throw new Error('referencePublicKey is not a valid PublicKey instance');
        }

        let found;
        try {
            found = await findReference(connection, referencePublicKey, {
                finality: 'finalized',
            });
            console.log(found.signature);

            const solscan = `https://solscan.io/tx/${found.signature}`;

            const response = await validateTransfer(
                connection,
                found.signature,
                {
                    recipient,
                    amount,
                    splToken: isSPLToken ? usdcTokenAdress : undefined,
                    reference: referencePublicKey,
                    memo,
                },
                { commitment: 'confirmed' }
            );

            console.log(response, 'response');
            if (response) {
                cookieStore.delete('paymentRequests');
                cookieStore.delete('productRTE');
                cookieStore.delete('productRTECurrency');

                const paymentVerified = {
                    amount,
                    memo,
                    message,
                    isSPLToken,
                    solscan,
                };

                await createPaymentRTEVerified(paymentVerified);

                return NextResponse.json({ status: 'verified' }, { status: 200 });
            } else {
                return NextResponse.json({ status: 'not found' }, { status: 404 });
            }
        } catch (error) {
            if (error instanceof FindReferenceError) {
                return NextResponse.json({ status: 'not found' }, { status: 404 });
            } else {
                throw error;
            }
        }
    } catch (error) {
        console.error('Error:', error);
        return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
    }
}

Additional Context:

The issue only occurs with USDC payments. The QR code generation and payment processing work correctly, but the verification step fails. The transaction appears on the blockchain explorer, indicating that the payment is indeed completed.

Environment: Nextjs14 Solana Pay SDK version: last version Solana Blockchain endpoint: rpc quick node mainnet

Possible Causes:

There might be a discrepancy in how USDC payments are being handled or stored compared to SOL payments. The validateTransfer method might not be correctly configured for USDC SPL token transactions.

cSarcasme commented 1 month ago

?????????????????????????????

ahmedelboshi commented 1 month ago

i got same issue in but in other token i think the issue in handle spl token verification i will try to check source code if i found solution i will notify you

saikishore222 commented 1 month ago

Even though I am having Same issue, Today i try to send usdc , iam getting signature , if i have to validate Transfer, my validation becoming fail

cSarcasme commented 1 month ago

i have done that for usdc with other thing of solana/pay just for the verification.

I have done like that i have create a memo with a id different for each transction and i verify on the wallet where the money arrive if i have a transction with x amount an d a memo of that id and if i have it it i have he transaction and with that you can get the solscan and other thing and it is the payment is verify

@saikishore222