Re-level non-docs content to top of repo and rename content→docs

This commit is contained in:
mDuo13
2024-01-31 16:24:01 -08:00
parent f841ef173c
commit c10beb85c2
2907 changed files with 1 additions and 1 deletions

View File

@@ -0,0 +1,3 @@
# Address Encoding
Encode XRP Ledger addresses in base58. (This reference implementation is equivalent to the ones included in most client libraries.)

View File

@@ -0,0 +1,45 @@
'use strict';
const assert = require('assert');
const crypto = require('crypto');
const R_B58_DICT = 'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz';
const base58 = require('base-x')(R_B58_DICT);
assert(crypto.getHashes().includes('sha256'));
assert(crypto.getHashes().includes('ripemd160'));
// Start with a public key. secp256k1 keys should be 33 bytes;
// Ed25519 keys should be 32 bytes prefixed with 0xED (a total of 33 bytes).
// Ed25519 key:
const pubkey_hex =
'ED9434799226374926EDA3B54B1B461B4ABF7237962EAE18528FEA67595397FA32';
//// secp256k1 key:
// const pubkey_hex =
// '0303E20EC6B4A39A629815AE02C0A1393B9225E3B890CAE45B59F42FA29BE9668D';
const pubkey = Buffer.from(pubkey_hex, 'hex');
assert(pubkey.length == 33);
// Calculate the RIPEMD160 hash of the SHA-256 hash of the public key
// This is the "Account ID"
const pubkey_inner_hash = crypto.createHash('sha256').update(pubkey);
const pubkey_outer_hash = crypto.createHash('ripemd160');
pubkey_outer_hash.update(pubkey_inner_hash.digest());
const account_id = pubkey_outer_hash.digest();
// Prefix the Account ID with the type prefix for an XRPL Classic Address, then
// calculate a checksum as the first 4 bytes of the SHA-256 of the SHA-256
// of the Account ID
const address_type_prefix = Buffer.from([0x00]);
const payload = Buffer.concat([address_type_prefix, account_id]);
const chksum_hash1 = crypto.createHash('sha256').update(payload).digest();
const chksum_hash2 = crypto.createHash('sha256').update(chksum_hash1).digest();
const checksum = chksum_hash2.slice(0,4);
// Concatenate the address type prefix, the payload, and the checksum.
// Base-58 encode the encoded value to get the address.
const dataToEncode = Buffer.concat([payload, checksum]);
const address = base58.encode(dataToEncode);
console.log(address);
// rnBFvgZphmN39GWzUJeUitaP22Fr9be75H (secp256k1 example)
// rDTXLQ7ZKZVKz33zJbHjgVShjsBnqMBhmN (Ed25519 example)

View File

@@ -0,0 +1,9 @@
{
"name": "address_encoding",
"version": "0.0.1",
"license": "MIT",
"//": "Change the license to something appropriate. You may want to use 'UNLICENSED' if you are just starting out.",
"dependencies": {
"base-x": "*",
}
}

View File

@@ -0,0 +1,67 @@
from typing import Union, List
from hashlib import sha256
import hashlib
R58dict = b'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz'
def scrub_input(input: Union[str, bytes]) -> bytes:
if isinstance(input, str):
input = input.encode('ascii')
return input
def b58encode_int(
integer: int, default_one: bool = True, alphabet: bytes = R58dict
) -> bytes:
"""
Encode an integer using Base58
"""
if not integer and default_one:
return alphabet[0:1]
string = b""
base = len(alphabet)
while integer:
integer, idx = divmod(integer, base)
string = alphabet[idx:idx + 1] + string
return string
def b58encode(
v: Union[str, bytes], alphabet: bytes = R58dict
) -> bytes:
"""
Encode a string using Base58
"""
v = scrub_input(v)
origlen = len(v)
v = v.lstrip(b'\0')
newlen = len(v)
acc = int.from_bytes(v, byteorder='big')
result = b58encode_int(acc, default_one=False, alphabet=alphabet)
return alphabet[0:1] * (origlen - newlen) + result
_CLASSIC_ADDRESS_PREFIX: List[int] = [0x0]
# Public Key -> AccountID
# Ed25519 key:
public_key = "ED9434799226374926EDA3B54B1B461B4ABF7237962EAE18528FEA67595397FA32"
# Calculate the RIPEMD160 hash of the SHA-256 hash of the public key
# This is the "Account ID"
sha_hash = hashlib.sha256(bytes.fromhex(public_key)).digest()
account_id = hashlib.new("ripemd160", sha_hash).digest()
encoded_prefix = bytes(_CLASSIC_ADDRESS_PREFIX)
payload = encoded_prefix + account_id
v = scrub_input(payload)
digest = sha256(sha256(v).digest()).digest()
check = b58encode(v + digest[:4], alphabet=R58dict)
print(check.decode("utf-8"))
# rDTXLQ7ZKZVKz33zJbHjgVShjsBnqMBhmN (Ed25519)

View File

@@ -0,0 +1 @@
Wallet/

View File

@@ -0,0 +1,92 @@
# Airgapped Wallet
Airgapped describes a state where a device or a system becomes fully disconnected from other devices and systems. It is the maximum protection for a system against unwanted visitors/viruses, this allows any sensitive data like a private key to be stored without worry of it being compromised as long as reasonable security practices are being practiced.
This airgapped XRP wallet allows users to sign a Payment transaction in a secure environment without the private key being exposed to a machine connected to the internet. The private key and seed is encrypted by password and stored securely.
*Note*: You should not use this airgapped wallet in production, it should only be used for educational purposes only.
This code sample consists of 2 parts:
- `airgapped-wallet.js` - This code should be stored in a standalone airgapped machine, it consist of features to generate a wallet, store a keypair securely, sign a transaction and share the signed transaction via QR code.
- `relay-transaction.js` - This code could be stored in any online machine, no credentials is stored on this code other than a signed transaction which would be sent to an XRPL node for it to be validated on the ledger.
Preferably, `airgapped-wallet.js` should be on a Linux machine while `relay-transaction.js` could be on any operating system.
# Security Practices
Strongly note that an airgapped system's security is not determined by its code alone but the security practices that are being followed by an operator.
There are channels that can be maliciously used by outside parties to infiltrate an airgapped system and steal sensitive information.
There are other ways malware could interact across airgapped networks, but they all involve an infected USB drive or a similar device introducing malware onto the airgapped machine. They could also involve a person physically accessing the computer, compromising it and installing malware or modifying its hardware.
This is why it is also recommended to encrypt sensitive information being stored in an airgapped machine.
The airgapped machine should have a few rules enforced to close any possible channels getting abused to leak information outside of the machine:
### Wifi
- Disable any wireless networking hardware on the airgapped machine. For example, if you have a desktop PC with a Wifi card, open the PC and remove the Wifi hardware. If you cannot do that, you could go to the systems BIOS or UEFI firmware and disable the Wifi hardware.
### BlueTooth
- BlueTooth can be maliciously used by neighboring devices to steal data from an airgapped machine. It is recommended to remove or disable the BlueTooth hardware.
### USB
- The USB port can be used to transfer files in and out of the airgapped machine and this may act as a threat to an airgapped machine if the USB drive is infected with a malware. So after installing & setting up this airgapped wallet, it is highly recommended to block off all USB ports by using a USB blocker and not use them.
Do not reconnect the airgapped machine to a network, even when you need to transfer files! An effective airgapped machine should only serve 1 purpose, which is to store data and never open up a gateway for hackers to abuse and steal data.
# Tutorial
For testing purposes, you would need to have 2 machines and 1 phone in hand to scan the QR code.
1. 1st machine would be airgapped, following the security practices written [here](#security-practices). It stores and manages an XRPL Wallet.
2. 2nd machine would be a normal computer connected to the internet. It relays a signed transaction blob to a rippled node.
3. The phone would be used to scan a QR code, which contains a signed transaction blob. The phone would transmit it to the 2nd machine.
The diagram below shows you the process of submitting a transaction to the XRPL:
<p align="center">
<img src="https://user-images.githubusercontent.com/87929946/197970678-2a1b7f7e-d91e-424e-915e-5ba7d34689cc.png" width=75% height=75%>
</p>
# Setup
- Machine 1 - An airgapped computer (during setup, it must be connected to the internet to download the files)
- Machine 2 - A normal computer connected to the internet
- Phone - A normal phone with a working camera to scan a QR
## Machine 1 Setup
Since this machine will be airgapped, it is best to use Linux as the Operating System.
1. Clone all the files under the [`airgapped-wallet`](https://github.com/XRPLF/xrpl-dev-portal/tree/master/content/_code-samples/airgapped-wallet/js) directory
2. Import all the modules required by running: `npm install`
3. Airgap the machine by following the security practices written [here](#security-practices).
4. Run `node airgapped-wallet.js`
5. Scan the QR code and fund the account using the [testnet faucet](https://test.bithomp.com/faucet/)
6. Re-run the script and input '1' to generate a new transaction by following the instructions.
7. Use your phone to scan the QR code, then to send the signed transaction to Machine 2 for submission
## Phone Setup
The phone requires a working camera that is able to scan a QR code and an internet connection for it to be able to transmit the signed transaction blob to Machine 2.
Once you have signed a transaction in the airgapped machine, a QR code will be generated which will contain the signed transaction blob. Example:
<img src="https://user-images.githubusercontent.com/87929946/196018292-f210a9f2-c5f8-412e-98c1-361a72286378.png" width=20% height=20%>
Scan the QR code using the phone, copy it to the clipboard, and transmit it to Machine 2, which will then be sending it to a rippled node.
You can send a message to yourself using Discord, WhatsApp or even e-mail, then open up the message using Machine 2 to receive the signed transaction blob.
## Machine 2 Setup
This machine will be used to transmit a signed transaction blob from Machine 1, it would require internet access.
1. Clone all the files under the [`airgapped-wallet`](https://github.com/XRPLF/xrpl-dev-portal/tree/master/content/_code-samples/airgapped-wallet/js) directory
2. Import all the modules required by running `npm install`
3. Run `relay-transaction.js` and copy-and-paste the received output of Machine 1 when prompted

View File

@@ -0,0 +1,223 @@
const crypto = require("crypto")
const fs = require('fs')
const fernet = require("fernet");
const open = require('open');
const path = require('path')
const prompt = require('prompt')
const { generateSeed, deriveAddress, deriveKeypair } = require("ripple-keypairs/dist/")
const QRCode = require('qrcode')
const xrpl = require('xrpl')
const demoAccountSeed = 'sskwYQmxT7SA37ceRaGXA5PhQYrDS'
const demoAccountAddress = 'rEDd3Wy76Ta1WqfDP2DcnBKHu31SpSiUQrS'
const demoDestinationSeed = 'sEdVokfq7fVXXjZTii2WhtpqGbJni6s'
const demoDestinationAddress = 'rBgNowfkmPczhMjHRYnBPsuSodDHWHQLdj'
const FEE = '12'
const LEDGER_OFFSET = 300
const WALLET_DIR = 'Wallet'
/**
* Generates a new (unfunded) wallet
*
* @returns {{address: *, seed: *}}
*/
createWallet = function () {
const seed = generateSeed()
const {publicKey, privateKey} = deriveKeypair(seed)
const address = deriveAddress(publicKey)
console.log(
"XRP Wallet Credentials " +
"Wallet Address: " + address +
"Seed: " + seed
)
return {address, seed}
}
/**
* Signs transaction and returns signed transaction blob in QR code
*
* @param xrpAmount
* @param destination
* @param ledgerSequence
* @param walletSequence
* @param password
* @returns {Promise<void>}
*/
signTransaction = async function (xrpAmount, destination, ledgerSequence, walletSequence, 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
})
const seed = token.decode();
const wallet = xrpl.Wallet.fromSeed(seed)
const paymentTx = {
'TransactionType': 'Payment',
'Account': wallet.classicAddress,
'Amount': xrpl.xrpToDrops(xrpAmount),
'Destination': destination
}
// Normally we would fetch certain needed values like Fee,
// LastLedgerSequence snd programmatically, like so:
//
// const preparedTx = await client.autofill(paymentTx)
//
// But since this is an airgapped wallet without internet
// connection, we have to do it manually:
//
// paymentTx.Sequence is set in setNextValidSequenceNumber() via sugar/autofill
// paymentTx.LastLedgerSequence is set in setLatestValidatedLedgerSequence() via sugar/autofill
// paymentTx.Fee is set in getFeeXrp() via sugar/getFeeXrp
paymentTx.Sequence = walletSequence
paymentTx.LastLedgerSequence = ledgerSequence + LEDGER_OFFSET
paymentTx.Fee = FEE
const signedTx = wallet.sign(paymentTx)
fs.writeFileSync(path.join(__dirname, WALLET_DIR , 'tx_blob.txt'), signedTx.tx_blob)
QRCode.toFile(path.join(__dirname, WALLET_DIR , 'tx_blob.png'), signedTx.tx_blob)
open(path.join(__dirname, WALLET_DIR , 'tx_blob.png'))
}
main = async function () {
if (!fs.existsSync(WALLET_DIR )) {
// Create Wallet directory in case it does not exist yet
fs.mkdirSync(path.join(__dirname, WALLET_DIR ));
}
if (!fs.existsSync(path.join(__dirname, WALLET_DIR , 'address.txt'))) {
// Generate a new (unfunded) Wallet
const {address, seed} = createWallet()
prompt.start();
const {password} = await prompt.get([{
name: 'password',
description: 'Creating a brand new Wallet, please enter a new password \n Enter Password:',
type: 'string',
required: true
}])
prompt.stop();
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)
fs.writeFileSync(path.join(__dirname, WALLET_DIR , 'address.txt'), address)
QRCode.toFile(path.join(__dirname, WALLET_DIR , 'address.png'), address)
console.log(''
+ 'Finished generating an account.\n'
+ 'Wallet Address: ' + address + '\n'
+ 'Please scan the QR code on your phone and use https://test.bithomp.com/faucet/ to fund the account.\n'
+ 'After that, you\'re able to sign transactions and transmit them to Machine 2 (online machine).')
return
}
prompt.start();
console.log(''
+ '1. Transact XRP.\n'
+ '2. Generate an XRP wallet (read only)\n'
+ '3. Showcase XRP Wallet Address (QR Code)\n'
+ '4. Exit')
const {menu} = await prompt.get([{
name: 'menu',
description: 'Enter Index:',
type: 'integer',
required: true
}])
if (menu === 1) {
const {
password,
xrpAmount,
destinationAddress,
accountSequence,
ledgerSequence
} = await prompt.get([{
name: 'password',
description: 'Enter Password',
type: 'string',
required: true
}, {
name: 'xrpAmount',
description: 'Enter XRP To Send',
type: 'number',
required: true
}, {
name: 'destinationAddress',
description: 'If you just want to try it out, you can use the faucet account rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe. Enter Destination',
type: 'string',
required: true
}, {
name: 'accountSequence',
description: 'Look up the \'Next Sequence\' for the account using test.bithomp.com and enter it',
type: 'integer',
required: true
}, {
name: 'ledgerSequence',
description: 'Look up the latest ledger sequence on testnet.xrpl.org and enter it below!',
type: 'integer',
required: true
}])
await signTransaction(xrpAmount, destinationAddress, ledgerSequence, accountSequence, password)
} else if (menu === 2) {
const {address, seed} = createWallet()
console.log('Generated readonly Wallet (address: ' + address + ' seed: ' + seed + ')')
} else if (menu === 3) {
const address = fs.readFileSync(path.join(__dirname, WALLET_DIR , 'address.txt')).toString()
console.log('Wallet Address: ' + address)
open(path.join(__dirname, WALLET_DIR , 'address.png'))
} else {
return
}
prompt.stop();
}
main()

View File

@@ -0,0 +1,18 @@
{
"name": "airgapped-wallet",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"fernet": "^0.4.0",
"open": "^8.4.0",
"pbkdf2-hmac": "^1.1.0",
"prompt": "^1.3.0",
"qrcode": "^1.5.1",
"xrpl": "^2.11.0"
},
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
}
}

View File

@@ -0,0 +1,37 @@
const prompt = require('prompt')
const xrpl = require('xrpl')
sendTransaction = async function (tx_blob) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233')
await client.connect()
console.log("Connected to node")
const tx = await client.submitAndWait(tx_blob)
const txHash = tx.result.hash
const txDestination = tx.result.Destination
const txXrpAmount = xrpl.dropsToXrp(tx.result.Amount)
const txAccount = tx.result.Account
console.log("XRPL Explorer: https://testnet.xrpl.org/transactions/" + txHash)
console.log("Transaction Hash: " + txHash)
console.log("Transaction Destination: " + txDestination)
console.log("XRP sent: " + txXrpAmount)
console.log("Wallet used: " + txAccount)
await client.disconnect()
}
main = async function () {
const {tx_blob} = await prompt.get([{
name: 'tx_blob',
description: 'Set tx to \'tx_blob\' received from scanning the QR code generated by the airgapped wallet',
type: 'string',
required: true
}])
await sendTransaction(tx_blob)
}
main()

View File

