DEV Community

Daniel Ioni
Daniel Ioni

Posted on

Monero Integration in Node.js: A Complete Technical Guide (RPC, Subaddresses, and Production Setup)

--password "STRONG_PASSWORD_HERE" \
--daemon-address node.moneroworld.com:18089

3.4 Wallet Files Structure
text

/var/lib/monero/wallets/
├── myzubster # Wallet data (binary)
├── myzubster.address.txt # Primary address (public)
└── myzubster.keys # Private keys (DO NOT SHARE)

3.5 Get Primary Address
bash

cat /var/lib/monero/wallets/myzubster.address.txt

Example: 4A6B...XXXX...XXXX... (testnet starts with 9, mainnet with 4)

  1. Wallet RPC Server 4.1 Manual Start (for Testing) bash

./monero-wallet-rpc \
--wallet-file /var/lib/monero/wallets/myzubster \
--password "STRONG_PASSWORD_HERE" \
--rpc-bind-port 18083 \
--rpc-bind-ip 127.0.0.1 \
--daemon-address testnet.community:28081 \
--testnet \
--disable-rpc-login \
--log-level 0 \
--max-log-file-size 104850000 \
--max-log-files 50 \
--non-interactive \
--pidfile /run/monero-wallet-rpc.pid

4.2 Verify RPC is Running
bash

curl -X POST http://127.0.0.1:18083/json_rpc \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"0","method":"get_version"}'

Expected response:
json

{
"id": "0",
"jsonrpc": "2.0",
"result": {
"release": true,
"version": 65567
}
}

4.3 Production systemd Service

Create /etc/systemd/system/monero-wallet-rpc.service:
ini

[Unit]
Description=Monero Wallet RPC (MyZubster)
After=network.target
Wants=network.target

