woodser / monero-ts

TypeScript library for using Monero
http://woodser.github.io/monero-ts/typedocs
MIT License
210 stars 71 forks source link

Monero-ts in NextJS dev server leads to hangup #181

Closed AlexAnarcho closed 7 months ago

AlexAnarcho commented 8 months ago

Heyo, Running monero-ts v0.9.5 and nextjs v14 leads to a hangup in the dev server. I have a monero-wallet-rpc running in a separate terminal and connect to it on my NextJS web server. Now, running the dev server and using a server-side function to access the wallet leads to a hangup of the application. No error, the browser window just remains in a perpetual loop state. In the server terminal I see the following output:

./node_modules/web-worker/node.js
Critical dependency: the request of a dependency is an expression

Import trace for requested module:
./node_modules/web-worker/node.js
./node_modules/monero-ts/dist/src/main/ts/common/LibraryUtils.js
./node_modules/monero-ts/dist/index.js
./src/server/xmrWallet.ts
./src/server/api/routers/invoice.ts
./src/server/api/root.ts
./src/trpc/server.ts
./src/app/dashboard/page.tsx

Now, curious, when building the server with yarn build and starting the application with yarn start works just fine. The server can handle the wallet and does not hang.

So my hunch is that it has to do with the way a NextJS dev server works under the hood. Here is the code for my monero-wallet-rpc on the server:

/* This file handles the logic for the monero wallet running on the
 * server and checking the income of transactions to pay invoices */
import * as moneroTs from "monero-ts";
import { db } from "~/server/db";
import { env } from "~/env";
import { type Invoice } from "@prisma/client";

async function initWallet() {
  const walletRpc = await moneroTs.connectToWalletRpc({
    uri: env.MONERO_RPC_URI,
    rejectUnauthorized: false,
  });

  // TODO set onBalanceReceived listener and credit the appropriate account

  const wallet = await walletRpc.openWallet({
    path: env.MONERO_WALLET_PATH,
    password: env.MONERO_WALLET_PW,
  });
  await wallet.addListener(
    new (class extends moneroTs.MoneroWalletListener {
      async onOutputReceived(output: moneroTs.MoneroOutputWallet) {
        // --- Parse the transaction
        const targetAddressIndex = output.getSubaddressIndex();
        const subaddress = (
          await wallet.getSubaddress(0, targetAddressIndex)
        ).getAddress();
        const amount = convertBigIntToXmrFloat(output.getAmount());
        const transactionKey = output.getKeyImage().getHex();
        const isConfirmed = output.getTx().getIsConfirmed();
        const isLocked = output.getTx().getIsLocked();

        // --- Existing Tx or new?
        const existingTx = await db.transaction.findFirst({
          where: { transactionKey },
        });

        const relatedFeedItem = await db.invoice.findFirst({
          where: { subaddress },
        });

        await db.transaction.upsert({
          where: { transactionKey: transactionKey },
          update: { isConfirmed, isUnlocked: !isLocked },
          create: {
            transactionKey: transactionKey,
            isConfirmed,
            isUnlocked: !isLocked,
            amount: Number(amount),
          },
        });

        if (existingTx) return;
        await updateInvoiceWithTx(relatedFeedItem, amount);

        // --- Finishing up
        await wallet.save();
      }
    })(),
  );
  return wallet;
}

async function updateInvoiceWithTx(invoice: Invoice | null, amount: number) {
  // new tx was received, update the amount of the invoice + unlocking logic
  if (!invoice) return;

  const newAmount = (invoice?.payedAmount ?? 0) + amount;

  const isPayed =
    !!invoice?.expectedAmount && invoice?.expectedAmount <= newAmount;

  await db.invoice.update({
    where: { id: invoice.id },
    data: {
      paidStatus: isPayed ? "paid" : "unpaid",
      payedAmount: newAmount,
    },
  });

  // revalidatePath("/"); // not sure if we need this here at all
}

export async function getFreshSubaddress(
  moneroWallet: moneroTs.MoneroWalletRpc,
): Promise<string> {
  const { currentSubaddressIndex } = await db.serverXmrSetting.findFirstOrThrow(
    {
      where: { id: 1 },
    },
  );

  const newSubaddress = moneroWallet.getSubaddress(0, currentSubaddressIndex);
  const newIndex = currentSubaddressIndex + 1;

  await db.serverXmrSetting.update({
    where: { id: 1 },
    data: {
      currentSubaddressIndex: newIndex,
    },
  });

  return (await newSubaddress).getAddress();
}

export function convertBigIntToXmrFloat(amount: bigint) {
  return parseFloat((Number(amount) / 1000000000000).toFixed(12));
}

const globalForXmrWallet = globalThis as unknown as {
  xmrWallet: moneroTs.MoneroWalletRpc;
};

