mirror of
https://github.com/XRPLF/xrpl-dev-portal.git
synced 2025-11-19 11:15:49 +00:00
Re-level non-docs content to top of repo and rename content→docs
This commit is contained in:
3
_code-samples/address_encoding/README.md
Normal file
3
_code-samples/address_encoding/README.md
Normal 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.)
|
||||
45
_code-samples/address_encoding/js/encode_address.js
Normal file
45
_code-samples/address_encoding/js/encode_address.js
Normal 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)
|
||||
9
_code-samples/address_encoding/js/package.json
Normal file
9
_code-samples/address_encoding/js/package.json
Normal 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": "*",
|
||||
}
|
||||
}
|
||||
67
_code-samples/address_encoding/py/encode_address.py
Normal file
67
_code-samples/address_encoding/py/encode_address.py
Normal 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)
|
||||
|
||||
1
_code-samples/airgapped-wallet/js/.gitignore
vendored
Normal file
1
_code-samples/airgapped-wallet/js/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Wallet/
|
||||
92
_code-samples/airgapped-wallet/js/README.md
Normal file
92
_code-samples/airgapped-wallet/js/README.md
Normal 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 system’s 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
|
||||
223
_code-samples/airgapped-wallet/js/airgapped-wallet.js
Normal file
223
_code-samples/airgapped-wallet/js/airgapped-wallet.js
Normal 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()
|
||||
18
_code-samples/airgapped-wallet/js/package.json
Normal file
18
_code-samples/airgapped-wallet/js/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
37
_code-samples/airgapped-wallet/js/relay-transaction.js
Normal file
37
_code-samples/airgapped-wallet/js/relay-transaction.js
Normal 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()
|
||||
117
_code-samples/airgapped-wallet/py/README.md
Normal file
117
_code-samples/airgapped-wallet/py/README.md
Normal 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 system’s 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.
|
||||
223
_code-samples/airgapped-wallet/py/airgapped-wallet-TEST.py
Normal file
223
_code-samples/airgapped-wallet/py/airgapped-wallet-TEST.py
Normal 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 !@# -+= }{/"
|
||||
)
|
||||
226
_code-samples/airgapped-wallet/py/airgapped-wallet.py
Normal file
226
_code-samples/airgapped-wallet/py/airgapped-wallet.py
Normal 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()
|
||||
50
_code-samples/airgapped-wallet/py/relay-transaction.py
Normal file
50
_code-samples/airgapped-wallet/py/relay-transaction.py
Normal 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)
|
||||
25
_code-samples/airgapped-wallet/py/requirements.txt
Normal file
25
_code-samples/airgapped-wallet/py/requirements.txt
Normal 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
|
||||
3
_code-samples/build-a-browser-wallet/README.md
Normal file
3
_code-samples/build-a-browser-wallet/README.md
Normal 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.
|
||||
3
_code-samples/build-a-browser-wallet/js/.env
Normal file
3
_code-samples/build-a-browser-wallet/js/.env
Normal file
@@ -0,0 +1,3 @@
|
||||
CLIENT="wss://s.altnet.rippletest.net:51233/"
|
||||
EXPLORER_NETWORK="testnet"
|
||||
SEED="s████████████████████████████"
|
||||
24
_code-samples/build-a-browser-wallet/js/.gitignore
vendored
Normal file
24
_code-samples/build-a-browser-wallet/js/.gitignore
vendored
Normal 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?
|
||||
7
_code-samples/build-a-browser-wallet/js/.prettierrc
Normal file
7
_code-samples/build-a-browser-wallet/js/.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 150,
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true,
|
||||
"semi": true
|
||||
}
|
||||
24
_code-samples/build-a-browser-wallet/js/README.md
Normal file
24
_code-samples/build-a-browser-wallet/js/README.md
Normal 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
|
||||
148
_code-samples/build-a-browser-wallet/js/index.css
Normal file
148
_code-samples/build-a-browser-wallet/js/index.css
Normal 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;
|
||||
}
|
||||
41
_code-samples/build-a-browser-wallet/js/index.html
Normal file
41
_code-samples/build-a-browser-wallet/js/index.html
Normal 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>
|
||||
71
_code-samples/build-a-browser-wallet/js/index.js
Normal file
71
_code-samples/build-a-browser-wallet/js/index.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
21
_code-samples/build-a-browser-wallet/js/package.json
Normal file
21
_code-samples/build-a-browser-wallet/js/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
_code-samples/build-a-browser-wallet/js/src/assets/xrpl.svg
Normal file
20
_code-samples/build-a-browser-wallet/js/src/assets/xrpl.svg
Normal 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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
146
_code-samples/build-a-browser-wallet/js/src/send-xrp/send-xrp.js
Normal file
146
_code-samples/build-a-browser-wallet/js/src/send-xrp/send-xrp.js
Normal 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';
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
43
_code-samples/build-a-browser-wallet/js/vite.config.js
Normal file
43
_code-samples/build-a-browser-wallet/js/vite.config.js
Normal 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;
|
||||
1028
_code-samples/build-a-browser-wallet/js/yarn.lock
Normal file
1028
_code-samples/build-a-browser-wallet/js/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
3
_code-samples/build-a-desktop-wallet/README.md
Normal file
3
_code-samples/build-a-desktop-wallet/README.md
Normal 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.
|
||||
132
_code-samples/build-a-desktop-wallet/js/.gitignore
vendored
Normal file
132
_code-samples/build-a-desktop-wallet/js/.gitignore
vendored
Normal 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/
|
||||
27
_code-samples/build-a-desktop-wallet/js/0-hello/index.js
Normal file
27
_code-samples/build-a-desktop-wallet/js/0-hello/index.js
Normal 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()
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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>
|
||||
53
_code-samples/build-a-desktop-wallet/js/2-async/index.js
Normal file
53
_code-samples/build-a-desktop-wallet/js/2-async/index.js
Normal 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)
|
||||
@@ -0,0 +1,7 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
onUpdateLedgerData: (callback) => {
|
||||
ipcRenderer.on('update-ledger-data', callback)
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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>
|
||||
78
_code-samples/build-a-desktop-wallet/js/3-account/index.js
Normal file
78
_code-samples/build-a-desktop-wallet/js/3-account/index.js
Normal 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)
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
@@ -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>
|
||||
84
_code-samples/build-a-desktop-wallet/js/5-password/index.js
Normal file
84
_code-samples/build-a-desktop-wallet/js/5-password/index.js
Normal 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)
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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>"
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
84
_code-samples/build-a-desktop-wallet/js/6-styling/index.js
Normal file
84
_code-samples/build-a-desktop-wallet/js/6-styling/index.js
Normal 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)
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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>"
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
92
_code-samples/build-a-desktop-wallet/js/7-send-xrp/index.js
Normal file
92
_code-samples/build-a-desktop-wallet/js/7-send-xrp/index.js
Normal 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)
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
})
|
||||
@@ -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 = ''
|
||||
})
|
||||
@@ -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>
|
||||
43
_code-samples/build-a-desktop-wallet/js/README.md
Normal file
43
_code-samples/build-a-desktop-wallet/js/README.md
Normal 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
|
||||
```
|
||||
@@ -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 |
7
_code-samples/build-a-desktop-wallet/js/bootstrap/bootstrap.bundle.min.js
vendored
Normal file
7
_code-samples/build-a-desktop-wallet/js/bootstrap/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
_code-samples/build-a-desktop-wallet/js/bootstrap/bootstrap.min.css
vendored
Normal file
7
_code-samples/build-a-desktop-wallet/js/bootstrap/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
78
_code-samples/build-a-desktop-wallet/js/bootstrap/custom.css
Normal file
78
_code-samples/build-a-desktop-wallet/js/bootstrap/custom.css
Normal 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;
|
||||
}
|
||||
29
_code-samples/build-a-desktop-wallet/js/library/3_helpers.js
Normal file
29
_code-samples/build-a-desktop-wallet/js/library/3_helpers.js
Normal 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 }
|
||||
34
_code-samples/build-a-desktop-wallet/js/library/4_helpers.js
Normal file
34
_code-samples/build-a-desktop-wallet/js/library/4_helpers.js
Normal 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 }
|
||||
136
_code-samples/build-a-desktop-wallet/js/library/5_helpers.js
Normal file
136
_code-samples/build-a-desktop-wallet/js/library/5_helpers.js
Normal 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 }
|
||||
28
_code-samples/build-a-desktop-wallet/js/library/7_helpers.js
Normal file
28
_code-samples/build-a-desktop-wallet/js/library/7_helpers.js
Normal 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 }
|
||||
110
_code-samples/build-a-desktop-wallet/js/library/8_helpers.js
Normal file
110
_code-samples/build-a-desktop-wallet/js/library/8_helpers.js
Normal 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 }
|
||||
28
_code-samples/build-a-desktop-wallet/js/package.json
Normal file
28
_code-samples/build-a-desktop-wallet/js/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
43
_code-samples/build-a-desktop-wallet/py/1_hello.py
Normal file
43
_code-samples/build-a-desktop-wallet/py/1_hello.py
Normal 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()
|
||||
114
_code-samples/build-a-desktop-wallet/py/2_threaded.py
Normal file
114
_code-samples/build-a-desktop-wallet/py/2_threaded.py
Normal 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()
|
||||
292
_code-samples/build-a-desktop-wallet/py/3_account.py
Normal file
292
_code-samples/build-a-desktop-wallet/py/3_account.py
Normal 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()
|
||||
412
_code-samples/build-a-desktop-wallet/py/4_tx_history.py
Normal file
412
_code-samples/build-a-desktop-wallet/py/4_tx_history.py
Normal 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()
|
||||
582
_code-samples/build-a-desktop-wallet/py/5_send_xrp.py
Normal file
582
_code-samples/build-a-desktop-wallet/py/5_send_xrp.py
Normal 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()
|
||||
@@ -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()
|
||||
889
_code-samples/build-a-desktop-wallet/py/7_owned_objects.py
Normal file
889
_code-samples/build-a-desktop-wallet/py/7_owned_objects.py
Normal 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()
|
||||
947
_code-samples/build-a-desktop-wallet/py/8_regular_key.py
Executable file
947
_code-samples/build-a-desktop-wallet/py/8_regular_key.py
Executable 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()
|
||||
15
_code-samples/build-a-desktop-wallet/py/README.md
Normal file
15
_code-samples/build-a-desktop-wallet/py/README.md
Normal 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
|
||||
```
|
||||
4
_code-samples/build-a-desktop-wallet/py/requirements.txt
Normal file
4
_code-samples/build-a-desktop-wallet/py/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
xrpl-py==2.0.0
|
||||
wxPython==4.2.1
|
||||
toml==0.10.2
|
||||
requests==2.31.0
|
||||
44
_code-samples/build-a-desktop-wallet/py/verify_domain.py
Normal file
44
_code-samples/build-a-desktop-wallet/py/verify_domain.py
Normal 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"]))
|
||||
3
_code-samples/checks/README.md
Normal file
3
_code-samples/checks/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Use Checks
|
||||
|
||||
Create, cash, and cancel Checks for exact or flexible amounts.
|
||||
100
_code-samples/checks/cli/account_objects-resp-clean.txt
Normal file
100
_code-samples/checks/cli/account_objects-resp-clean.txt
Normal 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
|
||||
}
|
||||
}
|
||||
100
_code-samples/checks/cli/account_objects-resp.txt
Normal file
100
_code-samples/checks/cli/account_objects-resp.txt
Normal 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
|
||||
}
|
||||
}
|
||||
6
_code-samples/checks/cli/sign-cancel-req.sh
Normal file
6
_code-samples/checks/cli/sign-cancel-req.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
rippled sign s████████████████████████████ '{
|
||||
"TransactionType": "CheckCancel",
|
||||
"Account": "rUn84CUYbNjRoTQ6mSW7BVJPSVJNLb1QLo",
|
||||
"CheckID": "49647F0D748DC3FE26BDACBC57F251AADEFFF391403EC9BF87C97F67E9977FB0",
|
||||
"Fee": "12"
|
||||
}'
|
||||
20
_code-samples/checks/cli/sign-cancel-resp.txt
Normal file
20
_code-samples/checks/cli/sign-cancel-resp.txt
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
7
_code-samples/checks/cli/sign-cash-exact-req.sh
Normal file
7
_code-samples/checks/cli/sign-cash-exact-req.sh
Normal file
@@ -0,0 +1,7 @@
|
||||
rippled sign s████████████████████████████ '{
|
||||
"Account": "rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy",
|
||||
"TransactionType": "CheckCash",
|
||||
"Amount": "100000000",
|
||||
"CheckID": "838766BA2B995C00744175F69A1B11E32C3DBC40E64801A4056FCBD657F57334",
|
||||
"Fee": "12"
|
||||
}'
|
||||
21
_code-samples/checks/cli/sign-cash-exact-resp.txt
Normal file
21
_code-samples/checks/cli/sign-cash-exact-resp.txt
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
_code-samples/checks/cli/sign-cash-flex-req.sh
Normal file
6
_code-samples/checks/cli/sign-cash-flex-req.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
rippled sign s████████████████████████████ '{
|
||||
"Account": "rGPnRH1EBpHeTF2QG8DCAgM7z5pb75LAis",
|
||||
"TransactionType": "CheckCash",
|
||||
"DeliverMin": "95000000",
|
||||
"CheckID": "84C61BE9B39B2C4A2267F67504404F1EC76678806C1B901EA781D1E3B4CE0CD9"
|
||||
}'
|
||||
21
_code-samples/checks/cli/sign-cash-flex-resp.txt
Normal file
21
_code-samples/checks/cli/sign-cash-flex-resp.txt
Normal 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
Reference in New Issue
Block a user