59 KiB
parent, targets, blurb
| parent | targets | blurb | ||
|---|---|---|---|---|
| build-apps.html |
|
Build a graphical desktop wallet for the XRPL using JavaScript. |
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 at least some rough understanding of what the XRP Ledger, it's 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.
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.
- Showing how much XRP is set aside for the account's reserve requirement.
- 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 (
DisallowXRPflag 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 package.json file 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": "^2.6.0"
},
"devDependencies": {
"electron": "22.3.2"
}
}
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.jsfile 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
viewin the root directory of the project. -
Now, inside the
viewfolder, add atemplate.htmlfile 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 content/_code-samples/build-a-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.js,
view/0_hello.html,
1. Ledger Index
Full code for this step:
1_ledger-index.js,
view/1_preload.js,
view/1_ledger-index.html,
view/1_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.jsby adding the following snippet in the import section at the top of the file below thepathimport:
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
}
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.
- In order to attach a preloader script, modify the
createWindowmethod inindex.jsby 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
viewfolder, create a filepreload.jswith 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 to
// 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 browsers window object which can be used to subscribe frontend logic to events broadcast from the main logic in index.js.
In the browser, window.electronAPI.onUpdateLedgerIndex(callback) can now be used tp 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.htmladd 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.jsfile in theviewfolder 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.jsby 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(onUpdateLedgerIndexinview/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. We could do this in the console, in a
<script>tag in the frontend or in our case in a separate file. - Lastly, we dispatch the event from the main logic in
index.jsviaappWindow.webContents.send('update-ledger-index', value).
To get the application running at this early stage of development, run the following command:
npm run start
To run the reference application found in content/_code-samples/build-a-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-subscribe.js,
view/2_preload.js,
view/2_async-subscribe.html,
view/2_renderer.js.
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.jsremove thegetValidatedLedgerIndexfunction. Then update theapp.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 oneliner 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. This also reduces the complexity of the application and saves us a couple of lines of code.
- Then, update
preload.jsby renaming theonUpdateLedgerIndextoonUpdateLedgerDataand theupdate-ledger-indexevent 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.htmlfile by adding placeholders to the<body>section:
<body>
<!-- Step 2 code additions - start -->
<h3>Build a XRPL Wallet - Part 2/8</h3>
Latest validated ledger <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/render.jsfile 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
To run the reference application found in content/_code-samples/build-a-wallet/desktop-js for this step, run:
npm run ledger-index
3. Display an Account
Full code for this step:
3_account.js.
library/3_helper.js.
view/3_preload.js.
view/3_account.html.
view/3_renderer.js.
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.jswith the following content:
3_helpers.js
{{ include_code("_code-samples/build-a-wallet/desktop-js/library/3_helpers.js", language="js") }}
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.jsand addipcMainto the imports from therequire('electron')line. Then add the new helper file at the bottom of the include section:
const { app, BrowserWindow, ipcMain} = require('electron')
const path = require('path')
const xrpl = require("xrpl")
// Step 3 code additions - start
const { prepareReserve, prepareAccountData, prepareLedgerData} = require('./library/3_helpers')
// Step 3 code additions - end
const TESTNET_URL = "wss://s.altnet.rippletest.net:51233"
- Modify
index.jsin the following way:
const main = async () => {
const appWindow = createWindow()
ipcMain.on('address-entered', async (event, address) => {
let reserve = null
const client = new xrpl.Client(TESTNET_URL)
await client.connect()
// Step 3 code modifications - start
// 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) => {
reserve = prepareReserve(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, reserve)
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 a transaction is an operation that changes the accounts state.
In addition to the subscriptions we added each 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 which would trigger 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 we can observe a notable difference to the previous step. Until now we just used ipcRenderer to pick up on events from the main logic, now we are using it bidirectional to send events from the frontend to the main logic:
onEnterAccountAddress: (address) => {
ipcRenderer.send('address-entered', address)
}
- Then, modify the
view/template.htmlby replacing the<body>with this markup:
<body>
<h3>Build a XRPL Wallet - Part 3/8</h3>
<fieldset>
<legend>Account</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/>
XRP Reserved: <strong id="account-reserve"></strong><br/>
</fieldset>
<fieldset>
<legend>Latest validated ledger</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="reset">Reset</button>
<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.jswith the following code:
{{ include_code("_code-samples/build-a-wallet/desktop-js/view/3_renderer.js", language="js") }}
Then run the application with:
npm run start
To run the reference application found in content/_code-samples/build-a-wallet/desktop-js for this step, run:
npm run account
4. Show Account's Transactions
Full code for this step:
4_tx-history.js.
library/3_helper.js.
library/4_helper.js.
view/4_preload.js.
view/4_tx-history.html.
view/4_renderer.js.
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
libraryfolder, add a new file4_helpers.js. Then add the following helper function to that file:
{{ include_code("_code-samples/build-a-wallet/desktop-js/library/4_helpers.js", language="js") }}
- Now, in
index.html, require the new helper function at the bottom of the import section like so:
const { prepareReserve, prepareAccountData, prepareLedgerData} = require('./library/3_helpers')
const { prepareTxData } = require('./library/4_helpers')
- In the main file, update the listener function subscribed to the
transactionevent by adding the following snippet:
// 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, reserve)
appWindow.webContents.send('update-account-data', accountData)
// Step 3 code additions - start
const transactions = prepareTxData([{tx: transaction.transaction}])
appWindow.webContents.send('update-transaction-data', transactions)
// Step 4 code additions - end
})
- In
view/preload.js, add the following code at the bottom ofexposeInMainWorld():
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.htmlby 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 5 code additions - end -->
<dialog id="account-address-dialog">
<form method="dialog">
The table here will be filled dynamically with the accounts 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. Now you might need an 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 are new to the XRPL an need an account address, you can get accounts on the testnet. Here you can also use the sandbox to issue XRP transactions, which then should show up in our app.
To run the reference application found in content/_code-samples/build-a-wallet/desktop-js for this step, run:
npm run tx-history
5. Saving the Private Keys with a Password
Full code for this step:
5_password.js.
library/3_helper.js.
library/4_helper.js.
library/5_helper.js.
view/5_preload.js.
view/5_password.html.
view/5_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, which will have to be handled properly.
In this step we will query the user for an account seed and a password save this seed with a salted password.
- In the
libraryfolder, add a new file5_helpers.jswith the following content:
{{ include_code("_code-samples/build-a-wallet/desktop-js/library/5_helpers.js", language="js") }}
- Modify the import section at the top of
index.jsto look like this:
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. The helper functions that we have written before now get used in our new helper class, which not only adds new functionality but encapsulates our subscriptions and initial requests in two helper functions. Those will be used as one-liners replacing a lot of lines that started to bloat our main logic file.
We also added a new constant containing the directory name where we are going to store our encrypted seed.
- In
index.jsreplace the existingmainfunction with the following one:
const main = async () => {
const appWindow = createWindow()
// Create Wallet directory in case it does not exist yet
if (!fs.existsSync(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 {
seed = loadSaltedSeed('../' + WALLET_DIR, password)
}
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)
})
// 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')
}
})
}
The subscription to the address-entered frontend is gone, as we are now going to use a full-fledged wallet, for which we are going to prompt the user or a seed and a password to encrypt it with. If there already is a seed, the user will only be asked for his password which is in turn used to decrypt the seed.
- Then modify the
view/preload.jsfile (Note that theonEnterAccountAddressfunction 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)
},
// Step 5 code additions - end
onUpdateLedgerData: (callback) => {
ipcRenderer.on('update-ledger-data', callback)
},
- Then, in
view/templte.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="reset">Reset</button>
<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="reset">Reset</button>
<button type="submit">Confirm</button>
</div>
</form>
</dialog>
- In
view/renderer.jsadd at the top:
window.electronAPI.onOpenSeedDialog((_event) => {
const seedDialog = document.getElementById('seed-dialog');
const seedInput = seedDialog.querySelector('input');
const submitButton = seedDialog.querySelector('button[type="submit"]');
const resetButton = seedDialog.querySelector('button[type="reset"]');
submitButton.addEventListener('click', () => {
const seed = seedInput.value;
window.electronAPI.onEnterSeed(seed)
seedDialog.close()
});
resetButton.addEventListener('click', () => {
seedInput.value = '';
});
seedDialog.showModal()
})
window.electronAPI.onOpenPasswordDialog((_event) => {
const passwordDialog = document.getElementById('password-dialog');
const passwordInput = passwordDialog.querySelector('input');
const submitButton = passwordDialog.querySelector('button[type="submit"]');
const resetButton = passwordDialog.querySelector('button[type="reset"]');
submitButton.addEventListener('click', () => {
const password = passwordInput.value;
window.electronAPI.onEnterPassword(password)
passwordDialog.close()
});
resetButton.addEventListener('click', () => {
passwordInput.value = '';
});
passwordDialog.showModal()
});
You should now run the application twice: At the first run, you will be asked for a seed and a password. When you run it the second time it will prompt you for the password and you should be good to go instantly:
npm run start
To run the reference application found in content/_code-samples/build-a-wallet/desktop-js for this step, run:
npm run password
6. Styling
Full code for this step:
6_styling.js.
library/3_helper.js.
library/4_helper.js.
library/5_helper.js.
view/6_preload.js.
view/6_styling.html.
view/6_renderer.js.
After finishing this step the application should look like this:
-
Copy the folder
bootstrapand its contents to your project directory. -
Also, copy the file
view/custom.cssas well asXRPLedger_DevPortal-white.svgto theviewdirectory. -
Change the content of
view/template.htmlwith 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="./custom.css"/>
</head>
<body>
<main>
<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 bg-light">
<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>
<li class="list-group-item">XRP Reserved: <strong id="account-reserve"></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="reset">Reset</button>
<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="reset">Reset</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
To run the reference application found in content/_code-samples/build-a-wallet/desktop-js for this step, run:
npm run styling
7. Send XRP
Full code for this step:
7_send-xrp.js.
library/3_helper.js.
library/4_helper.js.
library/5_helper.js.
library/6_helper.js.
library/7_helper.js.
view/7_preload.js.
view/7_send-xrp.html.
view/7_renderer.js.
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.jsand add the following contents:
{{ include_code("_code-samples/build-a-wallet/desktop-js/library/7_helpers.js", language="js") }}
- 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-eventfrom 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.jsby 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>
<button type="button" class="btn btn-primary" id="send-xrp-modal-button">
Send XRP
</button>
</div>
- In the same file, 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>
- Update
view/renderer.jsby adding:
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) => {
destinationAddressEl.value = ''
destinationTagEl.value = ''
amountEl.value = ''
})
Now, Run the following command:
npm run start
To run the reference application found in content/_code-samples/build-a-wallet/desktop-js for this step, run:
npm run send-xrp
8. Domain Verification and Polish
Full code for this step:
8_domain-verification.js.
library/3_helper.js.
library/4_helper.js.
library/5_helper.js.
library/6_helper.js.
library/7_helper.js.
library/8_helper.js.
view/8_prelaod.js.
view/8_domain-verification.html.
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 one 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.
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
libraryfolder, add a new file4_helpers.js. Then add the following contents to that file:
{{ include_code("_code-samples/build-a-wallet/desktop-js/library/8_helpers.js", language="js") }}
The code in the helper class basically issues an account_info request to look up the account in the ledger.
If the account does exist, the code checks for the lsfDisallowXRP flag. Note that this is an lsf (ledger state flag) value because this is an object from the ledger state data; these are different than the flag values the [AccountSet transaction][] uses to configure the same settings.
- 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
- At the end of 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.jsand 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.htmladd the following lines just before the<input>element withid="input-destination-address:
<div class="input-group mb-3">
<div class="accountVerificationIndicator">
<span>Verification status:</span>
</div>
<input type="text" class="form-control" placeholder="rn95xwUymaMyzAKnZUGuynjZ6qk9RzV4Q7" id="input-destination-address">
<!-- Step 8 code additions - start -->
<span class="input-group-text">To (Address)</span>
<!-- Step 8 code additions - start -->
</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})
})
The updated preloader view/8_preloader.js is also modified the same way by adding the following two event listeners:
view/8_preload.js
{{ include_code("_code-samples/build-a-wallet/desktop-js/view/8_preload.js", language="js", lines="33-38") }}
To get the application running at this stage of development, run the following command:
npm run domain-verification
Test your wallet app the same way you did in the previous steps. 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 test X-addresses, try the following addresses:
| Address | Destination Tag | Test Net? |
|---|---|---|
T7YChPFWifjCAXLEtg5N74c7fSAYsvPKxzQAET8tbZ8q3SC |
0 | Yes |
T7YChPFWifjCAXLEtg5N74c7fSAYsvJVm6xKZ14AmjegwRM |
None | Yes |
X7d3eHCXzwBeWrZec1yT24iZerQjYLjJrFT7A8ZMzzYWCCj |
0 | No |
X7d3eHCXzwBeWrZec1yT24iZerQjYLeTFXz1GU9RBnWr7gZ |
None | No |
X7d3eHCXzwBeWrZec1yT24iZerQjYLeTFXz1GU9RBnWr7gZ |
None | No |
Next Steps & Topics for further research
TBD
- Promises / async
- Electron framework
- Event Handler








