Build a Desktop Wallet in JavaScript
This tutorial demonstrates how to build a desktop wallet for the XRP Ledger using the JavaScript programming language, the Electron Framework and various libraries. This application can be used as a starting point for building a more complex and powerful application, as a reference point for building comparable apps, or as a learning experience to better understand how to integrate XRP Ledger functionality into a larger project.
Prerequisites
To complete this tutorial, you should meet the following requirements:
- You have Node.js 14+ installed.
- You are somewhat familiar with modern JavaScript programming and have completed the Get Started Using JavaScript tutorial.
- You have some understanding of the XRP Ledger, its capabilities, and of cryptocurrency in general. Ideally you have completed the Basic XRPL guide.
Source Code
You can find the complete source code for all of this tutorial's examples in the code samples section of this website's repository. After a npm install
in this directory you can run the application for each step as described in the scripts
section of package.json
, e.g, npm run ledger-index
.
library
, bootstrap
, and WALLET_DIR
contents.Rationale
This tutorial will take you through the process of creating a XRP Wallet application from scratch. Starting with a simple, "Hello World" like example with minimal functionality, we will step-by-step add more complex features.
We will use the well-established Electron Framework to let us use JavaScript to write this desktop application.
Goals
At the end of this tutorial, you will have built a JavaScript Wallet application that looks something like this:
The look and feel of the user interface should be roughly the same regardless of operating system, as the Electron Framework allows us to write cross-platform applications that are styled with HTML and CSS just like a web-based application.
The application we are going to build here will be capable of the following:
- Showing updates to the XRP Ledger in real-time.
- Viewing any XRP Ledger account's activity "read-only" including showing how much XRP was delivered by each transaction.
- Sending direct XRP payments, and providing feedback about the intended destination address, including:
- Whether the intended destination already exists in the XRP Ledger, or the payment would have to fund its creation.
- If the address doesn't want to receive XRP (Disallow XRP flag enabled).
- If the address has a verified domain name associated with it.
The application in this tutorial doesn't have the ability to send or trade tokens or use other payment types like Escrow or Payment Channels. However, it provides a foundation that you can implement those and other features on top of.
In addition to the above features, you'll also learn a bit about Events, IPC (inter-process-communication) and asynchronous (async) code in JavaScript.
Steps
0. Project setup - Hello World
- To initialize the project, create a file named
package.json
with the following content:
{ "name": "xrpl-javascript-desktop-wallet", "version": "1.0.0", "license": "MIT", "scripts": { "start": "electron ./index.js" }, "dependencies": { "async": "^3.2.4", "fernet": "^0.4.0", "node-fetch": "^2.6.9", "pbkdf2-hmac": "^1.1.0", "open": "^8.4.0", "toml": "^3.0.0", "xrpl": "^3.0.0" }, "devDependencies": { "electron": "22.3.24" } }
Here we define the libraries our application will use in the dependencies
section as well as shortcuts for running our application in the scripts
section.
- After you create your package.json file, install those dependencies by running the following command:
npm install
This installs the Electron Framework, the xrpl.js client library and a couple of helpers we are going to need for our application to work.
- In the root folder, create an
index.js
file with the following content:
const { app, BrowserWindow } = require('electron') const path = require('path') /** * This is our main function, it creates our application window, preloads the code we will need to communicate * between the renderer Process and the main Process, loads a layout and performs the main logic */ const createWindow = () => { // Creates the application window const appWindow = new BrowserWindow({ width: 1024, height: 768 }) // Loads a layout appWindow.loadFile(path.join(__dirname, 'view', 'template.html')) return appWindow } // Here we have to wait for the application to signal that it is ready // to execute our code. In this case we just create a main window. app.whenReady().then(() => { createWindow() })
Make a new folder named
view
in the root directory of the project.Now, inside the
view
folder, add atemplate.html
file with the following content:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8" /> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'" /> <meta http-equiv="X-Content-Security-Policy" content="default-src 'self'; script-src 'self'" /> <title>XRPL Wallet Tutorial (JavaScript / Electron)</title> </head> <body> <h3>Build a XRPL Wallet</h3> <span>Hello world!</span> </body> </html>
- Now, start the application with the following command:
npm run start
You should see a window appear that displays the text "Build a XRPL Wallet" and "Hello world!" To run the reference application found in _code-samples/build-a-desktop-wallet/desktop-js
for this step, run:
npm run hello
In the next steps we will continually expand on this very basic setup. To better keep track of all the changes that will be made, the files in the reference section are numbered/prefixed with the respective step number:
Full code for this step: 0-hello/0_hello.js
, 0-hello/view/0_hello.html
,
1. Ledger Index
Full code for this step: 1-ledger-index/index.js
, 1-ledger-index/view/preload.js
, 1-ledger-index/view/template.html
, 1-ledger-index/view/renderer.js
.
Our first step was to have a running "Hello World" application. Now we want to expand on that so that the application can interact on a very basic level with the XRP Ledger and display some information about the current ledger state on the screen. After completing this step, the - for the time being unstyled - application should look like this:
- Update
index.js
by adding the following snippet in the import section at the top of the file below thepath
import:
const { app, BrowserWindow } = require('electron') const path = require('path') // Step 3 code additions - start const xrpl = require("xrpl") const TESTNET_URL = "wss://s.altnet.rippletest.net:51233" /** * This function creates a WebService client, which connects to the XRPL and fetches the latest ledger index. * * @returns {Promise<number>} */ const getValidatedLedgerIndex = async () => { const client = new xrpl.Client(TESTNET_URL) await client.connect() // Reference: https://xrpl.org/ledger.html#ledger const ledgerRequest = { "command": "ledger", "ledger_index": "validated" } const ledgerResponse = await client.request(ledgerRequest) await client.disconnect() return ledgerResponse.result.ledger_index } // Step 3 code additions - end /** * This is our main function, it creates our application window, preloads the code we will need to communicate * between the renderer Process and the main Process, loads a layout and performs the main logic */ const createWindow = () => {
This helper function does the following: It establishes a WebSocket connection to the XRP Ledger, calls the XRP Ledger API's ledger method and returns the ledger index from the response. We will wire up this function at the end of this step.
- In order to attach a preloader script, modify the
createWindow
method inindex.js
by adding the following code:
// Creates the application window const appWindow = new BrowserWindow({ width: 1024, height: 768, // Step 1 code additions - start webPreferences: { preload: path.join(__dirname, 'view', 'preload.js'), }, // Step 1 code additions - end })
- Now in the
view
folder, create a filepreload.js
with the following content:
const { contextBridge, ipcRenderer } = require('electron'); // Expose functionality from main process (aka. "backend") to be used by the renderer process(aka. "backend") contextBridge.exposeInMainWorld('electronAPI', { // By calling "onUpdateLedgerIndex" in the frontend process we can now attach a callback function // by making onUpdateLedgerIndex available at the window level. // The subscribed function gets triggered whenever the backend process triggers the event 'update-ledger-index' onUpdateLedgerIndex: (callback) => { ipcRenderer.on('update-ledger-index', callback) } })
This preloader script is used to expose functions to the browser's window object, so that the user interface can react to events broadcast from the main logic in index.js
.
In the browser, window.electronAPI.onUpdateLedgerIndex(callback)
can now be used to pass a callback function via ipcRenderer.on('eventName', callback)
that will be triggered by appWindow.webContents.send('eventName', value)
.
- Now, in
view/template.html
, replace the body in order to show a placeholder for the ledger index instead of "Hello world!"
<body> <!-- Step 1 code modifications - start --> <h3>Build a XRPL Wallet</h3> Latest validated ledger index: <strong id="ledger-index"></strong> <!-- Step 1 code modifications - end --> </body>
- In
view/template.html
add the following line at the bottom of the file:
</body> <!-- Step 1 code additions - start --> <script src="renderer.js"></script> <!-- Step 1 code additions - end --> </html>
- Now create the
renderer.js
file in theview
folder with the following code:
const ledgerIndexEl = document.getElementById('ledger-index') // Here we define the callback function that performs the content update // whenever 'update-ledger-index' is called by the main process window.electronAPI.onUpdateLedgerIndex((_event, value) => { ledgerIndexEl.innerText = value })
- To wire up our main application to send the ledger index to the frontend, modify
index.js
by adding the following snippet replacing the last section in the file:
// Here we have to wait for the application to signal that it is ready // to execute our code. In this case we create a main window, query // the ledger for its latest index and submit the result to the main // window where it will be displayed app.whenReady().then(() => { // Step 1 code additions - start const appWindow = createWindow() getValidatedLedgerIndex().then((value) => { appWindow.webContents.send('update-ledger-index', value) }) // Step 1 code additions - end })
Here we first call our helper function getValidatedLedgerIndex()
and then broadcast an event named update-ledger-index
. This attaches a payload containing the latest ledger information which can be handled by the frontend.
This example shows how to do Inter Process Communication (IPC) in Electron. Technically, JavaScript has no true parallel processes or threading because it follows a single-threaded event-driven paradigm. Nonetheless Electron provides us with two IPC modules called ipcMain
and ipcRenderer
. We can roughly equate ipcMain
to a backend process and ipcRenderer
to a frontend process when we think in terms of client-server applications. It works as follows:
- We started by creating a function that enables the frontend to subscribe to backend events via the
ContextBridge
(onUpdateLedgerIndex
inview/preload.js
) - Then we make the function available by putting it in a preloader script to ensure it is loaded and can be used by the frontend.
- On the frontend, we can then use that function to attach a callback that handles frontend updates when the event is dispatched. In this case, we created a separate file,
renderer.js
, to define and attach the callback. - Lastly, we dispatch the event from the main logic in
index.js
usingappWindow.webContents.send('update-ledger-index', value)
.
To get the application running at this early stage of development, run the following command:
npm run start
You should see something like 'Latest validated ledger index: 39296259'. The number will be different since it is the latest ledger index as of when the app started. We'll make it continuously update later on.
To run the reference application found in _code-samples/build-a-desktop-wallet/desktop-js
for this step, run:
npm run ledger-index
2. Show Ledger Updates by using WebSocket subscriptions
Full code for this step: 2-async/index.js
, 2-async/view/preload.js
, 2-async/view/renderer.js
, 2-async/view/template.html
.
Our application so far only shows the latest validated ledger sequence at the time when we opened it. Let's take things up a notch and add some dashboard like functionality where our wallet app will keep in sync with the ledger and display the latest specs and stats like a clock that is keeping track of time. The result will look something like this:
In
index.js
remove thegetValidatedLedgerIndex
function.Then update the
app.whenReady().then()
section at the bottom of the file section in the following way:
/** * This function creates a XRPL client, subscribes to 'ledger' events from the XRPL and broadcasts those by * dispatching the 'update-ledger-data' event which will be picked up by the frontend * * @returns {Promise<void>} */ const main = async () => { const appWindow = createWindow() const client = new xrpl.Client(TESTNET_URL) await client.connect() // Subscribe client to 'ledger' events // Reference: https://xrpl.org/subscribe.html await client.request({ "command": "subscribe", "streams": ["ledger"] }) // Dispatch 'update-ledger-data' event client.on("ledgerClosed", async (ledger) => { appWindow.webContents.send('update-ledger-data', ledger) }) } app.whenReady().then(main)
Here, we have reduced the app.whenReady
logic to an one-liner and put the necessary functionality into a separate main()
function. The most relevant piece of code here is the swapping of a single call to the ledger for a subscription. Our client is now connecting to the XRPL via WebSockets. This establishes a permanent bidirectional connection to the XRPL, which allows us to subscribe to events that the server sends out. This saves resources on the server, which now only sends out data we explicitly asked for when a change happens, as well as the client which does not have to sort through incoming data for relevant changes. It also reduces the complexity of the application and saves us a couple of lines of code.
- Then, update
preload.js
by renaming theonUpdateLedgerIndex
toonUpdateLedgerData
and theupdate-ledger-index
event toupdate-ledger-data
:
const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { onUpdateLedgerData: (callback) => { ipcRenderer.on('update-ledger-data', callback) } })
This renaming might seem a bit nit-picky, but now we actually pass on an object of values instead of a single integer.
- Next, modify the
view/template.html
file by adding placeholders to the<body>
section:
<body> <!-- Step 2 code additions - start --> <h3>Build a XRPL Wallet - Part 2/8</h3> <b>Latest validated ledger stats</b><br /> Ledger Index: <strong id="ledger-index"></strong><br /> Ledger Hash: <strong id="ledger-hash"></strong><br /> Close Time: <strong id="ledger-close-time"></strong><br /> <!-- Step 2 code additions - end --> </body>
- Modify the
view/renderer.js
file to handle the new placeholders. Note that the renamed preloader function is now reflected inwindow.electronAPI.onUpdateLedgerData
:
const ledgerIndexEl = document.getElementById('ledger-index') const ledgerHashEl = document.getElementById('ledger-hash') const ledgerCloseTimeEl = document.getElementById('ledger-close-time') window.electronAPI.onUpdateLedgerData((_event, value) => { ledgerIndexEl.innerText = value.ledger_index ledgerHashEl.innerText = value.ledger_hash ledgerCloseTimeEl.innerText = value.ledger_time })
This should make our application listen to regular updates of the ledger and display them in the frontend.
Now run the application with the following command:
npm run start
This time the application should be the same as in the last step, with the difference that the latest ledger index value gets updated roughly very 3-5 seconds.
To run the reference application found in _code-samples/build-a-desktop-wallet/desktop-js
for this step, run:
npm run async
3. Display an Account
Full code for this step: library/3_helpers.js
. 3-account/index.js
, 3-account/view/preload.js
, 3-account/view/renderer.js
, 3-account/view/template.html
.
We now have a permanent connection to the XRPL and some code to bring the delivered data to life on our screen, it's time to add some "wallet" functionality by managing an individual account.
We will ask the user for address of the account to monitor by using a HTML dialog element. We will furthermore refactor the application by encapsulating some functionality in a library. After finishing this step the application should look like this:
- In the project root, create a new directory named
library
. Inside this directory, create a file3_helpers.js
with the following content:
3_helpers.js
const xrpl = require("xrpl"); // The rippled server and its APIs represent time as an unsigned integer. // This number measures the number of seconds since the "Ripple Epoch" of // January 1, 2000 (00:00 UTC). This is like the way the Unix epoch works, // Reference: https://xrpl.org/basic-data-types.html const RIPPLE_EPOCH = 946684800; const prepareAccountData = (rawAccountData) => { return { classicAddress: rawAccountData.Account, xAddress: xrpl.classicAddressToXAddress(rawAccountData.Account, false, true), xrpBalance: xrpl.dropsToXrp(rawAccountData.Balance) } } const prepareLedgerData = (rawLedgerData) => { const timestamp = RIPPLE_EPOCH + (rawLedgerData.ledger_time ?? rawLedgerData.close_time) const dateTime = new Date(timestamp * 1000) const dateTimeString = dateTime.toLocaleDateString() + ' ' + dateTime.toLocaleTimeString() return { ledgerIndex: rawLedgerData.ledger_index, ledgerHash: rawLedgerData.ledger_hash, ledgerCloseTime: dateTimeString } } module.exports = { prepareAccountData, prepareLedgerData }
Here we define three utility functions that will transform data we receive from the ledger into flat value objects for easy digestion in the frontend code. As we progress in this tutorial, we will keep this pattern of adding functionality by adding files that are prefixed by the step number.
- Modify
index.js
and addipcMain
to the imports on therequire('electron')
line. Then add the new helper file at the bottom of the include section:
// Step 3 code additions - start const { app, BrowserWindow, ipcMain} = require('electron') // Step 3 code additions - end const path = require('path') const xrpl = require("xrpl") // Step 3 code additions - start const { prepareAccountData, prepareLedgerData } = require('./library/3_helpers') // Step 3 code additions - end const TESTNET_URL = "wss://s.altnet.rippletest.net:51233"
- Modify
index.js
in the following way:
const main = async () => { const appWindow = createWindow() // Step 3 code modifications - start ipcMain.on('address-entered', async (event, address) => { const client = new xrpl.Client(TESTNET_URL) await client.connect() // Reference: https://xrpl.org/subscribe.html await client.request({ "command": "subscribe", "streams": ["ledger"], "accounts": [address] }) // Reference: https://xrpl.org/subscribe.html#ledger-stream client.on("ledgerClosed", async (rawLedgerData) => { const ledger = prepareLedgerData(rawLedgerData) appWindow.webContents.send('update-ledger-data', ledger) }) // Initial Ledger Request -> Get account details on startup // Reference: https://xrpl.org/ledger.html const ledgerResponse = await client.request({ "command": "ledger" }) const initialLedgerData = prepareLedgerData(ledgerResponse.result.closed.ledger) appWindow.webContents.send('update-ledger-data', initialLedgerData) // Reference: https://xrpl.org/subscribe.html#transaction-streams client.on("transaction", async (transaction) => { // Reference: https://xrpl.org/account_info.html const accountInfoRequest = { "command": "account_info", "account": address, "ledger_index": transaction.ledger_index } const accountInfoResponse = await client.request(accountInfoRequest) const accountData = prepareAccountData(accountInfoResponse.result.account_data) appWindow.webContents.send('update-account-data', accountData) }) // Initial Account Request -> Get account details on startup // Reference: https://xrpl.org/account_info.html const accountInfoResponse = await client.request({ "command": "account_info", "account": address, "ledger_index": "current" }) const accountData = prepareAccountData(accountInfoResponse.result.account_data) appWindow.webContents.send('update-account-data', accountData) }) // Step 3 code modifications - end }
As the account we want to query is known only after the user enters an address, we had to wrap our application logic into an event handler:
ipcMain.on('address-entered', async (event, address) => { // ... })
In addition to the subscription to the ledger stream we also can subscribe the client to specific addresses, and we use this feature here to subscribe to an account address which we are going to prompt the user for:
await client.request({ "command": "subscribe", "streams": ["ledger"], "accounts": [address] })
After this subscription our code attached listeners to the ledgerClosed
and the transactions
event. As soon as a transaction
event is triggered, we do an account_info
request to get the latest account status, as the transaction could have changed our account's state.
In addition to the subscriptions we added an initial ledger
and accountInfo
request to have some data at application startup. Otherwise we would see empty fields until something happened on the ledger that triggered one of our subscriptions.
- Now, add the following code to
preload.js
:
const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { onUpdateLedgerData: (callback) => { ipcRenderer.on('update-ledger-data', callback) }, // Step 3 code additions - start onEnterAccountAddress: (address) => { ipcRenderer.send('address-entered', address) }, onUpdateAccountData: (callback) => { ipcRenderer.on('update-account-data', callback) } //Step 3 code additions - end })
Here is a notable difference from the previous step: previously we only used ipcRenderer
to send events from the main logic to the frontend, but now we use it bidirectionally, also sending events from the frontend to the main logic:
onEnterAccountAddress: (address) => { ipcRenderer.send('address-entered', address) }
- Then, modify the
view/template.html
by replacing the<body>
with this markup:
<body> <h3>Build a XRPL Wallet - Part 3/8</h3> <fieldset> <legend>Account info</legend> Classic Address: <strong id="account-address-classic"></strong><br/> X-Address: <strong id="account-address-x"></strong><br/> XRP Balance: <strong id="account-balance"></strong><br/> </fieldset> <fieldset> <legend>Latest validated ledger stats</legend> Ledger Index: <strong id="ledger-index"></strong><br/> Ledger Hash: <strong id="ledger-hash"></strong><br/> Close Time: <strong id="ledger-close-time"></strong><br/> </fieldset> <dialog id="account-address-dialog"> <form method="dialog"> <div> <label for="address-input">Enter account address:</label> <input type="text" id="address-input" name="address-input" /> </div> <div> <button type="submit">Confirm</button> </div> </form> </dialog> </body>
- To incorporate the refactored markup, handle the HTML dialog element and well as the new account data section replace the contents of
view/renderer.js
with the following code:
document.addEventListener('DOMContentLoaded', openAccountAddressDialog); function openAccountAddressDialog(){ const accountAddressDialog = document.getElementById('account-address-dialog'); const accountAddressInput = accountAddressDialog.querySelector('input'); const submitButton = accountAddressDialog.querySelector('button[type="submit"]'); submitButton.addEventListener('click', () => { const address = accountAddressInput.value; window.electronAPI.onEnterAccountAddress(address) accountAddressDialog.close() }); accountAddressDialog.showModal() } const ledgerIndexEl = document.getElementById('ledger-index') const ledgerHashEl = document.getElementById('ledger-hash') const ledgerCloseTimeEl = document.getElementById('ledger-close-time') window.electronAPI.onUpdateLedgerData((_event, ledger) => { ledgerIndexEl.innerText = ledger.ledgerIndex ledgerHashEl.innerText = ledger.ledgerHash ledgerCloseTimeEl.innerText = ledger.ledgerCloseTime }) const accountAddressClassicEl = document.getElementById('account-address-classic') const accountAddressXEl = document.getElementById('account-address-x') const accountBalanceEl = document.getElementById('account-balance') window.electronAPI.onUpdateAccountData((_event, value) => { accountAddressClassicEl.innerText = value.classicAddress accountAddressXEl.innerText = value.xAddress accountBalanceEl.innerText = value.xrpBalance })
The parts at the beginning and end are totally new, but the section in the middle is almost the same as before. The difference is that the names of a few fields have changed, since we're now passing them from prepareLedgerData(...)
. For example ledger.ledger_time
is now ledger.ledgerCloseTime
instead.
Now you need an XRPL account address to monitor. If you already have one or know where to find an example, you can now run the application by executing:
npm run start
If you need a Testnet address, you can get one from the faucet. Then you can paste that address into the Transaction Sender to send XRP transactions that your app can see.
On, startup, the application should display a simple dialog prompting the user for an XRPL account address. After entering the address the application should display some basic information about that account and about the ledger.
To run the reference application found in _code-samples/build-a-desktop-wallet/desktop-js
for this step, run:
npm run account
4. Show Account's Transactions
Full code for this step: library/3_helpers.js
, library/4_helpers.js
, 4-tx-history/index.js
, 4-tx-history/view/preload.js
, 4-tx-history/view/renderer.js
, 4-tx-history/view/index.html
.
At this point, our wallet shows the account's balance getting updated, but doesn't give us any clue about how this state came about, namely the actual transactions that caused the updates. So, our next step is to display the account's up to date transaction history using subscriptions once again:
- In the
library
folder, add a new file4_helpers.js
. Then add the following helper function to that file:
const xrpl = require("xrpl"); const prepareTxData = (transactions) => { return transactions.map(transaction => { let tx_value = "-" if (transaction.meta !== undefined && transaction.meta.delivered_amount !== undefined) { tx_value = getDisplayableAmount(transaction.meta.delivered_amount) } return { confirmed: transaction.tx.date, type: transaction.tx.TransactionType, from: transaction.tx.Account, to: transaction.tx.Destination ?? "-", value: tx_value, hash: transaction.tx.hash } }) } const getDisplayableAmount = (rawAmount) => { if (rawAmount === 'unavailable') { // Special case for pre-2014 partial payments. return rawAmount } else if (typeof rawAmount === 'string') { // It's an XRP amount in drops. Convert to decimal. return xrpl.dropsToXrp(rawAmount) + ' XRP' } else { //It's a token (IOU) amount. return rawAmount.value + ' ' + rawAmount.currency } } module.exports = { prepareTxData }
- Now, in
index.js
, require the new helper function at the bottom of the import section like so:
const { prepareAccountData, prepareLedgerData} = require('./library/3_helpers') const { prepareTxData } = require('./library/4_helpers')
- In
index.js
, update the listener function subscribed to thetransaction
to update the transaction table as new transactions come in:
// Wait for transaction on subscribed account and re-request account data client.on("transaction", async (transaction) => { // Reference: https://xrpl.org/account_info.html const accountInfoRequest = { "command": "account_info", "account": address, "ledger_index": transaction.ledger_index } const accountInfoResponse = await client.request(accountInfoRequest) const accountData = prepareAccountData(accountInfoResponse.result.account_data) appWindow.webContents.send('update-account-data', accountData) // Step 4 code additions - start const transactions = prepareTxData([{tx: transaction.transaction}]) appWindow.webContents.send('update-transaction-data', transactions) // Step 4 code additions - end })
- Still in
index.js
, add a new section after the initial account request to populate the transactions table when the application starts:
// Initial Account Request -> Get account details on startup // Reference: https://xrpl.org/account_info.html const accountInfoResponse = await client.request({ "command": "account_info", "account": address, "ledger_index": "current" }) const accountData = prepareAccountData(accountInfoResponse.result.account_data) appWindow.webContents.send('update-account-data', accountData) // Step 4 code additions - start // Initial Transaction Request -> List account transactions on startup // Reference: https://xrpl.org/account_tx.html const txResponse = await client.request({ "command": "account_tx", "account": address }) const transactions = prepareTxData(txResponse.result.transactions) appWindow.webContents.send('update-transaction-data', transactions) // Step 4 code additions - end
- In
view/preload.js
, add the following code at the bottom ofexposeInMainWorld()
:
contextBridge.exposeInMainWorld('electronAPI', { onUpdateLedgerData: (callback) => { ipcRenderer.on('update-ledger-data', callback) }, onEnterAccountAddress: (address) => { ipcRenderer.send('address-entered', address) }, onUpdateAccountData: (callback) => { ipcRenderer.on('update-account-data', callback) }, // Step 4 code additions - start onUpdateTransactionData: (callback) => { ipcRenderer.on('update-transaction-data', callback) } // Step 4 code additions - end })
- Modify
view/template.html
by adding a new fieldset below the ones that are already there:
... Close Time: <strong id="ledger-close-time"></strong><br/> </fieldset> <!-- Step 4 code additions - start --> <fieldset> <legend>Transactions:</legend> <table id="tx-table"> <thead> <tr> <th>Confirmed</th> <th>Type</th> <th>From</th> <th>To</th> <th>Value Delivered</th> <th>Hash</th> </tr> </thead> <tbody></tbody> </table> </fieldset> <!-- Step 4 code additions - end --> <dialog id="account-address-dialog"> <form method="dialog">
The table here will be filled dynamically with the account's transactions.
- Add the following code at the bottom of
view/renderer.js
:
const txTableBodyEl = document.getElementById('tx-table').tBodies[0] window.testEl = txTableBodyEl window.electronAPI.onUpdateTransactionData((_event, transactions) => { for (let transaction of transactions) { txTableBodyEl.insertAdjacentHTML( 'beforeend', "<tr>" + "<td>" + transaction.confirmed + "</td>" + "<td>" + transaction.type + "</td>" + "<td>" + transaction.from + "</td>" + "<td>" + transaction.to + "</td>" + "<td>" + transaction.value + "</td>" + "<td>" + transaction.hash + "</td>" + "</tr>" ) } })
If you have come this far - congrats. Run the application by executing:
npm run start
Our application should by now display all the stuff from the last step plus an additional list of transactions associated with the given account.
To run the reference application found in _code-samples/build-a-desktop-wallet/desktop-js
for this step, run:
npm run tx-history
5. Saving the Private Keys with a Password
Full code for this step: library/3_helpers.js
. library/4_helpers.js
. library/5_helpers.js
. 5-password/index.js
. 5-password/view/preload.js
. 5-password/view/template.html
. 5-password/view/renderer.js
.
After finishing this step the application should look like this:
By now we always query the user for an account address at application startup. We more or less have a monitoring tool for accounts that queries publicly available data. Because we want to have real wallet functionality including sending XRP, we will have to deal with private keys and seeds.
In this step we will query the user for a seed and a password they can use to access it later. In order to protect the password, we'll add a salt to the password.
- In the
library
folder, add a new file5_helpers.js
with the following content:
const {prepareAccountData, prepareLedgerData} = require("./3_helpers"); const {prepareTxData} = require("./4_helpers"); const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); const fernet = require("fernet"); /** * Fetches some initial data to be displayed on application startup * * @param client * @param wallet * @param appWindow * @returns {Promise<void>} */ const initialize = async (client, wallet, appWindow) => { // Reference: https://xrpl.org/account_info.html const accountInfoResponse = await client.request({ "command": "account_info", "account": wallet.address, "ledger_index": "current" }) const accountData = prepareAccountData(accountInfoResponse.result.account_data) appWindow.webContents.send('update-account-data', accountData) // Reference: https://xrpl.org/account_tx.html const txResponse = await client.request({ "command": "account_tx", "account": wallet.address }) const transactions = prepareTxData(txResponse.result.transactions) appWindow.webContents.send('update-transaction-data', transactions) } /** * Handles the subscriptions to ledger events and the internal routing of the responses * * @param client * @param wallet * @param appWindow * @returns {Promise<void>} */ const subscribe = async (client, wallet, appWindow) => { // Reference: https://xrpl.org/subscribe.html await client.request({ "command": "subscribe", "streams": ["ledger"], "accounts": [wallet.address] }) // Reference: https://xrpl.org/subscribe.html#ledger-stream client.on("ledgerClosed", async (rawLedgerData) => { const ledger = prepareLedgerData(rawLedgerData) appWindow.webContents.send('update-ledger-data', ledger) }) // Wait for transaction on subscribed account and re-request account data client.on("transaction", async (transaction) => { // Reference: https://xrpl.org/account_info.html const accountInfoRequest = { "command": "account_info", "account": wallet.address, "ledger_index": transaction.ledger_index } const accountInfoResponse = await client.request(accountInfoRequest) const accountData = prepareAccountData(accountInfoResponse.result.account_data) appWindow.webContents.send('update-account-data', accountData) const transactions = prepareTxData([{tx: transaction.transaction}]) appWindow.webContents.send('update-transaction-data', transactions) }) } /** * Saves the wallet seed using proper cryptographic functions * * @param WALLET_DIR * @param seed * @param password */ const saveSaltedSeed = (WALLET_DIR, seed, password)=> { const salt = crypto.randomBytes(20).toString('hex') fs.writeFileSync(path.join(__dirname, WALLET_DIR, 'salt.txt'), salt); // Hashing salted password using Password-Based Key Derivation Function 2 const derivedKey = crypto.pbkdf2Sync(password, salt, 1000, 32, 'sha256') // Generate a Fernet secret we can use for symmetric encryption const secret = new fernet.Secret(derivedKey.toString('base64')); // Generate encryption token with secret, time and initialization vector // In a real-world use case we would have current time and a random IV, // but for demo purposes being deterministic is just fine const token = new fernet.Token({ secret: secret, time: Date.parse(1), iv: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] }) const privateKey = token.encode(seed) fs.writeFileSync(path.join(__dirname, WALLET_DIR, 'seed.txt'), privateKey) } /** * Loads the plaintext value of the encrypted seed * * @param WALLET_DIR * @param password * @returns {*} */ const loadSaltedSeed = (WALLET_DIR, password) => { const salt = fs.readFileSync(path.join(__dirname, WALLET_DIR, 'salt.txt')).toString() const encodedSeed = fs.readFileSync(path.join(__dirname, WALLET_DIR, 'seed.txt')).toString() // Hashing salted password using Password-Based Key Derivation Function 2 const derivedKey = crypto.pbkdf2Sync(password, salt, 1000, 32, 'sha256') // Generate a Fernet secret we can use for symmetric encryption const secret = new fernet.Secret(derivedKey.toString('base64')); // Generate decryption token const token = new fernet.Token({ secret: secret, token: encodedSeed, ttl: 0 }) return token.decode(); } module.exports = { initialize, subscribe, saveSaltedSeed, loadSaltedSeed }
- Modify the import section at the top of
index.js
to look like this:
const {app, BrowserWindow, ipcMain} = require('electron') const fs = require('fs') const path = require('path') const xrpl = require("xrpl") const { initialize, subscribe, saveSaltedSeed, loadSaltedSeed } = require('./library/5_helpers') const TESTNET_URL = "wss://s.altnet.rippletest.net:51233" const WALLET_DIR = 'Wallet' const createWindow = () => {
Note that we have reduced the imports to one line since the new helper file imports and re-exports the other helper functions.
We also added a new constant containing the directory name where we are going to store our encrypted seed.
- In
index.js
replace the existingmain
function with the following one:
const main = async () => { const appWindow = createWindow() // Create Wallet directory in case it does not exist yet if (!fs.existsSync(path.join(__dirname, WALLET_DIR))) { fs.mkdirSync(path.join(__dirname, WALLET_DIR)); } let seed = null; ipcMain.on('seed-entered', async (event, providedSeed) => { seed = providedSeed appWindow.webContents.send('open-password-dialog') }) ipcMain.on('password-entered', async (event, password) => { if (!fs.existsSync(path.join(__dirname, WALLET_DIR , 'seed.txt'))) { saveSaltedSeed('../' + WALLET_DIR, seed, password) } else { try { seed = loadSaltedSeed('../' + WALLET_DIR, password) } catch (error) { appWindow.webContents.send('open-password-dialog', true) return } } const wallet = xrpl.Wallet.fromSeed(seed) const client = new xrpl.Client(TESTNET_URL) await client.connect() await subscribe(client, wallet, appWindow) await initialize(client, wallet, appWindow) }) ipcMain.on('request-seed-change', (event) => { fs.rmSync(path.join(__dirname, WALLET_DIR , 'seed.txt')) fs.rmSync(path.join(__dirname, WALLET_DIR , 'salt.txt')) appWindow.webContents.send('open-seed-dialog') }) // We have to wait for the application frontend to be ready, otherwise // we might run into a race condition and the open-dialog events // get triggered before the callbacks are attached appWindow.once('ready-to-show', () => { // If there is no seed present yet, ask for it, otherwise query for the password // for the seed that has been saved if (!fs.existsSync(path.join(__dirname, WALLET_DIR, 'seed.txt'))) { appWindow.webContents.send('open-seed-dialog') } else { appWindow.webContents.send('open-password-dialog') } }) }
Since we are now making this a full-fledged wallet, instead of asking the user for an address we will now be prompting the user for a seed and password to encrypt the seed. If there is already a seed, the user will only be asked for their password.
- Then modify the
view/preload.js
file (Note that theonEnterAccountAddress
function is no longer needed):
contextBridge.exposeInMainWorld('electronAPI', { // Step 5 code additions - start onOpenSeedDialog: (callback) => { ipcRenderer.on('open-seed-dialog', callback) }, onEnterSeed: (seed) => { ipcRenderer.send('seed-entered', seed) }, onOpenPasswordDialog: (callback) => { ipcRenderer.on('open-password-dialog', callback) }, onEnterPassword: (password) => { ipcRenderer.send('password-entered', password) }, requestSeedChange: () => { ipcRenderer.send('request-seed-change') }, // Step 5 code additions - end onUpdateLedgerData: (callback) => { ipcRenderer.on('update-ledger-data', callback) },
- Then, in
view/template.html
, replace the existing HTML dialog element for the account with the new ones for seed and password:
<dialog id="seed-dialog"> <form method="dialog"> <div> <label for="seed-input">Enter seed:</label> <input type="text" id="seed-input" name="seed-input" /> </div> <div> <button type="submit">Confirm</button> </div> </form> </dialog> <dialog id="password-dialog"> <form method="dialog"> <div> <label for="password-input">Enter password (min-length 5):</label> <input type="text" id="password-input" name="password-input" /><br /> <span class="invalid-password"></span> </div> <div> <button type="button">Change Seed</button> <button type="submit">Submit</button> </div> </form> </dialog>
- In
view/renderer.js
, replace theopenAccountAddressDialog
part at the top:
// Remove the following section in Step 5 document.addEventListener('DOMContentLoaded', openAccountAddressDialog); function openAccountAddressDialog(){ const accountAddressDialog = document.getElementById('account-address-dialog'); const accountAddressInput = accountAddressDialog.querySelector('input'); const submitButton = accountAddressDialog.querySelector('button[type="submit"]'); submitButton.addEventListener('click', () => { const address = accountAddressInput.value; window.electronAPI.onEnterAccountAddress(address) accountAddressDialog.close() }); accountAddressDialog.showModal() }
With this code:
const seedDialog = document.getElementById('seed-dialog') const seedInput = seedDialog.querySelector('input') const seedSubmitButton = seedDialog.querySelector('button[type="submit"]') const seedSubmitFn = () => { const seed = seedInput.value window.electronAPI.onEnterSeed(seed) seedDialog.close() } window.electronAPI.onOpenSeedDialog((_event) => { seedSubmitButton.addEventListener('click', seedSubmitFn, {once : true}); seedDialog.showModal() }) const passwordDialog = document.getElementById('password-dialog') const passwordInput = passwordDialog.querySelector('input') const passwordError = passwordDialog.querySelector('span.invalid-password') const passwordSubmitButton = passwordDialog.querySelector('button[type="submit"]') const changeSeedButton = passwordDialog.querySelector('button[type="button"]') const handlePasswordSubmitFn = () => { const password = passwordInput.value window.electronAPI.onEnterPassword(password) passwordDialog.close() } const handleChangeSeedFn = () => { passwordDialog.close() window.electronAPI.requestSeedChange() } window.electronAPI.onOpenPasswordDialog((_event, showInvalidPassword = false) => { if (showInvalidPassword) { passwordError.innerHTML = 'INVALID PASSWORD' } passwordSubmitButton.addEventListener('click', handlePasswordSubmitFn, {once : true}); changeSeedButton.addEventListener('click', handleChangeSeedFn, {once : true}); passwordDialog.showModal() });
Start up the application:
npm run start
On first run, It should first prompt you for an account seed and then for a password.
You can generate a test account's seed via the testnet faucet here.
After you have created a wallet this way, you should close the application and start it up a second time: On second run it should prompt you for the password, and you should see the same result as in the last step.
To run the reference application found in _code-samples/build-a-desktop-wallet/desktop-js
for this step, run:
npm run password
6. Styling
Full code for this step: library/3_helpers.js
, library/4_helpers.js
, library/5_helpers.js
, 6-styling/index.js
, 6-styling/view/preload.js
, 6-styling/view/template.html
, 6-styling/view/renderer.js
, bootstrap/bootstrap.bundle.min.js
, bootstrap/bootstrap.min.css
, bootstrap/custom.css
, bootstrap/XRPLedger_DevPortal-white.svg
.
After finishing this step the application should look like this:
In the project root, create a new folder
bootstrap
and add the following files into that directory:bootstrap.bundle.min.js
,bootstrap.min.css
,custom.css
,XRPLedger_DevPortal-white.svg
Change the content of
view/template.html
to be the following code:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"/> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>XRPL Wallet Tutorial (JavaScript / Electron)</title> <link rel="stylesheet" href="../bootstrap/bootstrap.min.css"/> <link rel="stylesheet" href="../bootstrap/custom.css"/> </head> <body> <main class="bg-light"> <div class="sidebar d-flex flex-column flex-shrink-0 p-3 text-white bg-dark"> <a href="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none"> <img class="logo" height="40"/> </a> <hr> <ul class="nav nav-pills flex-column mb-auto" role="tablist"> <li class="nav-item"> <button class="nav-link active" id="dashboard-tab" data-bs-toggle="tab" data-bs-target="#dashboard" type="button" role="tab" aria-controls="dashboard" aria-selected="true"> Dashboard </button> </li> <li> <button class="nav-link" data-bs-toggle="tab" id="transactions-tab" data-bs-target="#transactions" type="button" role="tab" aria-controls="transactions" aria-selected="false"> Transactions </button> </li> </ul> </div> <div class="divider"></div> <div class="main-content tab-content d-flex flex-column flex-shrink-0 p-3"> <div class="header border-bottom"> <h3> Build a XRPL Wallet <small class="text-muted">- Part 6/8</small> </h3> </div> <div class="tab-pane fade show active" id="dashboard" role="tabpanel" aria-labelledby="dashboard-tab"> <h3>Account:</h3> <ul class="list-group"> <li class="list-group-item">Classic Address: <strong id="account-address-classic"></strong></li> <li class="list-group-item">X-Address: <strong id="account-address-x"></strong></li> <li class="list-group-item">XRP Balance: <strong id="account-balance"></strong></li> </ul> <div class="spacer"></div> <h3> Ledger <small class="text-muted">(Latest validated ledger)</small> </h3> <ul class="list-group"> <li class="list-group-item">Ledger Index: <strong id="ledger-index"></strong></li> <li class="list-group-item">Ledger Hash: <strong id="ledger-hash"></strong></li> <li class="list-group-item">Close Time: <strong id="ledger-close-time"></strong></li> </ul> </div> <div class="tab-pane fade" id="transactions" role="tabpanel" aria-labelledby="transactions-tab"> <h3>Transactions:</h3> <table id="tx-table" class="table"> <thead> <tr> <th>Confirmed</th> <th>Type</th> <th>From</th> <th>To</th> <th>Value Delivered</th> <th>Hash</th> </tr> </thead> <tbody></tbody> </table> </div> </div> </main> <dialog id="seed-dialog"> <form method="dialog"> <div> <label for="seed-input">Enter seed:</label> <input type="text" id="seed-input" name="seed-input" /> </div> <div> <button type="submit">Confirm</button> </div> </form> </dialog> <dialog id="password-dialog"> <form method="dialog"> <div> <label for="password-input">Enter password (min-length: 5):</label> <input type="text" id="password-input" name="password-input" /> </div> <div> <button type="button">Change Seed</button> <button type="submit">Confirm</button> </div> </form> </dialog> </body> <script src="../bootstrap/bootstrap.bundle.min.js"></script> <script src="renderer.js"></script> </html>
Here we basically added the Boostrap Framework and a little custom styling to our application. We'll leave it at that for this Step - to get the application running at this stage of development, run the following command:
npm run start
This time the application should display a dashboard like layout with the XRP logo and a navigation ono the left side, and a content area comprising most of the screen. You should be able to switch from the dashboard to the transactions list and back.
To run the reference application found in _code-samples/build-a-desktop-wallet/desktop-js
for this step, run:
npm run styling
7. Send XRP
Full code for this step: library/3_helpers.js
, library/4_helpers.js
, library/5_helpers.js
, library/6_helpers.js
, library/7_helpers.js
, 7-send-xrp/index.js
, 7-send-xrp/view/preload.js
, 7-send-xrp/view/renderer.js
, 7-send-xrp/view/template.html
.
Up until now we have enabled our app to query and display data from the XRPL. Now it's time to actively participate in the ledger by enabling our application to send transactions. For now, we can stick to sending direct XRP payments because there are more complexities involved in sending issued tokens. After finishing this step the application should look like this:
- Create the file
library/7_helpers.js
and add the following contents:
const xrpl = require("xrpl"); /** * Prepares, signs and submits a payment transaction * * @param paymentData * @param client * @param wallet * @returns {Promise<*>} */ const sendXrp = async (paymentData, client, wallet) => { // Reference: https://xrpl.org/submit.html#request-format-1 const paymentTx = { "TransactionType": "Payment", "Account": wallet.address, "Amount": xrpl.xrpToDrops(paymentData.amount), "Destination": paymentData.destinationAddress, "DestinationTag": parseInt(paymentData.destinationTag) } const preparedTx = await client.autofill(paymentTx) const signedTx = wallet.sign(preparedTx) return await client.submitAndWait(signedTx.tx_blob) } module.exports = { sendXrp }
(There was no 6-helpers.js
, so don't worry!) 2. Add the new function to the import section in index.js
:
const { initialize, subscribe, saveSaltedSeed, loadSaltedSeed } = require('./library/5_helpers') const { sendXrp } = require('./library/7_helpers')
- Still in
index.js
, add an event listener handling thesend-xrp-event
from the frontend dialog:
await initialize(client, wallet, appWindow) // Step 7 code additions - start ipcMain.on('send-xrp-action', (event, paymentData) => { sendXrp(paymentData, client, wallet).then((result) => { appWindow.webContents.send('send-xrp-transaction-finish', result) }) }) // Step 7 code additions - start
- Modify
view/preload.js
by adding two new functions:
onClickSendXrp: (paymentData) => { ipcRenderer.send('send-xrp-action', paymentData) }, onSendXrpTransactionFinish: (callback) => { ipcRenderer.on('send-xrp-transaction-finish', callback) }
- In
view/template.html
, add a button to toggle the modal dialog housing the "Send XRP" logic:
<div class="header border-bottom"> <h3> Build a XRPL Wallet <small class="text-muted">- Part 7/8</small> </h3> <!-- Step 7 code additions - start --> <button type="button" class="btn btn-primary" id="send-xrp-modal-button"> Send XRP </button> <!-- Step 7 code additions - end --> </div>
- In the same file, at the end of the
<main>
section, add said modal dialog:
<div class="modal fade" id="send-xrp-modal" tabindex="-1"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h1 class="modal-title fs-5" id="send-xrp-modal-label">Send XRP</h1> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> </div> <div class="modal-body"> <div class="input-group mb-3"> <input type="text" class="form-control" placeholder="r9jEyy3nrB8D7uRc5w2k3tizKQ1q8cpeHU" id="input-destination-address"> <span class="input-group-text">To (Address)</span> </div> <div class="input-group mb-3"> <input type="text" class="form-control" placeholder="12345" id="input-destination-tag"> <span class="input-group-text">Destination Tag</span> </div> <div class="input-group mb-3"> <input type="text" class="form-control" placeholder="100" id="input-xrp-amount"> <span class="input-group-text">Amount of XRP</span> </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-primary" id="send-xrp-submit-button">Send</button> </div> </div> </div> </div>
- Add the following code to the bottom of
view/renderer.js
:
const modalButton = document.getElementById('send-xrp-modal-button') const modalDialog = new bootstrap.Modal(document.getElementById('send-xrp-modal')) modalButton.addEventListener('click', () => { modalDialog.show() }) const destinationAddressEl = document.getElementById('input-destination-address') const destinationTagEl = document.getElementById('input-destination-tag') const amountEl = document.getElementById('input-xrp-amount') const sendXrpButtonEl = document.getElementById('send-xrp-submit-button') sendXrpButtonEl.addEventListener('click', () => { modalDialog.hide() const destinationAddress = destinationAddressEl.value const destinationTag = destinationTagEl.value const amount = amountEl.value window.electronAPI.onClickSendXrp({destinationAddress, destinationTag, amount}) }) window.electronAPI.onSendXrpTransactionFinish((_event, result) => { alert('Result: ' + result.result.meta.TransactionResult) destinationAddressEl.value = '' destinationTagEl.value = '' amountEl.value = '' })
Now, Run the following command:
npm run start
The application should now display a "Send XRP" button in the top right corner, which should open a Modal dialog on clicking. You can use this Dialog to send XRP tokens from this account to another, and the balance update as well as the transaction should be reflected in the app.
If you need an account address to send the XRP to, you can create an account on the testnet.
To run the reference application found in _code-samples/build-a-desktop-wallet/desktop-js
for this step, run:
npm run send-xrp
8. Domain Verification and Polish
Full code for this step: library/3_helpers.js
, library/4_helpers.js
, library/5_helpers.js
, library/6_helpers.js
, library/7_helpers.js
, library/8_helpers.js
, 8-domain-verification/index.js
, 8-domain-verification/view/8_prelaod.js
, 8-domain-verification/view/8_domain-verification.html
, 8-domain-verification/view/8_renderer.js
.
One of the biggest shortcomings of the wallet app from the previous step is that it doesn't provide a lot of protections or feedback for users to save them from human error and scams. These sorts of protections are extra important when dealing with the cryptocurrency space because decentralized systems like the XRP Ledger don't have an admin or support team one can ask to cancel or refund a payment if you made a mistake such as sending it to the wrong address.
This step shows how to add some checks on destination addresses to warn the user before sending XRP.
One type of check we could make is to verify the domain name associated with an XRP Ledger address; this is called account domain verification. When an account's domain is verified, we can could show it like this:
- In the
library
folder, add a new file8_helpers.js
. Then add the following contents to that file:
const fetch = require('node-fetch') const toml = require('toml'); const { convertHexToString } = require("xrpl/dist/npm/utils/stringConversion"); const lsfDisallowXRP = 0x00080000; /* Example lookups |------------------------------------|---------------|-----------| | Address | Domain | Verified | |------------------------------------|---------------|-----------| | rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW | mduo13.com | YES | | rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn | xrpl.org | NO | | rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe | n/a | NO | |------------------------------------|---------------|-----------| */ /** * Check a potential destination address's details, and pass them back to the "Send XRP" dialog: * - Is the account funded? If not, payments below the reserve base will fail * - Do they have DisallowXRP enabled? If so, the user should be warned they don't want XRP, but can click through. * - Do they have a verified Domain? If so, we want to show the user the associated domain info. * * @param accountData * @returns {Promise<{domain: string, verified: boolean}|{domain: string, verified: boolean}>} */ async function checkDestination(accountData) { const accountStatus = { "funded": null, "disallow_xrp": null, "domain_verified": null, "domain_str": "" // the decoded domain, regardless of verification } accountStatus["disallow_xrp"] = !!(accountData & lsfDisallowXRP); return verifyAccountDomain(accountData) } /** * Verify an account using a xrp-ledger.toml file. * https://xrpl.org/xrp-ledger-toml.html#xrp-ledgertoml-file * * @param accountData * @returns {Promise<{domain: string, verified: boolean}>} */ async function verifyAccountDomain(accountData) { const domainHex = accountData["Domain"] if (!domainHex) { return { domain:"", verified: false } } let verified = false const domain = convertHexToString(domainHex) const tomlUrl = `https://${domain}/.well-known/xrp-ledger.toml` const tomlResponse = await fetch(tomlUrl) if (!tomlResponse.ok) { return { domain: domain, verified: false } } const tomlData = await tomlResponse.text() const parsedToml = toml.parse(tomlData) const tomlAccounts = parsedToml["ACCOUNTS"] for (const tomlAccount of tomlAccounts) { if (tomlAccount["address"] === accountData["Account"]) { verified = true } } return { domain: domain, verified: verified } } /** * Verifies if a given address has validated status * * @param accountAddress * @param client * @returns {Promise<{domain: string, verified: boolean}>} */ async function verify(accountAddress, client) { // Reference: https://xrpl.org/account_info.html const request = { "command": "account_info", "account": accountAddress, "ledger_index": "validated" } try { const response = await client.request(request) return await checkDestination(response.result.account_data) } catch { return { domain: 'domain', verified: false } } } module.exports = { verify }
The code in 8_helpers.js
looks up the account on the ledger by sending an account_info
request.
If the account does exist, the code checks for the lsfDisallowXRP
flag.
- Import the new helper function in
index.js
:
const { initialize, subscribe, saveSaltedSeed, loadSaltedSeed } = require('./library/5_helpers') const { sendXrp } = require('./library/7_helpers') // Step 8 code additions - start const { verify } = require('./library/8_helpers') // Step 8 code additions - end
- After the callback function
ipcMain.on('send-xrp-action', callback)
add the following event handler:
ipcMain.on('send-xrp-action', (event, paymentData) => { sendXrp(paymentData, client, wallet).then((result) => { appWindow.webContents.send('send-xrp-transaction-finish', result) }) }) // Step 8 code additions - start ipcMain.on('destination-account-change', (event, destinationAccount) => { verify(destinationAccount, client).then((result) => { appWindow.webContents.send('update-domain-verification-data', result) }) }) // Step 8 code additions - end
- Modify
view/preload.js
and add the following two functions to'electronAPI'
:
onDestinationAccountChange: (callback) => { ipcRenderer.send('destination-account-change', callback) }, onUpdateDomainVerificationData: (callback) => { ipcRenderer.on('update-domain-verification-data', callback) }
Finally, the code decodes the account's Domain
field, if present, and performs domain verification using the method imported above.
- Update the view logic - in
view/template.html
add the following lines just before the<input>
element withid="input-destination-address
:
<div class="input-group mb-3"> <!-- Step 8 code additions - start --> <div class="accountVerificationIndicator"> <span>Verification status:</span> </div> <!-- Step 8 code additions - end --> <input type="text" class="form-control" placeholder="r9jEyy3nrB8D7uRc5w2k3tizKQ1q8cpeHU" id="input-destination-address"> <span class="input-group-text">To (Address)</span> </div>
- Lastly, modify the renderer as described below:
modalButton.addEventListener('click', () => { modalDialog.show() }) // Step 8 code additions - start const accountVerificationEl = document.querySelector('.accountVerificationIndicator span') // Step 8 code additions - end const destinationAddressEl = document.getElementById('input-destination-address') const destinationTagEl = document.getElementById('input-destination-tag') const amountEl = document.getElementById('input-xrp-amount') const sendXrpButtonEl = document.getElementById('send-xrp-submit-button') // Step 8 code additions - start destinationAddressEl.addEventListener('input', (event) => { window.electronAPI.onDestinationAccountChange(destinationAddressEl.value) }) window.electronAPI.onUpdateDomainVerificationData((_event, result) => { accountVerificationEl.textContent = `Domain: ${result.domain || 'n/a'} Verified: ${result.verified}` }) // Step 8 code additions - end sendXrpButtonEl.addEventListener('click', () => { modalDialog.hide() const destinationAddress = destinationAddressEl.value const destinationTag = destinationTagEl.value const amount = amountEl.value window.electronAPI.onClickSendXrp({destinationAddress, destinationTag, amount}) })
Run the following command:
npm run start
Test your wallet app the same way you did in the previous steps. It should display a hint about the receiving account when opening up the "Send XRP" dialog and entering the address. To test domain verification, try entering the following addresses in the "To" box of the Send XRP dialog:
Address | Domain | Verified? |
---|---|---|
rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW | mduo13.com | ✅ Yes |
rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn | xrpl.org | ❌ No |
rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe | (Not set) | ❌ No |
To run the reference application, run:
npm run domain-verification
Summary
Congratulations, you now have created your own wallet application! In completing this tutorial, you've not only learned how to interact with the XRP Ledger, but also which challenges this provokes when building a user facing application. So let's do a recap of what you have accomplished:
- First you set up the project and created a basic electron application.
- In Step 1 you did your first ledger query and had the application display the latest closed ledger index, using Electron's inter-process-communication.
- You expanded on that in Step 2, where you established a permanent subscription to the XRPL and had the application display ledger updates in real time.
- In Step 3 you had the application monitor a specific account, prompting the user for an account address and having data flow bi-directionally from
ipcRender
toipcMain
and back. - You then added a dynamic list of the account's transaction to the application.
- In Step 5 you implemented a password-protected seed vault, so that secret information can remain protected when not in use.
- Then you gave the application a facelift by adding bootstrap and restructuring the template.
- In Step 7 you added wallet functionalities to what was so far a monitoring application by adding a dialog and the corresponding backend functionality to transfer XRP.
- Finally, you stepped into some finer details of XRPL development by implementing the domain verification check.