@@ -0,0 +1,117 @@
# Airgapped Wallet
Airgapped describes a state where a device or a system becomes fully disconnected from other devices and systems. It is the maximum protection for a system against unwanted visitors/viruses, this allows any sensitive data like a private key to be stored without worry of it being compromised as long as reasonable security practices are being practiced.
This airgapped XRP wallet allows users to sign a Payment transaction in a secure environment without the private key being exposed to a machine connected to the internet. The private key and seed is encrypted by password and stored securely.
*Note*: You should not use this airgapped wallet in production, it should only be used for educational purposes only.
This code sample consists of 2 parts:
- `airgapped-wallet.py` - This code should be stored in a standalone airgapped machine, it consist of features to generate a wallet, store a keypair securely, sign a transaction and share the signed transaction via QR code.
- `relay-transaction.py` - This code could be stored in any online machine, no credentials is stored on this code other than a signed transaction which would be sent to an XRPL node for it to be validated on the ledger.
Preferably, `airgapped-wallet.py` should be on a Linux machine while `relay-transaction.py` could be on any operating system.
# Security Practices
Strongly note that an airgapped system's security is not determined by its code alone but the security practices that are being followed by an operator.
There are channels that can be maliciously used by outside parties to infiltrate an airgapped system and steal sensitive information.
There are other ways malware could interact across airgapped networks, but they all involve an infected USB drive or a similar device introducing malware onto the airgapped machine. They could also involve a person physically accessing the computer, compromising it and installing malware or modifying its hardware.
This is why it is also recommended to encrypt sensitive information being stored in an airgapped machine.
The airgapped machine should have a few rules enforced to close any possible channels getting abused to leak information outside of the machine:
### Wifi
- Disable any wireless networking hardware on the airgapped machine. For example, if you have a desktop PC with a Wifi card, open the PC and remove the Wifi hardware. If you cannot do that, you could go to the systems BIOS or UEFI firmware and disable the Wifi hardware.
### BlueTooth
- BlueTooth can be maliciously used by neighboring devices to steal data from an airgapped machine. It is recommended to remove or disable the BlueTooth hardware.
### USB
- The USB port can be used to transfer files in and out of the airgapped machine and this may act as a threat to an airgapped machine if the USB drive is infected with a malware. So after installing & setting up this airgapped wallet, it is highly recommended to block off all USB ports by using a USB blocker and not use them.
Do not reconnect the airgapped machine to a network, even when you need to transfer files! An effective airgapped machine should only serve 1 purpose, which is to store data and never open up a gateway for hackers to abuse and steal data.
# Tutorial
For testing purposes, you would need to have 2 machines and 1 phone in hand to scan the QR code.
1. 1st machine would be airgapped, following the security practices written [here](#security-practices). It stores and manages an XRPL Wallet.
2. 2nd machine would be a normal computer connected to the internet. It relays a signed transaction blob to a rippled node.
3. The phone would be used to scan a QR code, which contains a signed transaction blob. The phone would transmit it to the 2nd machine.
The diagram below shows you the process of submitting a transaction to the XRPL:
<p align="center">
<img src="https://user-images.githubusercontent.com/87929946/197970678-2a1b7f7e-d91e-424e-915e-5ba7d34689cc.png" width=75% height=75%>
</p>
# Setup
- Machine 1 - An airgapped computer (during setup, it must be connected to the internet to download the files)
- Machine 2 - A normal computer connected to the internet
- Phone - A normal phone with a working camera to scan a QR
## Machine 1 Setup
Since this machine will be airgapped, it is best to use Linux as the Operating System.
1. Install Python 3.8:
**Linux Command Line**:
```
sudo apt-get update
sudo apt-get install python3.8 python3-pip
```
**Website**: https://www.python.org/downloads/source/
2. Clone all the files under the [`airgapped-wallet`](https://github.com/XRPLF/xrpl-dev-portal/tree/master/content/_code-samples/airgapped-wallet/py) directory
3. Import all the modules required by running:
```
pip install -r requirements.txt
```
4. Airgap the machine by following the security practices written [here](#security-practices).
5. Run `airgapped-wallet.py`
6. Scan the QR code and fund the account using the [testnet faucet](https://test.bithomp.com/faucet/)
7. Re-run the script and input '1' to generate a new transaction by following the instructions.
8. Use your phone to scan the QR code, then to send the signed transaction to Machine 2 for submission
## Machine 2 Setup
This machine will be used to transmit a signed transaction blob from Machine 1, it would require internet access.
1. Install Python 3.8
**Linux Command Line**:
```
sudo apt-get update
sudo apt-get install python3.8 python3-pip
```
**Website**: https://www.python.org/downloads/source/
2. Clone all the files under the [`airgapped-wallet`](https://github.com/XRPLF/xrpl-dev-portal/tree/master/content/_code-samples/airgapped-wallet/py) directory
3. Import all the modules required by running:
```
pip install -r requirements.txt
```
4. Edit line 47 @ `relay-transaction.py` and insert the signed transaction blob from scanning the QR code Machine 1 generated.
5. Run `relay-transaction.py`
## Phone Setup
The phone requires a working camera that is able to scan a QR code and an internet connection for it to be able to transmit the signed transaction blob to Machine 2.
Once you have signed a transaction in the airgapped machine, a QR code will be generated which will contain the signed transaction blob. Example:
<img src="https://user-images.githubusercontent.com/87929946/196018292-f210a9f2-c5f8-412e-98c1-361a72286378.png" width=20% height=20%>
Scan the QR code using the phone and transmit it to Machine 2, which will then be sending it to a rippled node.
You can send a message to yourself using Discord, WhatsApp or even e-mail, then open up the message using Machine 2 to receive the signed transaction blob.

View File

@@ -0,0 +1,223 @@
import os
import base64
import qrcode
import platform
from PIL import Image
from pathlib import Path, PureWindowsPath, PurePath
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from xrpl.core import keypairs
from xrpl.utils import xrp_to_drops
from xrpl.models.transactions import Payment
from xrpl.transaction import sign
from xrpl.wallet.main import Wallet
def create_wallet(silent: False):
"""
Generates a keypair
"""
if not silent:
print("1. Generating seed...")
seed = keypairs.generate_seed()
print("2. Deriving keypair from seed...")
pub, priv = keypairs.derive_keypair(seed)
print("3. Deriving classic addresses from keypair..\n")
address = keypairs.derive_classic_address(pub)
else:
seed = keypairs.generate_seed()
pub, priv = keypairs.derive_keypair(seed)
address = keypairs.derive_classic_address(pub)
return address, seed
def sign_transaction(xrp_amount, destination, ledger_seq, wallet_seq, password):
"""
Signs transaction and returns signed transaction blob in QR code
"""
print("1. Retrieving encrypted private key and salt...")
with open(get_path("/WalletTEST/private.txt"), "r") as f:
seed = f.read()
seed = bytes.fromhex(seed)
with open(get_path("/WalletTEST/salt.txt"), "rb") as f:
salt = f.read()
print("2. Initializing key...")
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
iterations=100000,
salt=salt
)
key = base64.urlsafe_b64encode(kdf.derive(bytes(password.encode())))
crypt = Fernet(key)
print("3. Decrypting wallet's private key using password")
seed = crypt.decrypt(seed)
print("4. Initializing wallet using decrypted private key")
_wallet = Wallet.from_seed(seed=seed.decode())
validated_seq = ledger_seq
print("5. Constructing payment transaction...")
my_tx_payment = Payment(
account=_wallet.address,
amount=xrp_to_drops(xrp=xrp_amount),
destination=destination,
last_ledger_sequence=validated_seq + 100,
# +100 to catch up with the ledger when we transmit the signed tx blob to Machine 2
sequence=wallet_seq,
fee="10"
)
print("6. Signing transaction...")
my_tx_payment_signed = sign(transaction=my_tx_payment, wallet=_wallet)
img = qrcode.make(my_tx_payment_signed.to_dict())
print("7. Displaying signed transaction blob's QR code on the screen...")
img.save(get_path("/WalletTEST/transactionID.png"))
image = Image.open(get_path("/WalletTEST/transactionID.png"))
image.show()
print(f"RESULT: {my_tx_payment_signed.to_dict()}")
print("END RESULT: Successful")
def get_path(file):
"""
Get path (filesystem management)
"""
global File_
# Checks what OS is being used
OS = platform.system()
usr = Path.home()
# Get PATH format based on the OS
if OS == "Windows":
File_ = PureWindowsPath(str(usr) + file)
else: # Assuming Linux-style file format
File_ = PurePath(str(usr) + file)
return str(File_)
def create_wallet_directory():
global File, Path_
OS = platform.system()
usr = Path.home()
if OS == "Windows":
# If it's Windows, use this path:
print("- OS Detected: Windows")
File = PureWindowsPath(str(usr) + '/WalletTEST')
Path_ = str(PureWindowsPath(str(usr)))
else:
print("- OS Detected: Linux")
# If it's Linux, use this path:
File = PurePath(str(usr) + '/WalletTEST')
Path_ = str(PurePath(str(usr)))
if not os.path.exists(File):
print("1. Generating wallet's keypair...")
pub, seed = create_wallet(silent=True)
print("2. Creating wallet's file directory...")
os.makedirs(File)
print("3. Generating and saving public key's QR code...")
img = qrcode.make(pub)
img.save(get_path("/WalletTEST/public.png"))
print("4. Generating and saving wallet's salt...")
salt = os.urandom(16)
with open(get_path("/WalletTEST/salt.txt"), "wb") as f:
f.write(salt)
print("5. Generating wallet's filesystem password...")
password = "This is a unit test password 123 !@# -+= }{/"
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
iterations=100000,
salt=salt
)
key = base64.urlsafe_b64encode(kdf.derive(bytes(password.encode())))
crypt = Fernet(key)
print("6. Encrypting and saving private key by password...")
priv = crypt.encrypt(bytes(seed, encoding='utf-8'))
seed = crypt.encrypt(bytes(seed, encoding='utf-8'))
with open(get_path("/WalletTEST/seed.txt"), "w") as f:
f.write(seed.hex())
with open(get_path("/WalletTEST/private.txt"), "w") as f:
f.write(priv.hex())
with open(get_path("/WalletTEST/public.txt"), "w") as f:
f.write(pub)
if os.path.exists(File):
print(f"0. Wallet's filesystem already exist as the unit test has been performed before. Directory: {File}")
def showcase_wallet_address_qr_code():
with open(get_path("/WalletTEST/public.txt"), "r") as f:
print(f"0. Wallet Address: {f.read()}")
__path = get_path("/WalletTEST/public.png")
print(f"1. Getting address from {__path}...")
print("2. Displaying QR code on the screen...")
image = Image.open(get_path("/WalletTEST/public.png"))
image.show()
if __name__ == '__main__':
print("Airgapped Machine Unit Test (5 functions):\n")
print(f"UNIT TEST 1. create_wallet():")
_address, _seed = create_wallet(silent=False)
print(f"-- RESULTS --\n"
f"Address: {_address}\n"
f"Seed: {_seed}\n"
f"END RESULT: Successful"
)
print(f"\nUNIT TEST 2. create_wallet_directory():")
create_wallet_directory()
print("RESULT: Successful")
print("\nUNIT TEST 3. showcase_wallet_address_qr_code():")
showcase_wallet_address_qr_code()
print("RESULT: Successful")
print("\nUNIT TEST 4. get_path():")
print("1. Getting files' path...\n")
txt_file = get_path("/WalletTEST/FILE123.txt")
png_file = get_path("/WalletTEST/PIC321.png")
print(f"-- RESULTS --\n"
f"txt_file: {txt_file}\n"
f"png_file: {png_file}\n"
f"END RESULT: Successful")
print("\nUNIT TEST 5. sign_transaction():")
print("Parameters: xrp_amount, destination, ledger_seq, wallet_seq, password")
sign_transaction(
xrp_amount=10,
destination="rPEpirdT9UCNbnaZMJ4ENwKAwJqrTpvgMQ",
ledger_seq=32602000,
wallet_seq=32600100,
password="This is a unit test password 123 !@# -+= }{/"
)

View File

@@ -0,0 +1,226 @@
import os
import shutil
import base64
import qrcode
import platform
from PIL import Image
from pathlib import Path, PureWindowsPath, PurePath
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from xrpl.wallet import Wallet
from xrpl.core import keypairs
from xrpl.utils import xrp_to_drops
from xrpl.models.transactions import Payment
from xrpl.transaction import sign
def create_wallet():
"""
Generates a keypair
"""
seed = keypairs.generate_seed()
pub, priv = keypairs.derive_keypair(seed)
address = keypairs.derive_classic_address(pub)
print(
f"\n\n XRP WALLET CREDENTIALS"
f"\n Wallet Address: {address}"
f"\n Seed: {seed}"
)
return address, seed
def sign_transaction(_xrp_amount, _destination, _ledger_seq, _wallet_seq, password):
"""
Signs transaction and returns signed transaction blob in QR code
"""
with open(get_path("/Wallet/private.txt"), "r") as f:
_seed = f.read()
_seed = bytes.fromhex(_seed)
with open(get_path("/Wallet/salt.txt"), "rb") as f:
salt = f.read()
# Line 49-58: initialize key
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
iterations=100000,
salt=salt
)
key = base64.urlsafe_b64encode(kdf.derive(bytes(password.encode())))
crypt = Fernet(key)
# Decrypts the wallet's private key
_seed = crypt.decrypt(_seed)
_wallet = Wallet.from_seed(seed=_seed.decode())
validated_seq = _ledger_seq
# Construct Payment transaction
my_tx_payment = Payment(
account=_wallet.address,
amount=xrp_to_drops(xrp=_xrp_amount),
destination=_destination,
last_ledger_sequence=validated_seq + 100,
# +100 to catch up with the ledger when we transmit the signed tx blob to Machine 2
sequence=_wallet_seq,
fee="10"
)
# Signs transaction and displays the signed_tx blob in QR code
# Scan the QR code and transmit the signed_tx blob to an online machine (Machine 2) to relay it to the XRPL
my_tx_payment_signed = sign(transaction=my_tx_payment, wallet=_wallet)
img = qrcode.make(my_tx_payment_signed.to_dict())
img.save(get_path("/Wallet/transactionID.png"))
image = Image.open(get_path("/Wallet/transactionID.png"))
image.show()
def get_path(file):
"""
Get path (filesystem management)
"""
global File_
# Checks what OS is being us
OS = platform.system()
usr = Path.home()
# Get PATH format based on the OS
if OS == "Windows":
File_ = PureWindowsPath(str(usr) + file)
else: # Assuming Linux-style file format, use this path:
File_ = PurePath(str(usr) + file)
return str(File_)
def main():
global File, Path_
# Gets the machine's operating system (OS)
OS = platform.system()
usr = Path.home()
if OS == "Windows":
# If it's Windows, use this path:
File = PureWindowsPath(str(usr) + '/Wallet')
Path_ = str(PureWindowsPath(str(usr)))
else: # Assuming Linux-style file format, use this path:
File = PurePath(str(usr) + '/Wallet')
Path_ = str(PurePath(str(usr)))
# If the Wallet's folder already exists, continue on
if os.path.exists(File) and os.path.exists(get_path("/Wallet/public.txt")):
while True:
ask = int(input("\n 1. Transact XRP"
"\n 2. Generate an XRP wallet (read only)"
"\n 3. Showcase XRP Wallet Address (QR Code)"
"\n 4. Exit"
"\n\n Enter Index: "
))
if ask == 1:
password = str(input(" Enter Password: "))
amount = float(input("\n Enter XRP To Send: "))
destination = input("If you just want to try it out, you can use the faucet account rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe"
"\n Enter Destination: ")
wallet_sequence = int(input("Look up the 'Next Sequence' for the account using test.bithomp.com and enter it below!"
"\n Enter Wallet Sequence: "))
ledger_sequence = int(input("Look up the latest ledger sequence on testnet.xrpl.org and enter it below!"
"\n Enter Ledger Sequence: "))
sign_transaction(_xrp_amount=amount,
_destination=destination,
_ledger_seq=ledger_sequence,
_wallet_seq=wallet_sequence,
password=password
)
del destination, amount, wallet_sequence, ledger_sequence
if ask == 2:
_pub, _seed = create_wallet()
if ask == 3:
with open(get_path("/Wallet/public.txt"), "r") as f:
print(f"\n Wallet Address: {f.read()}")
image = Image.open(get_path("/Wallet/public.png"))
image.show()
if ask == 4:
return 0
else:
# If the Wallet's folder does not exist, create one and store wallet data (encrypted private key, encrypted seed, account address)
# If the Wallet's directory exists but files are missing, delete it and generate a new wallet
if os.path.exists(File):
confirmation = input(f"We've detected missing files on {File}, would you like to delete your wallet's credentials & generate new wallet credentials? (YES/NO):")
if confirmation == "YES":
confirmation_1 = input(f"All wallet credentials will be lost if you continue, are you sure? (YES/NO): ")
if confirmation_1 == "YES":
shutil.rmtree(File)
else:
print("Aborted: Wallet credentials are still intact")
return 0
else:
print("- Wallet credentials are still intact")
return 0
os.makedirs(File)
pub, seed = create_wallet()
img = qrcode.make(pub)
img.save(get_path("/Wallet/public.png"))
print("\nCreating a brand new Wallet, please enter a new password")
password = str(input("\n Enter Password: "))
salt = os.urandom(16)
with open(get_path("/Wallet/salt.txt"), "wb") as f:
f.write(salt)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
iterations=100000,
salt=salt
)
key = base64.urlsafe_b64encode(kdf.derive(bytes(password.encode())))
crypt = Fernet(key)
priv = crypt.encrypt(bytes(seed, encoding='utf-8'))
seed = crypt.encrypt(bytes(seed, encoding='utf-8'))
with open(get_path("/Wallet/seed.txt"), "w") as f:
f.write(seed.hex())
with open(get_path("/Wallet/private.txt"), "w") as f:
f.write(priv.hex())
with open(get_path("/Wallet/public.txt"), "w") as f:
f.write(pub)
openimg = Image.open(get_path("/Wallet/public.png"))
openimg.show()
print("\nFinished generating an account.")
print(f"\nWallet Address: {pub}")
print("\nPlease scan the QR code on your phone and use https://test.bithomp.com/faucet/ to fund the account."
"\nAfter that, you're able to sign transactions and transmit them to Machine 2 (online machine).")
# Loop back to the start after setup
main()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,50 @@
from xrpl.clients import JsonRpcClient
from xrpl.models.transactions import Payment
from xrpl.transaction import submit_and_wait
def connect_node(_node):
"""
Connects to a node
"""
JSON_RPC_URL = _node
_client = JsonRpcClient(url=JSON_RPC_URL)
print("\n --- Connected to Node")
return _client
def send_transaction(transaction_dict):
"""
Connects to a node -> Send Transaction
Main Function to send transaction to the XRPL
"""
client = connect_node("https://s.altnet.rippletest.net:51234/")
# TESTNET: "https://s.altnet.rippletest.net:51234/"
# MAINNET: "https://s2.ripple.com:51234/"
# Since we manually inserted the tx blob, we need to initialize it into a Payment so xrpl-py could process it
my_tx_signed = Payment.from_dict(transaction_dict)
tx = submit_and_wait(transaction=my_tx_signed, client=client)
tx_hash = tx.result['hash']
tx_destination = tx.result['Destination']
tx_xrp_amount = int(tx.result['Amount']) / 1000000
tx_account = tx.result['Account']
print(f"\n XRPL Explorer: https://testnet.xrpl.org/transactions/{tx_hash}"
f"\n Transaction Hash: {tx_hash}"
f"\n Transaction Destination: {tx_destination}"
f"\n Transacted XRP: {tx_xrp_amount}"
f"\n Wallet Used: {tx_account}"
)
if __name__ == '__main__':
tx_blob = "ENTER TX BLOB HERE"
if tx_blob == "ENTER TX BLOB HERE":
print("Set tx to 'tx_blob' received from scanning the QR code generated by the airgapped wallet")
else:
send_transaction(tx_blob)

View File

@@ -0,0 +1,25 @@
anyio==3.2.1
asgiref==3.4.1
base58==2.1.0
certifi==2023.7.22
cffi==1.15.0
colorama==0.4.4
cryptography==41.0.6
Django==3.2.23
ECPy==1.2.5
h11==0.12.0
httpcore==0.13.6
idna==3.2
image==1.5.33
pifacedigitalio==3.0.5
Pillow==10.2.0
pycparser==2.20
pytz==2021.1
qrcode==7.2
rfc3986==1.5.0
six==1.16.0
sniffio==1.2.0
sqlparse==0.4.4
typing-extensions==4.2.0
websockets==10.0
xrpl-py==2.0.0

View File

@@ -0,0 +1,3 @@
# Build a Browser Wallet
Implement a non-custodial wallet application that runs on in a web browser and can check an account's balances, send XRP, and notify when the account receives incoming transactions.

View File

@@ -0,0 +1,3 @@
CLIENT="wss://s.altnet.rippletest.net:51233/"
EXPLORER_NETWORK="testnet"
SEED="s████████████████████████████"

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,7 @@
{
"trailingComma": "es5",
"printWidth": 150,
"tabWidth": 4,
"singleQuote": true,
"semi": true
}

View File

@@ -0,0 +1,24 @@
# Pre-requisites
To implement this tutorial you should have a basic understanding of JavaScript and Node.js. You should also have a basic idea about XRP Ledger. For more information, visit the [XRP Ledger Dev Portal](https://xrpl.org) and the [XRPL Learning Portal](https://learn.xrpl.org/) for videos, libraries, and other resources.
Follow the steps below to get started:
1. [Node.js](https://nodejs.org/en/download/) (v10.15.3 or higher)
2. Install [Yarn](https://yarnpkg.com/en/docs/install) (v1.17.3 or higher) or [NPM](https://www.npmjs.com/get-npm) (v6.4.1 or higher)
3. Add your Seed, Client, and specify testnet/mainnet in .env file. Example .env file is provided in the repo.
4. Run `yarn install` or `npm install` to install dependencies
5. Start the app with `yarn dev` or `npm dev`
# Goals
At the end of this tutorial, you should be able to build a simple XRP wallet that can:
- Shows updates to the XRP Ledger in real-time.
- Can view any XRP Ledger account's activity "read-only" including showing how much XRP was delivered by each transaction.
- Shows how much XRP is set aside for the account's reserve requirement.
- Can send direct XRP payments, and provides feedback about the intended destination address, including:
- Displays available balance in your account
- Verifies that the destination address is valid
- Validates amount input to ensure it is a valid number and that the account has enough XRP to send
- Allows addition of the destination tag

View File

@@ -0,0 +1,148 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: white;
cursor: pointer;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
min-width: 320px;
min-height: 100vh;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
}
.main_content {
display: flex;
flex-direction: column;
gap: 25px;
}
.main_logo {
align-self: center;
}
.logo_link {
align-self: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vanilla:hover {
filter: drop-shadow(0 0 2em #848080f5);
}
.wallet_details {
display: flex;
flex-direction: column;
gap: 5px;
padding: 20px;
border: 1px solid white;
border-radius: 10px;
}
.ledger_details, .send_xrp_container, .tx_history_container {
display: flex;
flex-direction: column;
gap: 5px;
padding: 20px;
border: 1px solid white;
border-radius: 10px;
}
.send_xrp_container label {
padding: 10px 0 0 0;
}
.invalid {
border: 1px solid red !important;
}
.send_xrp_container input {
padding: 10px;
border-radius: 10px;
border: 1px solid black;
background: lightgray;
color: black;
outline: none;
}
.heading_h3 {
font-size: 25px;
font-weight: bold;
padding: 0 0 10px 0;
}
.links {
display: flex;
gap: 12px;
}
button {
padding: 5px 12px;
background: inherit;
cursor: pointer;
border: 1px solid white;
border-radius: 10px;
font-size: 14px;
}
button:hover {
color: black;
background: white;
}
.submit_tx_button {
color: black;
background: white;
margin: 30px 0 0 0;
}
.submit_tx_button:disabled {
color: gray;
background: lightgray;
cursor: not-allowed;
}
.tx_history_data {
display: table;
text-align: center;
border-spacing: 10px;
}
.tx_history_data th {
border-bottom: 1px solid white;
padding: 0 0 5px 0;
}

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./src/assets/xrpl.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Simple XRPL Wallet</title>
<link rel="preload" href="./src/send-xrp/send-xrp.html">
<link rel="preload" href="./src/transaction-history/transaction-history.html">
<link rel="preload" href="index.css" as="style">
<link rel="stylesheet" href="index.css">
</head>
<body>
<div id="app">
<div class="main_content">
<div class="main_logo" id="heading_logo"></div>
<div class="links">
<button class="send_xrp" id="send_xrp_button">Send XRP</button>
<button class="transaction_history" id="transaction_history_button">Transaction History</button>
</div>
<div class="wallet_details" id="wallet">
<div class="heading_h3">Account Info:</div>
<div id="loading_wallet_details">Loading Wallet Details...</div>
<span class="wallet_address"></span>
<span class="wallet_balance"></span>
<span class="wallet_reserve"></span>
<span class="wallet_xaddress"></span>
<span class="view_more"><a id="view_more_button">View More</a></span>
</div>
<div class="ledger_details">
<div class="heading_h3">Latest Validated Ledger:</div>
<div id="loading_ledger_details">Loading Ledger Details...</div>
<span class="ledger_index" id="ledger_index"></span>
<span class="ledger_hash" id="ledger_hash"></span>
<span class="close_time" id="close_time"></span>
</div>
</div>
</div>
<script type="module" src="/index.js"></script>
</body>
</html>

View File

@@ -0,0 +1,71 @@
import { Client, dropsToXrp, rippleTimeToISOTime } from 'xrpl';
import addXrplLogo from './src/helpers/render-xrpl-logo';
import getWalletDetails from './src/helpers/get-wallet-details.js';
// Optional: Render the XRPL logo
addXrplLogo();
const client = new Client(process.env.CLIENT); // Get the client from the environment variables
// Get the elements from the DOM
const sendXrpButton = document.querySelector('#send_xrp_button');
const txHistoryButton = document.querySelector('#transaction_history_button');
const walletElement = document.querySelector('#wallet');
const walletLoadingDiv = document.querySelector('#loading_wallet_details');
const ledgerLoadingDiv = document.querySelector('#loading_ledger_details');
// Add event listeners to the buttons
sendXrpButton.addEventListener('click', () => {
window.location.pathname = '/src/send-xrp/send-xrp.html';
});
txHistoryButton.addEventListener('click', () => {
window.location.pathname = '/src/transaction-history/transaction-history.html';
});
// Self-invoking function to connect to the client
(async () => {
try {
await client.connect(); // Connect to the client
// Subscribe to the ledger stream
await client.request({
command: 'subscribe',
streams: ['ledger'],
});
// Fetch the wallet details
getWalletDetails({ client })
.then(({ account_data, accountReserves, xAddress, address }) => {
walletElement.querySelector('.wallet_address').textContent = `Wallet Address: ${account_data.Account}`;
walletElement.querySelector('.wallet_balance').textContent = `Wallet Balance: ${dropsToXrp(account_data.Balance)} XRP`;
walletElement.querySelector('.wallet_reserve').textContent = `Wallet Reserve: ${accountReserves} XRP`;
walletElement.querySelector('.wallet_xaddress').textContent = `X-Address: ${xAddress}`;
// Redirect on View More link click
walletElement.querySelector('#view_more_button').addEventListener('click', () => {
window.open(`https://${process.env.EXPLORER_NETWORK}.xrpl.org/accounts/${address}`, '_blank');
});
})
.finally(() => {
walletLoadingDiv.style.display = 'none';
});
// Fetch the latest ledger details
client.on('ledgerClosed', (ledger) => {
ledgerLoadingDiv.style.display = 'none';
const ledgerIndex = document.querySelector('#ledger_index');
const ledgerHash = document.querySelector('#ledger_hash');
const closeTime = document.querySelector('#close_time');
ledgerIndex.textContent = `Ledger Index: ${ledger.ledger_index}`;
ledgerHash.textContent = `Ledger Hash: ${ledger.ledger_hash}`;
closeTime.textContent = `Close Time: ${rippleTimeToISOTime(ledger.ledger_time)}`;
});
} catch (error) {
await client.disconnect();
console.log(error);
}
})();

View File

@@ -0,0 +1,21 @@
{
"name": "simple-xrpl-wallet",
"type": "module",
"scripts": {
"dev": "vite"
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"crypto-browserify": "^3.12.0",
"events": "^3.3.0",
"https-browserify": "^1.0.0",
"rollup-plugin-polyfill-node": "^0.12.0",
"stream-browserify": "^3.0.0",
"stream-http": "^3.2.0",
"vite": "^4.5.2"
},
"dependencies": {
"dotenv": "^16.0.3",
"xrpl": "^2.11.0"
}
}

View File

@@ -0,0 +1,20 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" inkscape:version="1.0 (4035a4fb49, 2020-05-01)" sodipodi:docname="XRPLedger_DevPortal-white.svg" id="svg991" version="1.1" fill="none" viewBox="0 0 468 116" height="116" width="468">
<metadata id="metadata997">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<defs id="defs995"/>
<sodipodi:namedview inkscape:current-layer="svg991" inkscape:window-maximized="1" inkscape:window-y="1" inkscape:window-x="0" inkscape:cy="58" inkscape:cx="220.57619" inkscape:zoom="2.8397436" inkscape:pagecheckerboard="true" showgrid="false" id="namedview993" inkscape:window-height="1028" inkscape:window-width="1920" inkscape:pageshadow="2" inkscape:pageopacity="0" guidetolerance="10" gridtolerance="10" objecttolerance="10" borderopacity="1" bordercolor="#666666" pagecolor="#ffffff"/>
<g style="opacity:1" id="g989" opacity="0.9">
<path style="opacity:1" id="path979" fill="white" d="M191.43 51.8301L197.43 39.7701H207.5L197.29 57.7701L207.76 76.1901H197.66L191.57 63.8701L185.47 76.1901H175.4L185.87 57.7701L175.66 39.7701H185.74L191.43 51.8301ZM223.5 63.2201H218.73V76.0801H210V39.7701H224.3C228.67 39.7701 231.98 40.7001 234.37 42.6901C235.58 43.6506 236.546 44.8828 237.191 46.2866C237.835 47.6905 238.14 49.2265 238.08 50.7701C238.155 52.9971 237.605 55.2005 236.49 57.1301C235.322 58.9247 233.668 60.3501 231.72 61.2401L239.27 75.9401V76.3401H229.86L223.5 63.2201ZM218.86 56.4701H224.43C225.109 56.5414 225.795 56.4589 226.437 56.2286C227.079 55.9984 227.661 55.6263 228.14 55.1401C229.022 54.1082 229.492 52.7871 229.46 51.4301C229.509 50.754 229.416 50.0752 229.189 49.4366C228.962 48.798 228.605 48.2135 228.14 47.7201C227.653 47.2459 227.07 46.8825 226.429 46.6547C225.789 46.4269 225.107 46.34 224.43 46.4001H218.86V56.4701ZM251.73 63.7501V76.0801H243V39.7701H257.58C260.143 39.7175 262.683 40.2618 265 41.3601C267.03 42.3389 268.758 43.8489 270 45.7301C271.199 47.6808 271.797 49.9415 271.72 52.2301C271.773 53.8434 271.455 55.4474 270.789 56.9179C270.123 58.3884 269.128 59.6859 267.88 60.7101C265.36 62.8301 261.88 63.8901 257.41 63.8901H251.72L251.73 63.7501ZM251.73 57.0001H257.42C258.12 57.0708 258.827 56.9885 259.491 56.7588C260.156 56.5292 260.763 56.1577 261.27 55.6701C261.742 55.209 262.106 54.6484 262.334 54.0291C262.563 53.4098 262.65 52.7474 262.59 52.0901C262.68 50.6061 262.209 49.1425 261.27 47.9901C260.812 47.4622 260.24 47.0449 259.597 46.7695C258.955 46.4941 258.258 46.3678 257.56 46.4001H251.73V57.0001ZM296.73 69.4501H312V76.2101H287.9V39.7701H296.65V69.4501H296.73ZM337.81 60.7101H324.07V69.4501H340.37V76.2101H315.37V39.7701H340.55V46.5301H324.25V54.2101H337.9L337.81 60.7101ZM343.37 76.0801V39.7701H355.16C358.25 39.7375 361.295 40.5058 364 42.0001C366.568 43.4425 368.655 45.6093 370 48.2301C371.442 50.9709 372.216 54.0134 372.26 57.1101V58.8301C372.324 61.9609 371.595 65.0569 370.14 67.8301C368.731 70.4528 366.622 72.6337 364.048 74.1306C361.475 75.6275 358.537 76.3819 355.56 76.3101H343.42L343.37 76.0801ZM352.12 46.5301V69.4501H355.12C356.241 69.5121 357.36 69.3038 358.383 68.8426C359.407 68.3814 360.304 67.6809 361 66.8001C362.333 64.9534 363 62.3034 363 58.8501V57.2601C363 53.6801 362.333 51.0301 361 49.3101C360.287 48.4178 359.37 47.7109 358.325 47.2495C357.28 46.7881 356.14 46.5859 355 46.6601H352.09L352.12 46.5301ZM405.83 71.7001C404.158 73.3731 402.096 74.6035 399.83 75.2801C397.058 76.2148 394.145 76.6647 391.22 76.6101C386.45 76.6101 382.6 75.1501 379.82 72.2301C377.04 69.3101 375.45 65.2301 375.18 60.0401V56.8601C375.131 53.6307 375.765 50.4274 377.04 47.4601C378.217 44.9066 380.101 42.7443 382.47 41.2301C384.959 39.7722 387.806 39.038 390.69 39.1101C395.19 39.1101 398.77 40.1701 401.29 42.2901C403.81 44.4101 405.29 47.4601 405.66 51.5601H397.18C397.066 49.909 396.355 48.3558 395.18 47.1901C394.003 46.2386 392.51 45.7671 391 45.8701C389.971 45.8449 388.953 46.0881 388.047 46.5755C387.14 47.0629 386.376 47.7779 385.83 48.6501C384.465 51.0865 383.823 53.8617 383.98 56.6501V58.9001C383.98 62.4801 384.64 65.2601 385.83 67.1201C387.02 68.9801 389.01 69.9001 391.66 69.9001C393.521 70.0287 395.363 69.4621 396.83 68.3101V62.6101H390.74V56.6101H405.58V71.7001H405.83ZM433 60.7001H419.22V69.4401H435.51V76.2001H410.51V39.7701H435.69V46.5301H419.39V54.2101H433.17V60.7101L433 60.7001ZM452.21 63.2101H447.44V76.0801H438.56V39.7701H452.87C457.25 39.7701 460.56 40.7001 462.94 42.6901C464.152 43.649 465.119 44.8809 465.764 46.2851C466.409 47.6893 466.712 49.2261 466.65 50.7701C466.725 52.9971 466.175 55.2005 465.06 57.1301C463.892 58.9247 462.238 60.3501 460.29 61.2401L467.85 75.9401V76.3401H458.44L452.21 63.2101ZM447.44 56.4601H453C453.679 56.5314 454.365 56.4489 455.007 56.2186C455.649 55.9884 456.231 55.6163 456.71 55.1301C457.579 54.0965 458.038 52.7798 458 51.4301C458.046 50.7542 457.953 50.0761 457.726 49.4378C457.499 48.7996 457.143 48.2149 456.68 47.7201C456.197 47.2499 455.618 46.8888 454.983 46.6611C454.348 46.4334 453.672 46.3444 453 46.4001H447.43L447.44 56.4601Z" opacity="0.9"/>
<path style="opacity:1" id="path981" fill="white" d="M35.4 7.20001H38.2V8.86606e-06H35.4C32.4314 -0.00262172 29.4914 0.580149 26.7482 1.71497C24.0051 2.8498 21.5126 4.5144 19.4135 6.61353C17.3144 8.71266 15.6498 11.2051 14.515 13.9483C13.3801 16.6914 12.7974 19.6314 12.8 22.6V39C12.806 42.4725 11.4835 45.8158 9.10354 48.3445C6.72359 50.8732 3.46651 52.3957 0 52.6L0.2 56.2L0 59.8C3.46651 60.0043 6.72359 61.5268 9.10354 64.0555C11.4835 66.5842 12.806 69.9275 12.8 73.4V92.3C12.7894 98.5716 15.2692 104.591 19.6945 109.035C24.1198 113.479 30.1284 115.984 36.4 116V108.8C32.0513 108.797 27.8814 107.069 24.8064 103.994C21.7314 100.919 20.0026 96.7487 20 92.4V73.4C20.003 70.0079 19.1752 66.6667 17.5889 63.6684C16.0026 60.6701 13.706 58.1059 10.9 56.2C13.698 54.286 15.9885 51.7202 17.5738 48.7237C19.1592 45.7272 19.9918 42.39 20 39V22.6C20.0184 18.5213 21.6468 14.615 24.5309 11.7309C27.415 8.84683 31.3213 7.21842 35.4 7.20001V7.20001Z" opacity="0.9"/>
<path style="opacity:1" id="path983" fill="white" d="M118.6 7.2H115.8V0H118.6C124.58 0.0158944 130.309 2.40525 134.528 6.643C138.747 10.8808 141.111 16.6202 141.1 22.6V39C141.094 42.4725 142.416 45.8158 144.796 48.3445C147.176 50.8732 150.433 52.3957 153.9 52.6L153.7 56.2L153.9 59.8C150.433 60.0043 147.176 61.5268 144.796 64.0555C142.416 66.5842 141.094 69.9275 141.1 73.4V92.3C141.111 98.5716 138.631 104.591 134.206 109.035C129.78 113.479 123.772 115.984 117.5 116V108.8C121.849 108.797 126.019 107.069 129.094 103.994C132.169 100.919 133.897 96.7487 133.9 92.4V73.4C133.897 70.0079 134.725 66.6667 136.311 63.6684C137.897 60.6701 140.194 58.1059 143 56.2C140.202 54.286 137.911 51.7201 136.326 48.7237C134.741 45.7272 133.908 42.39 133.9 39V22.6C133.911 20.5831 133.523 18.584 132.759 16.7173C131.995 14.8507 130.87 13.1533 129.448 11.7225C128.027 10.2916 126.337 9.1556 124.475 8.37952C122.613 7.60345 120.617 7.20261 118.6 7.2V7.2Z" opacity="0.9"/>
<path style="opacity:1" id="path985" fill="white" d="M103.2 29H113.9L91.6 49.9C87.599 53.5203 82.3957 55.525 77 55.525C71.6042 55.525 66.4009 53.5203 62.4 49.9L40.1 29H50.8L67.7 44.8C70.2237 47.1162 73.5245 48.4013 76.95 48.4013C80.3754 48.4013 83.6763 47.1162 86.2 44.8L103.2 29Z" opacity="0.9"/>
<path style="opacity:1" id="path987" fill="white" d="M50.7 87H40L62.4 66C66.3788 62.3351 71.5905 60.3007 77 60.3007C82.4095 60.3007 87.6212 62.3351 91.6 66L114 87H103.3L86.3 71C83.7763 68.6838 80.4755 67.3987 77.05 67.3987C73.6245 67.3987 70.3237 68.6838 67.8 71L50.7 87Z" opacity="0.9"/>
</g>
<script xmlns=""/></svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -0,0 +1,44 @@
import { Client, Wallet, classicAddressToXAddress } from 'xrpl';
export default async function getWalletDetails({ client }) {
try {
const wallet = Wallet.fromSeed(process.env.SEED); // Convert the seed to a wallet : https://xrpl.org/cryptographic-keys.html
// Get the wallet details: https://xrpl.org/account_info.html
const {
result: { account_data },
} = await client.request({
command: 'account_info',
account: wallet.address,
ledger_index: 'validated',
});
const ownerCount = account_data.OwnerCount || 0;
// Get the reserve base and increment
const {
result: {
info: {
validated_ledger: { reserve_base_xrp, reserve_inc_xrp },
},
},
} = await client.request({
command: 'server_info',
});
// Calculate the reserves by multiplying the owner count by the increment and adding the base reserve to it.
const accountReserves = ownerCount * reserve_inc_xrp + reserve_base_xrp;
console.log('Got wallet details!');
return {
account_data,
accountReserves,
xAddress: classicAddressToXAddress(wallet.address, false, false), // Learn more: https://xrpaddress.info/
address: wallet.address
};
} catch (error) {
console.log('Error getting wallet details', error);
return error;
}
}

View File

@@ -0,0 +1,13 @@
import xrplLogo from '../assets/xrpl.svg';
export default function renderXrplLogo() {
document.getElementById('heading_logo').innerHTML = `
<a
href="https://xrpl.org/"
target="_blank"
class="logo_link"
>
<img id="xrpl_logo" class="logo vanilla" alt="XRPL logo" src="${xrplLogo}" />
</a>
`;
}

View File

@@ -0,0 +1,18 @@
import { Wallet } from 'xrpl';
export default async function submitTransaction({ client, tx }) {
try {
// Create a wallet using the seed
const wallet = await Wallet.fromSeed(process.env.SEED);
tx.Account = wallet.address;
// Sign and submit the transaction : https://xrpl.org/send-xrp.html#send-xrp
const response = await client.submit(tx, { wallet });
console.log(response);
return response;
} catch (error) {
console.log(error);
return null;
}
}

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="../assets/xrpl.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Simple XRPL Wallet</title>
<link rel="preload" href="../../index.css" as="style">
<link rel="stylesheet" href="../../index.css">
</head>
<body>
<div id="app">
<div class="main_content">
<div class="main_logo" id="heading_logo"></div>
<div class="links">
<button class="home" id="home_button">Home</button>
<button class="transaction_history" id="transaction_history_button">Transaction History</button>
</div>
<div class="send_xrp_container">
<div class="heading_h3">Send XRP</div>
<div class="available_balance" id="available_balance"></div>
<label for="destination_address">Destination Address:</label>
<input type="text" id="destination_address" placeholder="Destination Address" maxlength="35" />
<span class="isvalid_destination_address" id="isvalid_destination_address"></span>
<label for="amount">Amount:</label>
<input type="text" id="amount" placeholder="Amount" type="mobile" />
<label for="destination_tag">Destination Tag:</label>
<input type="text" id="destination_tag" placeholder="Destination Tag" />
<button class="submit_tx_button" id="submit_tx_button">Submit Transaction</button>
</div>
</div>
</div>
<script type="module" src="./send-xrp.js"></script>
</body>
</html>

View File

@@ -0,0 +1,146 @@
import { Client, Wallet, dropsToXrp, isValidClassicAddress, xrpToDrops } from 'xrpl';
import getWalletDetails from '../helpers/get-wallet-details';
import renderXrplLogo from '../helpers/render-xrpl-logo';
import submitTransaction from '../helpers/submit-transaction';
// Optional: Render the XRPL logo
renderXrplLogo();
const client = new Client(process.env.CLIENT); // Get the client from the environment variables
// Self-invoking function to connect to the client
(async () => {
try {
await client.connect(); // Connect to the client
const wallet = Wallet.fromSeed(process.env.SEED); // Convert the seed to a wallet : https://xrpl.org/cryptographic-keys.html
// Subscribe to account transaction stream
await client.request({
command: 'subscribe',
accounts: [wallet.address],
});
// Fetch the wallet details and show the available balance
await getWalletDetails({ client }).then(({ accountReserves, account_data }) => {
availableBalanceElement.textContent = `Available Balance: ${dropsToXrp(account_data.Balance) - accountReserves} XRP`;
});
} catch (error) {
await client.disconnect();
console.log(error);
}
})();
// Get the elements from the DOM
const homeButton = document.querySelector('#home_button');
const txHistoryButton = document.querySelector('#transaction_history_button');
const destinationAddress = document.querySelector('#destination_address');
const amount = document.querySelector('#amount');
const destinationTag = document.querySelector('#destination_tag');
const submitTxBtn = document.querySelector('#submit_tx_button');
const availableBalanceElement = document.querySelector('#available_balance');
// Disable the submit button by default
submitTxBtn.disabled = true;
let isValidDestinationAddress = false;
const allInputs = document.querySelectorAll('#destination_address, #amount');
// Add event listener to the redirect buttons
homeButton.addEventListener('click', () => {
window.location.pathname = '/index.html';
});
txHistoryButton.addEventListener('click', () => {
window.location.pathname = '/src/transaction-history/transaction-history.html';
});
// Update the account balance on successful transaction
client.on('transaction', (response) => {
if (response.validated && response.transaction.TransactionType === 'Payment') {
getWalletDetails({ client }).then(({ accountReserves, account_data }) => {
availableBalanceElement.textContent = `Available Balance: ${dropsToXrp(account_data.Balance) - accountReserves} XRP`;
});
}
});
const validateAddress = () => {
destinationAddress.value = destinationAddress.value.trim();
// Check if the address is valid
if (isValidClassicAddress(destinationAddress.value)) {
// Remove the invalid class if the address is valid
destinationAddress.classList.remove('invalid');
isValidDestinationAddress = true;
} else {
// Add the invalid class if the address is invalid
isValidDestinationAddress = false;
destinationAddress.classList.add('invalid');
}
};
// Add event listener to the destination address
destinationAddress.addEventListener('input', validateAddress);
// Add event listener to the amount input
amount.addEventListener('keydown', (event) => {
const codes = [8, 190];
const regex = /^[0-9\b.]+$/;
// Allow: backspace, delete, tab, escape, enter and .
if (!(regex.test(event.key) || codes.includes(event.keyCode))) {
event.preventDefault();
return false;
}
return true;
});
// NOTE: Keep this code at the bottom of the other input event listeners
// All the inputs should have a value to enable the submit button
for (let i = 0; i < allInputs.length; i++) {
allInputs[i].addEventListener('input', () => {
let values = [];
allInputs.forEach((v) => values.push(v.value));
submitTxBtn.disabled = !isValidDestinationAddress || values.includes('');
});
}
// Add event listener to the submit button
submitTxBtn.addEventListener('click', async () => {
try {
console.log('Submitting transaction');
submitTxBtn.disabled = true;
submitTxBtn.textContent = 'Submitting...';
// Create the transaction object: https://xrpl.org/transaction-common-fields.html
const txJson = {
TransactionType: 'Payment',
Amount: xrpToDrops(amount.value), // Convert XRP to drops: https://xrpl.org/basic-data-types.html#specifying-currency-amounts
Destination: destinationAddress.value,
};
// Get the destination tag if it exists
if (destinationTag?.value !== '') {
txJson.DestinationTag = destinationTag.value;
}
// Submit the transaction to the ledger
const { result } = await submitTransaction({ client, tx: txJson });
const txResult = result?.meta?.TransactionResult || result?.engine_result || ''; // Response format: https://xrpl.org/transaction-results.html
// Check if the transaction was successful or not and show the appropriate message to the user
if (txResult === 'tesSUCCESS') {
alert('Transaction submitted successfully!');
} else {
throw new Error(txResult);
}
} catch (error) {
alert('Error submitting transaction, Please try again.');
console.error(error);
} finally {
// Re-enable the submit button after the transaction is submitted so the user can submit another transaction
submitTxBtn.disabled = false;
submitTxBtn.textContent = 'Submit Transaction';
}
});

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="../assets/xrpl.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Simple XRPL Wallet</title>
<link rel="preload" href="../../index.css" as="style">
<link rel="stylesheet" href="../../index.css">
</head>
<body>
<div id="app">
<div class="main_content">
<div class="main_logo" id="heading_logo"></div>
<div class="links">
<button class="home" id="home_button">Home</button>
<button class="send_xrp" id="send_xrp_button">Send XRP</button>
</div>
<div class="tx_history_container">
<div class="heading_h3">Transaction History</div>
<div class="tx_history_data" id="tx_history_data"></div>
<button class="load_more_button" id="load_more_button">Load More</button>
</div>
</div>
</div>
<script type="module" src="./transaction-history.js"></script>
</body>
</html>

View File

@@ -0,0 +1,172 @@
import { Client, Wallet, convertHexToString, dropsToXrp } from 'xrpl';
import renderXrplLogo from '../helpers/render-xrpl-logo';
// Optional: Render the XRPL logo
renderXrplLogo();
// Declare the variables
let marker = null;
// Get the elements from the DOM
const txHistoryElement = document.querySelector('#tx_history_data');
const sendXrpButton = document.querySelector('#send_xrp_button');
const homeButton = document.querySelector('#home_button');
const loadMore = document.querySelector('#load_more_button');
// Add event listeners to the buttons
sendXrpButton.addEventListener('click', () => {
window.location.pathname = '/src/send-xrp/send-xrp.html';
});
homeButton.addEventListener('click', () => {
window.location.pathname = '/index.html';
});
// Add the header to the table
const header = document.createElement('tr');
header.innerHTML = `
<th>Account</th>
<th>Destination</th>
<th>Fee (XRP)</th>
<th>Amount Delivered</th>
<th>Transaction Type</th>
<th>Result</th>
<th>Link</th>
`;
txHistoryElement.appendChild(header);
// Converts the hex value to a string
function getTokenName(currencyCode) {
if (!currencyCode) return "";
if (currencyCode.length === 3 && currencyCode.trim().toLowerCase() !== 'xrp') {
// "Standard" currency code
return currencyCode.trim();
}
if (currencyCode.match(/^[a-fA-F0-9]{40}$/)) {
// Hexadecimal currency code
const text_code = convertHexToString(value).replaceAll('\u0000', '')
if (text_code.match(/[a-zA-Z0-9]{3,}/) && text_code.trim().toLowerCase() !== 'xrp') {
// ASCII or UTF-8 encoded alphanumeric code, 3+ characters long
return text_code;
}
// Other hex format, return as-is.
// For parsing other rare formats, see https://github.com/XRPLF/xrpl-dev-portal/blob/master/content/_code-samples/normalize-currency-codes/js/normalize-currency-code.js
return currencyCode;
}
return "";
}
function renderAmount(delivered) {
if (delivered === 'unavailable') {
// special case for pre-2014 partial payments
return 'unavailable';
} else if (typeof delivered === 'string') {
// It's an XRP amount in drops. Convert to decimal.
return `${dropsToXrp(delivered)} XRP`;
} else if (typeof delivered === 'object') {
// It's a token amount.
return `${delivered.value} ${getTokenName(delivered.currency)}.${delivered.issuer}`;
} else {
// Could be undefined -- not all transactions deliver value
return "-"
}
}
// Fetches the transaction history from the ledger
async function fetchTxHistory() {
try {
loadMore.textContent = 'Loading...';
loadMore.disabled = true;
const wallet = Wallet.fromSeed(process.env.SEED);
const client = new Client(process.env.CLIENT);
// Wait for the client to connect
await client.connect();
// Get the transaction history
const payload = {
command: 'account_tx',
account: wallet.address,
limit: 10,
};
if (marker) {
payload.marker = marker;
}
// Wait for the response: use the client.request() method to send the payload
const { result } = await client.request(payload);
const { transactions, marker: nextMarker } = result;
// Add the transactions to the table
const values = transactions.map((transaction) => {
const { meta, tx } = transaction;
return {
Account: tx.Account,
Destination: tx.Destination,
Fee: tx.Fee,
Hash: tx.hash,
TransactionType: tx.TransactionType,
result: meta?.TransactionResult,
delivered: meta?.delivered_amount
};
});
// If there are no more transactions, hide the load more button
loadMore.style.display = nextMarker ? 'block' : 'none';
// If there are no transactions, show a message
// Create a new row: https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement
// Add the row to the table: https://developer.mozilla.org/en-US/docs/Web/API/Node/appendChild
if (values.length === 0) {
const row = document.createElement('tr');
row.innerHTML = `<td colspan="6">No transactions found</td>`;
txHistoryElement.appendChild(row);
} else {
// Otherwise, show the transactions by iterating over each transaction and adding it to the table
values.forEach((value) => {
const row = document.createElement('tr');
// Add the transaction details to the row
row.innerHTML = `
${value.Account ? `<td>${value.Account}</td>` : '-'}
${value.Destination ? `<td>${value.Destination}</td>` : '-'}
${value.Fee ? `<td>${dropsToXrp(value.Fee)}</td>` : '-'}
${renderAmount(value.delivered)}
${value.TransactionType ? `<td>${value.TransactionType}</td>` : '-'}
${value.result ? `<td>${value.result}</td>` : '-'}
${value.Hash ? `<td><a href="https://${process.env.EXPLORER_NETWORK}.xrpl.org/transactions/${value.Hash}" target="_blank">View</a></td>` : '-'}`;
// Add the row to the table
txHistoryElement.appendChild(row);
});
}
// Disconnect
await client.disconnect();
// Enable the load more button only if there are more transactions
loadMore.textContent = 'Load More';
loadMore.disabled = false;
// Return the marker
return nextMarker ?? null;
} catch (error) {
console.log(error);
return null;
}
}
// Render the transaction history
async function renderTxHistory() {
// Fetch the transaction history
marker = await fetchTxHistory();
loadMore.addEventListener('click', async () => {
const nextMarker = await fetchTxHistory();
marker = nextMarker;
});
}
// Call the renderTxHistory() function
renderTxHistory();

View File

@@ -0,0 +1,43 @@
import { defineConfig, loadEnv } from 'vite';
import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill';
import polyfillNode from 'rollup-plugin-polyfill-node';
const viteConfig = ({ mode }) => {
process.env = { ...process.env, ...loadEnv(mode, '', '') };
return defineConfig({
define: {
'process.env': process.env,
},
optimizeDeps: {
esbuildOptions: {
define: {
global: 'globalThis',
},
plugins: [
NodeGlobalsPolyfillPlugin({
process: true,
buffer: true,
}),
],
},
},
build: {
rollupOptions: {
plugins: [polyfillNode()],
},
},
resolve: {
alias: {
events: 'events',
crypto: 'crypto-browserify',
stream: 'stream-browserify',
http: 'stream-http',
https: 'https-browserify',
ws: 'xrpl/dist/npm/client/WSWrapper',
},
},
});
};
export default viteConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
# Build a Wallet
Implement a non-custodial wallet application that runs on a desktop and can check an account's balances, send XRP, and notify when the account receives incoming transactions.

View File

@@ -0,0 +1,132 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
Wallet/

View File

@@ -0,0 +1,27 @@
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()
})

View File

@@ -0,0 +1,18 @@
<!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 - Part 0/8</h3>
<span>Hello world!</span>
</body>
</html>

View File

@@ -0,0 +1,63 @@
const { app, BrowserWindow } = require('electron')
const path = require('path')
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 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,
webPreferences: {
preload: path.join(__dirname, 'view', 'preload.js'),
},
})
// 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 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(() => {
const appWindow = createWindow()
getValidatedLedgerIndex().then((value) => {
appWindow.webContents.send('update-ledger-index', value)
})
})