export function calculateDeltaToGoal(amount = 0, goal: number | null) {
  if (!goal) return 0;
  return parseFloat(
    ((goal * 1000000000000 - amount * 1000000000000) / 1000000000000).toFixed(
      12,
    ),
  );
}

// helper for develop?
export const xmrWallet = globalForXmrWallet.xmrWallet ?? (await initWallet());

if (env.NODE_ENV !== "production") globalForXmrWallet.xmrWallet = xmrWallet;
export default xmrWallet;

As you can see in the last part, I am doing something that I just copied from the prisma pattern. The xmrWallet should kinda be a global on the server, only one instance that holds the open wallet with its functionality.

woodser commented 8 months ago

Looks like it's trying to evaluate this dynamic dependency statically, which doesn't apply in your code anyway:

https://github.com/woodser/monero-ts/blob/046785f3ad7e6896b06abd1531ed7eebc6a04493/src/main/ts/common/LibraryUtils.ts#L164

Maybe there is a way to configure excluding web-worker? In webpack, there is the externals field.

Alternatively maybe the dynamic import could be removed somehow.

AlexAnarcho commented 8 months ago

Hey woodser, thanks so much for the quick reply! According to the NextJS documentation webpack can be customized. So I tried this in my next.config.js:

  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.resolve.fallback.child_process = false;
      config.resolve.fallback.fs = false;
      config.externals = {
        bufferutil: "bufferutil",
        "utf-8-validate": "utf-8-validate",
      };
    } else {
      config.externals = {
        "web-worker": "webWorker",
      };
    }

and this works! In the sense that the errors don't show during development, but the app still hangs. Also with this the entire application just won't build anymore. This is the error on build with the externals:

> Export encountered errors on following paths:
    /_error: /404
    /_error: /500
    /_not-found
    /dashboard/page: /dashboard
    /dashboard/tx-history/page: /dashboard/tx-history
    /login/page: /login
    /page: /
    /registration/existing/page: /registration/existing
    /registration/new/page: /registration/new
    /registration/page: /registration
    /registration/username/page: /registration/username
    /registration/view-only/page: /registration/view-only
    /setup/page: /setup
    /streams/page: /streams
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

Could you explain what you mean with your two possible solutions? I think we may be on a good path here, but I just lack the understanding of what is causing the issue.

BTW, here is the entire codebase if you want to check it out https://github.com/tipxmr/tipxmr/commit/8f4dc832839bc90fe83b1be9f1aadeb5bbf74b0e

woodser commented 8 months ago

Option 1 is configure NextJS to work with monero-ts by not statically analyzing this dynamic import.

Option 2 is changing monero-ts somehow to remove the dynamic import so it's not an issue for any build environment.

I'd first start with simple configuration to exclude the import though.

Wondering if

config.externals = {
  "web-worker": "webWorker",
};

should be

config.externals = {
   "web-worker": "web-worker",
 };

?

Not sure without installing the NextJS environment and playing with it.

woodser commented 7 months ago

I created a minimal sample application with NextJS to recreate the problem in the dev server: https://github.com/woodser/xmr-next-app

I can confirm I see the same issue connecting to wallet RPC with monero-ts 0.9.5.

This issue appears to be fixed in monero-ts 0.9.6, so it can connect to wallet RPC. Can you try that version?

However, there's still an error loading the wasm modules: "Failed to parse URL from /Users/woodser/git/xmr-next-app/node_modules/monero-ts/dist/dist/monero_wallet_full.wasm".

So native capabilities aren't available.

AlexAnarcho commented 7 months ago

Heyo, thank you so much for the support and helping out!

I am also using 0.9.6 now and I think the issue is actually with tRPC, which acts as a api between front-end and back-end. I have the following next.config.js now:

...
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.resolve.fallback.child_process = false;
      config.resolve.fallback.fs = false;
      config.externals = {
        bufferutil: "bufferutil",
        "utf-8-validate": "utf-8-validate",
      };
    } else {
      config.externals = {
        "web-worker": "web-worker",
      };
    }

    return config;
  },

...

and the import trace error is gone.

I can also fetch new subaddresses in server-components like so:

export default async function DashboardPage() {
  const session = await getServerAuthSession();
  if (!session?.user) return <>No session</>;

  const mostRecentInvoice = await api.invoice.mostRecentInvoice.query({
    streamerId: session.user?.id,
  });

  const dashboardInfo = await api.streamer.dashboard.query();
  const subaddress = (await xmrWallet.createSubaddress(0)).getAddress();
  console.log({ subaddress });

  return (
...)}

I think that putting the Monero code inside a tRPC procedure is actually causing the issue, especially with the development server. tRPC with monero-ts code (accessing the walletRpc) works when building the application (💡 btw, you have to have the monero-wallet-rpc running while building).