[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/opt/monero
ExecStart=/opt/monero/monero-wallet-rpc \
--wallet-file /var/lib/monero/wallets/myzubster \
--password "STRONG_PASSWORD_HERE" \
--rpc-bind-port 18083 \
--rpc-bind-ip 127.0.0.1 \
--daemon-address testnet.community:28081 \
--testnet \
--disable-rpc-login \
--log-level 0 \
--pidfile /run/monero-wallet-rpc.pid
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

Enable and start:
bash

systemctl daemon-reload
systemctl enable monero-wallet-rpc
systemctl start monero-wallet-rpc
systemctl status monero-wallet-rpc

  1. Node.js Monero Service 5.1 Install Dependencies bash

cd /opt/myzubster
npm install axios

5.2 Create services/moneroService.js
javascript

const axios = require('axios');
const crypto = require('crypto');

// Configuration from environment
const RPC_URL = process.env.MONERO_RPC_URL || 'http://127.0.0.1:18083/json_rpc';
const WALLET_ADDRESS = process.env.MONERO_WALLET_ADDRESS || 'XXXX';
const NETWORK = process.env.MONERO_NETWORK || 'testnet';
const CONFIRMATIONS_REQUIRED = parseInt(process.env.MONERO_CONFIRMATIONS || '1', 10);

/**

  • JSON-RPC call to Monero wallet
    */
    const rpc = async (method, params = {}) => {
    try {
    const response = await axios.post(RPC_URL, {
    jsonrpc: '2.0',
    id: crypto.randomBytes(8).toString('hex'),
    method,
    params,
    }, {
    timeout: 30000,
    headers: { 'Content-Type': 'application/json' },
    });

    if (response.data.error) {
    throw new Error(Monero RPC error: ${response.data.error.message});
    }

    return response.data.result;
    } catch (error) {
    console.error([Monero RPC] Method ${method} failed:, error.response?.data || error.message);
    throw new Error(Monero RPC error: ${method} - ${error.message});
    }
    };

/**

  • Generate a subaddress for an order */ const generateSubaddress = async (label = '') => { const result = await rpc('create_address', { label: label.slice(0, 64), // Max 64 chars account_index: 0 });

return {
address: result.address,
index: result.address_index,
label: result.label || label,
};
};

/**

  • Get wallet balance */ const getBalance = async (accountIndex = 0) => { const result = await rpc('get_balance', { account_index: accountIndex });

return {
balance: result.balance / 1e12, // Convert from atomic units
unlockedBalance: result.unlocked_balance / 1e12,
blocksToUnlock: result.blocks_to_unlock || 0,
};
};

/**

  • Check if a subaddress received a payment */ const checkPayment = async (address, expectedAmount, confirmations = CONFIRMATIONS_REQUIRED) => { // Get all incoming transfers const transfers = await rpc('get_transfers', { in: true, account_index: 0, filter_by_height: true, min_height: 0, });

const incoming = transfers.in || [];
const pending = transfers.pending || [];

// Check confirmed transfers
for (const tx of incoming) {
if (tx.address === address &&
tx.amount / 1e12 >= expectedAmount &&
tx.confirmations >= confirmations) {
return {
received: true,
amount: tx.amount / 1e12,
txid: tx.txid,
confirmations: tx.confirmations,
timestamp: tx.timestamp,
blockHeight: tx.height,
isConfirmed: true,
};
}
}

// Check pending (unconfirmed) transfers
for (const tx of pending) {
if (tx.address === address && tx.amount / 1e12 >= expectedAmount) {
return {
received: true,
amount: tx.amount / 1e12,
txid: tx.txid,
confirmations: 0,
timestamp: tx.timestamp,
blockHeight: null,
isConfirmed: false,
};
}
}

return { received: false };
};

/**

  • Send XMR from wallet */ const sendPayment = async (destinationAddress, amount, priority = 1) => { const result = await rpc('transfer', { destinations: [{ address: destinationAddress, amount: Math.floor(amount * 1e12) // Convert to atomic units }], priority, // 1 = default, 2 = unimportant, 3 = normal, 4 = elevated, 5 = priority ring_size: 7, // Standard ring size get_tx_key: true, do_not_relay: false, });

return {
txid: result.tx_hash,
txKey: result.tx_key,
amount: amount,
fee: result.fee / 1e12,
multiSigTx: result.multisig_tx || false,
};
};

/**

  • Get transaction details */ const getTransaction = async (txid) => { const result = await rpc('get_transfer_by_txid', { txid }); return { txid: result.txid, amount: result.amount / 1e12, fee: result.fee / 1e12, confirmations: result.confirmations, timestamp: result.timestamp, address: result.address, destination: result.destinations || [], }; };

/**

  • Get all subaddresses */ const getSubaddresses = async (accountIndex = 0) => { const result = await rpc('get_address', { account_index: accountIndex, view_all: true, }); return result.addresses.map(addr => ({ address: addr.address, index: addr.address_index, label: addr.label || '', })); };

module.exports = {
rpc,
generateSubaddress,
getBalance,
checkPayment,
sendPayment,
getTransaction,
getSubaddresses,
};

  1. Environment Configuration

Create .env in project root:
env

Monero Configuration

MONERO_RPC_URL=http://127.0.0.1:18083/json_rpc
MONERO_WALLET_ADDRESS=XXXX
MONERO_VIEW_KEY=XXXX # Optional, for read-only access
MONERO_NETWORK=testnet
MONERO_CONFIRMATIONS=1

Payment Configuration

PAYMENT_MODE=monero
PAYMENT_TIMEOUT=3600 # Seconds to wait for payment
PAYMENT_EXPIRY=86400 # 24 hours

  1. Order Model Integration

Add Monero fields to models/Order.js:
javascript

const OrderSchema = new mongoose.Schema({
// ... existing fields ...

// Monero payment fields
moneroSubaddress: {
type: String,
default: null,
index: true,
},
moneroAddressIndex: {
type: Number,
default: null,
},
moneroPaymentStatus: {
type: String,
enum: ['pending', 'detected', 'confirmed', 'expired', 'failed'],
default: 'pending',
},
moneroPaymentTxid: {
type: String,
default: null,
},
moneroPaymentAmount: {
type: Number,
default: 0,
},
moneroPaymentConfirmations: {
type: Number,
default: 0,
},
moneroPaymentDetectedAt: {
type: Date,
default: null,
},
moneroPaymentConfirmedAt: {
type: Date,
default: null,
},
moneroPaymentExpiresAt: {
type: Date,
default: null,
},
});

  1. Order Creation with Subaddress

In routes/orders.js:
javascript

const moneroService = require('../services/moneroService');

router.post('/', async (req, res) => {
try {
const { offerId, quantity = 1 } = req.body;
const buyerId = req.user._id;

// Validate offer
const offer = await Offer.findById(offerId);
if (!offer) {
  return res.status(404).json({ error: 'Offer not found' });
}

// Calculate total
const totalPrice = offer.price * quantity;
const expiresAt = new Date(Date.now() + (parseInt(process.env.PAYMENT_EXPIRY, 10) * 1000));

// Create order
const order = new Order({
  offer: offerId,
  buyer: buyerId,
  quantity,
  totalPrice,
  status: 'pending',
  moneroPaymentStatus: 'pending',
  moneroPaymentExpiresAt: expiresAt,
});

// Generate Monero subaddress
try {
  const sub = await moneroService.generateSubaddress(`order-${order._id}`);
  order.moneroSubaddress = sub.address;
  order.moneroAddressIndex = sub.index;
  await order.save();
} catch (moneroError) {
  console.error('Failed to generate subaddress:', moneroError);
  // Continue - order is created but payment not yet linked
}

await order.populate('offer', 'title price');
await order.populate('buyer', 'name email');

res.status(201).json({
  success: true,
  order,
  payment: {
    moneroSubaddress: order.moneroSubaddress,
    addressIndex: order.moneroAddressIndex,
    totalPrice: order.totalPrice,
    currency: 'XMR',
    expiresAt: order.moneroPaymentExpiresAt,
  },
});
Enter fullscreen mode Exit fullscreen mode

} catch (error) {
console.error('Order creation error:', error);
res.status(500).json({ error: 'Failed to create order' });
}
});

  1. Payment Monitoring Service

Create services/paymentMonitor.js:
javascript

const Order = require('../models/Order');
const moneroService = require('./moneroService');

const MONITOR_INTERVAL = parseInt(process.env.MONITOR_INTERVAL || '30000', 10); // 30 seconds
const MAX_RETRIES = 3;

/**

  • Monitor pending orders for payments */ const monitorPayments = async () => { console.log('[PaymentMonitor] Scanning for pending payments...');

const pendingOrders = await Order.find({
moneroPaymentStatus: { $in: ['pending', 'detected'] },
moneroSubaddress: { $ne: null },
moneroPaymentExpiresAt: { $gt: new Date() },
});

console.log([PaymentMonitor] Found ${pendingOrders.length} pending orders);

for (const order of pendingOrders) {
try {
await checkOrderPayment(order);
} catch (error) {
console.error([PaymentMonitor] Error checking order ${order._id}:, error);
}
}
};

/**

  • Check a single order for payment */ const checkOrderPayment = async (order) => { const result = await moneroService.checkPayment( order.moneroSubaddress, order.totalPrice, parseInt(process.env.MONERO_CONFIRMATIONS || '1', 10) );

if (result.received) {
order.moneroPaymentStatus = result.isConfirmed ? 'confirmed' : 'detected';
order.moneroPaymentTxid = result.txid;
order.moneroPaymentAmount = result.amount;
order.moneroPaymentConfirmations = result.confirmations || 0;

if (result.isConfirmed) {
  order.moneroPaymentConfirmedAt = new Date();
  order.status = 'paid';
} else {
  order.moneroPaymentDetectedAt = new Date();
}

await order.save();
console.log(`[PaymentMonitor] Order ${order._id} updated: ${order.moneroPaymentStatus}`);
Enter fullscreen mode Exit fullscreen mode

}
};

/**

  • Start monitoring */ const startMonitoring = () => { console.log([PaymentMonitor] Starting (interval: ${MONITOR_INTERVAL}ms)); setInterval(monitorPayments, MONITOR_INTERVAL); monitorPayments(); // Run immediately };

module.exports = { startMonitoring };

  1. Testing the Integration 10.1 Test Subaddress Generation (Direct RPC) bash

curl -X POST http://127.0.0.1:18083/json_rpc \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "0",
"method": "create_address",
"params": { "label": "test-order" }
}'

Expected response:
json

{
"id": "0",
"jsonrpc": "2.0",
"result": {
"address": "9xY...XXXX...XXXX",
"address_index": 1,
"label": "test-order"
}
}

10.2 Test Complete Flow
bash

Get token

TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"Test123!"}' \
| jq -r '.token')

Create skill

SKILL_ID=$(curl -s -X POST http://localhost:3000/api/skills \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Monero Skill","description":"Test skill","category":"Informatica","zone":"Online"}' \
| jq -r '._id')

Create offer

OFFER_ID=$(curl -s -X POST http://localhost:3000/api/offers \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"title\":\"Test Offer\",\"description\":\"Test\",\"price\":0.5,\"skill\":\"$SKILL_ID\",\"category\":\"Informatica\"}" \
| jq -r '.offer._id')

Create order (generates subaddress)

curl -X POST http://localhost:3000/api/orders \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"offerId\":\"$OFFER_ID\",\"quantity\":1}" \
| jq '.'

  1. Troubleshooting 11.1 "no connection to daemon"

Cause: Wallet RPC cannot connect to Monero node.

Solution:
bash

Test daemon connectivity

curl -X POST http://testnet.community:28081/json_rpc \
-d '{"jsonrpc":"2.0","id":"0","method":"get_version"}' \
-H "Content-Type: application/json"

If fails, use a different node

Try: node.moneroworld.com:18089 (mainnet)

Try: testnet.moneroworld.com:38081 (testnet)

11.2 "Couldn't connect to server" on RPC

Check:
bash

netstat -tulpn | grep 18083

Should show monero-wallet-rpc listening on 127.0.0.1:18083

Check process

ps aux | grep monero-wallet-rpc

Check logs

journalctl -u monero-wallet-rpc -n 50 --no-pager

11.3 "E11000 duplicate key error"

Cause: Unique index conflict on orderNumber field.

Solution:
bash

mongosh myzubster --eval "db.orders.getIndexes()"
mongosh myzubster --eval "db.orders.dropIndex('orderNumber_1')"
mongosh myzubster --eval "db.orders.createIndex({ orderNumber: 1 }, { unique: true, sparse: true })"

  1. Production Checklist Item Check Wallet password stored securely (not in code) ✅ RPC bound to localhost only (127.0.0.1) ✅ systemd service configured with restart policy ✅ Environment variables loaded from .env ✅ Logging enabled with rotation ✅ Firewall rules applied (ports 18083 not exposed) ✅ Mainnet wallet ready (if moving to production) ✅ Backup of wallet keys stored securely ✅ Monitoring alerts configured ✅
  2. Conclusion

You now have a complete Monero payment integration:

✅ Wallet RPC configured as a systemd service

✅ Node.js service with full RPC coverage

✅ Subaddress generation per order

✅ Payment monitoring with automatic status updates

✅ Testnet integration for safe testing
Enter fullscreen mode Exit fullscreen mode

Moving to Mainnet:

Generate a new wallet without --testnet

Use node.moneroworld.com:18089 as daemon

Update .env with MONERO_NETWORK=mainnet

Test with small amounts first
Enter fullscreen mode Exit fullscreen mode

🔗 Project Links

Live site: https://myzubster.com

Tor onion: http://olqcnbdlt35k2stmmwvzhvuetu2fc4us2jnn5wg6y6wlcddihfmdomid.onion

GitHub: https://github.com/DanielIoni-creator/MyZubsterGateway
Enter fullscreen mode Exit fullscreen mode

If you found this guide helpful, please star the repo and share it with others interested in privacy-focused crypto integrations! 🚀

Top comments (0)