View File

@@ -0,0 +1,11 @@
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)
}
})

View File

@@ -0,0 +1,7 @@
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
})

View File

@@ -0,0 +1,20 @@
<!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 - Part 1/8</h3>
Latest validated ledger index: <strong id="ledger-index"></strong>
</body>
<script src="renderer.js"></script>
</html>

View File

@@ -0,0 +1,53 @@
const { app, BrowserWindow } = require('electron')
const path = require('path')
const xrpl = require("xrpl")
const TESTNET_URL = "wss://s.altnet.rippletest.net:51233"
/**
* This function creates our application window
*
* @returns {Electron.CrossProcessExports.BrowserWindow}
*/
const createWindow = () => {
const appWindow = new BrowserWindow({
width: 1024,
height: 768,
webPreferences: {
preload: path.join(__dirname, 'view', 'preload.js'),
},
})
appWindow.loadFile(path.join(__dirname, 'view', 'template.html'))
return appWindow
}
/**
* 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)

View File

@@ -0,0 +1,7 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
onUpdateLedgerData: (callback) => {
ipcRenderer.on('update-ledger-data', callback)
}
})

View File

@@ -0,0 +1,15 @@
const ledgerIndexEl = document.getElementById('ledger-index')
// Step 2 code additions - start
const ledgerHashEl = document.getElementById('ledger-hash')
const ledgerCloseTimeEl = document.getElementById('ledger-close-time')
// Step 2 code additions - end
window.electronAPI.onUpdateLedgerData((_event, value) => {
ledgerIndexEl.innerText = value.ledger_index
// Step 2 code additions - start
ledgerHashEl.innerText = value.ledger_hash
ledgerCloseTimeEl.innerText = value.ledger_time
// Step 2 code additions - end
})

View File

@@ -0,0 +1,21 @@
<!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 - 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 />
</body>
<script src="renderer.js"></script>
</html>

View File

@@ -0,0 +1,78 @@
const { app, BrowserWindow, ipcMain} = require('electron')
const path = require('path')
const xrpl = require("xrpl")
const { prepareAccountData, prepareLedgerData} = require('../library/3_helpers')
const TESTNET_URL = "wss://s.altnet.rippletest.net:51233"
const createWindow = () => {
const appWindow = new BrowserWindow({
width: 1024,
height: 768,
webPreferences: {
preload: path.join(__dirname, 'view', 'preload.js'),
},
})
appWindow.loadFile(path.join(__dirname, 'view', 'template.html'))
return appWindow
}
const main = async () => {
const appWindow = createWindow()
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 initialAccountData = prepareAccountData(accountInfoResponse.result.account_data)
appWindow.webContents.send('update-account-data', initialAccountData)
})
}
app.whenReady().then(main)

View File

@@ -0,0 +1,16 @@
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
})

View File

@@ -0,0 +1,35 @@
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
})

View File

@@ -0,0 +1,45 @@
<!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 - 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/>
</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="submit">Confirm</button>
</div>
</form>
</dialog>
</body>
<script src="renderer.js"></script>
</html>

View File

@@ -0,0 +1,84 @@
const {app, BrowserWindow, ipcMain} = require('electron')
const path = require('path')
const xrpl = require("xrpl")
const { prepareAccountData, prepareLedgerData} = require('../library/3_helpers')
const { prepareTxData } = require('../library/4_helpers')
const TESTNET_URL = "wss://s.altnet.rippletest.net:51233"
const createWindow = () => {
const appWindow = new BrowserWindow({
width: 1024,
height: 768,
webPreferences: {
preload: path.join(__dirname, 'view', 'preload.js'),
},
})
appWindow.loadFile(path.join(__dirname, 'view', 'template.html'))
return appWindow
}
const main = async () => {
const appWindow = createWindow()
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)
})
// 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)
const transactions = prepareTxData([{tx: transaction.transaction}])
appWindow.webContents.send('update-transaction-data', transactions)
})
// 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)
// 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)
})
}
app.whenReady().then(main)

View File

@@ -0,0 +1,19 @@
const { contextBridge, ipcRenderer } = require('electron');
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
})

View File

@@ -0,0 +1,55 @@
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
})
// Step 4 code additions - start
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>"
)
}
})
// Step 4 code additions - end

View File

@@ -0,0 +1,61 @@
<!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 - Part 4/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/>
</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>
<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>
<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">Submit</button>
</div>
</form>
</dialog>
</body>
<script src="renderer.js"></script>
</html>

View File

@@ -0,0 +1,84 @@
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 = () => {
const appWindow = new BrowserWindow({
width: 1024,
height: 768,
webPreferences: {
preload: path.join(__dirname, 'view', 'preload.js'),
},
})
appWindow.loadFile(path.join(__dirname, 'view', 'template.html'))
return appWindow
}
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')
}
})
}
app.whenReady().then(main)

View File

@@ -0,0 +1,31 @@
const { contextBridge, ipcRenderer } = require('electron');
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)
},
onUpdateAccountData: (callback) => {
ipcRenderer.on('update-account-data', callback)
},
onUpdateTransactionData: (callback) => {
ipcRenderer.on('update-transaction-data', callback)
}
})

View File

@@ -0,0 +1,81 @@
// Step 5 code additions - start
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()
});
// Step 5 code additions - end
const ledgerIndexEl = document.getElementById('ledger-index')
const ledgerHashEl = document.getElementById('ledger-hash')
const ledgerCloseTimeEl = document.getElementById('ledger-close-time')
window.electronAPI.onUpdateLedgerData((_eventledger, 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
})
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>"
)
}
})

View File

@@ -0,0 +1,75 @@
<!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 - Part 5/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/>
</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>
<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>
<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">Submit</button>
</div>
</form>
</dialog>
<dialog id="password-dialog">
<form method="dialog">
<div>
<label for="password-input">Enter password (min-length 5):</label><br />
<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>
</body>
<script src="renderer.js"></script>
</html>

View File

@@ -0,0 +1,84 @@
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 = () => {
const appWindow = new BrowserWindow({
width: 1024,
height: 768,
webPreferences: {
preload: path.join(__dirname, 'view', 'preload.js'),
},
})
appWindow.loadFile(path.join(__dirname, 'view', 'template.html'))
return appWindow
}
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 ope-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')
}
})
}
app.whenReady().then(main)

View File

@@ -0,0 +1,31 @@
const { contextBridge, ipcRenderer } = require('electron');
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)
},
onUpdateAccountData: (callback) => {
ipcRenderer.on('update-account-data', callback)
},
onUpdateTransactionData: (callback) => {
ipcRenderer.on('update-transaction-data', callback)
}
})

View File

@@ -0,0 +1,79 @@
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()
});
const ledgerIndexEl = document.getElementById('ledger-index')
const ledgerHashEl = document.getElementById('ledger-hash')
const ledgerCloseTimeEl = document.getElementById('ledger-close-time')
window.electronAPI.onUpdateLedgerData((_eventledger, 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
})
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>"
)
}
})

View File

@@ -0,0 +1,120 @@
<!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">Submit</button>
</div>
</form>
</dialog>
<dialog id="password-dialog">
<form method="dialog">
<div>
<label for="password-input">Enter password (min-length 5):</label><br />
<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>
</body>
<script src="../../bootstrap/bootstrap.bundle.min.js"></script>
<script src="renderer.js"></script>
</html>

View File

@@ -0,0 +1,92 @@
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 { sendXrp } = require('../library/7_helpers')
const TESTNET_URL = "wss://s.altnet.rippletest.net:51233"
const WALLET_DIR = '../Wallet'
const createWindow = () => {
const appWindow = new BrowserWindow({
width: 1024,
height: 768,
webPreferences: {
preload: path.join(__dirname, 'view', 'preload.js'),
},
})
appWindow.loadFile(path.join(__dirname, 'view', 'template.html'))
return appWindow
}
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('send-xrp-action', (event, paymentData) => {
sendXrp(paymentData, client, wallet).then((result) => {
appWindow.webContents.send('send-xrp-transaction-finish', result)
})
})
})
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 ope-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')
}
})
}
app.whenReady().then(main)

View File

@@ -0,0 +1,37 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
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')
},
onUpdateLedgerData: (callback) => {
ipcRenderer.on('update-ledger-data', callback)
},
onUpdateAccountData: (callback) => {
ipcRenderer.on('update-account-data', callback)
},
onUpdateTransactionData: (callback) => {
ipcRenderer.on('update-transaction-data', callback)
},
// Step 7 code additions - start
onClickSendXrp: (paymentData) => {
ipcRenderer.send('send-xrp-action', paymentData)
},
onSendXrpTransactionFinish: (callback) => {
ipcRenderer.on('send-xrp-transaction-finish', callback)
}
// Step 7 code additions - start
})

View File

@@ -0,0 +1,107 @@
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()
});
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
})
const txTableBodyEl = document.getElementById('tx-table').tBodies[0]
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>"
)
}
})
// Step 7 code additions - start
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 = ''
})
// Step 7 code additions - end

View File

@@ -0,0 +1,156 @@
<!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 7/8</small>
</h3>
<button type="button" class="btn btn-primary" id="send-xrp-modal-button">
Send XRP
</button>
</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>
<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" value="rP4zcp52pa7ZjhjtU9LrnFcitBUadNW8Xz"
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" value="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" value="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>
</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><br />
<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>
</body>
<script src="../../bootstrap/bootstrap.bundle.min.js"></script>
<script src="renderer.js"></script>
</html>

View File

@@ -0,0 +1,99 @@
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 { sendXrp } = require('../library/7_helpers')
const { verify } = require('../library/8_helpers')
const TESTNET_URL = "wss://s.altnet.rippletest.net:51233"
const WALLET_DIR = '../Wallet'
const createWindow = () => {
const appWindow = new BrowserWindow({
width: 1024,
height: 768,
webPreferences: {
preload: path.join(__dirname, 'view', 'preload.js'),
},
})
appWindow.loadFile(path.join(__dirname, 'view', 'template.html'))
return appWindow
}
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('send-xrp-action', (event, paymentData) => {
sendXrp(paymentData, client, wallet).then((result) => {
appWindow.webContents.send('send-xrp-transaction-finish', result)
})
})
ipcMain.on('destination-account-change', (event, destinationAccount) => {
verify(destinationAccount, client).then((result) => {
appWindow.webContents.send('update-domain-verification-data', result)
})
})
})
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 ope-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')
}
})
}
app.whenReady().then(main)

View File

@@ -0,0 +1,44 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
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')
},
onUpdateLedgerData: (callback) => {
ipcRenderer.on('update-ledger-data', callback)
},
onUpdateAccountData: (callback) => {
ipcRenderer.on('update-account-data', callback)
},
onUpdateTransactionData: (callback) => {
ipcRenderer.on('update-transaction-data', callback)
},
onClickSendXrp: (paymentData) => {
ipcRenderer.send('send-xrp-action', paymentData)
},
onSendXrpTransactionFinish: (callback) => {
ipcRenderer.on('send-xrp-transaction-finish', callback)
},
// Step 8 code additions - start
onDestinationAccountChange: (callback) => {
ipcRenderer.send('destination-account-change', callback)
},
onUpdateDomainVerificationData: (callback) => {
ipcRenderer.on('update-domain-verification-data', callback)
},
// Step 8 code additions - start
})

View File

@@ -0,0 +1,119 @@
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()
});
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
})
const txTableBodyEl = document.getElementById('tx-table').tBodies[0]
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>"
)
}
})
const modalButton = document.getElementById('send-xrp-modal-button')
const modalDialog = new bootstrap.Modal(document.getElementById('send-xrp-modal'))
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})
})
window.electronAPI.onSendXrpTransactionFinish((_event, result) => {
alert('Result: ' + result.result.meta.TransactionResult)
destinationAddressEl.value = ''
destinationTagEl.value = ''
amountEl.value = ''
})

View File

@@ -0,0 +1,159 @@
<!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 8/8</small>
</h3>
<button type="button" class="btn btn-primary" id="send-xrp-modal-button">
Send XRP
</button>
</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>
<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">
<div class="accountVerificationIndicator">
<span>Verification status:</span>
</div>
<input type="text" class="form-control" value="rP4zcp52pa7ZjhjtU9LrnFcitBUadNW8Xz"
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" value="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" value="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>
</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><br />
<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>
</body>
<script src="../../bootstrap/bootstrap.bundle.min.js"></script>
<script src="renderer.js"></script>
</html>

View File

@@ -0,0 +1,43 @@
# Build a Desktop Wallet Sample Code (JavaScript)
Build a non-custodial XRP Ledger wallet application in JavaScript that runs on the desktop using Electron.
For the full documentation, refer to the [Build a Wallet in JavaScript tutorial](https://xrpl.org/build-a-wallet-in-javascript.html).
## TL;DR
Setup:
```sh
npm install
```
Run any of the scripts (higher numbers are more complete/advanced examples):
```sh
npm run hello
```
```sh
npm run async-poll
```
```sh
npm run async-subscribe
```
```sh
npm run account
```
```sh
npm run tx-history
```
```sh
npm run send-xrp
```
```sh
npm run domain-verification
```

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
sodipodi:docname="XRPLedger_DevPortal-white.svg"
id="svg991"
version="1.1"
fill="none"
viewBox="0 0 468 116"
height="116"
width="468">
<metadata
id="metadata997">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs995" />
<sodipodi:namedview
inkscape:current-layer="svg991"
inkscape:window-maximized="1"
inkscape:window-y="1"
inkscape:window-x="0"
inkscape:cy="58"
inkscape:cx="220.57619"
inkscape:zoom="2.8397436"
inkscape:pagecheckerboard="true"
showgrid="false"
id="namedview993"
inkscape:window-height="1028"
inkscape:window-width="1920"
inkscape:pageshadow="2"
inkscape:pageopacity="0"
guidetolerance="10"
gridtolerance="10"
objecttolerance="10"
borderopacity="1"
bordercolor="#666666"
pagecolor="#ffffff" />
<g
style="opacity:1"
id="g989"
opacity="0.9">
<path
style="opacity:1"
id="path979"
fill="white"
d="M191.43 51.8301L197.43 39.7701H207.5L197.29 57.7701L207.76 76.1901H197.66L191.57 63.8701L185.47 76.1901H175.4L185.87 57.7701L175.66 39.7701H185.74L191.43 51.8301ZM223.5 63.2201H218.73V76.0801H210V39.7701H224.3C228.67 39.7701 231.98 40.7001 234.37 42.6901C235.58 43.6506 236.546 44.8828 237.191 46.2866C237.835 47.6905 238.14 49.2265 238.08 50.7701C238.155 52.9971 237.605 55.2005 236.49 57.1301C235.322 58.9247 233.668 60.3501 231.72 61.2401L239.27 75.9401V76.3401H229.86L223.5 63.2201ZM218.86 56.4701H224.43C225.109 56.5414 225.795 56.4589 226.437 56.2286C227.079 55.9984 227.661 55.6263 228.14 55.1401C229.022 54.1082 229.492 52.7871 229.46 51.4301C229.509 50.754 229.416 50.0752 229.189 49.4366C228.962 48.798 228.605 48.2135 228.14 47.7201C227.653 47.2459 227.07 46.8825 226.429 46.6547C225.789 46.4269 225.107 46.34 224.43 46.4001H218.86V56.4701ZM251.73 63.7501V76.0801H243V39.7701H257.58C260.143 39.7175 262.683 40.2618 265 41.3601C267.03 42.3389 268.758 43.8489 270 45.7301C271.199 47.6808 271.797 49.9415 271.72 52.2301C271.773 53.8434 271.455 55.4474 270.789 56.9179C270.123 58.3884 269.128 59.6859 267.88 60.7101C265.36 62.8301 261.88 63.8901 257.41 63.8901H251.72L251.73 63.7501ZM251.73 57.0001H257.42C258.12 57.0708 258.827 56.9885 259.491 56.7588C260.156 56.5292 260.763 56.1577 261.27 55.6701C261.742 55.209 262.106 54.6484 262.334 54.0291C262.563 53.4098 262.65 52.7474 262.59 52.0901C262.68 50.6061 262.209 49.1425 261.27 47.9901C260.812 47.4622 260.24 47.0449 259.597 46.7695C258.955 46.4941 258.258 46.3678 257.56 46.4001H251.73V57.0001ZM296.73 69.4501H312V76.2101H287.9V39.7701H296.65V69.4501H296.73ZM337.81 60.7101H324.07V69.4501H340.37V76.2101H315.37V39.7701H340.55V46.5301H324.25V54.2101H337.9L337.81 60.7101ZM343.37 76.0801V39.7701H355.16C358.25 39.7375 361.295 40.5058 364 42.0001C366.568 43.4425 368.655 45.6093 370 48.2301C371.442 50.9709 372.216 54.0134 372.26 57.1101V58.8301C372.324 61.9609 371.595 65.0569 370.14 67.8301C368.731 70.4528 366.622 72.6337 364.048 74.1306C361.475 75.6275 358.537 76.3819 355.56 76.3101H343.42L343.37 76.0801ZM352.12 46.5301V69.4501H355.12C356.241 69.5121 357.36 69.3038 358.383 68.8426C359.407 68.3814 360.304 67.6809 361 66.8001C362.333 64.9534 363 62.3034 363 58.8501V57.2601C363 53.6801 362.333 51.0301 361 49.3101C360.287 48.4178 359.37 47.7109 358.325 47.2495C357.28 46.7881 356.14 46.5859 355 46.6601H352.09L352.12 46.5301ZM405.83 71.7001C404.158 73.3731 402.096 74.6035 399.83 75.2801C397.058 76.2148 394.145 76.6647 391.22 76.6101C386.45 76.6101 382.6 75.1501 379.82 72.2301C377.04 69.3101 375.45 65.2301 375.18 60.0401V56.8601C375.131 53.6307 375.765 50.4274 377.04 47.4601C378.217 44.9066 380.101 42.7443 382.47 41.2301C384.959 39.7722 387.806 39.038 390.69 39.1101C395.19 39.1101 398.77 40.1701 401.29 42.2901C403.81 44.4101 405.29 47.4601 405.66 51.5601H397.18C397.066 49.909 396.355 48.3558 395.18 47.1901C394.003 46.2386 392.51 45.7671 391 45.8701C389.971 45.8449 388.953 46.0881 388.047 46.5755C387.14 47.0629 386.376 47.7779 385.83 48.6501C384.465 51.0865 383.823 53.8617 383.98 56.6501V58.9001C383.98 62.4801 384.64 65.2601 385.83 67.1201C387.02 68.9801 389.01 69.9001 391.66 69.9001C393.521 70.0287 395.363 69.4621 396.83 68.3101V62.6101H390.74V56.6101H405.58V71.7001H405.83ZM433 60.7001H419.22V69.4401H435.51V76.2001H410.51V39.7701H435.69V46.5301H419.39V54.2101H433.17V60.7101L433 60.7001ZM452.21 63.2101H447.44V76.0801H438.56V39.7701H452.87C457.25 39.7701 460.56 40.7001 462.94 42.6901C464.152 43.649 465.119 44.8809 465.764 46.2851C466.409 47.6893 466.712 49.2261 466.65 50.7701C466.725 52.9971 466.175 55.2005 465.06 57.1301C463.892 58.9247 462.238 60.3501 460.29 61.2401L467.85 75.9401V76.3401H458.44L452.21 63.2101ZM447.44 56.4601H453C453.679 56.5314 454.365 56.4489 455.007 56.2186C455.649 55.9884 456.231 55.6163 456.71 55.1301C457.579 54.0965 458.038 52.7798 458 51.4301C458.046 50.7542 457.953 50.0761 457.726 49.4378C457.499 48.7996 457.143 48.2149 456.68 47.7201C456.197 47.2499 455.618 46.8888 454.983 46.6611C454.348 46.4334 453.672 46.3444 453 46.4001H447.43L447.44 56.4601Z"
opacity="0.9" />
<path
style="opacity:1"
id="path981"
fill="white"
d="M35.4 7.20001H38.2V8.86606e-06H35.4C32.4314 -0.00262172 29.4914 0.580149 26.7482 1.71497C24.0051 2.8498 21.5126 4.5144 19.4135 6.61353C17.3144 8.71266 15.6498 11.2051 14.515 13.9483C13.3801 16.6914 12.7974 19.6314 12.8 22.6V39C12.806 42.4725 11.4835 45.8158 9.10354 48.3445C6.72359 50.8732 3.46651 52.3957 0 52.6L0.2 56.2L0 59.8C3.46651 60.0043 6.72359 61.5268 9.10354 64.0555C11.4835 66.5842 12.806 69.9275 12.8 73.4V92.3C12.7894 98.5716 15.2692 104.591 19.6945 109.035C24.1198 113.479 30.1284 115.984 36.4 116V108.8C32.0513 108.797 27.8814 107.069 24.8064 103.994C21.7314 100.919 20.0026 96.7487 20 92.4V73.4C20.003 70.0079 19.1752 66.6667 17.5889 63.6684C16.0026 60.6701 13.706 58.1059 10.9 56.2C13.698 54.286 15.9885 51.7202 17.5738 48.7237C19.1592 45.7272 19.9918 42.39 20 39V22.6C20.0184 18.5213 21.6468 14.615 24.5309 11.7309C27.415 8.84683 31.3213 7.21842 35.4 7.20001V7.20001Z"
opacity="0.9" />
<path
style="opacity:1"
id="path983"
fill="white"
d="M118.6 7.2H115.8V0H118.6C124.58 0.0158944 130.309 2.40525 134.528 6.643C138.747 10.8808 141.111 16.6202 141.1 22.6V39C141.094 42.4725 142.416 45.8158 144.796 48.3445C147.176 50.8732 150.433 52.3957 153.9 52.6L153.7 56.2L153.9 59.8C150.433 60.0043 147.176 61.5268 144.796 64.0555C142.416 66.5842 141.094 69.9275 141.1 73.4V92.3C141.111 98.5716 138.631 104.591 134.206 109.035C129.78 113.479 123.772 115.984 117.5 116V108.8C121.849 108.797 126.019 107.069 129.094 103.994C132.169 100.919 133.897 96.7487 133.9 92.4V73.4C133.897 70.0079 134.725 66.6667 136.311 63.6684C137.897 60.6701 140.194 58.1059 143 56.2C140.202 54.286 137.911 51.7201 136.326 48.7237C134.741 45.7272 133.908 42.39 133.9 39V22.6C133.911 20.5831 133.523 18.584 132.759 16.7173C131.995 14.8507 130.87 13.1533 129.448 11.7225C128.027 10.2916 126.337 9.1556 124.475 8.37952C122.613 7.60345 120.617 7.20261 118.6 7.2V7.2Z"
opacity="0.9" />
<path
style="opacity:1"
id="path985"
fill="white"
d="M103.2 29H113.9L91.6 49.9C87.599 53.5203 82.3957 55.525 77 55.525C71.6042 55.525 66.4009 53.5203 62.4 49.9L40.1 29H50.8L67.7 44.8C70.2237 47.1162 73.5245 48.4013 76.95 48.4013C80.3754 48.4013 83.6763 47.1162 86.2 44.8L103.2 29Z"
opacity="0.9" />
<path
style="opacity:1"
id="path987"
fill="white"
d="M50.7 87H40L62.4 66C66.3788 62.3351 71.5905 60.3007 77 60.3007C82.4095 60.3007 87.6212 62.3351 91.6 66L114 87H103.3L86.3 71C83.7763 68.6838 80.4755 67.3987 77.05 67.3987C73.6245 67.3987 70.3237 68.6838 67.8 71L50.7 87Z"
opacity="0.9" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,78 @@
body {
min-height: 100vh;
min-height: -webkit-fill-available;
}
html {
height: -webkit-fill-available;
}
main {
display: flex;
flex-wrap: nowrap;
height: 100vh;
height: -webkit-fill-available;
max-height: 100vh;
overflow-x: auto;
overflow-y: hidden;
}
.sidebar {
width: 200px;
}
.logo {
margin-left: 0;
content: url(XRPLedger_DevPortal-white.svg);
width: 162px;
height: 40px;
display: block;
}
.divider {
flex-shrink: 0;
width: 20px;
height: 100vh;
background-color: rgba(0, 0, 0, .1);
border: solid rgba(0, 0, 0, .15);
border-width: 1px 0;
box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15);
}
.main-content {
width: 808px;
}
.nav-link, .nav-link:hover {
color: white;
width: 100%;
}
.header {
position: relative;
margin-bottom: 20px;
}
.header button {
position: absolute;
right: 0;
top: -4px;
}
.spacer {
height: 20px;
}
.invalid-password {
color: #dc3545;
}
.accountVerificationIndicator{
width: 100%;
}
.accountVerificationIndicator span {
font-size: 9px;
color: grey;
}

View File

@@ -0,0 +1,29 @@
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 }

View File

@@ -0,0 +1,34 @@
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 }

View File

@@ -0,0 +1,136 @@
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 }

View File

@@ -0,0 +1,28 @@
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 }

View File

@@ -0,0 +1,110 @@
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 }

View File

@@ -0,0 +1,28 @@
{
"name": "xrpl-javascript-desktop-wallet",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"hello": "electron 0-hello/index.js",
"ledger-index": "electron 1-ledger-index/index.js",
"async": "electron 2-async/index.js",
"account": "electron 3-account/index.js",
"tx-history": "electron 4-tx-history/index.js",
"password": "electron 5-password/index.js",
"styling": "electron 6-styling/index.js",
"send-xrp": "electron 7-send-xrp/index.js",
"domain-verification": "electron 8-domain-verification/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.25"
}
}

View File

@@ -0,0 +1,43 @@
# "Build a Wallet" tutorial, step 1: slightly more than "Hello World"
# This step demonstrates a simple GUI and XRPL connectivity.
# License: MIT. https://github.com/XRPLF/xrpl-dev-portal/blob/master/LICENSE
import xrpl
import wx
class TWaXLFrame(wx.Frame):
"""
Tutorial Wallet for the XRP Ledger (TWaXL)
user interface, main frame.
"""
def __init__(self, url):
wx.Frame.__init__(self, None, title="TWaXL", size=wx.Size(800,400))
self.client = xrpl.clients.JsonRpcClient(url)
main_panel = wx.Panel(self)
self.ledger_info = wx.StaticText(main_panel,
label=self.get_validated_ledger())
def get_validated_ledger(self):
try:
response = self.client.request(xrpl.models.requests.Ledger(
ledger_index="validated"
))
except Exception as e:
return f"Failed to get validated ledger from server. ({e})"
if response.is_successful():
return f"Latest validated ledger: {response.result['ledger_index']}"
else:
# Connected to the server, but the request failed. This can
# happen if, for example, the server isn't synced to the network
# so it doesn't have the latest validated ledger.
return f"Server returned an error: {response.result['error_message']}"
if __name__ == "__main__":
JSON_RPC_URL = "https://s.altnet.rippletest.net:51234/"
app = wx.App()
frame = TWaXLFrame(JSON_RPC_URL)
frame.Show()
app.MainLoop()

View File

@@ -0,0 +1,114 @@
# "Build a Wallet" tutorial, step 2: Watch ledger closes from a worker thread.
# This step builds an app architecture that keeps the GUI responsive while
# showing realtime updates to the XRP Ledger.
# License: MIT. https://github.com/XRPLF/xrpl-dev-portal/blob/master/LICENSE
import xrpl
import wx
import asyncio
from threading import Thread
class XRPLMonitorThread(Thread):
"""
A worker thread to watch for new ledger events and pass the info back to
the main frame to be shown in the UI. Using a thread lets us maintain the
responsiveness of the UI while doing work in the background.
"""
def __init__(self, url, gui):
Thread.__init__(self, daemon=True)
# Note: For thread safety, this thread should treat self.gui as
# read-only; to modify the GUI, use wx.CallAfter(...)
self.gui = gui
self.url = url
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.loop.set_debug(True)
def run(self):
"""
This thread runs a never-ending event-loop that monitors messages coming
from the XRPL, sending them to the GUI thread when necessary, and also
handles making requests to the XRPL when the GUI prompts them.
"""
self.loop.run_forever()
async def watch_xrpl(self):
"""
This is the task that opens the connection to the XRPL, then handles
incoming subscription messages by dispatching them to the appropriate
part of the GUI.
"""
async with xrpl.asyncio.clients.AsyncWebsocketClient(self.url) as self.client:
await self.on_connected()
async for message in self.client:
mtype = message.get("type")
if mtype == "ledgerClosed":
wx.CallAfter(self.gui.update_ledger, message)
async def on_connected(self):
"""
Set up initial subscriptions and populate the GUI with data from the
ledger on startup. Requires that self.client be connected first.
"""
# Set up a subscriptions for new ledgers
response = await self.client.request(xrpl.models.requests.Subscribe(
streams=["ledger"]
))
# The immediate response contains details for the last validated ledger.
# We can use this to fill in that area of the GUI without waiting for a
# new ledger to close.
wx.CallAfter(self.gui.update_ledger, response.result)
class TWaXLFrame(wx.Frame):
"""
Tutorial Wallet for the XRP Ledger (TWaXL)
user interface, main frame.
"""
def __init__(self, url):
wx.Frame.__init__(self, None, title="TWaXL", size=wx.Size(800,400))
self.build_ui()
# Start background thread for updates from the ledger ------------------
self.worker = XRPLMonitorThread(url, self)
self.worker.start()
self.run_bg_job(self.worker.watch_xrpl())
def build_ui(self):
"""
Called during __init__ to set up all the GUI components.
"""
main_panel = wx.Panel(self)
self.ledger_info = wx.StaticText(main_panel, label="Not connected")
main_sizer = wx.BoxSizer(wx.VERTICAL)
main_sizer.Add(self.ledger_info, 1, flag=wx.EXPAND|wx.ALL, border=5)
main_panel.SetSizer(main_sizer)
def run_bg_job(self, job):
"""
Schedules a job to run asynchronously in the XRPL worker thread.
The job should be a Future (for example, from calling an async function)
"""
task = asyncio.run_coroutine_threadsafe(job, self.worker.loop)
def update_ledger(self, message):
"""
Process a ledger subscription message to update the UI with
information about the latest validated ledger.
"""
close_time_iso = xrpl.utils.ripple_time_to_datetime(message["ledger_time"]).isoformat()
self.ledger_info.SetLabel(f"Latest validated ledger:\n"
f"Ledger Index: {message['ledger_index']}\n"
f"Ledger Hash: {message['ledger_hash']}\n"
f"Close time: {close_time_iso}")
if __name__ == "__main__":
WS_URL = "wss://s.altnet.rippletest.net:51233" # Testnet
app = wx.App()
frame = TWaXLFrame(WS_URL)
frame.Show()
app.MainLoop()

View File

@@ -0,0 +1,292 @@
# "Build a Wallet" tutorial, step 3: Take account input & show account info
# This step demonstrates how to parse user input into account information and
# look up that information on the XRP Ledger.
# License: MIT. https://github.com/XRPLF/xrpl-dev-portal/blob/master/LICENSE
import xrpl
import wx
import asyncio
from threading import Thread
from decimal import Decimal
class XRPLMonitorThread(Thread):
"""
A worker thread to watch for new ledger events and pass the info back to
the main frame to be shown in the UI. Using a thread lets us maintain the
responsiveness of the UI while doing work in the background.
"""
def __init__(self, url, gui):
Thread.__init__(self, daemon=True)
# Note: For thread safety, this thread should treat self.gui as
# read-only; to modify the GUI, use wx.CallAfter(...)
self.gui = gui
self.url = url
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.loop.set_debug(True)
def run(self):
"""
This thread runs a never-ending event-loop that monitors messages coming
from the XRPL, sending them to the GUI thread when necessary, and also
handles making requests to the XRPL when the GUI prompts them.
"""
self.loop.run_forever()
async def watch_xrpl_account(self, address, wallet=None):
"""
This is the task that opens the connection to the XRPL, then handles
incoming subscription messages by dispatching them to the appropriate
part of the GUI.
"""
self.account = address
self.wallet = wallet
async with xrpl.asyncio.clients.AsyncWebsocketClient(self.url) as self.client:
await self.on_connected()
async for message in self.client:
mtype = message.get("type")
if mtype == "ledgerClosed":
wx.CallAfter(self.gui.update_ledger, message)
elif mtype == "transaction":
response = await self.client.request(xrpl.models.requests.AccountInfo(
account=self.account,
ledger_index=message["ledger_index"]
))
wx.CallAfter(self.gui.update_account, response.result["account_data"])
async def on_connected(self):
"""
Set up initial subscriptions and populate the GUI with data from the
ledger on startup. Requires that self.client be connected first.
"""
# Set up 2 subscriptions: all new ledgers, and any new transactions that
# affect the chosen account.
response = await self.client.request(xrpl.models.requests.Subscribe(
streams=["ledger"],
accounts=[self.account]
))
# The immediate response contains details for the last validated ledger.
# We can use this to fill in that area of the GUI without waiting for a
# new ledger to close.
wx.CallAfter(self.gui.update_ledger, response.result)
# Get starting values for account info.
response = await self.client.request(xrpl.models.requests.AccountInfo(
account=self.account,
ledger_index="validated"
))
if not response.is_successful():
print("Got error from server:", response)
# This most often happens if the account in question doesn't exist
# on the network we're connected to. Better handling would be to use
# wx.CallAfter to display an error dialog in the GUI and possibly
# let the user try inputting a different account.
exit(1)
wx.CallAfter(self.gui.update_account, response.result["account_data"])
class AutoGridBagSizer(wx.GridBagSizer):
"""
Helper class for adding a bunch of items uniformly to a GridBagSizer.
"""
def __init__(self, parent):
wx.GridBagSizer.__init__(self, vgap=5, hgap=5)
self.parent = parent
def BulkAdd(self, ctrls):
"""
Given a two-dimensional iterable `ctrls`, add all the items in a grid
top-to-bottom, left-to-right, with each inner iterable being a row. Set
the total number of columns based on the longest iterable.
"""
flags = wx.EXPAND|wx.ALL|wx.RESERVE_SPACE_EVEN_IF_HIDDEN|wx.ALIGN_CENTER_VERTICAL
for x, row in enumerate(ctrls):
for y, ctrl in enumerate(row):
self.Add(ctrl, (x,y), flag=flags, border=5)
self.parent.SetSizer(self)
class TWaXLFrame(wx.Frame):
"""
Tutorial Wallet for the XRP Ledger (TWaXL)
user interface, main frame.
"""
def __init__(self, url, test_network=True):
wx.Frame.__init__(self, None, title="TWaXL", size=wx.Size(800,400))
self.test_network = test_network
# The ledger's current reserve settings. To be filled in later.
self.reserve_base = None
self.reserve_inc = None
self.build_ui()
# Pop up to ask user for their account ---------------------------------
address, wallet = self.prompt_for_account()
self.classic_address = address
# Start background thread for updates from the ledger ------------------
self.worker = XRPLMonitorThread(url, self)
self.worker.start()
self.run_bg_job(self.worker.watch_xrpl_account(address, wallet))
def build_ui(self):
"""
Called during __init__ to set up all the GUI components.
"""
main_panel = wx.Panel(self)
self.acct_info_area = wx.StaticBox(main_panel, label="Account Info")
lbl_address = wx.StaticText(self.acct_info_area, label="Classic Address:")
self.st_classic_address = wx.StaticText(self.acct_info_area, label="TBD")
lbl_xaddress = wx.StaticText(self.acct_info_area, label="X-Address:")
self.st_x_address = wx.StaticText(self.acct_info_area, label="TBD")
lbl_xrp_bal = wx.StaticText(self.acct_info_area, label="XRP Balance:")
self.st_xrp_balance = wx.StaticText(self.acct_info_area, label="TBD")
lbl_reserve = wx.StaticText(self.acct_info_area, label="XRP Reserved:")
self.st_reserve = wx.StaticText(self.acct_info_area, label="TBD")
aia_sizer = AutoGridBagSizer(self.acct_info_area)
aia_sizer.BulkAdd( ((lbl_address, self.st_classic_address),
(lbl_xaddress, self.st_x_address),
(lbl_xrp_bal, self.st_xrp_balance),
(lbl_reserve, self.st_reserve)) )
self.ledger_info = wx.StaticText(main_panel, label="Not connected")
main_sizer = wx.BoxSizer(wx.VERTICAL)
main_sizer.Add(self.acct_info_area, 1, flag=wx.EXPAND|wx.ALL, border=5)
main_sizer.Add(self.ledger_info, 1, flag=wx.EXPAND|wx.ALL, border=5)
main_panel.SetSizer(main_sizer)
def run_bg_job(self, job):
"""
Schedules a job to run asynchronously in the XRPL worker thread.
The job should be a Future (for example, from calling an async function)
"""
task = asyncio.run_coroutine_threadsafe(job, self.worker.loop)
def toggle_dialog_style(self, event):
"""
Automatically switches to a password-style dialog if it looks like the
user is entering a secret key, and display ***** instead of s12345...
"""
dlg = event.GetEventObject()
v = dlg.GetValue().strip()
if v[:1] == "s":
dlg.SetWindowStyle(wx.TE_PASSWORD)
else:
dlg.SetWindowStyle(wx.TE_LEFT)
def prompt_for_account(self):
"""
Prompt the user for an account to use, in a base58-encoded format:
- master key seed: Grants read-write access.
(assumes the master key pair is not disabled)
- classic address. Grants read-only access.
- X-address. Grants read-only access.
Exits with error code 1 if the user cancels the dialog, if the input
doesn't match any of the formats, or if the user inputs an X-address
intended for use on a different network type (test/non-test).
Populates the classic address and X-address labels in the UI.
Returns (classic_address, wallet) where wallet is None in read-only mode
"""
account_dialog = wx.TextEntryDialog(self,
"Please enter an account address (for read-only)"
" or your secret (for read-write access)",
caption="Enter account",
value="rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe")
account_dialog.Bind(wx.EVT_TEXT, self.toggle_dialog_style)
if account_dialog.ShowModal() != wx.ID_OK:
# If the user presses Cancel on the account entry, exit the app.
exit(1)
value = account_dialog.GetValue().strip()
account_dialog.Destroy()
classic_address = ""
wallet = None
x_address = ""
if xrpl.core.addresscodec.is_valid_xaddress(value):
x_address = value
classic_address, dest_tag, test_network = xrpl.core.addresscodec.xaddress_to_classic_address(value)
if test_network != self.test_network:
on_net = "a test network" if self.test_network else "Mainnet"
print(f"X-address {value} is meant for a different network type"
f"than this client is connected to."
f"(Client is on: {on_net})")
exit(1)
elif xrpl.core.addresscodec.is_valid_classic_address(value):
classic_address = value
x_address = xrpl.core.addresscodec.classic_address_to_xaddress(
value, tag=None, is_test_network=self.test_network)
else:
try:
# Check if it's a valid seed
seed_bytes, alg = xrpl.core.addresscodec.decode_seed(value)
wallet = xrpl.wallet.Wallet.from_seed(seed=value)
x_address = wallet.get_xaddress(is_test=self.test_network)
classic_address = wallet.address
except Exception as e:
print(e)
exit(1)
# Update the UI with the address values
self.st_classic_address.SetLabel(classic_address)
self.st_x_address.SetLabel(x_address)
return classic_address, wallet
def update_ledger(self, message):
"""
Process a ledger subscription message to update the UI with
information about the latest validated ledger.
"""
close_time_iso = xrpl.utils.ripple_time_to_datetime(message["ledger_time"]).isoformat()
self.ledger_info.SetLabel(f"Latest validated ledger:\n"
f"Ledger Index: {message['ledger_index']}\n"
f"Ledger Hash: {message['ledger_hash']}\n"
f"Close time: {close_time_iso}")
# Save reserve settings so we can calculate account reserve
self.reserve_base = xrpl.utils.drops_to_xrp(str(message["reserve_base"]))
self.reserve_inc = xrpl.utils.drops_to_xrp(str(message["reserve_inc"]))
def calculate_reserve_xrp(self, owner_count):
"""
Calculates how much XRP the user needs to reserve based on the account's
OwnerCount and the reserve values in the latest ledger.
"""
if self.reserve_base == None or self.reserve_inc == None:
return None
oc_decimal = Decimal(owner_count)
reserve_xrp = self.reserve_base + (self.reserve_inc * oc_decimal)
return reserve_xrp
def update_account(self, acct):
"""
Update the account info UI based on an account_info response.
"""
xrp_balance = str(xrpl.utils.drops_to_xrp(acct["Balance"]))
self.st_xrp_balance.SetLabel(xrp_balance)
# Display account reserve.
reserve_xrp = self.calculate_reserve_xrp(acct.get("OwnerCount", 0))
if reserve_xrp != None:
self.st_reserve.SetLabel(str(reserve_xrp))
if __name__ == "__main__":
WS_URL = "wss://s.altnet.rippletest.net:51233" # Testnet
app = wx.App()
frame = TWaXLFrame(WS_URL, test_network=True)
frame.Show()
app.MainLoop()

View File

@@ -0,0 +1,412 @@
# "Build a Wallet" tutorial, step 4: Show transaction history
# This step adds a tab that summarizes transactions the user's account has been
# affected by recently, including transactions sent, received, or otherwise
# impacting the user's account.
# License: MIT. https://github.com/XRPLF/xrpl-dev-portal/blob/master/LICENSE
import xrpl
import wx
import wx.dataview
import wx.adv
import asyncio
from threading import Thread
from decimal import Decimal
class XRPLMonitorThread(Thread):
"""
A worker thread to watch for new ledger events and pass the info back to
the main frame to be shown in the UI. Using a thread lets us maintain the
responsiveness of the UI while doing work in the background.
"""
def __init__(self, url, gui):
Thread.__init__(self, daemon=True)
# Note: For thread safety, this thread should treat self.gui as
# read-only; to modify the GUI, use wx.CallAfter(...)
self.gui = gui
self.url = url
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.loop.set_debug(True)
def run(self):
"""
This thread runs a never-ending event-loop that monitors messages coming
from the XRPL, sending them to the GUI thread when necessary, and also
handles making requests to the XRPL when the GUI prompts them.
"""
self.loop.run_forever()
async def watch_xrpl_account(self, address, wallet=None):
"""
This is the task that opens the connection to the XRPL, then handles
incoming subscription messages by dispatching them to the appropriate
part of the GUI.
"""
self.account = address
self.wallet = wallet
async with xrpl.asyncio.clients.AsyncWebsocketClient(self.url) as self.client:
await self.on_connected()
async for message in self.client:
mtype = message.get("type")
if mtype == "ledgerClosed":
wx.CallAfter(self.gui.update_ledger, message)
elif mtype == "transaction":
wx.CallAfter(self.gui.add_tx_from_sub, message)
response = await self.client.request(xrpl.models.requests.AccountInfo(
account=self.account,
ledger_index=message["ledger_index"]
))
wx.CallAfter(self.gui.update_account, response.result["account_data"])
async def on_connected(self):
"""
Set up initial subscriptions and populate the GUI with data from the
ledger on startup. Requires that self.client be connected first.
"""
# Set up 2 subscriptions: all new ledgers, and any new transactions that
# affect the chosen account.
response = await self.client.request(xrpl.models.requests.Subscribe(
streams=["ledger"],
accounts=[self.account]
))
# The immediate response contains details for the last validated ledger.
# We can use this to fill in that area of the GUI without waiting for a
# new ledger to close.
wx.CallAfter(self.gui.update_ledger, response.result)
# Get starting values for account info.
response = await self.client.request(xrpl.models.requests.AccountInfo(
account=self.account,
ledger_index="validated"
))
if not response.is_successful():
print("Got error from server:", response)
# This most often happens if the account in question doesn't exist
# on the network we're connected to. Better handling would be to use
# wx.CallAfter to display an error dialog in the GUI and possibly
# let the user try inputting a different account.
exit(1)
wx.CallAfter(self.gui.update_account, response.result["account_data"])
# Get the first page of the account's transaction history. Depending on
# the server we're connected to, the account's full history may not be
# available.
response = await self.client.request(xrpl.models.requests.AccountTx(
account=self.account
))
wx.CallAfter(self.gui.update_account_tx, response.result)
class AutoGridBagSizer(wx.GridBagSizer):
"""
Helper class for adding a bunch of items uniformly to a GridBagSizer.
"""
def __init__(self, parent):
wx.GridBagSizer.__init__(self, vgap=5, hgap=5)
self.parent = parent
def BulkAdd(self, ctrls):
"""
Given a two-dimensional iterable `ctrls`, add all the items in a grid
top-to-bottom, left-to-right, with each inner iterable being a row. Set
the total number of columns based on the longest iterable.
"""
flags = wx.EXPAND|wx.ALL|wx.RESERVE_SPACE_EVEN_IF_HIDDEN|wx.ALIGN_CENTER_VERTICAL
for x, row in enumerate(ctrls):
for y, ctrl in enumerate(row):
self.Add(ctrl, (x,y), flag=flags, border=5)
self.parent.SetSizer(self)
class TWaXLFrame(wx.Frame):
"""
Tutorial Wallet for the XRP Ledger (TWaXL)
user interface, main frame.
"""
def __init__(self, url, test_network=True):
wx.Frame.__init__(self, None, title="TWaXL", size=wx.Size(800,400))
self.test_network = test_network
# The ledger's current reserve settings. To be filled in later.
self.reserve_base = None
self.reserve_inc = None
self.build_ui()
# Pop up to ask user for their account ---------------------------------
address, wallet = self.prompt_for_account()
self.classic_address = address
# Start background thread for updates from the ledger ------------------
self.worker = XRPLMonitorThread(url, self)
self.worker.start()
self.run_bg_job(self.worker.watch_xrpl_account(address, wallet))
def build_ui(self):
"""
Called during __init__ to set up all the GUI components.
"""
self.tabs = wx.Notebook(self, style=wx.BK_DEFAULT)
# Tab 1: "Summary" pane ------------------------------------------------
main_panel = wx.Panel(self.tabs)
self.tabs.AddPage(main_panel, "Summary")
self.acct_info_area = wx.StaticBox(main_panel, label="Account Info")
lbl_address = wx.StaticText(self.acct_info_area, label="Classic Address:")
self.st_classic_address = wx.StaticText(self.acct_info_area, label="TBD")
lbl_xaddress = wx.StaticText(self.acct_info_area, label="X-Address:")
self.st_x_address = wx.StaticText(self.acct_info_area, label="TBD")
lbl_xrp_bal = wx.StaticText(self.acct_info_area, label="XRP Balance:")
self.st_xrp_balance = wx.StaticText(self.acct_info_area, label="TBD")
lbl_reserve = wx.StaticText(self.acct_info_area, label="XRP Reserved:")
self.st_reserve = wx.StaticText(self.acct_info_area, label="TBD")
aia_sizer = AutoGridBagSizer(self.acct_info_area)
aia_sizer.BulkAdd( ((lbl_address, self.st_classic_address),
(lbl_xaddress, self.st_x_address),
(lbl_xrp_bal, self.st_xrp_balance),
(lbl_reserve, self.st_reserve)) )
self.ledger_info = wx.StaticText(main_panel, label="Not connected")
main_sizer = wx.BoxSizer(wx.VERTICAL)
main_sizer.Add(self.acct_info_area, 1, flag=wx.EXPAND|wx.ALL, border=5)
main_sizer.Add(self.ledger_info, 1, flag=wx.EXPAND|wx.ALL, border=5)
main_panel.SetSizer(main_sizer)
# Tab 2: "Transaction History" pane ------------------------------------
objs_panel = wx.Panel(self.tabs)
self.tabs.AddPage(objs_panel, "Transaction History")
objs_sizer = wx.BoxSizer(wx.VERTICAL)
self.tx_list = wx.dataview.DataViewListCtrl(objs_panel)
self.tx_list.AppendTextColumn("Confirmed")
self.tx_list.AppendTextColumn("Type")
self.tx_list.AppendTextColumn("From")
self.tx_list.AppendTextColumn("To")
self.tx_list.AppendTextColumn("Value Delivered")
self.tx_list.AppendTextColumn("Identifying Hash")
self.tx_list.AppendTextColumn("Raw JSON")
objs_sizer.Add(self.tx_list, 1, wx.EXPAND|wx.ALL)
objs_panel.SetSizer(objs_sizer)
def run_bg_job(self, job):
"""
Schedules a job to run asynchronously in the XRPL worker thread.
The job should be a Future (for example, from calling an async function)
"""
task = asyncio.run_coroutine_threadsafe(job, self.worker.loop)
def toggle_dialog_style(self, event):
"""
Automatically switches to a password-style dialog if it looks like the
user is entering a secret key, and display ***** instead of s12345...
"""
dlg = event.GetEventObject()
v = dlg.GetValue().strip()
if v[:1] == "s":
dlg.SetWindowStyle(wx.TE_PASSWORD)
else:
dlg.SetWindowStyle(wx.TE_LEFT)
def prompt_for_account(self):
"""
Prompt the user for an account to use, in a base58-encoded format:
- master key seed: Grants read-write access.
(assumes the master key pair is not disabled)
- classic address. Grants read-only access.
- X-address. Grants read-only access.
Exits with error code 1 if the user cancels the dialog, if the input
doesn't match any of the formats, or if the user inputs an X-address
intended for use on a different network type (test/non-test).
Populates the classic address and X-address labels in the UI.
Returns (classic_address, wallet) where wallet is None in read-only mode
"""
account_dialog = wx.TextEntryDialog(self,
"Please enter an account address (for read-only)"
" or your secret (for read-write access)",
caption="Enter account",
value="rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe")
account_dialog.Bind(wx.EVT_TEXT, self.toggle_dialog_style)
if account_dialog.ShowModal() != wx.ID_OK:
# If the user presses Cancel on the account entry, exit the app.
exit(1)
value = account_dialog.GetValue().strip()
account_dialog.Destroy()
classic_address = ""
wallet = None
x_address = ""
if xrpl.core.addresscodec.is_valid_xaddress(value):
x_address = value
classic_address, dest_tag, test_network = xrpl.core.addresscodec.xaddress_to_classic_address(value)
if test_network != self.test_network:
on_net = "a test network" if self.test_network else "Mainnet"
print(f"X-address {value} is meant for a different network type"
f"than this client is connected to."
f"(Client is on: {on_net})")
exit(1)
elif xrpl.core.addresscodec.is_valid_classic_address(value):
classic_address = value
x_address = xrpl.core.addresscodec.classic_address_to_xaddress(
value, tag=None, is_test_network=self.test_network)
else:
try:
# Check if it's a valid seed
seed_bytes, alg = xrpl.core.addresscodec.decode_seed(value)
wallet = xrpl.wallet.Wallet.from_seed(seed=value)
x_address = wallet.get_xaddress(is_test=self.test_network)
classic_address = wallet.address
except Exception as e:
print(e)
exit(1)
# Update the UI with the address values
self.st_classic_address.SetLabel(classic_address)
self.st_x_address.SetLabel(x_address)
return classic_address, wallet
def update_ledger(self, message):
"""
Process a ledger subscription message to update the UI with
information about the latest validated ledger.
"""
close_time_iso = xrpl.utils.ripple_time_to_datetime(message["ledger_time"]).isoformat()
self.ledger_info.SetLabel(f"Latest validated ledger:\n"
f"Ledger Index: {message['ledger_index']}\n"
f"Ledger Hash: {message['ledger_hash']}\n"
f"Close time: {close_time_iso}")
# Save reserve settings so we can calculate account reserve
self.reserve_base = xrpl.utils.drops_to_xrp(str(message["reserve_base"]))
self.reserve_inc = xrpl.utils.drops_to_xrp(str(message["reserve_inc"]))
def calculate_reserve_xrp(self, owner_count):
"""
Calculates how much XRP the user needs to reserve based on the account's
OwnerCount and the reserve values in the latest ledger.
"""
if self.reserve_base == None or self.reserve_inc == None:
return None
oc_decimal = Decimal(owner_count)
reserve_xrp = self.reserve_base + (self.reserve_inc * oc_decimal)
return reserve_xrp
def update_account(self, acct):
"""
Update the account info UI based on an account_info response.
"""
xrp_balance = str(xrpl.utils.drops_to_xrp(acct["Balance"]))
self.st_xrp_balance.SetLabel(xrp_balance)
# Display account reserve.
reserve_xrp = self.calculate_reserve_xrp(acct.get("OwnerCount", 0))
if reserve_xrp != None:
self.st_reserve.SetLabel(str(reserve_xrp))
def displayable_amount(self, a):
"""
Convert an arbitrary amount value from the XRPL to a string to be
displayed to the user:
- Convert drops of XRP to 6-decimal XRP (e.g. '12.345000 XRP')
- For issued tokens, show amount, currency code, and issuer. For
example, 100 USD issued by address r12345... is returned as
'100 USD.r12345...'
Leaves non-standard (hex) currency codes as-is.
"""
if a == "unavailable":
# Special case for pre-2014 partial payments.
return a
elif type(a) == str:
# It's an XRP amount in drops. Convert to decimal.
return f"{xrpl.utils.drops_to_xrp(a)} XRP"
else:
# It's a token amount.
return f"{a['value']} {a['currency']}.{a['issuer']}"
def add_tx_row(self, t, prepend=False):
"""
Add one row to the account transaction history control. Helper function
called by other methods.
"""
conf_dt = xrpl.utils.ripple_time_to_datetime(t["tx"]["date"])
# Convert datetime to locale-default representation & time zone
confirmation_time = conf_dt.astimezone().strftime("%c")
tx_hash = t["tx"]["hash"]
tx_type = t["tx"]["TransactionType"]
from_acct = t["tx"].get("Account") or ""
if from_acct == self.classic_address:
from_acct = "(Me)"
to_acct = t["tx"].get("Destination") or ""
if to_acct == self.classic_address:
to_acct = "(Me)"
delivered_amt = t["meta"].get("delivered_amount")
if delivered_amt:
delivered_amt = self.displayable_amount(delivered_amt)
else:
delivered_amt = ""
cols = (confirmation_time, tx_type, from_acct, to_acct, delivered_amt,
tx_hash, str(t))
if prepend:
self.tx_list.PrependItem(cols)
else:
self.tx_list.AppendItem(cols)
def update_account_tx(self, data):
"""
Update the transaction history tab with information from an account_tx
response.
"""
txs = data["transactions"]
# Note: if you extend the code to do paginated responses, you might want
# to keep previous history instead of deleting the contents first.
self.tx_list.DeleteAllItems()
for t in txs:
self.add_tx_row(t)
def add_tx_from_sub(self, t):
"""
Add 1 transaction to the history based on a subscription stream message.
Assumes only validated transaction streams (e.g. transactions, accounts)
not proposed transaction streams.
Also, send a notification to the user about it.
"""
# Convert to same format as account_tx results
t["tx"] = t["transaction"]
self.add_tx_row(t, prepend=True)
# Scroll to top of list.
self.tx_list.EnsureVisible(self.tx_list.RowToItem(0))
# Send a notification message (aka a "toast") about the transaction.
# Note the transaction stream and account_tx include all transactions
# that "affect" the account, no just ones directly from/to the account.
# For example, if the account has issued tokens, it gets notified when
# other users transfer those tokens among themselves.
notif = wx.adv.NotificationMessage(title="New Transaction", message =
f"New {t['tx']['TransactionType']} transaction confirmed!")
notif.SetFlags(wx.ICON_INFORMATION)
notif.Show()
if __name__ == "__main__":
WS_URL = "wss://s.altnet.rippletest.net:51233" # Testnet
app = wx.App()
frame = TWaXLFrame(WS_URL, test_network=True)
frame.Show()
app.MainLoop()

View File

@@ -0,0 +1,582 @@
# "Build a Wallet" tutorial, step 5: Send XRP button.
# This step allows the user to send XRP payments, with a pop-up dialog to enter
# the relevant details.
# License: MIT. https://github.com/XRPLF/xrpl-dev-portal/blob/master/LICENSE
import xrpl
import wx
import wx.dataview
import wx.adv
import asyncio
import re
from threading import Thread
from decimal import Decimal
class XRPLMonitorThread(Thread):
"""
A worker thread to watch for new ledger events and pass the info back to
the main frame to be shown in the UI. Using a thread lets us maintain the
responsiveness of the UI while doing work in the background.
"""
def __init__(self, url, gui):
Thread.__init__(self, daemon=True)
# Note: For thread safety, this thread should treat self.gui as
# read-only; to modify the GUI, use wx.CallAfter(...)
self.gui = gui
self.url = url
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.loop.set_debug(True)
def run(self):
"""
This thread runs a never-ending event-loop that monitors messages coming
from the XRPL, sending them to the GUI thread when necessary, and also
handles making requests to the XRPL when the GUI prompts them.
"""
self.loop.run_forever()
async def watch_xrpl_account(self, address, wallet=None):
"""
This is the task that opens the connection to the XRPL, then handles
incoming subscription messages by dispatching them to the appropriate
part of the GUI.
"""
self.account = address
self.wallet = wallet
async with xrpl.asyncio.clients.AsyncWebsocketClient(self.url) as self.client:
await self.on_connected()
async for message in self.client:
mtype = message.get("type")
if mtype == "ledgerClosed":
wx.CallAfter(self.gui.update_ledger, message)
elif mtype == "transaction":
wx.CallAfter(self.gui.add_tx_from_sub, message)
response = await self.client.request(xrpl.models.requests.AccountInfo(
account=self.account,
ledger_index=message["ledger_index"]
))
wx.CallAfter(self.gui.update_account, response.result["account_data"])
async def on_connected(self):
"""
Set up initial subscriptions and populate the GUI with data from the
ledger on startup. Requires that self.client be connected first.
"""
# Set up 2 subscriptions: all new ledgers, and any new transactions that
# affect the chosen account.
response = await self.client.request(xrpl.models.requests.Subscribe(
streams=["ledger"],
accounts=[self.account]
))
# The immediate response contains details for the last validated ledger.
# We can use this to fill in that area of the GUI without waiting for a
# new ledger to close.
wx.CallAfter(self.gui.update_ledger, response.result)
# Get starting values for account info.
response = await self.client.request(xrpl.models.requests.AccountInfo(
account=self.account,
ledger_index="validated"
))
if not response.is_successful():
print("Got error from server:", response)
# This most often happens if the account in question doesn't exist
# on the network we're connected to. Better handling would be to use
# wx.CallAfter to display an error dialog in the GUI and possibly
# let the user try inputting a different account.
exit(1)
wx.CallAfter(self.gui.update_account, response.result["account_data"])
if self.wallet:
wx.CallAfter(self.gui.enable_readwrite)
# Get the first page of the account's transaction history. Depending on
# the server we're connected to, the account's full history may not be
# available.
response = await self.client.request(xrpl.models.requests.AccountTx(
account=self.account
))
wx.CallAfter(self.gui.update_account_tx, response.result)
async def send_xrp(self, paydata):
"""
Prepare, sign, and send an XRP payment with the provided parameters.
Expects a dictionary with:
{
"dtag": Destination Tag, as a string, optional
"to": Destination address (classic or X-address)
"amt": Amount of decimal XRP to send, as a string
}
"""
dtag = paydata.get("dtag", "")
if dtag.strip() == "":
dtag = None
if dtag is not None:
try:
dtag = int(dtag)
if dtag < 0 or dtag > 2**32-1:
raise ValueError("Destination tag must be a 32-bit unsigned integer")
except ValueError as e:
print("Invalid destination tag:", e)
print("Canceled sending payment.")
return
tx = xrpl.models.transactions.Payment(
account=self.account,
destination=paydata["to"],
amount=xrpl.utils.xrp_to_drops(paydata["amt"]),
destination_tag=dtag
)
# Autofill provides a sequence number, but this may fail if you try to
# send too many transactions too fast. You can send transactions more
# rapidly if you track the sequence number more carefully.
tx_signed = await xrpl.asyncio.transaction.autofill_and_sign(
tx, self.client, self.wallet)
await xrpl.asyncio.transaction.submit(tx_signed, self.client)
wx.CallAfter(self.gui.add_pending_tx, tx_signed)
class AutoGridBagSizer(wx.GridBagSizer):
"""
Helper class for adding a bunch of items uniformly to a GridBagSizer.
"""
def __init__(self, parent):
wx.GridBagSizer.__init__(self, vgap=5, hgap=5)
self.parent = parent
def BulkAdd(self, ctrls):
"""
Given a two-dimensional iterable `ctrls`, add all the items in a grid
top-to-bottom, left-to-right, with each inner iterable being a row. Set
the total number of columns based on the longest iterable.
"""
flags = wx.EXPAND|wx.ALL|wx.RESERVE_SPACE_EVEN_IF_HIDDEN|wx.ALIGN_CENTER_VERTICAL
for x, row in enumerate(ctrls):
for y, ctrl in enumerate(row):
self.Add(ctrl, (x,y), flag=flags, border=5)
self.parent.SetSizer(self)
class SendXRPDialog(wx.Dialog):
"""
Pop-up dialog that prompts the user for the information necessary to send a
direct XRP-to-XRP payment on the XRPL.
"""
def __init__(self, parent):
wx.Dialog.__init__(self, parent, title="Send XRP")
sizer = AutoGridBagSizer(self)
self.parent = parent
lbl_to = wx.StaticText(self, label="To (Address):")
lbl_dtag = wx.StaticText(self, label="Destination Tag:")
lbl_amt = wx.StaticText(self, label="Amount of XRP:")
self.txt_to = wx.TextCtrl(self)
self.txt_dtag = wx.TextCtrl(self)
self.txt_amt = wx.SpinCtrlDouble(self, value="20.0", min=0.000001)
self.txt_amt.SetDigits(6)
self.txt_amt.SetIncrement(1.0)
# The "Send" button is functionally an "OK" button except for the text.
self.btn_send = wx.Button(self, wx.ID_OK, label="Send")
btn_cancel = wx.Button(self, wx.ID_CANCEL)
sizer.BulkAdd(((lbl_to, self.txt_to),
(lbl_dtag, self.txt_dtag),
(lbl_amt, self.txt_amt),
(btn_cancel, self.btn_send)) )
sizer.Fit(self)
self.txt_dtag.Bind(wx.EVT_TEXT, self.on_dest_tag_edit)
self.txt_to.Bind(wx.EVT_TEXT, self.on_to_edit)
def get_payment_data(self):
"""
Construct a dictionary with the relevant payment details to pass to the
worker thread for making a payment. Called after the user clicks "Send".
"""
return {
"to": self.txt_to.GetValue().strip(),
"dtag": self.txt_dtag.GetValue().strip(),
"amt": self.txt_amt.GetValue(),
}
def on_to_edit(self, event):
"""
When the user edits the "To" field, check that the address is valid.
"""
v = self.txt_to.GetValue().strip()
if not (xrpl.core.addresscodec.is_valid_classic_address(v) or
xrpl.core.addresscodec.is_valid_xaddress(v) ):
self.btn_send.Disable()
elif v == self.parent.classic_address:
self.btn_send.Disable()
else:
self.btn_send.Enable()
def on_dest_tag_edit(self, event):
"""
When the user edits the Destination Tag field, strip non-numeric
characters from it.
"""
v = self.txt_dtag.GetValue().strip()
v = re.sub(r"[^0-9]", "", v)
self.txt_dtag.ChangeValue(v) # SetValue would generate another EVT_TEXT
self.txt_dtag.SetInsertionPointEnd()
class TWaXLFrame(wx.Frame):
"""
Tutorial Wallet for the XRP Ledger (TWaXL)
user interface, main frame.
"""
def __init__(self, url, test_network=True):
wx.Frame.__init__(self, None, title="TWaXL", size=wx.Size(800,400))
self.test_network = test_network
# The ledger's current reserve settings. To be filled in later.
self.reserve_base = None
self.reserve_inc = None
self.build_ui()
# Pop up to ask user for their account ---------------------------------
address, wallet = self.prompt_for_account()
self.classic_address = address
# Start background thread for updates from the ledger ------------------
self.worker = XRPLMonitorThread(url, self)
self.worker.start()
self.run_bg_job(self.worker.watch_xrpl_account(address, wallet))
def build_ui(self):
"""
Called during __init__ to set up all the GUI components.
"""
self.tabs = wx.Notebook(self, style=wx.BK_DEFAULT)
# Tab 1: "Summary" pane ------------------------------------------------
main_panel = wx.Panel(self.tabs)
self.tabs.AddPage(main_panel, "Summary")
self.acct_info_area = wx.StaticBox(main_panel, label="Account Info")
lbl_address = wx.StaticText(self.acct_info_area, label="Classic Address:")
self.st_classic_address = wx.StaticText(self.acct_info_area, label="TBD")
lbl_xaddress = wx.StaticText(self.acct_info_area, label="X-Address:")
self.st_x_address = wx.StaticText(self.acct_info_area, label="TBD")
lbl_xrp_bal = wx.StaticText(self.acct_info_area, label="XRP Balance:")
self.st_xrp_balance = wx.StaticText(self.acct_info_area, label="TBD")
lbl_reserve = wx.StaticText(self.acct_info_area, label="XRP Reserved:")
self.st_reserve = wx.StaticText(self.acct_info_area, label="TBD")
aia_sizer = AutoGridBagSizer(self.acct_info_area)
aia_sizer.BulkAdd( ((lbl_address, self.st_classic_address),
(lbl_xaddress, self.st_x_address),
(lbl_xrp_bal, self.st_xrp_balance),
(lbl_reserve, self.st_reserve)) )
# Send XRP button. Disabled until we have a secret key & network connection
self.sxb = wx.Button(main_panel, label="Send XRP")
self.sxb.SetToolTip("Disabled in read-only mode.")
self.sxb.Disable()
self.Bind(wx.EVT_BUTTON, self.click_send_xrp, source=self.sxb)
self.ledger_info = wx.StaticText(main_panel, label="Not connected")
main_sizer = wx.BoxSizer(wx.VERTICAL)
main_sizer.Add(self.acct_info_area, 1, flag=wx.EXPAND|wx.ALL, border=5)
main_sizer.Add(self.sxb, 0, flag=wx.ALL, border=5)
main_sizer.Add(self.ledger_info, 1, flag=wx.EXPAND|wx.ALL, border=5)
main_panel.SetSizer(main_sizer)
# Tab 2: "Transaction History" pane ------------------------------------
objs_panel = wx.Panel(self.tabs)
self.tabs.AddPage(objs_panel, "Transaction History")
objs_sizer = wx.BoxSizer(wx.VERTICAL)
self.tx_list = wx.dataview.DataViewListCtrl(objs_panel)
self.tx_list.AppendTextColumn("Confirmed")
self.tx_list.AppendTextColumn("Type")
self.tx_list.AppendTextColumn("From")
self.tx_list.AppendTextColumn("To")
self.tx_list.AppendTextColumn("Value Delivered")
self.tx_list.AppendTextColumn("Identifying Hash")
self.tx_list.AppendTextColumn("Raw JSON")
objs_sizer.Add(self.tx_list, 1, wx.EXPAND|wx.ALL)
self.pending_tx_rows = {} # Map pending tx hashes to rows in the history UI
objs_panel.SetSizer(objs_sizer)
def run_bg_job(self, job):
"""
Schedules a job to run asynchronously in the XRPL worker thread.
The job should be a Future (for example, from calling an async function)
"""
task = asyncio.run_coroutine_threadsafe(job, self.worker.loop)
def toggle_dialog_style(self, event):
"""
Automatically switches to a password-style dialog if it looks like the
user is entering a secret key, and display ***** instead of s12345...
"""
dlg = event.GetEventObject()
v = dlg.GetValue().strip()
if v[:1] == "s":
dlg.SetWindowStyle(wx.TE_PASSWORD)
else:
dlg.SetWindowStyle(wx.TE_LEFT)
def prompt_for_account(self):
"""
Prompt the user for an account to use, in a base58-encoded format:
- master key seed: Grants read-write access.
(assumes the master key pair is not disabled)
- classic address. Grants read-only access.
- X-address. Grants read-only access.
Exits with error code 1 if the user cancels the dialog, if the input
doesn't match any of the formats, or if the user inputs an X-address
intended for use on a different network type (test/non-test).
Populates the classic address and X-address labels in the UI.
Returns (classic_address, wallet) where wallet is None in read-only mode
"""
account_dialog = wx.TextEntryDialog(self,
"Please enter an account address (for read-only)"
" or your secret (for read-write access)",
caption="Enter account",
value="rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe")
account_dialog.Bind(wx.EVT_TEXT, self.toggle_dialog_style)
if account_dialog.ShowModal() != wx.ID_OK:
# If the user presses Cancel on the account entry, exit the app.
exit(1)
value = account_dialog.GetValue().strip()
account_dialog.Destroy()
classic_address = ""
wallet = None
x_address = ""
if xrpl.core.addresscodec.is_valid_xaddress(value):
x_address = value
classic_address, dest_tag, test_network = xrpl.core.addresscodec.xaddress_to_classic_address(value)
if test_network != self.test_network:
on_net = "a test network" if self.test_network else "Mainnet"
print(f"X-address {value} is meant for a different network type"
f"than this client is connected to."
f"(Client is on: {on_net})")
exit(1)
elif xrpl.core.addresscodec.is_valid_classic_address(value):
classic_address = value
x_address = xrpl.core.addresscodec.classic_address_to_xaddress(
value, tag=None, is_test_network=self.test_network)
else:
try:
# Check if it's a valid seed
seed_bytes, alg = xrpl.core.addresscodec.decode_seed(value)
wallet = xrpl.wallet.Wallet.from_seed(seed=value)
x_address = wallet.get_xaddress(is_test=self.test_network)
classic_address = wallet.address
except Exception as e:
print(e)
exit(1)
# Update the UI with the address values
self.st_classic_address.SetLabel(classic_address)
self.st_x_address.SetLabel(x_address)
return classic_address, wallet
def update_ledger(self, message):
"""
Process a ledger subscription message to update the UI with
information about the latest validated ledger.
"""
close_time_iso = xrpl.utils.ripple_time_to_datetime(message["ledger_time"]).isoformat()
self.ledger_info.SetLabel(f"Latest validated ledger:\n"
f"Ledger Index: {message['ledger_index']}\n"
f"Ledger Hash: {message['ledger_hash']}\n"
f"Close time: {close_time_iso}")
# Save reserve settings so we can calculate account reserve
self.reserve_base = xrpl.utils.drops_to_xrp(str(message["reserve_base"]))
self.reserve_inc = xrpl.utils.drops_to_xrp(str(message["reserve_inc"]))
def calculate_reserve_xrp(self, owner_count):
"""
Calculates how much XRP the user needs to reserve based on the account's
OwnerCount and the reserve values in the latest ledger.
"""
if self.reserve_base == None or self.reserve_inc == None:
return None
oc_decimal = Decimal(owner_count)
reserve_xrp = self.reserve_base + (self.reserve_inc * oc_decimal)
return reserve_xrp
def update_account(self, acct):
"""
Update the account info UI based on an account_info response.
"""
xrp_balance = str(xrpl.utils.drops_to_xrp(acct["Balance"]))
self.st_xrp_balance.SetLabel(xrp_balance)
# Display account reserve.
reserve_xrp = self.calculate_reserve_xrp(acct.get("OwnerCount", 0))
if reserve_xrp != None:
self.st_reserve.SetLabel(str(reserve_xrp))
def enable_readwrite(self):
"""
Enable buttons for sending transactions.
"""
self.sxb.Enable()
self.sxb.SetToolTip("")
def displayable_amount(self, a):
"""
Convert an arbitrary amount value from the XRPL to a string to be
displayed to the user:
- Convert drops of XRP to 6-decimal XRP (e.g. '12.345000 XRP')
- For issued tokens, show amount, currency code, and issuer. For
example, 100 USD issued by address r12345... is returned as
'100 USD.r12345...'
Leaves non-standard (hex) currency codes as-is.
"""
if a == "unavailable":
# Special case for pre-2014 partial payments.
return a
elif type(a) == str:
# It's an XRP amount in drops. Convert to decimal.
return f"{xrpl.utils.drops_to_xrp(a)} XRP"
else:
# It's a token amount.
return f"{a['value']} {a['currency']}.{a['issuer']}"
def add_tx_row(self, t, prepend=False):
"""
Add one row to the account transaction history control. Helper function
called by other methods.
"""
conf_dt = xrpl.utils.ripple_time_to_datetime(t["tx"]["date"])
# Convert datetime to locale-default representation & time zone
confirmation_time = conf_dt.astimezone().strftime("%c")
tx_hash = t["tx"]["hash"]
tx_type = t["tx"]["TransactionType"]
from_acct = t["tx"].get("Account") or ""
if from_acct == self.classic_address:
from_acct = "(Me)"
to_acct = t["tx"].get("Destination") or ""
if to_acct == self.classic_address:
to_acct = "(Me)"
delivered_amt = t["meta"].get("delivered_amount")
if delivered_amt:
delivered_amt = self.displayable_amount(delivered_amt)
else:
delivered_amt = ""
cols = (confirmation_time, tx_type, from_acct, to_acct, delivered_amt,
tx_hash, str(t))
if prepend:
self.tx_list.PrependItem(cols)
else:
self.tx_list.AppendItem(cols)
def update_account_tx(self, data):
"""
Update the transaction history tab with information from an account_tx
response.
"""
txs = data["transactions"]
# Note: if you extend the code to do paginated responses, you might want
# to keep previous history instead of deleting the contents first.
self.tx_list.DeleteAllItems()
for t in txs:
self.add_tx_row(t)
def add_tx_from_sub(self, t):
"""
Add 1 transaction to the history based on a subscription stream message.
Assumes only validated transaction streams (e.g. transactions, accounts)
not proposed transaction streams.
Also, send a notification to the user about it.
"""
# Convert to same format as account_tx results
t["tx"] = t["transaction"]
if t["tx"]["hash"] in self.pending_tx_rows.keys():
dvi = self.pending_tx_rows[t["tx"]["hash"]]
pending_row = self.tx_list.ItemToRow(dvi)
self.tx_list.DeleteItem(pending_row)
self.add_tx_row(t, prepend=True)
# Scroll to top of list.
self.tx_list.EnsureVisible(self.tx_list.RowToItem(0))
# Send a notification message (aka a "toast") about the transaction.
# Note the transaction stream and account_tx include all transactions
# that "affect" the account, no just ones directly from/to the account.
# For example, if the account has issued tokens, it gets notified when
# other users transfer those tokens among themselves.
notif = wx.adv.NotificationMessage(title="New Transaction", message =
f"New {t['tx']['TransactionType']} transaction confirmed!")
notif.SetFlags(wx.ICON_INFORMATION)
notif.Show()
def add_pending_tx(self, txm):
"""
Add a "pending" transaction to the history based on a transaction model
that was (presumably) just submitted.
"""
confirmation_time = "(pending)"
tx_type = txm.transaction_type
from_acct = txm.account
if from_acct == self.classic_address:
from_acct = "(Me)"
# Some transactions don't have a destination, so we need to handle that.
to_acct = getattr(txm, "destination", "")
if to_acct == self.classic_address:
to_acct = "(Me)"
# Delivered amount is only known after a transaction is processed, so
# leave this column empty in the display for pending transactions.
delivered_amt = ""
tx_hash = txm.get_hash()
cols = (confirmation_time, tx_type, from_acct, to_acct, delivered_amt,
tx_hash, str(txm.to_xrpl()))
self.tx_list.PrependItem(cols)
self.pending_tx_rows[tx_hash] = self.tx_list.RowToItem(0)
def click_send_xrp(self, event):
"""
Pop up a dialog for the user to input how much XRP to send where, and
send the transaction (if the user doesn't cancel).
"""
dlg = SendXRPDialog(self)
dlg.CenterOnScreen()
resp = dlg.ShowModal()
if resp != wx.ID_OK:
print("Send XRP canceled")
dlg.Destroy()
return
paydata = dlg.get_payment_data()
dlg.Destroy()
self.run_bg_job(self.worker.send_xrp(paydata))
notif = wx.adv.NotificationMessage(title="Sending!", message =
f"Sending a payment for {paydata['amt']} XRP!")
notif.SetFlags(wx.ICON_INFORMATION)
notif.Show()
if __name__ == "__main__":
WS_URL = "wss://s.altnet.rippletest.net:51233" # Testnet
app = wx.App()
frame = TWaXLFrame(WS_URL, test_network=True)
frame.Show()
app.MainLoop()

View File

@@ -0,0 +1,747 @@
# "Build a Wallet" tutorial, step 6: Verification and Polish
# This step adds safety checks to the Send XRP dialog, along with some other
# small improvements including account domain verification.
# License: MIT. https://github.com/XRPLF/xrpl-dev-portal/blob/master/LICENSE
import xrpl
import wx
import wx.dataview
import wx.adv
import asyncio
import re
from threading import Thread
from decimal import Decimal
from verify_domain import verify_account_domain
class XRPLMonitorThread(Thread):
"""
A worker thread to watch for new ledger events and pass the info back to
the main frame to be shown in the UI. Using a thread lets us maintain the
responsiveness of the UI while doing work in the background.
"""
def __init__(self, url, gui):
Thread.__init__(self, daemon=True)
# Note: For thread safety, this thread should treat self.gui as
# read-only; to modify the GUI, use wx.CallAfter(...)
self.gui = gui
self.url = url
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.loop.set_debug(True)
def run(self):
"""
This thread runs a never-ending event-loop that monitors messages coming
from the XRPL, sending them to the GUI thread when necessary, and also
handles making requests to the XRPL when the GUI prompts them.
"""
self.loop.run_forever()
async def watch_xrpl_account(self, address, wallet=None):
"""
This is the task that opens the connection to the XRPL, then handles
incoming subscription messages by dispatching them to the appropriate
part of the GUI.
"""
self.account = address
self.wallet = wallet
async with xrpl.asyncio.clients.AsyncWebsocketClient(self.url) as self.client:
await self.on_connected()
async for message in self.client:
mtype = message.get("type")
if mtype == "ledgerClosed":
wx.CallAfter(self.gui.update_ledger, message)
elif mtype == "transaction":
wx.CallAfter(self.gui.add_tx_from_sub, message)
response = await self.client.request(xrpl.models.requests.AccountInfo(
account=self.account,
ledger_index=message["ledger_index"]
))
wx.CallAfter(self.gui.update_account, response.result["account_data"])
async def on_connected(self):
"""
Set up initial subscriptions and populate the GUI with data from the
ledger on startup. Requires that self.client be connected first.
"""
# Set up 2 subscriptions: all new ledgers, and any new transactions that
# affect the chosen account.
response = await self.client.request(xrpl.models.requests.Subscribe(
streams=["ledger"],
accounts=[self.account]
))
# The immediate response contains details for the last validated ledger.
# We can use this to fill in that area of the GUI without waiting for a
# new ledger to close.
wx.CallAfter(self.gui.update_ledger, response.result)
# Get starting values for account info.
response = await self.client.request(xrpl.models.requests.AccountInfo(
account=self.account,
ledger_index="validated"
))
if not response.is_successful():
print("Got error from server:", response)
# This most often happens if the account in question doesn't exist
# on the network we're connected to. Better handling would be to use
# wx.CallAfter to display an error dialog in the GUI and possibly
# let the user try inputting a different account.
exit(1)
wx.CallAfter(self.gui.update_account, response.result["account_data"])
if self.wallet:
wx.CallAfter(self.gui.enable_readwrite)
# Get the first page of the account's transaction history. Depending on
# the server we're connected to, the account's full history may not be
# available.
response = await self.client.request(xrpl.models.requests.AccountTx(
account=self.account
))
wx.CallAfter(self.gui.update_account_tx, response.result)
async def check_destination(self, destination, dlg):
"""
Check a potential destination address's details, and pass them back to
a "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.
Requires that self.client be connected first.
"""
# The data to send back to the GUI thread: None for checks that weren't
# performed, True/False for actual results except where noted.
account_status = {
"funded": None,
"disallow_xrp": None,
"domain_verified": None,
"domain_str": "" # the decoded domain, regardless of verification
}
# Look up the account. If this fails, the account isn't funded.
try:
response = await xrpl.asyncio.account.get_account_info(destination,
self.client, ledger_index="validated")
account_status["funded"] = True
dest_acct = response.result["account_data"]
except xrpl.asyncio.clients.exceptions.XRPLRequestFailureException:
# Not funded, so the other checks don't apply.
account_status["funded"] = False
wx.CallAfter(dlg.update_dest_info, account_status)
return
# Check DisallowXRP flag
lsfDisallowXRP = 0x00080000
if dest_acct["Flags"] & lsfDisallowXRP:
account_status["disallow_xrp"] = True
else:
account_status["disallow_xrp"] = False
# Check domain verification
domain, verified = verify_account_domain(dest_acct)
account_status["domain_verified"] = verified
account_status["domain_str"] = domain
# Send data back to the main thread.
wx.CallAfter(dlg.update_dest_info, account_status)
async def send_xrp(self, paydata):
"""
Prepare, sign, and send an XRP payment with the provided parameters.
Expects a dictionary with:
{
"dtag": Destination Tag, as a string, optional
"to": Destination address (classic or X-address)
"amt": Amount of decimal XRP to send, as a string
}
"""
dtag = paydata.get("dtag", "")
if dtag.strip() == "":
dtag = None
if dtag is not None:
try:
dtag = int(dtag)
if dtag < 0 or dtag > 2**32-1:
raise ValueError("Destination tag must be a 32-bit unsigned integer")
except ValueError as e:
print("Invalid destination tag:", e)
print("Canceled sending payment.")
return
tx = xrpl.models.transactions.Payment(
account=self.account,
destination=paydata["to"],
amount=xrpl.utils.xrp_to_drops(paydata["amt"]),
destination_tag=dtag
)
# Autofill provides a sequence number, but this may fail if you try to
# send too many transactions too fast. You can send transactions more
# rapidly if you track the sequence number more carefully.
tx_signed = await xrpl.asyncio.transaction.autofill_and_sign(
tx, self.client, self.wallet)
await xrpl.asyncio.transaction.submit(tx_signed, self.client)
wx.CallAfter(self.gui.add_pending_tx, tx_signed)
class AutoGridBagSizer(wx.GridBagSizer):
"""
Helper class for adding a bunch of items uniformly to a GridBagSizer.
"""
def __init__(self, parent):
wx.GridBagSizer.__init__(self, vgap=5, hgap=5)
self.parent = parent
def BulkAdd(self, ctrls):
"""
Given a two-dimensional iterable `ctrls`, add all the items in a grid
top-to-bottom, left-to-right, with each inner iterable being a row. Set
the total number of columns based on the longest iterable.
"""
flags = wx.EXPAND|wx.ALL|wx.RESERVE_SPACE_EVEN_IF_HIDDEN|wx.ALIGN_CENTER_VERTICAL
for x, row in enumerate(ctrls):
for y, ctrl in enumerate(row):
self.Add(ctrl, (x,y), flag=flags, border=5)
self.parent.SetSizer(self)
class SendXRPDialog(wx.Dialog):
"""
Pop-up dialog that prompts the user for the information necessary to send a
direct XRP-to-XRP payment on the XRPL.
"""
def __init__(self, parent, max_send=100000000.0):
wx.Dialog.__init__(self, parent, title="Send XRP")
sizer = AutoGridBagSizer(self)
self.parent = parent
# Icons to indicate a validation error
bmp_err = wx.ArtProvider.GetBitmap(wx.ART_ERROR, wx.ART_CMN_DIALOG, size=(16,16))
self.err_to = wx.StaticBitmap(self, bitmap=bmp_err)
self.err_dtag = wx.StaticBitmap(self, bitmap=bmp_err)
self.err_amt = wx.StaticBitmap(self, bitmap=bmp_err)
self.err_to.Hide()
self.err_dtag.Hide()
self.err_amt.Hide()
# Icons for domain verification
bmp_check = wx.ArtProvider.GetBitmap(wx.ART_TICK_MARK, wx.ART_CMN_DIALOG, size=(16,16))
self.domain_text = wx.StaticText(self, label="")
self.domain_verified = wx.StaticBitmap(self, bitmap=bmp_check)
self.domain_verified.Hide()
if max_send <= 0:
max_send = 100000000.0
self.err_amt.Show()
self.err_amt.SetToolTip("Not enough XRP to pay the reserve and transaction cost!")
lbl_to = wx.StaticText(self, label="To (Address):")
lbl_dtag = wx.StaticText(self, label="Destination Tag:")
lbl_amt = wx.StaticText(self, label="Amount of XRP:")
self.txt_to = wx.TextCtrl(self)
self.txt_dtag = wx.TextCtrl(self)
self.txt_amt = wx.SpinCtrlDouble(self, value="20.0", min=0.000001, max=max_send)
self.txt_amt.SetDigits(6)
self.txt_amt.SetIncrement(1.0)
# The "Send" button is functionally an "OK" button except for the text.
self.btn_send = wx.Button(self, wx.ID_OK, label="Send")
btn_cancel = wx.Button(self, wx.ID_CANCEL)
sizer.BulkAdd(((lbl_to, self.txt_to, self.err_to),
(self.domain_verified, self.domain_text),
(lbl_dtag, self.txt_dtag, self.err_dtag),
(lbl_amt, self.txt_amt, self.err_amt),
(btn_cancel, self.btn_send)) )
sizer.Fit(self)
self.txt_dtag.Bind(wx.EVT_TEXT, self.on_dest_tag_edit)
self.txt_to.Bind(wx.EVT_TEXT, self.on_to_edit)
def get_payment_data(self):
"""
Construct a dictionary with the relevant payment details to pass to the
worker thread for making a payment. Called after the user clicks "Send".
"""
return {
"to": self.txt_to.GetValue().strip(),
"dtag": self.txt_dtag.GetValue().strip(),
"amt": self.txt_amt.GetValue(),
}
def on_to_edit(self, event):
"""
When the user edits the "To" field, check that the address is well-
formatted. If it's an X-address, fill in the destination tag and disable
it. Also, start a background check to confirm more details about the
address.
"""
v = self.txt_to.GetValue().strip()
# Reset warnings / domain verification
err_msg = ""
self.err_to.SetToolTip("")
self.err_to.Hide()
self.domain_text.SetLabel("")
self.domain_verified.Hide()
if xrpl.core.addresscodec.is_valid_xaddress(v):
cl_addr, tag, is_test = xrpl.core.addresscodec.xaddress_to_classic_address(v)
if tag is None: # Not the same as tag = 0
tag = ""
self.txt_dtag.ChangeValue(str(tag))
self.txt_dtag.Disable()
if cl_addr == self.parent.classic_address:
err_msg = "Can't send XRP to self."
elif is_test != self.parent.test_network:
err_msg = "This address is intended for a different network."
elif not self.txt_dtag.IsEditable():
self.txt_dtag.Clear()
self.txt_dtag.Enable()
if not (xrpl.core.addresscodec.is_valid_classic_address(v) or
xrpl.core.addresscodec.is_valid_xaddress(v) ):
self.btn_send.Disable()
err_msg = "Not a valid address."
elif v == self.parent.classic_address:
self.btn_send.Disable()
err_msg = "Can't send XRP to self."
else:
self.parent.run_bg_job(self.parent.worker.check_destination(v, self))
if err_msg:
self.err_to.SetToolTip(err_msg)
self.err_to.Show()
else:
self.err_to.Hide()
def on_dest_tag_edit(self, event):
"""
When the user edits the Destination Tag field, strip non-numeric
characters from it.
"""
v = self.txt_dtag.GetValue().strip()
v = re.sub(r"[^0-9]", "", v)
self.txt_dtag.ChangeValue(v) # SetValue would generate another EVT_TEXT
self.txt_dtag.SetInsertionPointEnd()
def update_dest_info(self, dest_status):
"""
Update the UI with details provided by a background job to check the
destination address.
"""
# Keep existing error message if there is one
try:
err_msg = self.err_to.GetToolTip().GetTip().strip()
except RuntimeError:
# This method can be called after the dialog it belongs to has been
# closed. In that case, there's nothing to do here.
return
if not dest_status["funded"]:
err_msg = ("Warning: this account does not exist. The payment will "
"fail unless you send enough to fund it.")
elif dest_status["disallow_xrp"]:
err_msg = "This account does not want to receive XRP."
# Domain verification
bmp_err = wx.ArtProvider.GetBitmap(wx.ART_ERROR, wx.ART_CMN_DIALOG, size=(16,16))
bmp_check = wx.ArtProvider.GetBitmap(wx.ART_TICK_MARK, wx.ART_CMN_DIALOG, size=(16,16))
domain = dest_status["domain_str"]
verified = dest_status["domain_verified"]
if not domain:
self.domain_text.Hide()
self.domain_verified.Hide()
elif verified:
self.domain_text.SetLabel(domain)
self.domain_text.Show()
self.domain_verified.SetToolTip("Domain verified")
self.domain_verified.SetBitmap(bmp_check)
self.domain_verified.Show()
else:
self.domain_text.SetLabel(domain)
self.domain_text.Show()
self.domain_verified.SetToolTip("Failed to verify domain")
self.domain_verified.SetBitmap(bmp_err)
self.domain_verified.Show()
if err_msg:
# Disabling the button is optional. These types of errors can be
# benign, so you could let the user "click through" them.
# self.btn_send.Disable()
self.err_to.SetToolTip(err_msg)
self.err_to.Show()
else:
self.btn_send.Enable()
self.err_to.SetToolTip("")
self.err_to.Hide()
class TWaXLFrame(wx.Frame):
"""
Tutorial Wallet for the XRP Ledger (TWaXL)
user interface, main frame.
"""
def __init__(self, url, test_network=True):
wx.Frame.__init__(self, None, title="TWaXL", size=wx.Size(800,400))
self.test_network = test_network
# The ledger's current reserve settings. To be filled in later.
self.reserve_base = None
self.reserve_inc = None
# This account's total XRP reserve including base + owner amounts
self.reserve_xrp = None
self.build_ui()
# Pop up to ask user for their account ---------------------------------
address, wallet = self.prompt_for_account()
self.classic_address = address
# Start background thread for updates from the ledger ------------------
self.worker = XRPLMonitorThread(url, self)
self.worker.start()
self.run_bg_job(self.worker.watch_xrpl_account(address, wallet))
def build_ui(self):
"""
Called during __init__ to set up all the GUI components.
"""
self.tabs = wx.Notebook(self, style=wx.BK_DEFAULT)
# Tab 1: "Summary" pane ------------------------------------------------
main_panel = wx.Panel(self.tabs)
self.tabs.AddPage(main_panel, "Summary")
self.acct_info_area = wx.StaticBox(main_panel, label="Account Info")
lbl_address = wx.StaticText(self.acct_info_area, label="Classic Address:")
self.st_classic_address = wx.StaticText(self.acct_info_area, label="TBD")
lbl_xaddress = wx.StaticText(self.acct_info_area, label="X-Address:")
self.st_x_address = wx.StaticText(self.acct_info_area, label="TBD")
lbl_xrp_bal = wx.StaticText(self.acct_info_area, label="XRP Balance:")
self.st_xrp_balance = wx.StaticText(self.acct_info_area, label="TBD")
lbl_reserve = wx.StaticText(self.acct_info_area, label="XRP Reserved:")
self.st_reserve = wx.StaticText(self.acct_info_area, label="TBD")
aia_sizer = AutoGridBagSizer(self.acct_info_area)
aia_sizer.BulkAdd( ((lbl_address, self.st_classic_address),
(lbl_xaddress, self.st_x_address),
(lbl_xrp_bal, self.st_xrp_balance),
(lbl_reserve, self.st_reserve)) )
# Send XRP button. Disabled until we have a secret key & network connection
self.sxb = wx.Button(main_panel, label="Send XRP")
self.sxb.SetToolTip("Disabled in read-only mode.")
self.sxb.Disable()
self.Bind(wx.EVT_BUTTON, self.click_send_xrp, source=self.sxb)
self.ledger_info = wx.StaticText(main_panel, label="Not connected")
main_sizer = wx.BoxSizer(wx.VERTICAL)
main_sizer.Add(self.acct_info_area, 1, flag=wx.EXPAND|wx.ALL, border=5)
main_sizer.Add(self.sxb, 0, flag=wx.ALL, border=5)
main_sizer.Add(self.ledger_info, 1, flag=wx.EXPAND|wx.ALL, border=5)
main_panel.SetSizer(main_sizer)
# Tab 2: "Transaction History" pane ------------------------------------
objs_panel = wx.Panel(self.tabs)
self.tabs.AddPage(objs_panel, "Transaction History")
objs_sizer = wx.BoxSizer(wx.VERTICAL)
self.tx_list = wx.dataview.DataViewListCtrl(objs_panel)
self.tx_list.AppendTextColumn("Confirmed")
self.tx_list.AppendTextColumn("Type")
self.tx_list.AppendTextColumn("From")
self.tx_list.AppendTextColumn("To")
self.tx_list.AppendTextColumn("Value Delivered")
self.tx_list.AppendTextColumn("Identifying Hash")
self.tx_list.AppendTextColumn("Raw JSON")
objs_sizer.Add(self.tx_list, 1, wx.EXPAND|wx.ALL)
self.pending_tx_rows = {} # Map pending tx hashes to rows in the history UI
objs_panel.SetSizer(objs_sizer)
def run_bg_job(self, job):
"""
Schedules a job to run asynchronously in the XRPL worker thread.
The job should be a Future (for example, from calling an async function)
"""
task = asyncio.run_coroutine_threadsafe(job, self.worker.loop)
def toggle_dialog_style(self, event):
"""
Automatically switches to a password-style dialog if it looks like the
user is entering a secret key, and display ***** instead of s12345...
"""
dlg = event.GetEventObject()
v = dlg.GetValue().strip()
if v[:1] == "s":
dlg.SetWindowStyle(wx.TE_PASSWORD)
else:
dlg.SetWindowStyle(wx.TE_LEFT)
def prompt_for_account(self):
"""
Prompt the user for an account to use, in a base58-encoded format:
- master key seed: Grants read-write access.
(assumes the master key pair is not disabled)
- classic address. Grants read-only access.
- X-address. Grants read-only access.
Exits with error code 1 if the user cancels the dialog, if the input
doesn't match any of the formats, or if the user inputs an X-address
intended for use on a different network type (test/non-test).
Populates the classic address and X-address labels in the UI.
Returns (classic_address, wallet) where wallet is None in read-only mode
"""
account_dialog = wx.TextEntryDialog(self,
"Please enter an account address (for read-only)"
" or your secret (for read-write access)",
caption="Enter account",
value="rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe")
account_dialog.Bind(wx.EVT_TEXT, self.toggle_dialog_style)
if account_dialog.ShowModal() != wx.ID_OK:
# If the user presses Cancel on the account entry, exit the app.
exit(1)
value = account_dialog.GetValue().strip()
account_dialog.Destroy()
classic_address = ""
wallet = None
x_address = ""
if xrpl.core.addresscodec.is_valid_xaddress(value):
x_address = value
classic_address, dest_tag, test_network = xrpl.core.addresscodec.xaddress_to_classic_address(value)
if test_network != self.test_network:
on_net = "a test network" if self.test_network else "Mainnet"
print(f"X-address {value} is meant for a different network type"
f"than this client is connected to."
f"(Client is on: {on_net})")
exit(1)
elif xrpl.core.addresscodec.is_valid_classic_address(value):
classic_address = value
x_address = xrpl.core.addresscodec.classic_address_to_xaddress(
value, tag=None, is_test_network=self.test_network)
else:
try:
# Check if it's a valid seed
seed_bytes, alg = xrpl.core.addresscodec.decode_seed(value)
wallet = xrpl.wallet.Wallet.from_seed(seed=value)
x_address = wallet.get_xaddress(is_test=self.test_network)
classic_address = wallet.address
except Exception as e:
print(e)
exit(1)
# Update the UI with the address values
self.st_classic_address.SetLabel(classic_address)
self.st_x_address.SetLabel(x_address)
return classic_address, wallet
def update_ledger(self, message):
"""
Process a ledger subscription message to update the UI with
information about the latest validated ledger.
"""
close_time_iso = xrpl.utils.ripple_time_to_datetime(message["ledger_time"]).isoformat()
self.ledger_info.SetLabel(f"Latest validated ledger:\n"
f"Ledger Index: {message['ledger_index']}\n"
f"Ledger Hash: {message['ledger_hash']}\n"
f"Close time: {close_time_iso}")
# Save reserve settings so we can calculate account reserve
self.reserve_base = xrpl.utils.drops_to_xrp(str(message["reserve_base"]))
self.reserve_inc = xrpl.utils.drops_to_xrp(str(message["reserve_inc"]))
def calculate_reserve_xrp(self, owner_count):
"""
Calculates how much XRP the user needs to reserve based on the account's
OwnerCount and the reserve values in the latest ledger.
"""
if self.reserve_base == None or self.reserve_inc == None:
return None
oc_decimal = Decimal(owner_count)
reserve_xrp = self.reserve_base + (self.reserve_inc * oc_decimal)
return reserve_xrp
def update_account(self, acct):
"""
Update the account info UI based on an account_info response.
"""
xrp_balance = str(xrpl.utils.drops_to_xrp(acct["Balance"]))
self.st_xrp_balance.SetLabel(xrp_balance)
# Display account reserve and save for calculating max send.
reserve_xrp = self.calculate_reserve_xrp(acct.get("OwnerCount", 0))
if reserve_xrp != None:
self.st_reserve.SetLabel(str(reserve_xrp))
self.reserve_xrp = reserve_xrp
def enable_readwrite(self):
"""
Enable buttons for sending transactions.
"""
self.sxb.Enable()
self.sxb.SetToolTip("")
def displayable_amount(self, a):
"""
Convert an arbitrary amount value from the XRPL to a string to be
displayed to the user:
- Convert drops of XRP to 6-decimal XRP (e.g. '12.345000 XRP')
- For issued tokens, show amount, currency code, and issuer. For
example, 100 USD issued by address r12345... is returned as
'100 USD.r12345...'
Leaves non-standard (hex) currency codes as-is.
"""
if a == "unavailable":
# Special case for pre-2014 partial payments.
return a
elif type(a) == str:
# It's an XRP amount in drops. Convert to decimal.
return f"{xrpl.utils.drops_to_xrp(a)} XRP"
else:
# It's a token amount.
return f"{a['value']} {a['currency']}.{a['issuer']}"
def add_tx_row(self, t, prepend=False):
"""
Add one row to the account transaction history control. Helper function
called by other methods.
"""
conf_dt = xrpl.utils.ripple_time_to_datetime(t["tx"]["date"])
# Convert datetime to locale-default representation & time zone
confirmation_time = conf_dt.astimezone().strftime("%c")
tx_hash = t["tx"]["hash"]
tx_type = t["tx"]["TransactionType"]
from_acct = t["tx"].get("Account") or ""
if from_acct == self.classic_address:
from_acct = "(Me)"
to_acct = t["tx"].get("Destination") or ""
if to_acct == self.classic_address:
to_acct = "(Me)"
delivered_amt = t["meta"].get("delivered_amount")
if delivered_amt:
delivered_amt = self.displayable_amount(delivered_amt)
else:
delivered_amt = ""
cols = (confirmation_time, tx_type, from_acct, to_acct, delivered_amt,
tx_hash, str(t))
if prepend:
self.tx_list.PrependItem(cols)
else:
self.tx_list.AppendItem(cols)
def update_account_tx(self, data):
"""
Update the transaction history tab with information from an account_tx
response.
"""
txs = data["transactions"]
# Note: if you extend the code to do paginated responses, you might want
# to keep previous history instead of deleting the contents first.
self.tx_list.DeleteAllItems()
for t in txs:
self.add_tx_row(t)
def add_tx_from_sub(self, t):
"""
Add 1 transaction to the history based on a subscription stream message.
Assumes only validated transaction streams (e.g. transactions, accounts)
not proposed transaction streams.
Also, send a notification to the user about it.
"""
# Convert to same format as account_tx results
t["tx"] = t["transaction"]
if t["tx"]["hash"] in self.pending_tx_rows.keys():
dvi = self.pending_tx_rows[t["tx"]["hash"]]
pending_row = self.tx_list.ItemToRow(dvi)
self.tx_list.DeleteItem(pending_row)
self.add_tx_row(t, prepend=True)
# Scroll to top of list.
self.tx_list.EnsureVisible(self.tx_list.RowToItem(0))
# Send a notification message (aka a "toast") about the transaction.
# Note the transaction stream and account_tx include all transactions
# that "affect" the account, no just ones directly from/to the account.
# For example, if the account has issued tokens, it gets notified when
# other users transfer those tokens among themselves.
notif = wx.adv.NotificationMessage(title="New Transaction", message =
f"New {t['tx']['TransactionType']} transaction confirmed!")
notif.SetFlags(wx.ICON_INFORMATION)
notif.Show()
def add_pending_tx(self, txm):
"""
Add a "pending" transaction to the history based on a transaction model
that was (presumably) just submitted.
"""
confirmation_time = "(pending)"
tx_type = txm.transaction_type
from_acct = txm.account
if from_acct == self.classic_address:
from_acct = "(Me)"
# Some transactions don't have a destination, so we need to handle that.
to_acct = getattr(txm, "destination", "")
if to_acct == self.classic_address:
to_acct = "(Me)"
# Delivered amount is only known after a transaction is processed, so
# leave this column empty in the display for pending transactions.
delivered_amt = ""
tx_hash = txm.get_hash()
cols = (confirmation_time, tx_type, from_acct, to_acct, delivered_amt,
tx_hash, str(txm.to_xrpl()))
self.tx_list.PrependItem(cols)
self.pending_tx_rows[tx_hash] = self.tx_list.RowToItem(0)
def click_send_xrp(self, event):
"""
Pop up a dialog for the user to input how much XRP to send where, and
send the transaction (if the user doesn't cancel).
"""
xrp_bal = Decimal(self.st_xrp_balance.GetLabelText())
tx_cost = Decimal("0.000010")
reserve = self.reserve_xrp or Decimal(0.000000)
dlg = SendXRPDialog(self, max_send=float(xrp_bal - reserve - tx_cost))
dlg.CenterOnScreen()
resp = dlg.ShowModal()
if resp != wx.ID_OK:
print("Send XRP canceled")
dlg.Destroy()
return
paydata = dlg.get_payment_data()
dlg.Destroy()
self.run_bg_job(self.worker.send_xrp(paydata))
notif = wx.adv.NotificationMessage(title="Sending!", message =
f"Sending a payment for {paydata['amt']} XRP!")
notif.SetFlags(wx.ICON_INFORMATION)
notif.Show()
if __name__ == "__main__":
WS_URL = "wss://s.altnet.rippletest.net:51233" # Testnet
app = wx.App()
frame = TWaXLFrame(WS_URL, test_network=True)
frame.Show()
app.MainLoop()

View File

@@ -0,0 +1,889 @@
# "Build a Wallet" tutorial, extra step: Tokens and Other objects
# Show (issued / fungible) tokens and other objects owned by an account.
# License: MIT. https://github.com/XRPLF/xrpl-dev-portal/blob/master/LICENSE
import xrpl
import wx
import wx.dataview
import wx.adv
import asyncio
import re
from threading import Thread
from decimal import Decimal
from verify_domain import verify_account_domain
class XRPLMonitorThread(Thread):
"""
A worker thread to watch for new ledger events and pass the info back to
the main frame to be shown in the UI. Using a thread lets us maintain the
responsiveness of the UI while doing work in the background.
"""
def __init__(self, url, gui):
Thread.__init__(self, daemon=True)
# Note: For thread safety, this thread should treat self.gui as
# read-only; to modify the GUI, use wx.CallAfter(...)
self.gui = gui
self.url = url
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.loop.set_debug(True)
def run(self):
"""
This thread runs a never-ending event-loop that monitors messages coming
from the XRPL, sending them to the GUI thread when necessary, and also
handles making requests to the XRPL when the GUI prompts them.
"""
self.loop.run_forever()
async def watch_xrpl_account(self, address, wallet=None):
"""
This is the task that opens the connection to the XRPL, then handles
incoming subscription messages by dispatching them to the appropriate
part of the GUI.
"""
self.account = address
self.wallet = wallet
async with xrpl.asyncio.clients.AsyncWebsocketClient(self.url) as self.client:
await self.on_connected()
async for message in self.client:
mtype = message.get("type")
if mtype == "ledgerClosed":
wx.CallAfter(self.gui.update_ledger, message)
elif mtype == "transaction":
wx.CallAfter(self.gui.add_tx_from_sub, message)
response = await self.client.request(xrpl.models.requests.AccountInfo(
account=self.account,
ledger_index=message["ledger_index"]
))
wx.CallAfter(self.gui.update_account, response.result["account_data"])
async def on_connected(self):
"""
Set up initial subscriptions and populate the GUI with data from the
ledger on startup. Requires that self.client be connected first.
"""
# Set up 2 subscriptions: all new ledgers, and any new transactions that
# affect the chosen account.
response = await self.client.request(xrpl.models.requests.Subscribe(
streams=["ledger"],
accounts=[self.account]
))
# The immediate response contains details for the last validated ledger.
# We can use this to fill in that area of the GUI without waiting for a
# new ledger to close.
wx.CallAfter(self.gui.update_ledger, response.result)
# Get starting values for account info.
response = await self.client.request(xrpl.models.requests.AccountInfo(
account=self.account,
ledger_index="validated"
))
if not response.is_successful():
print("Got error from server:", response)
# This most often happens if the account in question doesn't exist
# on the network we're connected to. Better handling would be to use
# wx.CallAfter to display an error dialog in the GUI and possibly
# let the user try inputting a different account.
exit(1)
wx.CallAfter(self.gui.update_account, response.result["account_data"])
if self.wallet:
wx.CallAfter(self.gui.enable_readwrite)
# Get the first page of the account's transaction history. Depending on
# the server we're connected to, the account's full history may not be
# available.
response = await self.client.request(xrpl.models.requests.AccountTx(
account=self.account
))
wx.CallAfter(self.gui.update_account_tx, response.result)
# Look up issued tokens
response = await self.client.request(xrpl.models.requests.AccountLines(
account=self.account,
ledger_index="validated"
))
if not response.is_successful():
print("Error getting account lines:", response)
else:
wx.CallAfter(self.gui.update_account_lines,
response.result["lines"])
# Look up all types of objects attached to the account
response = await self.client.request(xrpl.models.requests.AccountObjects(
account=self.account,
ledger_index="validated"
))
if not response.is_successful():
print("Error getting account objects:", response)
else:
wx.CallAfter(self.gui.update_account_objects,
response.result["account_objects"])
async def check_destination(self, destination, dlg):
"""
Check a potential destination address's details, and pass them back to
a "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.
Requires that self.client be connected first.
"""
# The data to send back to the GUI thread: None for checks that weren't
# performed, True/False for actual results except where noted.
account_status = {
"funded": None,
"disallow_xrp": None,
"domain_verified": None,
"domain_str": "" # the decoded domain, regardless of verification
}
# Look up the account. If this fails, the account isn't funded.
try:
response = await xrpl.asyncio.account.get_account_info(destination,
self.client, ledger_index="validated")
account_status["funded"] = True
dest_acct = response.result["account_data"]
except xrpl.asyncio.clients.exceptions.XRPLRequestFailureException:
# Not funded, so the other checks don't apply.
account_status["funded"] = False
wx.CallAfter(dlg.update_dest_info, account_status)
return
# Check DisallowXRP flag
lsfDisallowXRP = 0x00080000
if dest_acct["Flags"] & lsfDisallowXRP:
account_status["disallow_xrp"] = True
else:
account_status["disallow_xrp"] = False
# Check domain verification
domain, verified = verify_account_domain(dest_acct)
account_status["domain_verified"] = verified
account_status["domain_str"] = domain
# Send data back to the main thread.
wx.CallAfter(dlg.update_dest_info, account_status)
async def send_xrp(self, paydata):
"""
Prepare, sign, and send an XRP payment with the provided parameters.
Expects a dictionary with:
{
"dtag": Destination Tag, as a string, optional
"to": Destination address (classic or X-address)
"amt": Amount of decimal XRP to send, as a string
}
"""
dtag = paydata.get("dtag", "")
if dtag.strip() == "":
dtag = None
if dtag is not None:
try:
dtag = int(dtag)
if dtag < 0 or dtag > 2**32-1:
raise ValueError("Destination tag must be a 32-bit unsigned integer")
except ValueError as e:
print("Invalid destination tag:", e)
print("Canceled sending payment.")
return
tx = xrpl.models.transactions.Payment(
account=self.account,
destination=paydata["to"],
amount=xrpl.utils.xrp_to_drops(paydata["amt"]),
destination_tag=dtag
)
# Autofill provides a sequence number, but this may fail if you try to
# send too many transactions too fast. You can send transactions more
# rapidly if you track the sequence number more carefully.
tx_signed = await xrpl.asyncio.transaction.autofill_and_sign(
tx, self.client, self.wallet)
await xrpl.asyncio.transaction.submit(tx_signed, self.client)
wx.CallAfter(self.gui.add_pending_tx, tx_signed)
class AutoGridBagSizer(wx.GridBagSizer):
"""
Helper class for adding a bunch of items uniformly to a GridBagSizer.
"""
def __init__(self, parent):
wx.GridBagSizer.__init__(self, vgap=5, hgap=5)
self.parent = parent
def BulkAdd(self, ctrls):
"""
Given a two-dimensional iterable `ctrls`, add all the items in a grid
top-to-bottom, left-to-right, with each inner iterable being a row. Set
the total number of columns based on the longest iterable.
"""
flags = wx.EXPAND|wx.ALL|wx.RESERVE_SPACE_EVEN_IF_HIDDEN|wx.ALIGN_CENTER_VERTICAL
for x, row in enumerate(ctrls):
for y, ctrl in enumerate(row):
self.Add(ctrl, (x,y), flag=flags, border=5)
self.parent.SetSizer(self)
class SendXRPDialog(wx.Dialog):
"""
Pop-up dialog that prompts the user for the information necessary to send a
direct XRP-to-XRP payment on the XRPL.
"""
def __init__(self, parent, max_send=100000000.0):
wx.Dialog.__init__(self, parent, title="Send XRP")
sizer = AutoGridBagSizer(self)
self.parent = parent
# Icons to indicate a validation error
bmp_err = wx.ArtProvider.GetBitmap(wx.ART_ERROR, wx.ART_CMN_DIALOG, size=(16,16))
self.err_to = wx.StaticBitmap(self, bitmap=bmp_err)
self.err_dtag = wx.StaticBitmap(self, bitmap=bmp_err)
self.err_amt = wx.StaticBitmap(self, bitmap=bmp_err)
self.err_to.Hide()
self.err_dtag.Hide()
self.err_amt.Hide()
# Icons for domain verification
bmp_check = wx.ArtProvider.GetBitmap(wx.ART_TICK_MARK, wx.ART_CMN_DIALOG, size=(16,16))
self.domain_text = wx.StaticText(self, label="")
self.domain_verified = wx.StaticBitmap(self, bitmap=bmp_check)
self.domain_verified.Hide()
if max_send <= 0:
max_send = 100000000.0
self.err_amt.Show()
self.err_amt.SetToolTip("Not enough XRP to pay the reserve and transaction cost!")
lbl_to = wx.StaticText(self, label="To (Address):")
lbl_dtag = wx.StaticText(self, label="Destination Tag:")
lbl_amt = wx.StaticText(self, label="Amount of XRP:")
self.txt_to = wx.TextCtrl(self)
self.txt_dtag = wx.TextCtrl(self)
self.txt_amt = wx.SpinCtrlDouble(self, value="20.0", min=0.000001, max=max_send)
self.txt_amt.SetDigits(6)
self.txt_amt.SetIncrement(1.0)
# The "Send" button is functionally an "OK" button except for the text.
self.btn_send = wx.Button(self, wx.ID_OK, label="Send")
btn_cancel = wx.Button(self, wx.ID_CANCEL)
sizer.BulkAdd(((lbl_to, self.txt_to, self.err_to),
(self.domain_verified, self.domain_text),
(lbl_dtag, self.txt_dtag, self.err_dtag),
(lbl_amt, self.txt_amt, self.err_amt),
(btn_cancel, self.btn_send)) )
sizer.Fit(self)
self.txt_dtag.Bind(wx.EVT_TEXT, self.on_dest_tag_edit)
self.txt_to.Bind(wx.EVT_TEXT, self.on_to_edit)
def get_payment_data(self):
"""
Construct a dictionary with the relevant payment details to pass to the
worker thread for making a payment. Called after the user clicks "Send".
"""
return {
"to": self.txt_to.GetValue().strip(),
"dtag": self.txt_dtag.GetValue().strip(),
"amt": self.txt_amt.GetValue(),
}
def on_to_edit(self, event):
"""
When the user edits the "To" field, check that the address is well-
formatted. If it's an X-address, fill in the destination tag and disable
it. Also, start a background check to confirm more details about the
address.
"""
v = self.txt_to.GetValue().strip()
# Reset warnings / domain verification
err_msg = ""
self.err_to.SetToolTip("")
self.err_to.Hide()
self.domain_text.SetLabel("")
self.domain_verified.Hide()
if xrpl.core.addresscodec.is_valid_xaddress(v):
cl_addr, tag, is_test = xrpl.core.addresscodec.xaddress_to_classic_address(v)
if tag is None: # Not the same as tag = 0
tag = ""
self.txt_dtag.ChangeValue(str(tag))
self.txt_dtag.Disable()
if cl_addr == self.parent.classic_address:
err_msg = "Can't send XRP to self."
elif is_test != self.parent.test_network:
err_msg = "This address is intended for a different network."
elif not self.txt_dtag.IsEditable():
self.txt_dtag.Clear()
self.txt_dtag.Enable()
if not (xrpl.core.addresscodec.is_valid_classic_address(v) or
xrpl.core.addresscodec.is_valid_xaddress(v) ):
self.btn_send.Disable()
err_msg = "Not a valid address."
elif v == self.parent.classic_address:
self.btn_send.Disable()
err_msg = "Can't send XRP to self."
else:
self.parent.run_bg_job(self.parent.worker.check_destination(v, self))
if err_msg:
self.err_to.SetToolTip(err_msg)
self.err_to.Show()
else:
self.err_to.Hide()
def on_dest_tag_edit(self, event):
"""
When the user edits the Destination Tag field, strip non-numeric
characters from it.
"""
v = self.txt_dtag.GetValue().strip()
v = re.sub(r"[^0-9]", "", v)
self.txt_dtag.ChangeValue(v) # SetValue would generate another EVT_TEXT
self.txt_dtag.SetInsertionPointEnd()
def update_dest_info(self, dest_status):
"""
Update the UI with details provided by a background job to check the
destination address.
"""
# Keep existing error message if there is one
try:
err_msg = self.err_to.GetToolTip().GetTip().strip()
except RuntimeError:
# This method can be called after the dialog it belongs to has been
# closed. In that case, there's nothing to do here.
return
if not dest_status["funded"]:
err_msg = ("Warning: this account does not exist. The payment will "
"fail unless you send enough to fund it.")
elif dest_status["disallow_xrp"]:
err_msg = "This account does not want to receive XRP."
# Domain verification
bmp_err = wx.ArtProvider.GetBitmap(wx.ART_ERROR, wx.ART_CMN_DIALOG, size=(16,16))
bmp_check = wx.ArtProvider.GetBitmap(wx.ART_TICK_MARK, wx.ART_CMN_DIALOG, size=(16,16))
domain = dest_status["domain_str"]
verified = dest_status["domain_verified"]
if not domain:
self.domain_text.Hide()
self.domain_verified.Hide()
elif verified:
self.domain_text.SetLabel(domain)
self.domain_text.Show()
self.domain_verified.SetToolTip("Domain verified")
self.domain_verified.SetBitmap(bmp_check)
self.domain_verified.Show()
else:
self.domain_text.SetLabel(domain)
self.domain_text.Show()
self.domain_verified.SetToolTip("Failed to verify domain")
self.domain_verified.SetBitmap(bmp_err)
self.domain_verified.Show()
if err_msg:
# Disabling the button is optional. These types of errors can be
# benign, so you could let the user "click through" them.
# self.btn_send.Disable()
self.err_to.SetToolTip(err_msg)
self.err_to.Show()
else:
self.btn_send.Enable()
self.err_to.SetToolTip("")
self.err_to.Hide()
class TWaXLFrame(wx.Frame):
"""
Tutorial Wallet for the XRP Ledger (TWaXL)
user interface, main frame.
"""
def __init__(self, url, test_network=True):
wx.Frame.__init__(self, None, title="TWaXL", size=wx.Size(800,400))
self.test_network = test_network
# The ledger's current reserve settings. To be filled in later.
self.reserve_base = None
self.reserve_inc = None
# This account's total XRP reserve including base + owner amounts
self.reserve_xrp = None
self.build_ui()
# Pop up to ask user for their account ---------------------------------
address, wallet = self.prompt_for_account()
self.classic_address = address
# Start background thread for updates from the ledger ------------------
self.worker = XRPLMonitorThread(url, self)
self.worker.start()
self.run_bg_job(self.worker.watch_xrpl_account(address, wallet))
def build_ui(self):
"""
Called during __init__ to set up all the GUI components.
"""
self.tabs = wx.Notebook(self, style=wx.BK_DEFAULT)
# Tab 1: "Summary" pane ------------------------------------------------
main_panel = wx.Panel(self.tabs)
self.tabs.AddPage(main_panel, "Summary")
self.acct_info_area = wx.StaticBox(main_panel, label="Account Info")
lbl_address = wx.StaticText(self.acct_info_area, label="Classic Address:")
self.st_classic_address = wx.StaticText(self.acct_info_area, label="TBD")
lbl_xaddress = wx.StaticText(self.acct_info_area, label="X-Address:")
self.st_x_address = wx.StaticText(self.acct_info_area, label="TBD")
lbl_xrp_bal = wx.StaticText(self.acct_info_area, label="XRP Balance:")
self.st_xrp_balance = wx.StaticText(self.acct_info_area, label="TBD")
lbl_reserve = wx.StaticText(self.acct_info_area, label="XRP Reserved:")
self.st_reserve = wx.StaticText(self.acct_info_area, label="TBD")
aia_sizer = AutoGridBagSizer(self.acct_info_area)
aia_sizer.BulkAdd( ((lbl_address, self.st_classic_address),
(lbl_xaddress, self.st_x_address),
(lbl_xrp_bal, self.st_xrp_balance),
(lbl_reserve, self.st_reserve)) )
# Send XRP button. Disabled until we have a secret key & network connection
self.sxb = wx.Button(main_panel, label="Send XRP")
self.sxb.SetToolTip("Disabled in read-only mode.")
self.sxb.Disable()
self.Bind(wx.EVT_BUTTON, self.click_send_xrp, source=self.sxb)
self.ledger_info = wx.StaticText(main_panel, label="Not connected")
main_sizer = wx.BoxSizer(wx.VERTICAL)
main_sizer.Add(self.acct_info_area, 1, flag=wx.EXPAND|wx.ALL, border=5)
main_sizer.Add(self.sxb, 0, flag=wx.ALL, border=5)
main_sizer.Add(self.ledger_info, 1, flag=wx.EXPAND|wx.ALL, border=5)
main_panel.SetSizer(main_sizer)
# Tab 2: "Transaction History" pane ------------------------------------
txhistory_panel = wx.Panel(self.tabs)
self.tabs.AddPage(txhistory_panel, "Transaction History")
txhistory_sizer = wx.BoxSizer(wx.VERTICAL)
self.tx_list = wx.dataview.DataViewListCtrl(txhistory_panel)
self.tx_list.AppendTextColumn("Confirmed")
self.tx_list.AppendTextColumn("Type")
self.tx_list.AppendTextColumn("From")
self.tx_list.AppendTextColumn("To")
self.tx_list.AppendTextColumn("Value Delivered")
self.tx_list.AppendTextColumn("Identifying Hash")
self.tx_list.AppendTextColumn("Raw JSON")
txhistory_sizer.Add(self.tx_list, 1, wx.EXPAND|wx.ALL)
self.pending_tx_rows = {} # Map pending tx hashes to rows in the history UI
txhistory_panel.SetSizer(txhistory_sizer)
# Tab 3: "Tokens" pane -------------------------------------------------
tokens_panel = wx.Panel(self.tabs)
self.tabs.AddPage(tokens_panel, "Tokens")
tokens_sizer = wx.BoxSizer(wx.VERTICAL)
self.tkn_list = wx.dataview.DataViewListCtrl(tokens_panel)
self.tkn_list.AppendTextColumn("Currency")
self.tkn_list.AppendTextColumn("Issuer")
self.tkn_list.AppendTextColumn("Balance")
self.tkn_list.AppendTextColumn("Limit")
self.tkn_list.AppendTextColumn("Peer Limit")
self.tkn_list.AppendToggleColumn("Allows Rippling?", mode=wx.dataview.DATAVIEW_CELL_INERT)
self.tkn_list.AppendToggleColumn("Frozen?", mode=wx.dataview.DATAVIEW_CELL_INERT)
self.tkn_list.AppendToggleColumn("Authorized?", mode=wx.dataview.DATAVIEW_CELL_INERT)
self.tkn_list.AppendToggleColumn("Peer Allows Rippling?", mode=wx.dataview.DATAVIEW_CELL_INERT)
self.tkn_list.AppendToggleColumn("Frozen by Peer?", mode=wx.dataview.DATAVIEW_CELL_INERT)
self.tkn_list.AppendToggleColumn("Authorized by Peer?", mode=wx.dataview.DATAVIEW_CELL_INERT)
tokens_sizer.Add(self.tkn_list, 1, wx.EXPAND|wx.ALL)
tokens_panel.SetSizer(tokens_sizer)
# Tab 4: "Objects" pane ------------------------------------------------
objs_panel = wx.Panel(self.tabs)
self.tabs.AddPage(objs_panel, "Other Objects")
objs_sizer = wx.BoxSizer(wx.VERTICAL)
self.o_list = wx.dataview.DataViewListCtrl(objs_panel)
self.o_list.AppendTextColumn("Type")
self.o_list.AppendTextColumn("Summary")
objs_sizer.Add(self.o_list, 1, wx.EXPAND|wx.ALL)
objs_panel.SetSizer(objs_sizer)
def run_bg_job(self, job):
"""
Schedules a job to run asynchronously in the XRPL worker thread.
The job should be a Future (for example, from calling an async function)
"""
task = asyncio.run_coroutine_threadsafe(job, self.worker.loop)
def toggle_dialog_style(self, event):
"""
Automatically switches to a password-style dialog if it looks like the
user is entering a secret key, and display ***** instead of s12345...
"""
dlg = event.GetEventObject()
v = dlg.GetValue().strip()
if v[:1] == "s":
dlg.SetWindowStyle(wx.TE_PASSWORD)
else:
dlg.SetWindowStyle(wx.TE_LEFT)
def prompt_for_account(self):
"""
Prompt the user for an account to use, in a base58-encoded format:
- master key seed: Grants read-write access.
(assumes the master key pair is not disabled)
- classic address. Grants read-only access.
- X-address. Grants read-only access.
Exits with error code 1 if the user cancels the dialog, if the input
doesn't match any of the formats, or if the user inputs an X-address
intended for use on a different network type (test/non-test).
Populates the classic address and X-address labels in the UI.
Returns (classic_address, wallet) where wallet is None in read-only mode
"""
account_dialog = wx.TextEntryDialog(self,
"Please enter an account address (for read-only)"
" or your secret (for read-write access)",
caption="Enter account",
value="rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe")
account_dialog.Bind(wx.EVT_TEXT, self.toggle_dialog_style)
if account_dialog.ShowModal() != wx.ID_OK:
# If the user presses Cancel on the account entry, exit the app.
exit(1)
value = account_dialog.GetValue().strip()
account_dialog.Destroy()
classic_address = ""
wallet = None
x_address = ""
if xrpl.core.addresscodec.is_valid_xaddress(value):
x_address = value
classic_address, dest_tag, test_network = xrpl.core.addresscodec.xaddress_to_classic_address(value)
if test_network != self.test_network:
on_net = "a test network" if self.test_network else "Mainnet"
print(f"X-address {value} is meant for a different network type"
f"than this client is connected to."
f"(Client is on: {on_net})")
exit(1)
elif xrpl.core.addresscodec.is_valid_classic_address(value):
classic_address = value
x_address = xrpl.core.addresscodec.classic_address_to_xaddress(
value, tag=None, is_test_network=self.test_network)
else:
try:
# Check if it's a valid seed
seed_bytes, alg = xrpl.core.addresscodec.decode_seed(value)
wallet = xrpl.wallet.Wallet.from_seed(seed=value)
x_address = wallet.get_xaddress(is_test=self.test_network)
classic_address = wallet.address
except Exception as e:
print(e)
exit(1)
# Update the UI with the address values
self.st_classic_address.SetLabel(classic_address)
self.st_x_address.SetLabel(x_address)
return classic_address, wallet
def update_ledger(self, message):
"""
Process a ledger subscription message to update the UI with
information about the latest validated ledger.
"""
close_time_iso = xrpl.utils.ripple_time_to_datetime(message["ledger_time"]).isoformat()
self.ledger_info.SetLabel(f"Latest validated ledger:\n"
f"Ledger Index: {message['ledger_index']}\n"
f"Ledger Hash: {message['ledger_hash']}\n"
f"Close time: {close_time_iso}")
# Save reserve settings so we can calculate account reserve
self.reserve_base = xrpl.utils.drops_to_xrp(str(message["reserve_base"]))
self.reserve_inc = xrpl.utils.drops_to_xrp(str(message["reserve_inc"]))
def calculate_reserve_xrp(self, owner_count):
"""
Calculates how much XRP the user needs to reserve based on the account's
OwnerCount and the reserve values in the latest ledger.
"""
if self.reserve_base == None or self.reserve_inc == None:
return None
oc_decimal = Decimal(owner_count)
reserve_xrp = self.reserve_base + (self.reserve_inc * oc_decimal)
return reserve_xrp
def update_account(self, acct):
"""
Update the account info UI based on an account_info response.
"""
xrp_balance = str(xrpl.utils.drops_to_xrp(acct["Balance"]))
self.st_xrp_balance.SetLabel(xrp_balance)
# Display account reserve and save for calculating max send.
reserve_xrp = self.calculate_reserve_xrp(acct.get("OwnerCount", 0))
if reserve_xrp != None:
self.st_reserve.SetLabel(str(reserve_xrp))
self.reserve_xrp = reserve_xrp
def enable_readwrite(self):
"""
Enable buttons for sending transactions.
"""
self.sxb.Enable()
self.sxb.SetToolTip("")
def displayable_amount(self, a):
"""
Convert an arbitrary amount value from the XRPL to a string to be
displayed to the user:
- Convert drops of XRP to 6-decimal XRP (e.g. '12.345000 XRP')
- For issued tokens, show amount, currency code, and issuer. For
example, 100 USD issued by address r12345... is returned as
'100 USD.r12345...'
Leaves non-standard (hex) currency codes as-is.
"""
if a == "unavailable":
# Special case for pre-2014 partial payments.
return a
elif type(a) == str:
# It's an XRP amount in drops. Convert to decimal.
return f"{xrpl.utils.drops_to_xrp(a)} XRP"
else:
# It's a token amount.
return f"{a['value']} {a['currency']}.{a['issuer']}"
def add_tx_row(self, t, prepend=False):
"""
Add one row to the account transaction history control. Helper function
called by other methods.
"""
conf_dt = xrpl.utils.ripple_time_to_datetime(t["tx"]["date"])
# Convert datetime to locale-default representation & time zone
confirmation_time = conf_dt.astimezone().strftime("%c")
tx_hash = t["tx"]["hash"]
tx_type = t["tx"]["TransactionType"]
from_acct = t["tx"].get("Account") or ""
if from_acct == self.classic_address:
from_acct = "(Me)"
to_acct = t["tx"].get("Destination") or ""
if to_acct == self.classic_address:
to_acct = "(Me)"
delivered_amt = t["meta"].get("delivered_amount")
if delivered_amt:
delivered_amt = self.displayable_amount(delivered_amt)
else:
delivered_amt = ""
cols = (confirmation_time, tx_type, from_acct, to_acct, delivered_amt,
tx_hash, str(t))
if prepend:
self.tx_list.PrependItem(cols)
else:
self.tx_list.AppendItem(cols)
def update_account_tx(self, data):
"""
Update the transaction history tab with information from an account_tx
response.
"""
txs = data["transactions"]
# Note: if you extend the code to do paginated responses, you might want
# to keep previous history instead of deleting the contents first.
self.tx_list.DeleteAllItems()
for t in txs:
self.add_tx_row(t)
def add_tx_from_sub(self, t):
"""
Add 1 transaction to the history based on a subscription stream message.
Assumes only validated transaction streams (e.g. transactions, accounts)
not proposed transaction streams.
Also, send a notification to the user about it.
"""
# Convert to same format as account_tx results
t["tx"] = t["transaction"]
if t["tx"]["hash"] in self.pending_tx_rows.keys():
dvi = self.pending_tx_rows[t["tx"]["hash"]]
pending_row = self.tx_list.ItemToRow(dvi)
self.tx_list.DeleteItem(pending_row)
self.add_tx_row(t, prepend=True)
# Scroll to top of list.
self.tx_list.EnsureVisible(self.tx_list.RowToItem(0))
# Send a notification message (aka a "toast") about the transaction.
# Note the transaction stream and account_tx include all transactions
# that "affect" the account, no just ones directly from/to the account.
# For example, if the account has issued tokens, it gets notified when
# other users transfer those tokens among themselves.
notif = wx.adv.NotificationMessage(title="New Transaction", message =
f"New {t['tx']['TransactionType']} transaction confirmed!")
notif.SetFlags(wx.ICON_INFORMATION)
notif.Show()
def add_pending_tx(self, txm):
"""
Add a "pending" transaction to the history based on a transaction model
that was (presumably) just submitted.
"""
confirmation_time = "(pending)"
tx_type = txm.transaction_type
from_acct = txm.account
if from_acct == self.classic_address:
from_acct = "(Me)"
# Some transactions don't have a destination, so we need to handle that.
to_acct = getattr(txm, "destination", "")
if to_acct == self.classic_address:
to_acct = "(Me)"
# Delivered amount is only known after a transaction is processed, so
# leave this column empty in the display for pending transactions.
delivered_amt = ""
tx_hash = txm.get_hash()
cols = (confirmation_time, tx_type, from_acct, to_acct, delivered_amt,
tx_hash, str(txm.to_xrpl()))
self.tx_list.PrependItem(cols)
self.pending_tx_rows[tx_hash] = self.tx_list.RowToItem(0)
def click_send_xrp(self, event):
"""
Pop up a dialog for the user to input how much XRP to send where, and
send the transaction (if the user doesn't cancel).
"""
xrp_bal = Decimal(self.st_xrp_balance.GetLabelText())
tx_cost = Decimal("0.000010")
reserve = self.reserve_xrp or Decimal(0.000000)
dlg = SendXRPDialog(self, max_send=float(xrp_bal - reserve - tx_cost))
dlg.CenterOnScreen()
resp = dlg.ShowModal()
if resp != wx.ID_OK:
print("Send XRP canceled")
dlg.Destroy()
return
paydata = dlg.get_payment_data()
dlg.Destroy()
self.run_bg_job(self.worker.send_xrp(paydata))
notif = wx.adv.NotificationMessage(title="Sending!", message =
f"Sending a payment for {paydata['amt']} XRP!")
notif.SetFlags(wx.ICON_INFORMATION)
notif.Show()
def update_account_lines(self, lines):
"""
Update the Tokens tab based on an account_lines result.
This doesn't handle pagination.
"""
self.tkn_list.DeleteAllItems()
for l in lines:
self.tkn_list.AppendItem([
l["currency"],
l["account"],
l["balance"],
l["limit"],
l["limit_peer"],
not l.get("no_ripple", False),
l.get("freeze", False),
l.get("authorized", False),
l.get("freeze_peer", False),
not l.get("no_ripple_peer", False),
l.get("peer_authorized", False),
])
def update_account_objects(self, objs):
"""
Update the tab of objects owned with the results of an account_objects
call, skipping RippleState objects since those are represented in the
Tokens tab. This doesn't handle pagination.
"""
self.o_list.DeleteAllItems()
for o in objs:
if o["LedgerEntryType"] == "RippleState":
continue
elif o["LedgerEntryType"] == "Check":
check_amt = self.displayable_amount(o["SendMax"])
summary = f"Deliver up to {check_amt}"
if o["Account"] == self.classic_address:
# Outgoing check
summary += f" to {o['Destination']}"
else:
summary += f" from {o['Account']}"
elif o["LedgerEntryType"] == "DepositPreauth":
if o["Account"] == self.classic_address:
# We authorized them
summary = f"Authorized {o['Authorize']}"
else:
summary = f"Authorized by {o['Account']}"
elif o["LedgerEntryType"] == "Escrow":
escrow_amt = self.displayable_amount(o["Amount"])
summary = f"Hold {escrow_amt} "
if o["Account"] == self.classic_address:
# Outgoing escrow
summary += f"for {o['Destination']} "
else:
summary += f"from {o['Account']} "
if o.get("Condition"):
summary += "with condition "
if o.get("FinishAfter"):
fa_dt = xrpl.utils.ripple_time_to_datetime(o["FinishAfter"])
# Convert datetime to locale-default representation & time zone
fa_time = fa_dt.astimezone().strftime("%c")
summary += f"until {fa_time} "
if o.get("CancelAfter"):
ca_dt = xrpl.utils.ripple_time_to_datetime(o["CancelAfter"])
# Convert datetime to locale-default representation & time zone
ca_time = ca_dt.astimezone().strftime("%c")
summary += f"or cancel at {ca_time} "
elif o["LedgerEntryType"] == "Offer":
# An order we placed in the decentralized exchange
sell_amt = self.displayable_amount(o["TakerGets"])
buy_amt = self.displayable_amount(o["TakerPays"])
summary = f"Trade {sell_amt} to receive {buy_amt}"
elif o["LedgerEntryType"] == "PayChannel":
# Payment channels' balance is determined by the amount paid
# out of the amount funded
amt_dec = xrpl.utils.drops_to_xrp(o["Amount"])
bal_dec = xrpl.utils.drops_to_xrp(o["Balance"])
summary = f"{bal_dec} paid of {amt_dec} XRP"
if o["Account"] == self.classic_address:
# Outgoing channel
summary += f" to {o['Destination']}"
else:
summary += f" from {o['Account']}"
elif o["LedgerEntryType"] == "SignerList":
summary = f"Quorum: {o['SignerQuorum']}. Signers: "
summary += ", ".join([
f"{se['SignerEntry']['Account']} "
f"(Weight: {se['SignerEntry']['SignerWeight']})"
for se in o["SignerEntries"]])
elif o["LedgerEntryType"] == "Ticket":
summary = f"Ticket #{o['TicketSequence']}"
else:
summary = ""
cols = (o["LedgerEntryType"], summary)
self.o_list.AppendItem(cols)
if __name__ == "__main__":
WS_URL = "wss://s.altnet.rippletest.net:51233" # Testnet
#WS_URL = "wss://xrplcluster.com" # Mainnet
app = wx.App()
frame = TWaXLFrame(WS_URL, test_network=True)
frame.Show()
app.MainLoop()

View File

@@ -0,0 +1,947 @@
#!/usr/bin/env python
# "Build a Wallet" tutorial, extra step: Allow the user to switch to using a
# regular key. Doesn't actually including *setting* the regular key, though.
# Also adds a commandline switch for choosing the network.
# License: MIT. https://github.com/XRPLF/xrpl-dev-portal/blob/master/LICENSE
from argparse import ArgumentParser
import xrpl
import wx
import wx.dataview
import wx.adv
import asyncio
import re
from threading import Thread
from decimal import Decimal
from verify_domain import verify_account_domain
class XRPLMonitorThread(Thread):
"""
A worker thread to watch for new ledger events and pass the info back to
the main frame to be shown in the UI. Using a thread lets us maintain the
responsiveness of the UI while doing work in the background.
"""
def __init__(self, url, gui):
Thread.__init__(self, daemon=True)
# Note: For thread safety, this thread should treat self.gui as
# read-only; to modify the GUI, use wx.CallAfter(...)
self.gui = gui
self.url = url
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.loop.set_debug(True)
def run(self):
"""
This thread runs a never-ending event-loop that monitors messages coming
from the XRPL, sending them to the GUI thread when necessary, and also
handles making requests to the XRPL when the GUI prompts them.
"""
self.loop.run_forever()
async def watch_xrpl_account(self, address, wallet=None):
"""
This is the task that opens the connection to the XRPL, then handles
incoming subscription messages by dispatching them to the appropriate
part of the GUI.
"""
self.account = address
self.wallet = wallet
async with xrpl.asyncio.clients.AsyncWebsocketClient(self.url) as self.client:
await self.on_connected()
async for message in self.client:
mtype = message.get("type")
if mtype == "ledgerClosed":
wx.CallAfter(self.gui.update_ledger, message)
elif mtype == "transaction":
wx.CallAfter(self.gui.add_tx_from_sub, message)
response = await self.client.request(xrpl.models.requests.AccountInfo(
account=self.account,
ledger_index=message["ledger_index"]
))
wx.CallAfter(self.gui.update_account, response.result["account_data"])
async def on_connected(self):
"""
Set up initial subscriptions and populate the GUI with data from the
ledger on startup. Requires that self.client be connected first.
"""
# Set up 2 subscriptions: all new ledgers, and any new transactions that
# affect the chosen account.
response = await self.client.request(xrpl.models.requests.Subscribe(
streams=["ledger"],
accounts=[self.account]
))
# The immediate response contains details for the last validated ledger.
# We can use this to fill in that area of the GUI without waiting for a
# new ledger to close.
wx.CallAfter(self.gui.update_ledger, response.result)
# Get starting values for account info.
response = await self.client.request(xrpl.models.requests.AccountInfo(
account=self.account,
ledger_index="validated"
))
if not response.is_successful():
print("Got error from server:", response)
# This most often happens if the account in question doesn't exist
# on the network we're connected to. Better handling would be to use
# wx.CallAfter to display an error dialog in the GUI and possibly
# let the user try inputting a different account.
exit(1)
wx.CallAfter(self.gui.update_account, response.result["account_data"])
if self.wallet:
wx.CallAfter(self.gui.enable_readwrite)
# Get the first page of the account's transaction history. Depending on
# the server we're connected to, the account's full history may not be
# available.
response = await self.client.request(xrpl.models.requests.AccountTx(
account=self.account
))
wx.CallAfter(self.gui.update_account_tx, response.result)
# Look up issued tokens
response = await self.client.request(xrpl.models.requests.AccountLines(
account=self.account,
ledger_index="validated"
))
if not response.is_successful():
print("Error getting account lines:", response)
else:
wx.CallAfter(self.gui.update_account_lines,
response.result["lines"])
# Look up all types of objects attached to the account
response = await self.client.request(xrpl.models.requests.AccountObjects(
account=self.account,
ledger_index="validated"
))
if not response.is_successful():
print("Error getting account objects:", response)
else:
wx.CallAfter(self.gui.update_account_objects,
response.result["account_objects"])
async def set_regular_key(self, wallet):
"""
Check & set the regular key for this account
"""
response = await self.client.request(xrpl.models.requests.AccountInfo(
account=self.account,
ledger_index="validated"
))
if response.is_successful():
print("set regular key: got account")
if response.result["account_data"].get("RegularKey") == wallet.address:
print("set regular key: regular key matches")
self.wallet = wallet
wx.CallAfter(self.gui.enable_readwrite)
async def check_destination(self, destination, dlg):
"""
Check a potential destination address's details, and pass them back to
a "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.
Requires that self.client be connected first.
"""
# The data to send back to the GUI thread: None for checks that weren't
# performed, True/False for actual results except where noted.
account_status = {
"funded": None,
"disallow_xrp": None,
"domain_verified": None,
"domain_str": "" # the decoded domain, regardless of verification
}
# Look up the account. If this fails, the account isn't funded.
try:
response = await xrpl.asyncio.account.get_account_info(destination,
self.client, ledger_index="validated")
account_status["funded"] = True
dest_acct = response.result["account_data"]
except xrpl.asyncio.clients.exceptions.XRPLRequestFailureException:
# Not funded, so the other checks don't apply.
account_status["funded"] = False
wx.CallAfter(dlg.update_dest_info, account_status)
return
# Check DisallowXRP flag
lsfDisallowXRP = 0x00080000
if dest_acct["Flags"] & lsfDisallowXRP:
account_status["disallow_xrp"] = True
else:
account_status["disallow_xrp"] = False
# Check domain verification
domain, verified = verify_account_domain(dest_acct)
account_status["domain_verified"] = verified
account_status["domain_str"] = domain
# Send data back to the main thread.
wx.CallAfter(dlg.update_dest_info, account_status)
async def send_xrp(self, paydata):
"""
Prepare, sign, and send an XRP payment with the provided parameters.
Expects a dictionary with:
{
"dtag": Destination Tag, as a string, optional
"to": Destination address (classic or X-address)
"amt": Amount of decimal XRP to send, as a string
}
"""
dtag = paydata.get("dtag", "")
if dtag.strip() == "":
dtag = None
if dtag is not None:
try:
dtag = int(dtag)
if dtag < 0 or dtag > 2**32-1:
raise ValueError("Destination tag must be a 32-bit unsigned integer")
except ValueError as e:
print("Invalid destination tag:", e)
print("Canceled sending payment.")
return
tx = xrpl.models.transactions.Payment(
account=self.account,
destination=paydata["to"],
amount=xrpl.utils.xrp_to_drops(paydata["amt"]),
destination_tag=dtag
)
# Autofill provides a sequence number, but this may fail if you try to
# send too many transactions too fast. You can send transactions more
# rapidly if you track the sequence number more carefully.
tx_signed = await xrpl.asyncio.transaction.autofill_and_sign(
tx, self.client, self.wallet)
await xrpl.asyncio.transaction.submit(tx_signed, self.client)
wx.CallAfter(self.gui.add_pending_tx, tx_signed)
class AutoGridBagSizer(wx.GridBagSizer):
"""
Helper class for adding a bunch of items uniformly to a GridBagSizer.
"""
def __init__(self, parent):
wx.GridBagSizer.__init__(self, vgap=5, hgap=5)
self.parent = parent
def BulkAdd(self, ctrls):
"""
Given a two-dimensional iterable `ctrls`, add all the items in a grid
top-to-bottom, left-to-right, with each inner iterable being a row. Set
the total number of columns based on the longest iterable.
"""
flags = wx.EXPAND|wx.ALL|wx.RESERVE_SPACE_EVEN_IF_HIDDEN|wx.ALIGN_CENTER_VERTICAL
for x, row in enumerate(ctrls):
for y, ctrl in enumerate(row):
self.Add(ctrl, (x,y), flag=flags, border=5)
self.parent.SetSizer(self)
class SendXRPDialog(wx.Dialog):
"""
Pop-up dialog that prompts the user for the information necessary to send a
direct XRP-to-XRP payment on the XRPL.
"""
def __init__(self, parent, max_send=100000000.0):
wx.Dialog.__init__(self, parent, title="Send XRP")
sizer = AutoGridBagSizer(self)
self.parent = parent
# Icons to indicate a validation error
bmp_err = wx.ArtProvider.GetBitmap(wx.ART_ERROR, wx.ART_CMN_DIALOG, size=(16,16))
self.err_to = wx.StaticBitmap(self, bitmap=bmp_err)
self.err_dtag = wx.StaticBitmap(self, bitmap=bmp_err)
self.err_amt = wx.StaticBitmap(self, bitmap=bmp_err)
self.err_to.Hide()
self.err_dtag.Hide()
self.err_amt.Hide()
# Icons for domain verification
bmp_check = wx.ArtProvider.GetBitmap(wx.ART_TICK_MARK, wx.ART_CMN_DIALOG, size=(16,16))
self.domain_text = wx.StaticText(self, label="")
self.domain_verified = wx.StaticBitmap(self, bitmap=bmp_check)
self.domain_verified.Hide()
if max_send <= 0:
max_send = 100000000.0
self.err_amt.Show()
self.err_amt.SetToolTip("Not enough XRP to pay the reserve and transaction cost!")
lbl_to = wx.StaticText(self, label="To (Address):")
lbl_dtag = wx.StaticText(self, label="Destination Tag:")
lbl_amt = wx.StaticText(self, label="Amount of XRP:")
self.txt_to = wx.TextCtrl(self)
self.txt_dtag = wx.TextCtrl(self)
self.txt_amt = wx.SpinCtrlDouble(self, value="20.0", min=0.000001, max=max_send)
self.txt_amt.SetDigits(6)
self.txt_amt.SetIncrement(1.0)
# The "Send" button is functionally an "OK" button except for the text.
self.btn_send = wx.Button(self, wx.ID_OK, label="Send")
btn_cancel = wx.Button(self, wx.ID_CANCEL)
sizer.BulkAdd(((lbl_to, self.txt_to, self.err_to),
(self.domain_verified, self.domain_text),
(lbl_dtag, self.txt_dtag, self.err_dtag),
(lbl_amt, self.txt_amt, self.err_amt),
(btn_cancel, self.btn_send)) )
sizer.Fit(self)
self.txt_dtag.Bind(wx.EVT_TEXT, self.on_dest_tag_edit)
self.txt_to.Bind(wx.EVT_TEXT, self.on_to_edit)
def get_payment_data(self):
"""
Construct a dictionary with the relevant payment details to pass to the
worker thread for making a payment. Called after the user clicks "Send".
"""
return {
"to": self.txt_to.GetValue().strip(),
"dtag": self.txt_dtag.GetValue().strip(),
"amt": self.txt_amt.GetValue(),
}
def on_to_edit(self, event):
"""
When the user edits the "To" field, check that the address is well-
formatted. If it's an X-address, fill in the destination tag and disable
it. Also, start a background check to confirm more details about the
address.
"""
v = self.txt_to.GetValue().strip()
# Reset warnings / domain verification
err_msg = ""
self.err_to.SetToolTip("")
self.err_to.Hide()
self.domain_text.SetLabel("")
self.domain_verified.Hide()
if xrpl.core.addresscodec.is_valid_xaddress(v):
cl_addr, tag, is_test = xrpl.core.addresscodec.xaddress_to_classic_address(v)
if tag is None: # Not the same as tag = 0
tag = ""
self.txt_dtag.ChangeValue(str(tag))
self.txt_dtag.Disable()
if cl_addr == self.parent.classic_address:
err_msg = "Can't send XRP to self."
elif is_test != self.parent.test_network:
err_msg = "This address is intended for a different network."
elif not self.txt_dtag.IsEditable():
self.txt_dtag.Clear()
self.txt_dtag.Enable()
if not (xrpl.core.addresscodec.is_valid_classic_address(v) or
xrpl.core.addresscodec.is_valid_xaddress(v) ):
self.btn_send.Disable()
err_msg = "Not a valid address."
elif v == self.parent.classic_address:
self.btn_send.Disable()
err_msg = "Can't send XRP to self."
else:
self.parent.run_bg_job(self.parent.worker.check_destination(v, self))
if err_msg:
self.err_to.SetToolTip(err_msg)
self.err_to.Show()
else:
self.err_to.Hide()
def on_dest_tag_edit(self, event):
"""
When the user edits the Destination Tag field, strip non-numeric
characters from it.
"""
v = self.txt_dtag.GetValue().strip()
v = re.sub(r"[^0-9]", "", v)
self.txt_dtag.ChangeValue(v) # SetValue would generate another EVT_TEXT
self.txt_dtag.SetInsertionPointEnd()
def update_dest_info(self, dest_status):
"""
Update the UI with details provided by a background job to check the
destination address.
"""
# Keep existing error message if there is one
try:
err_msg = self.err_to.GetToolTip().GetTip().strip()
except RuntimeError:
# This method can be called after the dialog it belongs to has been
# closed. In that case, there's nothing to do here.
return
if not dest_status["funded"]:
err_msg = ("Warning: this account does not exist. The payment will "
"fail unless you send enough to fund it.")
elif dest_status["disallow_xrp"]:
err_msg = "This account does not want to receive XRP."
# Domain verification
bmp_err = wx.ArtProvider.GetBitmap(wx.ART_ERROR, wx.ART_CMN_DIALOG, size=(16,16))
bmp_check = wx.ArtProvider.GetBitmap(wx.ART_TICK_MARK, wx.ART_CMN_DIALOG, size=(16,16))
domain = dest_status["domain_str"]
verified = dest_status["domain_verified"]
if not domain:
self.domain_text.Hide()
self.domain_verified.Hide()
elif verified:
self.domain_text.SetLabel(domain)
self.domain_text.Show()
self.domain_verified.SetToolTip("Domain verified")
self.domain_verified.SetBitmap(bmp_check)
self.domain_verified.Show()
else:
self.domain_text.SetLabel(domain)
self.domain_text.Show()
self.domain_verified.SetToolTip("Failed to verify domain")
self.domain_verified.SetBitmap(bmp_err)
self.domain_verified.Show()
if err_msg:
# Disabling the button is optional. These types of errors can be
# benign, so you could let the user "click through" them.
#self.btn_send.Disable()
self.err_to.SetToolTip(err_msg)
self.err_to.Show()
else:
self.btn_send.Enable()
self.err_to.SetToolTip("")
self.err_to.Hide()
class TWaXLFrame(wx.Frame):
"""
Tutorial Wallet for the XRP Ledger (TWaXL)
user interface, main frame.
"""
def __init__(self, url, test_network=True):
wx.Frame.__init__(self, None, title="TWaXL", size=wx.Size(800,400))
self.test_network = test_network
self.url = url
# The ledger's current reserve settings. To be filled in later.
self.reserve_base = None
self.reserve_inc = None
# This account's total XRP reserve including base + owner amounts
self.reserve_xrp = None
self.build_ui()
# Pop up to ask user for their account ---------------------------------
address, wallet = self.prompt_for_account()
self.classic_address = address
# Start background thread for updates from the ledger ------------------
self.worker = XRPLMonitorThread(url, self)
self.worker.start()
self.run_bg_job(self.worker.watch_xrpl_account(address, wallet))
def build_ui(self):
"""
Called during __init__ to set up all the GUI components.
"""
self.tabs = wx.Notebook(self, style=wx.BK_DEFAULT)
# Tab 1: "Summary" pane ------------------------------------------------
main_panel = wx.Panel(self.tabs)
self.tabs.AddPage(main_panel, "Summary")
self.net_url = wx.StaticText(main_panel, label=f"Server: {self.url}")
self.acct_info_area = wx.StaticBox(main_panel, label="Account Info")
lbl_address = wx.StaticText(self.acct_info_area, label="Classic Address:")
self.st_classic_address = wx.StaticText(self.acct_info_area, label="TBD")
lbl_xaddress = wx.StaticText(self.acct_info_area, label="X-Address:")
self.st_x_address = wx.StaticText(self.acct_info_area, label="TBD")
lbl_xrp_bal = wx.StaticText(self.acct_info_area, label="XRP Balance:")
self.st_xrp_balance = wx.StaticText(self.acct_info_area, label="TBD")
lbl_reserve = wx.StaticText(self.acct_info_area, label="XRP Reserved:")
self.st_reserve = wx.StaticText(self.acct_info_area, label="TBD")
aia_sizer = AutoGridBagSizer(self.acct_info_area)
aia_sizer.BulkAdd( ((lbl_address, self.st_classic_address),
(lbl_xaddress, self.st_x_address),
(lbl_xrp_bal, self.st_xrp_balance),
(lbl_reserve, self.st_reserve)) )
# Send XRP button. Disabled until we have a secret key & network connection
self.sxb = wx.Button(main_panel, label="Send XRP")
self.sxb.SetToolTip("Disabled in read-only mode.")
self.sxb.Disable()
self.Bind(wx.EVT_BUTTON, self.click_send_xrp, source=self.sxb)
# Add Key button
self.urkb = wx.Button(main_panel, label="Use Regular Key")
self.Bind(wx.EVT_BUTTON, self.click_use_rk, source=self.urkb)
self.ledger_info = wx.StaticText(main_panel, label="Not connected")
main_sizer = wx.BoxSizer(wx.VERTICAL)
main_sizer.Add(self.net_url, 0, flag=wx.EXPAND|wx.ALL, border=5)
main_sizer.Add(self.acct_info_area, 1, flag=wx.EXPAND|wx.ALL, border=5)
button_bar = wx.BoxSizer(wx.HORIZONTAL)
button_bar.Add(self.sxb, 0, flag=wx.ALL, border=5)
button_bar.Add(self.urkb, 0, flag=wx.ALL, border=5)
main_sizer.Add(button_bar, 0, flag=wx.EXPAND|wx.ALL, border=5)
main_sizer.Add(self.ledger_info, 1, flag=wx.EXPAND|wx.ALL, border=5)
main_panel.SetSizer(main_sizer)
# Tab 2: "Transaction History" pane ------------------------------------
txhistory_panel = wx.Panel(self.tabs)
self.tabs.AddPage(txhistory_panel, "Transaction History")
txhistory_sizer = wx.BoxSizer(wx.VERTICAL)
self.tx_list = wx.dataview.DataViewListCtrl(txhistory_panel)
self.tx_list.AppendTextColumn("Confirmed")
self.tx_list.AppendTextColumn("Type")
self.tx_list.AppendTextColumn("From")
self.tx_list.AppendTextColumn("To")
self.tx_list.AppendTextColumn("Value Delivered")
self.tx_list.AppendTextColumn("Identifying Hash")
self.tx_list.AppendTextColumn("Raw JSON")
txhistory_sizer.Add(self.tx_list, 1, wx.EXPAND|wx.ALL)
self.pending_tx_rows = {} # Map pending tx hashes to rows in the history UI
txhistory_panel.SetSizer(txhistory_sizer)
# Tab 3: "Tokens" pane -------------------------------------------------
tokens_panel = wx.Panel(self.tabs)
self.tabs.AddPage(tokens_panel, "Tokens")
tokens_sizer = wx.BoxSizer(wx.VERTICAL)
self.tkn_list = wx.dataview.DataViewListCtrl(tokens_panel)
self.tkn_list.AppendTextColumn("Currency")
self.tkn_list.AppendTextColumn("Issuer")
self.tkn_list.AppendTextColumn("Balance")
self.tkn_list.AppendTextColumn("Limit")
self.tkn_list.AppendTextColumn("Peer Limit")
self.tkn_list.AppendToggleColumn("Allows Rippling?", mode=wx.dataview.DATAVIEW_CELL_INERT)
self.tkn_list.AppendToggleColumn("Frozen?", mode=wx.dataview.DATAVIEW_CELL_INERT)
self.tkn_list.AppendToggleColumn("Authorized?", mode=wx.dataview.DATAVIEW_CELL_INERT)
self.tkn_list.AppendToggleColumn("Peer Allows Rippling?", mode=wx.dataview.DATAVIEW_CELL_INERT)
self.tkn_list.AppendToggleColumn("Frozen by Peer?", mode=wx.dataview.DATAVIEW_CELL_INERT)
self.tkn_list.AppendToggleColumn("Authorized by Peer?", mode=wx.dataview.DATAVIEW_CELL_INERT)
tokens_sizer.Add(self.tkn_list, 1, wx.EXPAND|wx.ALL)
tokens_panel.SetSizer(tokens_sizer)
# Tab 4: "Objects" pane ------------------------------------------------
objs_panel = wx.Panel(self.tabs)
self.tabs.AddPage(objs_panel, "Other Objects")
objs_sizer = wx.BoxSizer(wx.VERTICAL)
self.o_list = wx.dataview.DataViewListCtrl(objs_panel)
self.o_list.AppendTextColumn("Type")
self.o_list.AppendTextColumn("Summary")
objs_sizer.Add(self.o_list, 1, wx.EXPAND|wx.ALL)
objs_panel.SetSizer(objs_sizer)
def run_bg_job(self, job):
"""
Schedules a job to run asynchronously in the XRPL worker thread.
The job should be a Future (for example, from calling an async function)
"""
task = asyncio.run_coroutine_threadsafe(job, self.worker.loop)
def toggle_dialog_style(self, event):
"""
Automatically switches to a password-style dialog if it looks like the
user is entering a secret key, and display ***** instead of s12345...
"""
dlg = event.GetEventObject()
v = dlg.GetValue().strip()
if v[:1] == "s":
dlg.SetWindowStyle(wx.TE_PASSWORD)
else:
dlg.SetWindowStyle(wx.TE_LEFT)
def click_use_rk(self, event):
"""
Change to using a regular key as the secret.
"""
addr, wallet = self.prompt_for_account(for_regular_key=True)
if not wallet:
print("Didn't get a seed, nevermind")
return
self.run_bg_job(self.worker.set_regular_key(wallet))
def prompt_for_account(self, for_regular_key=False):
"""
Prompt the user for an account to use, in a base58-encoded format:
- master key seed: Grants read-write access.
(assumes the master key pair is not disabled)
- classic address. Grants read-only access.
- X-address. Grants read-only access.
Exits with error code 1 if the user cancels the dialog, if the input
doesn't match any of the formats, or if the user inputs an X-address
intended for use on a different network type (test/non-test).
Populates the classic address and X-address labels in the UI.
Returns (classic_address, wallet) where wallet is None in read-only mode
"""
label = ("Please enter an account address (for read-only)"
" or your master secret (for read-write access).\n"
"To use a regular key, enter your address here for now.")
default_val = "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe"
if for_regular_key:
label = "Enter the regular key seed (for regular-key write access)"
default_val = ""
account_dialog = wx.TextEntryDialog(self, label,
caption="Enter account / seed",
value=default_val)
account_dialog.Bind(wx.EVT_TEXT, self.toggle_dialog_style)
if account_dialog.ShowModal() != wx.ID_OK:
# If the user presses Cancel on the account entry, exit the app.
# Or, if this is for the regular key thing, just return quietly
if for_regular_key:
return None, None
exit(1)
value = account_dialog.GetValue().strip()
account_dialog.Destroy()
classic_address = ""
wallet = None
x_address = ""
if xrpl.core.addresscodec.is_valid_xaddress(value):
x_address = value
classic_address, dest_tag, test_network = xrpl.core.addresscodec.xaddress_to_classic_address(value)
if test_network != self.test_network:
on_net = "a test network" if self.test_network else "Mainnet"
print(f"X-address {value} is meant for a different network type"
f"than this client is connected to."
f"(Client is on: {on_net})")
exit(1)
elif xrpl.core.addresscodec.is_valid_classic_address(value):
classic_address = value
x_address = xrpl.core.addresscodec.classic_address_to_xaddress(
value, tag=None, is_test_network=self.test_network)
else:
try:
# Check if it's a valid seed
seed_bytes, alg = xrpl.core.addresscodec.decode_seed(value)
wallet = xrpl.wallet.Wallet.from_seed(seed=value)
x_address = wallet.get_xaddress(is_test=self.test_network)
classic_address = wallet.address
except Exception as e:
print(e)
exit(1)
if not for_regular_key:
# Update the UI with the address values
self.st_classic_address.SetLabel(classic_address)
self.st_x_address.SetLabel(x_address)
return classic_address, wallet
def update_ledger(self, message):
"""
Process a ledger subscription message to update the UI with
information about the latest validated ledger.
"""
close_time_iso = xrpl.utils.ripple_time_to_datetime(message["ledger_time"]).isoformat()
self.ledger_info.SetLabel(f"Latest validated ledger:\n"
f"Ledger Index: {message['ledger_index']}\n"
f"Ledger Hash: {message['ledger_hash']}\n"
f"Close time: {close_time_iso}")
# Save reserve settings so we can calculate account reserve
self.reserve_base = xrpl.utils.drops_to_xrp(str(message["reserve_base"]))
self.reserve_inc = xrpl.utils.drops_to_xrp(str(message["reserve_inc"]))
def calculate_reserve_xrp(self, owner_count):
"""
Calculates how much XRP the user needs to reserve based on the account's
OwnerCount and the reserve values in the latest ledger.
"""
if self.reserve_base == None or self.reserve_inc == None:
return None
oc_decimal = Decimal(owner_count)
reserve_xrp = self.reserve_base + (self.reserve_inc * oc_decimal)
return reserve_xrp
def update_account(self, acct):
"""
Update the account info UI based on an account_info response.
"""
xrp_balance = str(xrpl.utils.drops_to_xrp(acct["Balance"]))
self.st_xrp_balance.SetLabel(xrp_balance)
# Display account reserve and save for calculating max send.
reserve_xrp = self.calculate_reserve_xrp(acct.get("OwnerCount", 0))
if reserve_xrp != None:
self.st_reserve.SetLabel(str(reserve_xrp))
self.reserve_xrp = reserve_xrp
def enable_readwrite(self):
"""
Enable buttons for sending transactions.
"""
self.sxb.Enable()
self.sxb.SetToolTip("")
def displayable_amount(self, a):
"""
Convert an arbitrary amount value from the XRPL to a string to be
displayed to the user:
- Convert drops of XRP to 6-decimal XRP (e.g. '12.345000 XRP')
- For issued tokens, show amount, currency code, and issuer. For
example, 100 USD issued by address r12345... is returned as
'100 USD.r12345...'
Leaves non-standard (hex) currency codes as-is.
"""
if a == "unavailable":
# Special case for pre-2014 partial payments.
return a
elif type(a) == str:
# It's an XRP amount in drops. Convert to decimal.
return f"{xrpl.utils.drops_to_xrp(a)} XRP"
else:
# It's a token amount.
return f"{a['value']} {a['currency']}.{a['issuer']}"
def add_tx_row(self, t, prepend=False):
"""
Add one row to the account transaction history control. Helper function
called by other methods.
"""
conf_dt = xrpl.utils.ripple_time_to_datetime(t["tx"]["date"])
# Convert datetime to locale-default representation & time zone
confirmation_time = conf_dt.astimezone().strftime("%c")
tx_hash = t["tx"]["hash"]
tx_type = t["tx"]["TransactionType"]
from_acct = t["tx"].get("Account") or ""
if from_acct == self.classic_address:
from_acct = "(Me)"
to_acct = t["tx"].get("Destination") or ""
if to_acct == self.classic_address:
to_acct = "(Me)"
delivered_amt = t["meta"].get("delivered_amount")
if delivered_amt:
delivered_amt = self.displayable_amount(delivered_amt)
else:
delivered_amt = ""
cols = (confirmation_time, tx_type, from_acct, to_acct, delivered_amt,
tx_hash, str(t))
if prepend:
self.tx_list.PrependItem(cols)
else:
self.tx_list.AppendItem(cols)
def update_account_tx(self, data):
"""
Update the transaction history tab with information from an account_tx
response.
"""
txs = data["transactions"]
# Note: if you extend the code to do paginated responses, you might want
# to keep previous history instead of deleting the contents first.
self.tx_list.DeleteAllItems()
for t in txs:
self.add_tx_row(t)
def add_tx_from_sub(self, t):
"""
Add 1 transaction to the history based on a subscription stream message.
Assumes only validated transaction streams (e.g. transactions, accounts)
not proposed transaction streams.
Also, send a notification to the user about it.
"""
# Convert to same format as account_tx results
t["tx"] = t["transaction"]
if t["tx"]["hash"] in self.pending_tx_rows.keys():
dvi = self.pending_tx_rows[t["tx"]["hash"]]
pending_row = self.tx_list.ItemToRow(dvi)
self.tx_list.DeleteItem(pending_row)
self.add_tx_row(t, prepend=True)
# Scroll to top of list.
self.tx_list.EnsureVisible(self.tx_list.RowToItem(0))
# Send a notification message (aka a "toast") about the transaction.
# Note the transaction stream and account_tx include all transactions
# that "affect" the account, no just ones directly from/to the account.
# For example, if the account has issued tokens, it gets notified when
# other users transfer those tokens among themselves.
notif = wx.adv.NotificationMessage(title="New Transaction", message =
f"New {t['tx']['TransactionType']} transaction confirmed!")
notif.SetFlags(wx.ICON_INFORMATION)
notif.Show()
def add_pending_tx(self, txm):
"""
Add a "pending" transaction to the history based on a transaction model
that was (presumably) just submitted.
"""
confirmation_time = "(pending)"
tx_type = txm.transaction_type
from_acct = txm.account
if from_acct == self.classic_address:
from_acct = "(Me)"
# Some transactions don't have a destination, so we need to handle that.
to_acct = getattr(txm, "destination", "")
if to_acct == self.classic_address:
to_acct = "(Me)"
# Delivered amount is only known after a transaction is processed, so
# leave this column empty in the display for pending transactions.
delivered_amt = ""
tx_hash = txm.get_hash()
cols = (confirmation_time, tx_type, from_acct, to_acct, delivered_amt,
tx_hash, str(txm.to_xrpl()))
self.tx_list.PrependItem(cols)
self.pending_tx_rows[tx_hash] = self.tx_list.RowToItem(0)
def click_send_xrp(self, event):
"""
Pop up a dialog for the user to input how much XRP to send where, and
send the transaction (if the user doesn't cancel).
"""
xrp_bal = Decimal(self.st_xrp_balance.GetLabelText())
tx_cost = Decimal("0.000010")
reserve = self.reserve_xrp or Decimal(0.000000)
dlg = SendXRPDialog(self, max_send=float(xrp_bal - reserve - tx_cost))
dlg.CenterOnScreen()
resp = dlg.ShowModal()
if resp != wx.ID_OK:
print("Send XRP canceled")
dlg.Destroy()
return
paydata = dlg.get_payment_data()
dlg.Destroy()
self.run_bg_job(self.worker.send_xrp(paydata))
notif = wx.adv.NotificationMessage(title="Sending!", message =
f"Sending a payment for {paydata['amt']} XRP!")
notif.SetFlags(wx.ICON_INFORMATION)
notif.Show()
def update_account_lines(self, lines):
"""
Update the Tokens tab based on an account_lines result.
This doesn't handle pagination.
"""
self.tkn_list.DeleteAllItems()
for l in lines:
self.tkn_list.AppendItem([
l["currency"],
l["account"],
l["balance"],
l["limit"],
l["limit_peer"],
not l.get("no_ripple", False),
l.get("freeze", False),
l.get("authorized", False),
l.get("freeze_peer", False),
not l.get("no_ripple_peer", False),
l.get("peer_authorized", False),
])
def update_account_objects(self, objs):
"""
Update the tab of objects owned with the results of an account_objects
call, skipping RippleState objects since those are represented in the
Tokens tab. This doesn't handle pagination.
"""
self.o_list.DeleteAllItems()
for o in objs:
if o["LedgerEntryType"] == "RippleState":
continue
elif o["LedgerEntryType"] == "Check":
check_amt = self.displayable_amount(o["SendMax"])
summary = f"Deliver up to {check_amt}"
if o["Account"] == self.classic_address:
# Outgoing check
summary += f" to {o['Destination']}"
else:
summary += f" from {o['Account']}"
elif o["LedgerEntryType"] == "DepositPreauth":
if o["Account"] == self.classic_address:
# We authorized them
summary = f"Authorized {o['Authorize']}"
else:
summary = f"Authorized by {o['Account']}"
elif o["LedgerEntryType"] == "Escrow":
escrow_amt = self.displayable_amount(o["Amount"])
summary = f"Hold {escrow_amt} "
if o["Account"] == self.classic_address:
# Outgoing escrow
summary += f"for {o['Destination']} "
else:
summary += f"from {o['Account']} "
if o.get("Condition"):
summary += "with condition "
if o.get("FinishAfter"):
fa_dt = xrpl.utils.ripple_time_to_datetime(o["FinishAfter"])
# Convert datetime to locale-default representation & time zone
fa_time = fa_dt.astimezone().strftime("%c")
summary += f"until {fa_time} "
if o.get("CancelAfter"):
ca_dt = xrpl.utils.ripple_time_to_datetime(o["CancelAfter"])
# Convert datetime to locale-default representation & time zone
ca_time = ca_dt.astimezone().strftime("%c")
summary += f"or cancel at {ca_time} "
elif o["LedgerEntryType"] == "Offer":
# An order we placed in the decentralized exchange
sell_amt = self.displayable_amount(o["TakerGets"])
buy_amt = self.displayable_amount(o["TakerPays"])
summary = f"Trade {sell_amt} to receive {buy_amt}"
elif o["LedgerEntryType"] == "PayChannel":
# Payment channels' balance is determined by the amount paid
# out of the amount funded
amt_dec = xrpl.utils.drops_to_xrp(o["Amount"])
bal_dec = xrpl.utils.drops_to_xrp(o["Balance"])
summary = f"{bal_dec} paid of {amt_dec} XRP"
if o["Account"] == self.classic_address:
# Outgoing channel
summary += f" to {o['Destination']}"
else:
summary += f" from {o['Account']}"
elif o["LedgerEntryType"] == "SignerList":
summary = f"Quorum: {o['SignerQuorum']}. Signers: "
summary += ", ".join([
f"{se['SignerEntry']['Account']} "
f"(Weight: {se['SignerEntry']['SignerWeight']})"
for se in o["SignerEntries"]])
elif o["LedgerEntryType"] == "Ticket":
summary = f"Ticket #{o['TicketSequence']}"
else:
summary = ""
cols = (o["LedgerEntryType"], summary)
self.o_list.AppendItem(cols)
if __name__ == "__main__":
networks = {
"mainnet": "wss://xrplcluster.com",
"testnet": "wss://s.altnet.rippletest.net:51233",
"devnet": "wss://s.devnet.rippletest.net:51233",
}
parser = ArgumentParser()
parser.add_argument("--network", "-n", choices=networks.keys(), default="testnet")
args = parser.parse_args()
app = wx.App()
frame = TWaXLFrame(networks[args.network], test_network=(not args.network=="mainnet"))
frame.Show()
app.MainLoop()

View File

@@ -0,0 +1,15 @@
# Build a Wallet Sample Code (Python)
This folder contains sample code for a non-custodial XRP Ledger wallet application in Python.
Setup:
```sh
pip install -r requirements.txt
```
Run any of the Python scripts (higher numbers are more complete/advanced examples):
```sh
python3 1_hello.py
```

View File

@@ -0,0 +1,4 @@
xrpl-py==2.0.0
wxPython==4.2.1
toml==0.10.2
requests==2.31.0

View File

@@ -0,0 +1,44 @@
# Domain verification of XRP Ledger accounts using xrp-ledger.toml file.
# For information on this process, see:
# https://xrpl.org/xrp-ledger-toml.html#account-verification
# License: MIT. https://github.com/XRPLF/xrpl-dev-portal/blob/master/LICENSE
import requests
import toml
import xrpl
def verify_account_domain(account):
"""
Verify an account using an xrp-ledger.toml file.
Params:
account:dict - the AccountRoot object to verify
Returns (domain:str, verified:bool)
"""
domain_hex = account.get("Domain")
if not domain_hex:
return "", False
verified = False
domain = xrpl.utils.hex_to_str(domain_hex)
toml_url = f"https://{domain}/.well-known/xrp-ledger.toml"
toml_response = requests.get(toml_url)
if toml_response.ok:
parsed_toml = toml.loads(toml_response.text)
toml_accounts = parsed_toml.get("ACCOUNTS", [])
for t_a in toml_accounts:
if t_a.get("address") == account.get("Account"):
verified = True
break
return domain, verified
if __name__ == "__main__":
from argparse import ArgumentParser
parser = ArgumentParser()
parser.add_argument("address", type=str,
help="Classic address to check domain verification of")
args = parser.parse_args()
client = xrpl.clients.JsonRpcClient("https://xrplcluster.com")
r = xrpl.account.get_account_info(args.address, client,
ledger_index="validated")
print(verify_account_domain(r.result["account_data"]))

View File

@@ -0,0 +1,3 @@
# Use Checks
Create, cash, and cancel Checks for exact or flexible amounts.

View File

@@ -0,0 +1,100 @@
{
"result": {
"account": "rBXsgNkPcDN2runsvWmwxk3Lh97zdgo9za",
"account_objects": [
{
"Account": "rBXsgNkPcDN2runsvWmwxk3Lh97zdgo9za",
"Destination": "rGPnRH1EBpHeTF2QG8DCAgM7z5pb75LAis",
"DestinationNode": "0000000000000000",
"Flags": 0,
"LedgerEntryType": "Check",
"OwnerNode": "0000000000000000",
"PreviousTxnID": "37D90463CDE0497DB12F18099296DA0E1E52334A785710B5F56BC9637F62429C",
"PreviousTxnLgrSeq": 8003261,
"SendMax": "999999000000",
"Sequence": 5,
"index": "2E0AD0740B79BE0AAE5EDD1D5FC79E3C5C221D23C6A7F771D85569B5B91195C2"
},
{
"Balance": {
"currency": "BAR",
"issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji",
"value": "0"
},
"Flags": 1179648,
"HighLimit": {
"currency": "BAR",
"issuer": "rGPnRH1EBpHeTF2QG8DCAgM7z5pb75LAis",
"value": "1234567890123450e79"
},
"HighNode": "0000000000000000",
"LedgerEntryType": "RippleState",
"LowLimit": {
"currency": "BAR",
"issuer": "rBXsgNkPcDN2runsvWmwxk3Lh97zdgo9za",
"value": "0"
},
"LowNode": "0000000000000000",
"PreviousTxnID": "D7687E275546322995764632799040CF5BDB597691683DE7C532A60BA64E5414",
"PreviousTxnLgrSeq": 8003321,
"index": "5A157543E6A19F14E559A3BE14876B48103502F3258893D4F6DF83E61884F20E"
},
{
"Account": "rBXsgNkPcDN2runsvWmwxk3Lh97zdgo9za",
"Destination": "rGPnRH1EBpHeTF2QG8DCAgM7z5pb75LAis",
"DestinationNode": "0000000000000000",
"DestinationTag": 1,
"Flags": 0,
"InvoiceID": "46060241FABCF692D4D934BA2A6C4427CD4279083E38C77CBE642243E43BE291",
"LedgerEntryType": "Check",
"OwnerNode": "0000000000000000",
"PreviousTxnID": "09D992D4C89E2A24D4BA9BB57ED81C7003815940F39B7C87ADBF2E49034380BB",
"PreviousTxnLgrSeq": 7841263,
"SendMax": "100000000",
"Sequence": 4,
"index": "84C61BE9B39B2C4A2267F67504404F1EC76678806C1B901EA781D1E3B4CE0CD9"
},
{
"Balance": {
"currency": "FOO",
"issuer": "rrrrrrrrrrrrrrrrrrrrBZbvji",
"value": "0"
},
"Flags": 2162688,
"HighLimit": {
"currency": "FOO",
"issuer": "rGPnRH1EBpHeTF2QG8DCAgM7z5pb75LAis",
"value": "0"
},
"HighNode": "0000000000000000",
"LedgerEntryType": "RippleState",
"LowLimit": {
"currency": "FOO",
"issuer": "rBXsgNkPcDN2runsvWmwxk3Lh97zdgo9za",
"value": "10000"
},
"LowNode": "0000000000000000",
"PreviousTxnID": "119400AC7A5B8BD3CC98265D0AB89FC59E6469ED64917425AEA52D40D83164A7",
"PreviousTxnLgrSeq": 8003297,
"index": "88003CF8348313E5CD720FBCCFADF4C4CE6C2C7F4093C943A3E01E8F547DBCAF"
},
{
"Account": "rBXsgNkPcDN2runsvWmwxk3Lh97zdgo9za",
"Destination": "rGPnRH1EBpHeTF2QG8DCAgM7z5pb75LAis",
"DestinationNode": "0000000000000000",
"Flags": 0,
"LedgerEntryType": "Check",
"OwnerNode": "0000000000000000",
"PreviousTxnID": "C0B27D20669BAB837B3CDF4B8148B988F17CE1EF8EDF48C806AE9BF69E16F441",
"PreviousTxnLgrSeq": 7835887,
"SendMax": "100000000",
"Sequence": 2,
"index": "CEA5F0BD7B2B5C85A70AE735E4CE722C43C86410A79AB87C11938AA13A11DBF9"
}
],
"ledger_hash": "386FE87ED505E28134AC7171A0B690BA87112334B22DD83194A4C7C3C9810E84",
"ledger_index": 8003351,
"status": "success",
"validated": true
}
}

View File

@@ -0,0 +1,100 @@
{
"result" : {
"account" : "rBXsgNkPcDN2runsvWmwxk3Lh97zdgo9za",
"account_objects" : [
{
"Account" : "rBXsgNkPcDN2runsvWmwxk3Lh97zdgo9za",
"Destination" : "rGPnRH1EBpHeTF2QG8DCAgM7z5pb75LAis",
"DestinationNode" : "0000000000000000",
"Flags" : 0,
"LedgerEntryType" : "Check",
"OwnerNode" : "0000000000000000",
"PreviousTxnID" : "37D90463CDE0497DB12F18099296DA0E1E52334A785710B5F56BC9637F62429C",
"PreviousTxnLgrSeq" : 8003261,
"SendMax" : "999999000000",
"Sequence" : 5,
"index" : "2E0AD0740B79BE0AAE5EDD1D5FC79E3C5C221D23C6A7F771D85569B5B91195C2"
},
{
"Balance" : {
"currency" : "BAR",
"issuer" : "rrrrrrrrrrrrrrrrrrrrBZbvji",
"value" : "0"
},
"Flags" : 1179648,
"HighLimit" : {
"currency" : "BAR",
"issuer" : "rGPnRH1EBpHeTF2QG8DCAgM7z5pb75LAis",
"value" : "1234567890123450e79"
},
"HighNode" : "0000000000000000",
"LedgerEntryType" : "RippleState",
"LowLimit" : {
"currency" : "BAR",
"issuer" : "rBXsgNkPcDN2runsvWmwxk3Lh97zdgo9za",
"value" : "0"
},
"LowNode" : "0000000000000000",
"PreviousTxnID" : "D7687E275546322995764632799040CF5BDB597691683DE7C532A60BA64E5414",
"PreviousTxnLgrSeq" : 8003321,
"index" : "5A157543E6A19F14E559A3BE14876B48103502F3258893D4F6DF83E61884F20E"
},
{
"Account" : "rBXsgNkPcDN2runsvWmwxk3Lh97zdgo9za",
"Destination" : "rGPnRH1EBpHeTF2QG8DCAgM7z5pb75LAis",
"DestinationNode" : "0000000000000000",
"DestinationTag" : 1,
"Flags" : 0,
"InvoiceID" : "46060241FABCF692D4D934BA2A6C4427CD4279083E38C77CBE642243E43BE291",
"LedgerEntryType" : "Check",
"OwnerNode" : "0000000000000000",
"PreviousTxnID" : "09D992D4C89E2A24D4BA9BB57ED81C7003815940F39B7C87ADBF2E49034380BB",
"PreviousTxnLgrSeq" : 7841263,
"SendMax" : "100000000",
"Sequence" : 4,
"index" : "84C61BE9B39B2C4A2267F67504404F1EC76678806C1B901EA781D1E3B4CE0CD9"
},
{
"Balance" : {
"currency" : "FOO",
"issuer" : "rrrrrrrrrrrrrrrrrrrrBZbvji",
"value" : "0"
},
"Flags" : 2162688,
"HighLimit" : {
"currency" : "FOO",
"issuer" : "rGPnRH1EBpHeTF2QG8DCAgM7z5pb75LAis",
"value" : "0"
},
"HighNode" : "0000000000000000",
"LedgerEntryType" : "RippleState",
"LowLimit" : {
"currency" : "FOO",
"issuer" : "rBXsgNkPcDN2runsvWmwxk3Lh97zdgo9za",
"value" : "10000"
},
"LowNode" : "0000000000000000",
"PreviousTxnID" : "119400AC7A5B8BD3CC98265D0AB89FC59E6469ED64917425AEA52D40D83164A7",
"PreviousTxnLgrSeq" : 8003297,
"index" : "88003CF8348313E5CD720FBCCFADF4C4CE6C2C7F4093C943A3E01E8F547DBCAF"
},
{
"Account" : "rBXsgNkPcDN2runsvWmwxk3Lh97zdgo9za",
"Destination" : "rGPnRH1EBpHeTF2QG8DCAgM7z5pb75LAis",
"DestinationNode" : "0000000000000000",
"Flags" : 0,
"LedgerEntryType" : "Check",
"OwnerNode" : "0000000000000000",
"PreviousTxnID" : "C0B27D20669BAB837B3CDF4B8148B988F17CE1EF8EDF48C806AE9BF69E16F441",
"PreviousTxnLgrSeq" : 7835887,
"SendMax" : "100000000",
"Sequence" : 2,
"index" : "CEA5F0BD7B2B5C85A70AE735E4CE722C43C86410A79AB87C11938AA13A11DBF9"
}
],
"ledger_hash" : "386FE87ED505E28134AC7171A0B690BA87112334B22DD83194A4C7C3C9810E84",
"ledger_index" : 8003351,
"status" : "success",
"validated" : true
}
}

View File

@@ -0,0 +1,6 @@
rippled sign s████████████████████████████ '{
"TransactionType": "CheckCancel",
"Account": "rUn84CUYbNjRoTQ6mSW7BVJPSVJNLb1QLo",
"CheckID": "49647F0D748DC3FE26BDACBC57F251AADEFFF391403EC9BF87C97F67E9977FB0",
"Fee": "12"
}'

View File

@@ -0,0 +1,20 @@
Loading: "/etc/opt/ripple/rippled.cfg"
2018-Jan-24 01:11:07 HTTPClient:NFO Connecting to 127.0.0.1:5005
{
"result" : {
"status" : "success",
"tx_blob" : "12001222800000002400000003501849647F0D748DC3FE26BDACBC57F251AADEFFF391403EC9BF87C97F67E9977FB068400000000000000C7321022C53CD19049F32F31848DD3B3BE5CEF6A2DD1EFDA7971AB3FA49B1BAF12AEF78744630440220615F9D19FA182F08530CD978A4C216C8676D0BA9EDB53A620AC909AA0EF0FE7E02203A09CC34C3DB85CCCB3137E78081F8F2B441FB0A3B9E40901F312D3CBA0A67A181147990EC5D1D8DF69E070A968D4B186986FDF06ED0",
"tx_json" : {
"Account" : "rUn84CUYbNjRoTQ6mSW7BVJPSVJNLb1QLo",
"CheckID" : "49647F0D748DC3FE26BDACBC57F251AADEFFF391403EC9BF87C97F67E9977FB0",
"Fee" : "12",
"Flags" : 2147483648,
"Sequence" : 3,
"SigningPubKey" : "022C53CD19049F32F31848DD3B3BE5CEF6A2DD1EFDA7971AB3FA49B1BAF12AEF78",
"TransactionType" : "CheckCancel",
"TxnSignature" : "30440220615F9D19FA182F08530CD978A4C216C8676D0BA9EDB53A620AC909AA0EF0FE7E02203A09CC34C3DB85CCCB3137E78081F8F2B441FB0A3B9E40901F312D3CBA0A67A1",
"hash" : "414558223CA8595916BB1FEF238B3BB601B7C0E52659292251CE613E6B4370F9"
}
}
}

View File

@@ -0,0 +1,7 @@
rippled sign s████████████████████████████ '{
"Account": "rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy",
"TransactionType": "CheckCash",
"Amount": "100000000",
"CheckID": "838766BA2B995C00744175F69A1B11E32C3DBC40E64801A4056FCBD657F57334",
"Fee": "12"
}'

View File

@@ -0,0 +1,21 @@
Loading: "/etc/opt/ripple/rippled.cfg"
2018-Jan-24 01:17:54 HTTPClient:NFO Connecting to 127.0.0.1:5005
{
"result" : {
"status" : "success",
"tx_blob" : "120011228000000024000000015018838766BA2B995C00744175F69A1B11E32C3DBC40E64801A4056FCBD657F57334614000000005F5E10068400000000000000C732102F135B14C552968B0ABE8493CC4C5795A7484D73F6BFD01379F73456F725F66ED74473045022100C64278AC90B841CD3EA9889A4847CAB3AC9927057A34130810FAA7FAC0C6E3290220347260A4C0A6DC9B699DA12510795B2B3414E1FA222AF743226345FBAAEF937C811449FF0C73CA6AF9733DA805F76CA2C37776B7C46B",
"tx_json" : {
"Account" : "rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy",
"Amount" : "100000000",
"CheckID" : "838766BA2B995C00744175F69A1B11E32C3DBC40E64801A4056FCBD657F57334",
"Fee" : "12",
"Flags" : 2147483648,
"Sequence" : 1,
"SigningPubKey" : "02F135B14C552968B0ABE8493CC4C5795A7484D73F6BFD01379F73456F725F66ED",
"TransactionType" : "CheckCash",
"TxnSignature" : "3045022100C64278AC90B841CD3EA9889A4847CAB3AC9927057A34130810FAA7FAC0C6E3290220347260A4C0A6DC9B699DA12510795B2B3414E1FA222AF743226345FBAAEF937C",
"hash" : "0521707D510858BC8AF69D2227E1D1ADA7DB7C5B4B74115BCD0D91B62AFA8EDC"
}
}
}

View File

@@ -0,0 +1,6 @@
rippled sign s████████████████████████████ '{
"Account": "rGPnRH1EBpHeTF2QG8DCAgM7z5pb75LAis",
"TransactionType": "CheckCash",
"DeliverMin": "95000000",
"CheckID": "84C61BE9B39B2C4A2267F67504404F1EC76678806C1B901EA781D1E3B4CE0CD9"
}'

View File

@@ -0,0 +1,21 @@
Loading: "/etc/opt/ripple/rippled.cfg"
2018-Apr-03 00:09:53 HTTPClient:NFO Connecting to 127.0.0.1:5005
{
"result" : {
"status" : "success",
"tx_blob" : "12001122800000002400000004501884C61BE9B39B2C4A2267F67504404F1EC76678806C1B901EA781D1E3B4CE0CD968400000000000000A6A4000000005A995C073210361ACFCB478BCAE01451F95060AF94F70365BF00D7B4661EC2C69EA383762516C7446304402203D7EC220D48AA040D6915C160275D202F7F808E2B58F11B1AB05FB5E5CFCC6C00220304BBD3AD32E13150E0ED7247F2ADFAE83D0ECE329E20CFE0F8DF352934DD2FC8114A8B6B9FF3246856CADC4A0106198C066EA1F9C39",
"tx_json" : {
"Account" : "rGPnRH1EBpHeTF2QG8DCAgM7z5pb75LAis",
"CheckID" : "84C61BE9B39B2C4A2267F67504404F1EC76678806C1B901EA781D1E3B4CE0CD9",
"DeliverMin" : "95000000",
"Fee" : "10",
"Flags" : 2147483648,
"Sequence" : 4,
"SigningPubKey" : "0361ACFCB478BCAE01451F95060AF94F70365BF00D7B4661EC2C69EA383762516C",
"TransactionType" : "CheckCash",
"TxnSignature" : "304402203D7EC220D48AA040D6915C160275D202F7F808E2B58F11B1AB05FB5E5CFCC6C00220304BBD3AD32E13150E0ED7247F2ADFAE83D0ECE329E20CFE0F8DF352934DD2FC",
"hash" : "A0AFE572E4736CBF49FF4D0D3FF8FDB0C4D31BD10CB4EB542230F85F0F2DD222"
}
}
}

Some files were not shown because too many files have changed in this diff Show More