Pretty much one of the only reasons why I use an external monero-wallet-rpc in the first place is because I have never really gotten the native monero stuff to work. Buut, it would be much cooler, since as far as I can tell, opening a walletFull in typescript allows for more granular wallet listeners.

AlexAnarcho commented 7 months ago

Omg, I got it! 🥳

So, in my last post I mentioned that I believe the issue is actually with tRPC. Turns out, that's right! I looked a little closer and welp, in T3 stack projects we have the server/api/trpc.ts file were we can generate a context for trpc on the server-side. Like, database, session... and moneroWallet! So, here is how it works:

In the file where I write my monero-ts code, at the end I do the following export to create a global variable to work with during development. This is necessary for hot reloading and such.

export const xmrWallet = globalForXmrWallet.xmrWallet ?? (await initWallet());

if (env.NODE_ENV !== "production") globalForXmrWallet.xmrWallet = xmrWallet;
export default xmrWallet;

Then, I can import the xmrWallet in my server/api/trpc.ts and pass it in the context:

import xmrWallet from "~/server/xmrWallet";
export const createTRPCContext = async (opts: { headers: Headers }) => {
  const session = await getServerAuthSession();
  const serverWallet = xmrWallet;

  return {
    db,
    serverWallet,
    session,
    ...opts,
  };
};

Now, I can access the serverWallet in my tRPC procedures 😍:

export const invoiceRouter = createTRPCRouter({
  create: publicProcedure
    .input(
      z.object({
        streamerId: z.string(),
        planType: z.enum(["basic", "premium"]),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const subaddress = (
        await ctx.serverWallet.createSubaddress(0)
      ).getAddress();
      console.log({ subaddress });

      const data = {
        ...input,
        subaddress,
      };
      return ctx.db.invoice.create({
        data,
      });
    }),
});

This is sooo dope. Thank you @woodser you are a rockstar!

AlexAnarcho commented 7 months ago

ah man, i think i celebrated too early... dev server still hangs even with the server wallet in the context..

AlexAnarcho commented 7 months ago

Heyo! Now I really got it! At least the dev server is working and I can use the serverWallet in trpc.

Here is what I did: I removed the stuff I had adopted earlier from the prisma file with the global export and stuff and just have a initWallet() function that returns the opened wallet with a listener attached. Then in the trpc context creation I call that function, get the wallet and store it in the context. This means that I can only use it in trpc procedures, but thats fine since they can be used on the server side as well.

So, just this:

import { initWallet } from "~/server/xmrWallet";
export const createTRPCContext = async (opts: { headers: Headers }) => {
  const session = await getServerAuthSession();

  // Here we create the wallet file for our server
  // The wallet is only an RPC (service runs externally)
  // and informs us about new payments
  // as well as provides us with new subaddresses for invoices
  const serverWallet = await initWallet();

  return {
    db,
    serverWallet,
    session,
    ...opts,
  };
};

and

export async function initWallet() {
  const walletRpc = await connectToWalletRpc({
    uri: env.MONERO_RPC_URI,
    rejectUnauthorized: false,
  });

  const wallet = await walletRpc.openWallet({
    path: env.MONERO_WALLET_PATH,
    password: env.MONERO_WALLET_PW,
  });
  await wallet.addListener(
    new (class extends MoneroWalletListener {
      async onOutputReceived(output: MoneroOutputWallet) {
        // --- Parse the transaction
        const targetAddressIndex = output.getSubaddressIndex();
        const subaddress = (
          await wallet.getSubaddress(0, targetAddressIndex)
        ).getAddress();
        const amount = convertBigIntToXmrFloat(output.getAmount());
        const transactionKey = output.getKeyImage().getHex();
        const isConfirmed = output.getTx().getIsConfirmed();
        const isLocked = output.getTx().getIsLocked();

        // --- Existing Tx or new?
        const existingTx = await db.transaction.findFirst({
          where: { transactionKey },
        });

        const relatedFeedItem = await db.invoice.findFirst({
          where: { subaddress },
        });

        await db.transaction.upsert({
          where: { transactionKey: transactionKey },
          update: { isConfirmed, isUnlocked: !isLocked },
          create: {
            transactionKey: transactionKey,
            isConfirmed,
            isUnlocked: !isLocked,
            amount: Number(amount),
          },
        });

        if (existingTx) return;
        await updateInvoiceWithTx(relatedFeedItem, amount);

        // --- Finishing up
        await wallet.save();
      }
    })(),
  );
  return wallet;
}

aaaand that works! So yeah, I think I overcomplicated things, but maybe somebody else finds this helpful.

0-don commented 4 months ago

this is the way, and dont use --turbo

next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config, { isServer }) => {
    if (isServer) config.externals.push("monero-ts");
    return config;
  },
};